feat: layout mobile, abas múltiplas e categorias em árvore

- Responsivo ≤760px: sidebar vira gaveta com backdrop, painel de versões em tela
  cheia, toolbar/menu rolam sem rótulos, alvos de toque, dvh + safe-area, fonte
  do editor ≥16px (anti-zoom iOS)
- Abas: várias notas abertas com estado sujo por aba, ✕ com confirmação,
  clique do meio, Alt+W / Alt+←/→ / Alt+1..9; abas e rascunhos sobrevivem à
  sessão via localStorage
- Categorias: modelo vira árvore (categories.json) com notas em múltiplas
  categorias; explorador no sidebar com expandir/renomear/mover/excluir
  (sem ciclos, GC de vínculos) e seletor multi-marca no editor; filtro por
  pasta recursivo por padrão; migração automática do campo único legado
- API: POST/PUT/DELETE /api/categories; notas aceitam categories: [ids]
- Testes: store (árvore, ciclo, migração, GC) e api (CRUD de categorias)
This commit is contained in:
lgcosta committed 2026-11-04 11:54:15 -03:00
1 parent 9bc5de4c56
commit 7e89207b70
10 files changed
+1737 -489

No files matched your search

+357 -72
View File
@@ -44,24 +44,24 @@ const (
// 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")
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")
)
// 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"`
Category string `json:"category"`
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"`
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.
@@ -105,27 +105,307 @@ type RevisionWithContent struct {
Content string `json:"content"`
}
// Filter restringe a Listagem de notas.
// 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 // "" = todas
Tag string // "" = todas (sem #)
Query string // busca em título, categoria e conteúdo (case-insensitive)
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
dir string
mu sync.RWMutex
cats []Category // cache da árvore; persistido em categories.json
}
// New abre (ou cria) o diretório raiz de dados.
// 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
}
}
return &Store{dir: dir}, nil
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
}
// ---------------------------------------------------------------------------
@@ -236,15 +516,11 @@ func normalizeFormat(f string) (string, error) {
return f, nil
}
func cleanNoteFields(title, category *string, format *string) error {
func cleanNoteFields(title, format *string) error {
*title = strings.TrimSpace(*title)
if r := []rune(*title); len(r) > maxTitleLen {
*title = string(r[:maxTitleLen])
}
*category = strings.TrimSpace(*category)
if r := []rune(*category); len(r) > maxCategoryLen {
*category = string(r[:maxCategoryLen])
}
f, err := normalizeFormat(*format)
if err != nil {
return err
@@ -267,8 +543,8 @@ var idRe = regexp.MustCompile(`^[a-z0-9]{6,32}$`)
// ---------------------------------------------------------------------------
// Create grava uma nova nota com sua primeira revisão.
func (s *Store) Create(title, format, category, content, author, message string) (*Note, error) {
if err := cleanNoteFields(&title, &category, &format); err != nil {
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 {
@@ -276,6 +552,10 @@ func (s *Store) Create(title, format, category, content, author, message string)
}
s.mu.Lock()
defer s.mu.Unlock()
cats, err := s.validateCatIDs(categories)
if err != nil {
return nil, err
}
id := newID()
now := time.Now().UTC()
@@ -287,7 +567,7 @@ func (s *Store) Create(title, format, category, content, author, message string)
return nil, err
}
n := &Note{
ID: id, Title: title, Format: format, Category: category,
ID: id, Title: title, Format: format, Categories: cats,
Tags: ExtractTags(content), CreatedAt: now, UpdatedAt: now,
Hash: hash, Bytes: len(content),
}
@@ -336,9 +616,9 @@ func (s *Store) Content(id string) (string, *Note, error) {
}
// Update salva metadados e, se o conteúdo mudou, uma nova revisão.
// Um conteúdo inalterado atualiza apenas título/categoria/formato.
func (s *Store) Update(id, title, format, category, content, author, message string) (*Note, error) {
if err := cleanNoteFields(&title, &category, &format); err != nil {
// Um conteúdo inalterado atualiza apenas título/categorias/formato.
func (s *Store) Update(id, 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 {
@@ -346,6 +626,10 @@ func (s *Store) Update(id, title, format, category, content, author, message str
}
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 {
@@ -375,7 +659,7 @@ func (s *Store) Update(id, title, format, category, content, author, message str
tags = ExtractTags(content)
}
now := time.Now().UTC()
meta := &Note{ID: n.ID, Title: title, Format: format, Category: category, Tags: tags,
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
@@ -468,6 +752,23 @@ func (s *Store) List(f Filter) ([]*Note, error) {
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
@@ -478,8 +779,23 @@ func (s *Store) List(f Filter) ([]*Note, error) {
if err != nil {
continue
}
if f.Category != "" && n.Category != f.Category {
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
@@ -495,10 +811,14 @@ func (s *Store) List(f Filter) ([]*Note, error) {
func (s *Store) matchesQuery(n *Note, q string) bool {
if strings.Contains(strings.ToLower(n.Title), q) ||
strings.Contains(strings.ToLower(n.Category), 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
@@ -671,41 +991,6 @@ func (s *Store) Tags() ([]TagCount, error) {
return out, nil
}
// CatCount é uma categoria agregada com número de notas. "" = sem categoria.
type CatCount struct {
Category string `json:"category"`
Count int `json:"count"`
}
// Categories devolve as categorias em uso.
func (s *Store) Categories() ([]CatCount, 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
}
counts[n.Category]++
}
out := make([]CatCount, 0, len(counts))
for c, k := range counts {
out = append(out, CatCount{Category: c, Count: k})
}
sort.Slice(out, func(i, j int) bool {
if (out[i].Category == "") != (out[j].Category == "") {
return out[j].Category == "" // sem categoria por último
}
return out[i].Category < out[j].Category
})
return out, nil
}
func fallback(v, def string) string {
v = strings.TrimSpace(v)
if v == "" {