- Menus: dropdowns agora com position:fixed posicionado via JS — no celular
o overflow-x:auto da menubar cortava os itens (bug relatado)
- Sincronia celular/desktop: PUT aceita base_hash opcional; divergência →
409 {server_hash} e diálogo "Conflito de edição" (recarregar | salvar por
cima); abas limpas recarregam sozinhas no focus/visibilitychange (debounce)
- WYSIWYG (Ctrl+M): contenteditable com render md→html no servidor e
serialização html→md própria (títulos, negrito/itálico/tachado, listas
com task, citação, código, blocos, links, tabelas, entidades); HTML edita
preservando tags/atributos, sanitizando script/on* na entrada; toolbar
usa execCommand no modo rico; colar é sempre texto puro; txt não aplica
- store/api: ErrConflict + teste de conflito na store e na API
- README: WYSIWYG, sincronia, base_hash, nota do fix de menu
1010 lines
25 KiB
Go
1010 lines
25 KiB
Go
// Package store implementa a persistência de notas em disco no estilo
|
|
// Git: conteúdo é armazenado como blobs endereçados por hash SHA-256 e
|
|
// cada salvamento cria uma revisão imutável apontando para seu pai.
|
|
package store
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"slices"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
"unicode"
|
|
)
|
|
|
|
// Formatos aceitos para uma nota.
|
|
const (
|
|
FormatTxt = "txt"
|
|
FormatMD = "md"
|
|
FormatHTML = "html"
|
|
)
|
|
|
|
var validFormats = map[string]string{
|
|
FormatTxt: ".txt",
|
|
FormatMD: ".md",
|
|
FormatHTML: ".html",
|
|
}
|
|
|
|
const (
|
|
// MaxContentBytes limita o tamanho do conteúdo de uma nota.
|
|
MaxContentBytes = 2 << 20 // 2 MiB
|
|
maxTitleLen = 200
|
|
maxCategoryLen = 100
|
|
maxTagLen = 64
|
|
)
|
|
|
|
// Erros sentinel da camada de armazenamento.
|
|
var (
|
|
ErrNotFound = errors.New("nota não encontrada")
|
|
ErrRevision = errors.New("revisão não encontrada")
|
|
ErrInvalid = errors.New("dados inválidos")
|
|
ErrTooLarge = errors.New("conteúdo excede o limite de tamanho")
|
|
ErrUnchanged = errors.New("conteúdo idêntico à versão atual")
|
|
ErrConflict = errors.New("a nota foi alterada desde a última sincronização")
|
|
)
|
|
|
|
// Note é o estado atual de uma nota (sem o conteúdo).
|
|
type Note struct {
|
|
ID string `json:"id"`
|
|
Title string `json:"title"`
|
|
Format string `json:"format"`
|
|
Categories []string `json:"categories"` // IDs de categoria (árvore)
|
|
Tags []string `json:"tags"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
Hash string `json:"hash"` // blob do conteúdo atual
|
|
Bytes int `json:"bytes"`
|
|
}
|
|
|
|
// Filename devolve "titulo.ext" sanitizado para download.
|
|
func (n *Note) Filename() string {
|
|
base := n.Title
|
|
if base == "" {
|
|
base = "sem-titulo"
|
|
}
|
|
var b strings.Builder
|
|
for _, r := range base {
|
|
switch {
|
|
case unicode.IsLetter(r) || unicode.IsDigit(r) || r == '-' || r == '_' || r == ' ':
|
|
b.WriteRune(r)
|
|
case r == '.':
|
|
// permite extensão dupla acidental; mantém apenas um ponto
|
|
if b.Len() > 0 && b.String()[b.Len()-1] != '.' {
|
|
b.WriteRune(r)
|
|
}
|
|
}
|
|
}
|
|
base = strings.TrimSpace(b.String())
|
|
if base == "" {
|
|
base = "sem-titulo"
|
|
}
|
|
return base + validFormats[n.Format]
|
|
}
|
|
|
|
// Revision é uma entrada imutável do histórico de uma nota.
|
|
type Revision struct {
|
|
Hash string `json:"hash"` // SHA-256 do conteúdo (hex)
|
|
Parent string `json:"parent"` // hash da revisão anterior; "" na primeira
|
|
Author string `json:"author"`
|
|
When time.Time `json:"when"`
|
|
Message string `json:"message"`
|
|
Bytes int `json:"bytes"`
|
|
}
|
|
|
|
// RevisionWithContent é uma revisão com seu conteúdo, para o painel de histórico.
|
|
type RevisionWithContent struct {
|
|
Revision
|
|
Content string `json:"content"`
|
|
}
|
|
|
|
// Filtro de listagem. Category aceita "" (todas), "none" (sem categoria) ou um
|
|
// ID de categoria; Recursive (padrão) inclui notas de categorias descendentes.
|
|
type Filter struct {
|
|
Category string
|
|
Recursive bool
|
|
Tag string // "" = todas (sem #)
|
|
Query string // busca em título, categoria e conteúdo (case-insensitive)
|
|
}
|
|
|
|
// Category é uma pasta da árvore de categorias.
|
|
type Category struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Parent string `json:"parent"` // "" = raiz
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
// CategoryView é uma categoria com agregações para a UI.
|
|
type CategoryView struct {
|
|
Category
|
|
NoteCount int `json:"note_count"` // notas com esta categoria exata
|
|
HasChild bool `json:"has_child"` // tem subcategorias filhas
|
|
}
|
|
|
|
// Store é seguro para concorrência.
|
|
type Store struct {
|
|
dir string
|
|
mu sync.RWMutex
|
|
cats []Category // cache da árvore; persistido em categories.json
|
|
}
|
|
|
|
// New abre (ou cria) o diretório raiz de dados e migra metadados legados
|
|
// (nota com campo único "category" vira categoria raiz de mesmo nome).
|
|
func New(dir string) (*Store, error) {
|
|
for _, sub := range []string{"notes", "blobs"} {
|
|
if err := os.MkdirAll(filepath.Join(dir, sub), 0o750); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
s := &Store{dir: dir}
|
|
if err := s.loadCats(); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := s.migrateLegacy(); err != nil {
|
|
return nil, err
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Árvore de categorias (categories.json)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func (s *Store) catsPath() string { return filepath.Join(s.dir, "categories.json") }
|
|
|
|
func (s *Store) loadCats() error {
|
|
s.cats = nil
|
|
var cats []Category
|
|
err := readJSON(s.catsPath(), &cats)
|
|
switch {
|
|
case errors.Is(err, ErrNotFound):
|
|
return nil
|
|
case err != nil:
|
|
return err
|
|
}
|
|
s.cats = cats
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) catByID(id string) *Category {
|
|
for i := range s.cats {
|
|
if s.cats[i].ID == id {
|
|
return &s.cats[i]
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// catSubtree devolve id e todos os descendentes (ordem de percurso).
|
|
func (s *Store) catSubtree(id string) []string {
|
|
out := []string{id}
|
|
for i := 0; i < len(out); i++ {
|
|
for _, c := range s.cats {
|
|
if c.Parent == out[i] {
|
|
out = append(out, c.ID)
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (s *Store) catName(id string) string {
|
|
if c := s.catByID(id); c != nil {
|
|
return c.Name
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// CreateCategory cria uma pasta; nomes únicos entre irmãos (case-insensitive).
|
|
func (s *Store) CreateCategory(name, parent string) (*Category, error) {
|
|
name = strings.TrimSpace(name)
|
|
if r := []rune(name); name == "" || len(r) > maxCategoryLen {
|
|
return nil, fmt.Errorf("%w: nome de categoria inválido", ErrInvalid)
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if parent != "" && s.catByID(parent) == nil {
|
|
return nil, fmt.Errorf("%w: categoria pai inexistente", ErrInvalid)
|
|
}
|
|
lower := strings.ToLower(name)
|
|
for _, c := range s.cats {
|
|
if c.Parent == parent && strings.ToLower(c.Name) == lower {
|
|
return nil, fmt.Errorf("%w: já existe uma categoria %q nesse nível", ErrInvalid, name)
|
|
}
|
|
}
|
|
cat := Category{ID: newID(), Name: name, Parent: parent, CreatedAt: time.Now().UTC()}
|
|
s.cats = append(s.cats, cat)
|
|
if err := writeJSONAtomic(s.catsPath(), s.cats); err != nil {
|
|
return nil, err
|
|
}
|
|
return &cat, nil
|
|
}
|
|
|
|
// UpdateCategory renomeia (name == "" mantém) e/ou move (parent != nil) a categoria.
|
|
func (s *Store) UpdateCategory(id, name string, parent *string) (*Category, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
c := s.catByID(id)
|
|
if c == nil {
|
|
return nil, ErrNotFound
|
|
}
|
|
if parent != nil && *parent != c.Parent {
|
|
if *parent != "" && s.catByID(*parent) == nil {
|
|
return nil, fmt.Errorf("%w: categoria pai inexistente", ErrInvalid)
|
|
}
|
|
for _, d := range s.catSubtree(id) { // sem ciclos: pai não pode ser descendente
|
|
if d == *parent {
|
|
return nil, fmt.Errorf("%w: não é possível mover uma categoria para dentro dela mesma", ErrInvalid)
|
|
}
|
|
}
|
|
c.Parent = *parent
|
|
}
|
|
if n := strings.TrimSpace(name); n != "" && n != c.Name {
|
|
if len([]rune(n)) > maxCategoryLen {
|
|
return nil, fmt.Errorf("%w: nome de categoria inválido", ErrInvalid)
|
|
}
|
|
lower := strings.ToLower(n)
|
|
for _, other := range s.cats {
|
|
if other.ID != id && other.Parent == c.Parent && strings.ToLower(other.Name) == lower {
|
|
return nil, fmt.Errorf("%w: já existe uma categoria %q nesse nível", ErrInvalid, n)
|
|
}
|
|
}
|
|
c.Name = n
|
|
}
|
|
if err := writeJSONAtomic(s.catsPath(), s.cats); err != nil {
|
|
return nil, err
|
|
}
|
|
cp := *c
|
|
return &cp, nil
|
|
}
|
|
|
|
// DeleteCategory remove a categoria e suas descendentes; notas permanecem,
|
|
// apenas perdem as vinculações removidas.
|
|
func (s *Store) DeleteCategory(id string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.catByID(id) == nil {
|
|
return ErrNotFound
|
|
}
|
|
sub := map[string]bool{}
|
|
for _, d := range s.catSubtree(id) {
|
|
sub[d] = true
|
|
}
|
|
kept := s.cats[:0]
|
|
for _, c := range s.cats {
|
|
if !sub[c.ID] {
|
|
kept = append(kept, c)
|
|
}
|
|
}
|
|
s.cats = kept
|
|
if err := writeJSONAtomic(s.catsPath(), s.cats); err != nil {
|
|
return err
|
|
}
|
|
ids, err := s.noteIDs()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, nid := range ids {
|
|
n, err := s.get(nid)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
cleaned := make([]string, 0, len(n.Categories))
|
|
changed := false
|
|
for _, cid := range n.Categories {
|
|
if sub[cid] {
|
|
changed = true
|
|
continue
|
|
}
|
|
cleaned = append(cleaned, cid)
|
|
}
|
|
if changed {
|
|
n.Categories = cleaned
|
|
if err := writeJSONAtomic(s.metaPath(nid), n); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Categories devolve a árvore completa (lista plana; a UI monta os nós).
|
|
func (s *Store) Categories() ([]CategoryView, error) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
counts := map[string]int{}
|
|
ids, err := s.noteIDs()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, id := range ids {
|
|
n, err := s.get(id)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
for _, cid := range n.Categories {
|
|
counts[cid]++
|
|
}
|
|
}
|
|
out := make([]CategoryView, 0, len(s.cats))
|
|
for _, c := range s.cats {
|
|
v := CategoryView{Category: c, NoteCount: counts[c.ID]}
|
|
for _, other := range s.cats {
|
|
if other.Parent == c.ID {
|
|
v.HasChild = true
|
|
break
|
|
}
|
|
}
|
|
out = append(out, v)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// validateCatIDs confere se todas as categorias existem (sob lock de escrita).
|
|
func (s *Store) validateCatIDs(ids []string) ([]string, error) {
|
|
seen := map[string]bool{}
|
|
var out []string
|
|
for _, id := range ids {
|
|
if id == "" || s.catByID(id) == nil {
|
|
return nil, fmt.Errorf("%w: categoria inexistente %q", ErrInvalid, id)
|
|
}
|
|
if !seen[id] {
|
|
seen[id] = true
|
|
out = append(out, id)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// migrateLegacy converte o campo único "category" (pré-árvore) em categoria raiz.
|
|
func (s *Store) migrateLegacy() error {
|
|
ids, err := s.noteIDs()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
changed := false
|
|
for _, id := range ids {
|
|
var meta struct {
|
|
Note
|
|
Category string `json:"category"` // legado
|
|
}
|
|
if err := readJSON(s.metaPath(id), &meta); err != nil {
|
|
continue
|
|
}
|
|
legacy := strings.TrimSpace(meta.Category)
|
|
if legacy == "" || len(meta.Categories) > 0 {
|
|
continue
|
|
}
|
|
var cat *Category
|
|
lower := strings.ToLower(legacy)
|
|
for i := range s.cats {
|
|
if s.cats[i].Parent == "" && strings.ToLower(s.cats[i].Name) == lower {
|
|
cat = &s.cats[i]
|
|
break
|
|
}
|
|
}
|
|
if cat == nil {
|
|
cat = &Category{ID: newID(), Name: legacy, Parent: "", CreatedAt: time.Now().UTC()}
|
|
s.cats = append(s.cats, *cat)
|
|
changed = true
|
|
}
|
|
n := meta.Note
|
|
n.Categories = []string{cat.ID}
|
|
if err := writeJSONAtomic(s.metaPath(id), n); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if changed {
|
|
return writeJSONAtomic(s.catsPath(), s.cats)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tipos internos persistidos
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func (s *Store) noteDir(id string) string { return filepath.Join(s.dir, "notes", id) }
|
|
func (s *Store) metaPath(id string) string { return filepath.Join(s.noteDir(id), "meta.json") }
|
|
func (s *Store) histPath(id string) string { return filepath.Join(s.noteDir(id), "history.json") }
|
|
func (s *Store) blobPath(hash string) string {
|
|
return filepath.Join(s.dir, "blobs", hash[:2], hash)
|
|
}
|
|
|
|
func hashContent(content string) string {
|
|
sum := sha256.Sum256([]byte(content))
|
|
return hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
func newID() string {
|
|
var buf [8]byte
|
|
if _, err := rand.Read(buf[:]); err != nil { // improvável; fallback temporal
|
|
return fmt.Sprintf("%012x", time.Now().UnixNano()&0xffffffffffff)
|
|
}
|
|
return hex.EncodeToString(buf[:])[:12]
|
|
}
|
|
|
|
// writeJSONAtomic grava v em JSON de forma atômica (tmp + rename).
|
|
func writeJSONAtomic(path string, v any) error {
|
|
data, err := json.MarshalIndent(v, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tmp := path + ".tmp"
|
|
if err := os.WriteFile(tmp, append(data, '\n'), 0o640); err != nil {
|
|
return err
|
|
}
|
|
return os.Rename(tmp, path)
|
|
}
|
|
|
|
func readJSON(path string, v any) error {
|
|
data, err := os.ReadFile(path)
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return ErrNotFound
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return json.Unmarshal(data, v)
|
|
}
|
|
|
|
// putBlobArmazenado grava o conteúdo se o blob ainda não existe.
|
|
func (s *Store) putBlob(content string) (string, error) {
|
|
hash := hashContent(content)
|
|
p := s.blobPath(hash)
|
|
if _, err := os.Stat(p); err == nil {
|
|
return hash, nil
|
|
} else if !errors.Is(err, os.ErrNotExist) {
|
|
return "", err
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(p), 0o750); err != nil {
|
|
return "", err
|
|
}
|
|
tmp := p + ".tmp"
|
|
if err := os.WriteFile(tmp, []byte(content), 0o640); err != nil {
|
|
return "", err
|
|
}
|
|
return hash, os.Rename(tmp, p)
|
|
}
|
|
|
|
func (s *Store) getBlob(hash string) (string, error) {
|
|
data, err := os.ReadFile(s.blobPath(hash))
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return "", ErrNotFound
|
|
}
|
|
return string(data), err
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Validação e tags
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// tagRe casa #tag: # seguido de letra e então letras/dígitos/_/-.
|
|
// Cabeçalhos Markdown ("# Título") têm espaço após # e ficam de fora.
|
|
var tagRe = regexp.MustCompile(`(^|[^\p{L}\p{N}_/])#(\p{L}[\p{L}\p{N}_-]{0,63})`)
|
|
|
|
// ExtractTags normaliza (minúsculas) e deduplica as tags presentes no conteúdo.
|
|
func ExtractTags(content string) []string {
|
|
seen := map[string]bool{}
|
|
var out []string
|
|
for _, m := range tagRe.FindAllStringSubmatch(content, -1) {
|
|
t := strings.ToLower(m[2])
|
|
t = strings.Trim(t, "-_") // "#dev-" e "#dev_" viram "#dev"
|
|
if t == "" || seen[t] {
|
|
continue
|
|
}
|
|
seen[t] = true
|
|
out = append(out, t)
|
|
}
|
|
slices.Sort(out)
|
|
return out
|
|
}
|
|
|
|
func normalizeFormat(f string) (string, error) {
|
|
f = strings.ToLower(strings.TrimPrefix(f, "."))
|
|
if _, ok := validFormats[f]; !ok {
|
|
return "", fmt.Errorf("%w: formato %q", ErrInvalid, f)
|
|
}
|
|
return f, nil
|
|
}
|
|
|
|
func cleanNoteFields(title, format *string) error {
|
|
*title = strings.TrimSpace(*title)
|
|
if r := []rune(*title); len(r) > maxTitleLen {
|
|
*title = string(r[:maxTitleLen])
|
|
}
|
|
f, err := normalizeFormat(*format)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
*format = f
|
|
return nil
|
|
}
|
|
|
|
func validateContent(content string) error {
|
|
if len(content) > MaxContentBytes {
|
|
return ErrTooLarge
|
|
}
|
|
return nil
|
|
}
|
|
|
|
var idRe = regexp.MustCompile(`^[a-z0-9]{6,32}$`)
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Operações públicas
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Create grava uma nova nota com sua primeira revisão.
|
|
func (s *Store) Create(title, format string, categories []string, content, author, message string) (*Note, error) {
|
|
if err := cleanNoteFields(&title, &format); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := validateContent(content); err != nil {
|
|
return nil, err
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
cats, err := s.validateCatIDs(categories)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
id := newID()
|
|
now := time.Now().UTC()
|
|
if err := os.MkdirAll(s.noteDir(id), 0o750); err != nil {
|
|
return nil, err
|
|
}
|
|
hash, err := s.putBlob(content)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
n := &Note{
|
|
ID: id, Title: title, Format: format, Categories: cats,
|
|
Tags: ExtractTags(content), CreatedAt: now, UpdatedAt: now,
|
|
Hash: hash, Bytes: len(content),
|
|
}
|
|
rev := Revision{Hash: hash, Parent: "", Author: fallback(author, "desconhecido"),
|
|
When: now, Message: fallback(message, "Criação da nota"), Bytes: len(content)}
|
|
if err := writeJSONAtomic(s.metaPath(id), n); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := writeJSONAtomic(s.histPath(id), []Revision{rev}); err != nil {
|
|
return nil, err
|
|
}
|
|
return n, nil
|
|
}
|
|
|
|
// Get devolve os metadados da nota id.
|
|
func (s *Store) Get(id string) (*Note, error) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return s.get(id)
|
|
}
|
|
|
|
func (s *Store) get(id string) (*Note, error) {
|
|
if !idRe.MatchString(id) {
|
|
return nil, ErrNotFound
|
|
}
|
|
var n Note
|
|
if err := readJSON(s.metaPath(id), &n); err != nil {
|
|
if errors.Is(err, ErrNotFound) {
|
|
return nil, ErrNotFound
|
|
}
|
|
return nil, err
|
|
}
|
|
return &n, nil
|
|
}
|
|
|
|
// Content devolve o conteúdo atual da nota.
|
|
func (s *Store) Content(id string) (string, *Note, error) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
n, err := s.get(id)
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
c, err := s.getBlob(n.Hash)
|
|
return c, n, err
|
|
}
|
|
|
|
// Update salva metadados e, se o conteúdo mudou, uma nova revisão.
|
|
// Um conteúdo inalterado atualiza apenas título/categorias/formato.
|
|
// baseHash != "" ativa controle otimista de concorrência: se o servidor já
|
|
// mudou desde essa base, devolve ErrConflict (e a nota atual) em vez de sobrescrever.
|
|
func (s *Store) Update(id, title, format string, categories []string, content, author, message, baseHash string) (*Note, error) {
|
|
if err := cleanNoteFields(&title, &format); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := validateContent(content); err != nil {
|
|
return nil, err
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
cats, err := s.validateCatIDs(categories)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
n, err := s.get(id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if baseHash != "" && baseHash != n.Hash {
|
|
return n, ErrConflict
|
|
}
|
|
newHash := hashContent(content)
|
|
contentChanged := newHash != n.Hash
|
|
if contentChanged {
|
|
if _, err := s.putBlob(content); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
hist, err := s.history(id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if contentChanged {
|
|
rev := Revision{Hash: newHash, Parent: n.Hash, Author: fallback(author, "desconhecido"),
|
|
When: time.Now().UTC(), Message: fallback(message, "Edição via editor"), Bytes: len(content)}
|
|
hist = append(hist, rev)
|
|
if err := writeJSONAtomic(s.histPath(id), hist); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
tags := n.Tags
|
|
if contentChanged {
|
|
tags = ExtractTags(content)
|
|
}
|
|
now := time.Now().UTC()
|
|
meta := &Note{ID: n.ID, Title: title, Format: format, Categories: cats, Tags: tags,
|
|
CreatedAt: n.CreatedAt, UpdatedAt: now, Hash: newHash, Bytes: len(content)}
|
|
if err := writeJSONAtomic(s.metaPath(id), meta); err != nil {
|
|
return nil, err
|
|
}
|
|
return meta, nil
|
|
}
|
|
|
|
// Delete remove a nota e faz GC dos blobs que ficaram órfãos.
|
|
func (s *Store) Delete(id string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if !idRe.MatchString(id) {
|
|
return ErrNotFound
|
|
}
|
|
if _, err := os.Stat(s.noteDir(id)); err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return ErrNotFound
|
|
}
|
|
return err
|
|
}
|
|
if err := os.RemoveAll(s.noteDir(id)); err != nil {
|
|
return err
|
|
}
|
|
return s.gcBlobs()
|
|
}
|
|
|
|
// gcBlobs apaga blobs não referenciados por nenhuma história restante.
|
|
func (s *Store) gcBlobs() error {
|
|
live := map[string]bool{}
|
|
ids, err := s.noteIDs()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, id := range ids {
|
|
hist, err := s.history(id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, r := range hist {
|
|
live[r.Hash] = true
|
|
}
|
|
}
|
|
root := filepath.Join(s.dir, "blobs")
|
|
shards, err := os.ReadDir(root)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, shard := range shards {
|
|
if !shard.IsDir() {
|
|
continue
|
|
}
|
|
dir := filepath.Join(root, shard.Name())
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, e := range entries {
|
|
if !live[e.Name()] {
|
|
if err := os.Remove(filepath.Join(dir, e.Name())); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
if remaining, err := os.ReadDir(dir); err == nil && len(remaining) == 0 {
|
|
_ = os.Remove(dir)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) noteIDs() ([]string, error) {
|
|
entries, err := os.ReadDir(filepath.Join(s.dir, "notes"))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var ids []string
|
|
for _, e := range entries {
|
|
if e.IsDir() && idRe.MatchString(e.Name()) {
|
|
ids = append(ids, e.Name())
|
|
}
|
|
}
|
|
return ids, nil
|
|
}
|
|
|
|
// List devolve notas conforme f, ordenadas por atualização mais recente.
|
|
func (s *Store) List(f Filter) ([]*Note, error) {
|
|
f.Category = strings.TrimSpace(f.Category)
|
|
f.Tag = strings.ToLower(strings.TrimPrefix(f.Tag, "#"))
|
|
f.Query = strings.ToLower(strings.TrimSpace(f.Query))
|
|
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
// Conjunto de categorias aceitas para o filtro (id + descendentes se recursivo).
|
|
var allowed map[string]bool
|
|
if f.Category != "" && f.Category != "none" {
|
|
if s.catByID(f.Category) == nil {
|
|
return nil, fmt.Errorf("%w: categoria inexistente", ErrInvalid)
|
|
}
|
|
allowed = map[string]bool{}
|
|
ids := []string{f.Category}
|
|
if f.Recursive {
|
|
ids = s.catSubtree(f.Category)
|
|
}
|
|
for _, id := range ids {
|
|
allowed[id] = true
|
|
}
|
|
}
|
|
|
|
ids, err := s.noteIDs()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]*Note, 0, len(ids))
|
|
for _, id := range ids {
|
|
n, err := s.get(id)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
switch f.Category {
|
|
case "":
|
|
case "none":
|
|
if len(n.Categories) > 0 {
|
|
continue
|
|
}
|
|
default:
|
|
hit := false
|
|
for _, cid := range n.Categories {
|
|
if allowed[cid] {
|
|
hit = true
|
|
break
|
|
}
|
|
}
|
|
if !hit {
|
|
continue
|
|
}
|
|
}
|
|
if f.Tag != "" && !slices.Contains(n.Tags, f.Tag) {
|
|
continue
|
|
}
|
|
if f.Query != "" && !s.matchesQuery(n, f.Query) {
|
|
continue
|
|
}
|
|
out = append(out, n)
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].UpdatedAt.After(out[j].UpdatedAt) })
|
|
return out, nil
|
|
}
|
|
|
|
func (s *Store) matchesQuery(n *Note, q string) bool {
|
|
if strings.Contains(strings.ToLower(n.Title), q) ||
|
|
slices.ContainsFunc(n.Tags, func(t string) bool { return strings.Contains(t, q) }) {
|
|
return true
|
|
}
|
|
for _, cid := range n.Categories {
|
|
if strings.Contains(strings.ToLower(s.catName(cid)), q) {
|
|
return true
|
|
}
|
|
}
|
|
content, err := s.getBlob(n.Hash)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return strings.Contains(strings.ToLower(content), q)
|
|
}
|
|
|
|
// History devolve as revisões da nota, da mais antiga para a mais nova.
|
|
func (s *Store) History(id string) ([]Revision, error) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
if _, err := s.get(id); err != nil {
|
|
return nil, err
|
|
}
|
|
return s.history(id)
|
|
}
|
|
|
|
func (s *Store) history(id string) ([]Revision, error) {
|
|
var hist []Revision
|
|
if err := readJSON(s.histPath(id), &hist); err != nil {
|
|
if errors.Is(err, ErrNotFound) {
|
|
return nil, fmt.Errorf("história ausente para %s: %w", id, ErrNotFound)
|
|
}
|
|
return nil, err
|
|
}
|
|
return hist, nil
|
|
}
|
|
|
|
// RevisionWithContent devolve uma revisão específica com seu conteúdo.
|
|
func (s *Store) RevisionWithContent(id, hash string) (*RevisionWithContent, error) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
hist, err := s.history(id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, r := range hist {
|
|
if r.Hash == hash {
|
|
content, err := s.getBlob(hash)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &RevisionWithContent{Revision: r, Content: content}, nil
|
|
}
|
|
}
|
|
return nil, ErrRevision
|
|
}
|
|
|
|
// DiffBetween devolve (conteúdo antigo, conteúdo novo) entre uma revisão e
|
|
// seu pai. Para a primeira revisão, o antigo é vazio.
|
|
func (s *Store) DiffBetween(id, hash string) (before, after string, rev Revision, err error) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
hist, err := s.history(id)
|
|
if err != nil {
|
|
return "", "", rev, err
|
|
}
|
|
for _, r := range hist {
|
|
if r.Hash == hash {
|
|
after, err = s.getBlob(r.Hash)
|
|
if err != nil {
|
|
return
|
|
}
|
|
if r.Parent != "" {
|
|
before, err = s.getBlob(r.Parent)
|
|
if err != nil {
|
|
return
|
|
}
|
|
}
|
|
rev = r
|
|
return
|
|
}
|
|
}
|
|
return "", "", rev, ErrRevision
|
|
}
|
|
|
|
// Restore cria uma nova revisão com o conteúdo de uma revisão anterior.
|
|
// Se o conteúdo já é o atual, devolve ErrUnchanged.
|
|
func (s *Store) Restore(id, hash, author string) (*Note, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
n, err := s.get(id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
content, err := s.getBlob(hash)
|
|
if err != nil {
|
|
if errors.Is(err, ErrNotFound) {
|
|
return nil, ErrRevision
|
|
}
|
|
return nil, err
|
|
}
|
|
hist, err := s.history(id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
found := false
|
|
for _, r := range hist {
|
|
if r.Hash == hash {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
return nil, ErrRevision
|
|
}
|
|
if hash == n.Hash {
|
|
return nil, ErrUnchanged
|
|
}
|
|
now := time.Now().UTC()
|
|
newHash, err := s.putBlob(content) // já existe (mesmo hash), no-op
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
short := hash
|
|
if len(short) > 8 {
|
|
short = short[:8]
|
|
}
|
|
rev := Revision{Hash: newHash, Parent: n.Hash, Author: fallback(author, "desconhecido"),
|
|
When: now, Message: fmt.Sprintf("Restauração da versão %s", short), Bytes: len(content)}
|
|
hist = append(hist, rev)
|
|
if err := writeJSONAtomic(s.histPath(id), hist); err != nil {
|
|
return nil, err
|
|
}
|
|
meta := *n
|
|
meta.Hash = newHash
|
|
meta.Bytes = len(content)
|
|
meta.Tags = ExtractTags(content)
|
|
meta.UpdatedAt = now
|
|
if err := writeJSONAtomic(s.metaPath(id), &meta); err != nil {
|
|
return nil, err
|
|
}
|
|
return &meta, nil
|
|
}
|
|
|
|
// TagCount é uma tag agregada com número de notas.
|
|
type TagCount struct {
|
|
Tag string `json:"tag"`
|
|
Count int `json:"count"`
|
|
}
|
|
|
|
// Tags devolve todas as tags em uso, ordenadas por contagem desc e nome.
|
|
func (s *Store) Tags() ([]TagCount, error) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
counts := map[string]int{}
|
|
ids, err := s.noteIDs()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, id := range ids {
|
|
n, err := s.get(id)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
for _, t := range n.Tags {
|
|
counts[t]++
|
|
}
|
|
}
|
|
out := make([]TagCount, 0, len(counts))
|
|
for t, c := range counts {
|
|
out = append(out, TagCount{Tag: t, Count: c})
|
|
}
|
|
sort.Slice(out, func(i, j int) bool {
|
|
if out[i].Count != out[j].Count {
|
|
return out[i].Count > out[j].Count
|
|
}
|
|
return out[i].Tag < out[j].Tag
|
|
})
|
|
return out, nil
|
|
}
|
|
|
|
func fallback(v, def string) string {
|
|
v = strings.TrimSpace(v)
|
|
if v == "" {
|
|
return def
|
|
}
|
|
if r := []rune(v); len(r) > 80 {
|
|
v = string(r[:80])
|
|
}
|
|
return v
|
|
}
|