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:
1 parent
9bc5de4c56
commit
7e89207b70
10 files changed
+1737
-489
No files matched your search
+357
-72
@@ -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 == "" {
|
||||
|
||||
Reference in new issue
Block a user