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

+97 -13
View File
@@ -61,13 +61,13 @@ func decode(t *testing.T, resp *http.Response, v any) {
}
type noteView struct {
ID string `json:"id"`
Title string `json:"title"`
Format string `json:"format"`
Category string `json:"category"`
Tags []string `json:"tags"`
Hash string `json:"hash"`
Content string `json:"content"`
ID string `json:"id"`
Title string `json:"title"`
Format string `json:"format"`
Categories []string `json:"categories"`
Tags []string `json:"tags"`
Hash string `json:"hash"`
Content string `json:"content"`
}
func TestAuthRequiredAndLoginFlow(t *testing.T) {
@@ -130,15 +130,26 @@ func TestNoteLifecycleWithHistoryAndRestore(t *testing.T) {
return do(t, srv, method, path, body, testToken, "")
}
// Criar.
resp := call("POST", "/api/notes",
`{"title":"Servidor","format":"md","category":"devops","content":"# Notas\n\n#linux #systemd","author":"lg","message":""}`)
// Criar uma categoria raiz e uma filha para vincular à nota.
resp := call("POST", "/api/categories", `{"name":"devops","parent":""}`)
if resp.StatusCode != http.StatusCreated {
t.Fatalf("create category: %s", resp.Status)
}
var cat struct {
ID string `json:"id"`
Name string `json:"name"`
}
decode(t, resp, &cat)
// Criar nota em múltiplas categorias.
resp = call("POST", "/api/notes",
`{"title":"Servidor","format":"md","categories":["`+cat.ID+`"],"content":"# Notas\n\n#linux #systemd","author":"lg","message":""}`)
if resp.StatusCode != http.StatusCreated {
t.Fatalf("create: %s", resp.Status)
}
var created noteView
decode(t, resp, &created)
if created.ID == "" || len(created.Tags) != 2 {
if created.ID == "" || len(created.Tags) != 2 || len(created.Categories) != 1 {
t.Fatalf("payload criado: %+v", created)
}
id := created.ID
@@ -161,7 +172,7 @@ func TestNoteLifecycleWithHistoryAndRestore(t *testing.T) {
// Atualizar: nova revisão.
resp = call("PUT", "/api/notes/"+id,
`{"title":"Servidor","format":"md","category":"devops","content":"# Notas\n\n#linux","author":"lg","message":"removi systemd"}`)
`{"title":"Servidor","format":"md","categories":["`+cat.ID+`"],"content":"# Notas\n\n#linux","author":"lg","message":"removi systemd"}`)
var updated noteView
decode(t, resp, &updated)
if updated.Hash == created.Hash {
@@ -231,7 +242,9 @@ func TestRenderMarkdownIsSafe(t *testing.T) {
srv := newTestServer(t)
resp := do(t, srv, "POST", "/api/render",
`{"content":"**negrito** <script>alert(1)</script>\nlinha1\nlinha2","format":"md"}`, testToken, "")
var out struct{ HTML string `json:"html"` }
var out struct {
HTML string `json:"html"`
}
decode(t, resp, &out)
if !strings.Contains(out.HTML, "<strong>negrito</strong>") {
t.Errorf("markdown não renderizado: %q", out.HTML)
@@ -251,6 +264,77 @@ func TestRenderMarkdownIsSafe(t *testing.T) {
resp.Body.Close()
}
func TestCategoryCRUDViaAPI(t *testing.T) {
srv := newTestServer(t)
call := func(method, path, body string) *http.Response {
return do(t, srv, method, path, body, testToken, "")
}
resp := call("POST", "/api/categories", `{"name":"infra","parent":""}`)
var infra struct{ ID string }
decode(t, resp, &infra)
resp = call("POST", "/api/categories", `{"name":"linux","parent":"`+infra.ID+`"}`)
var linux struct{ ID string }
decode(t, resp, &linux)
// duplicata entre irmãos → 400
resp = call("POST", "/api/categories", `{"name":"Infra","parent":""}`)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("duplicata: %s", resp.Status)
}
resp.Body.Close()
// nota na subcategoria
resp = call("POST", "/api/notes", `{"title":"Kernel","format":"txt","categories":["`+linux.ID+`"],"content":"x","author":"","message":""}`)
var note noteView
decode(t, resp, &note)
// filtro recursivo pela raiz (padrão) → 1 nota; não-recursivo → 0
resp = call("GET", "/api/notes?category="+infra.ID, "")
var rec []noteView
decode(t, resp, &rec)
if len(rec) != 1 {
t.Fatalf("recursivo: %+v", rec)
}
resp = call("GET", "/api/notes?category="+infra.ID+"&recursive=0", "")
var nonrec []noteView
decode(t, resp, &nonrec)
if len(nonrec) != 0 {
t.Fatalf("não-recursivo: %+v", nonrec)
}
// ciclo: mover infra para dentro de linux → 400
resp = call("PUT", "/api/categories/"+infra.ID, `{"name":"","parent":"`+linux.ID+`"}`)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("ciclo deveria dar 400, obtive %s", resp.Status)
}
resp.Body.Close()
// excluir raiz remove subtree e limpa a nota
resp = call("DELETE", "/api/categories/"+infra.ID, "")
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("delete: %s", resp.Status)
}
resp = call("GET", "/api/notes/"+note.ID, "")
decode(t, resp, &note)
if len(note.Categories) != 0 {
t.Errorf("nota deveria ficar órfã: %+v", note.Categories)
}
resp = call("GET", "/api/categories", "")
var cats []map[string]any
decode(t, resp, &cats)
if len(cats) != 0 {
t.Errorf("árvore deveria estar vazia: %+v", cats)
}
// categoria inexistente em nota → 400
resp = call("POST", "/api/notes", `{"title":"x","format":"txt","categories":["zzzzzzzzzzzz"],"content":"", "author":"","message":""}`)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("categoria fantasma: %s", resp.Status)
}
resp.Body.Close()
}
func TestInvalidPayloads(t *testing.T) {
srv := newTestServer(t)
resp := do(t, srv, "POST", "/api/notes", `{"format":"rtf","content":"x"}`, testToken, "")
+50
View File
@@ -0,0 +1,50 @@
package api
import (
"net/http"
)
type categoryInput struct {
Name string `json:"name"`
Parent *string `json:"parent"` // ausente = não move; "" = raiz
}
func (s *Server) handleCreateCategory(w http.ResponseWriter, r *http.Request) {
var in categoryInput
if err := decodeJSON(r, &in, 1<<20); err != nil {
writeError(w, http.StatusBadRequest, "corpo JSON inválido")
return
}
parent := ""
if in.Parent != nil {
parent = *in.Parent
}
c, err := s.st.CreateCategory(in.Name, parent)
if err != nil {
mapStoreError(w, err)
return
}
writeJSON(w, http.StatusCreated, c)
}
func (s *Server) handleUpdateCategory(w http.ResponseWriter, r *http.Request) {
var in categoryInput
if err := decodeJSON(r, &in, 1<<20); err != nil {
writeError(w, http.StatusBadRequest, "corpo JSON inválido")
return
}
c, err := s.st.UpdateCategory(r.PathValue("id"), in.Name, in.Parent)
if err != nil {
mapStoreError(w, err)
return
}
writeJSON(w, http.StatusOK, c)
}
func (s *Server) handleDeleteCategory(w http.ResponseWriter, r *http.Request) {
if err := s.st.DeleteCategory(r.PathValue("id")); err != nil {
mapStoreError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
+18 -16
View File
@@ -22,20 +22,20 @@ func (s *Server) serveIndex(w http.ResponseWriter, r *http.Request) {
// noteInput é o corpo aceito por POST/PUT de notas.
type noteInput struct {
Title string `json:"title"`
Format string `json:"format"`
Category string `json:"category"`
Content string `json:"content"`
Author string `json:"author"`
Message string `json:"message"`
Title string `json:"title"`
Format string `json:"format"`
Categories []string `json:"categories"` // IDs da árvore de categorias
Content string `json:"content"`
Author string `json:"author"`
Message string `json:"message"`
}
func (in noteInput) fields() (string, string, string, string, string, string) {
func (in noteInput) fields() (string, string, []string, string, string, string) {
f := in.Format
if f == "" {
f = store.FormatTxt
}
return in.Title, f, in.Category, in.Content, in.Author, in.Message
return in.Title, f, in.Categories, in.Content, in.Author, in.Message
}
// noteView é a representação completa (meta + conteúdo) de uma nota.
@@ -46,10 +46,12 @@ type noteView struct {
func (s *Server) handleListNotes(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
recursive := q.Get("recursive") != "0"
notes, err := s.st.List(store.Filter{
Category: q.Get("category"),
Tag: q.Get("tag"),
Query: q.Get("q"),
Category: q.Get("category"),
Recursive: recursive,
Tag: q.Get("tag"),
Query: q.Get("q"),
})
if err != nil {
mapStoreError(w, err)
@@ -67,8 +69,8 @@ func (s *Server) handleCreateNote(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "corpo JSON inválido")
return
}
title, format, category, content, author, message := in.fields()
n, err := s.st.Create(title, format, category, content, author, message)
title, format, categories, content, author, message := in.fields()
n, err := s.st.Create(title, format, categories, content, author, message)
if err != nil {
mapStoreError(w, err)
return
@@ -91,8 +93,8 @@ func (s *Server) handleUpdateNote(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "corpo JSON inválido")
return
}
title, format, category, content, author, message := in.fields()
n, err := s.st.Update(r.PathValue("id"), title, format, category, content, author, message)
title, format, categories, content, author, message := in.fields()
n, err := s.st.Update(r.PathValue("id"), title, format, categories, content, author, message)
if err != nil {
mapStoreError(w, err)
return
@@ -159,7 +161,7 @@ func (s *Server) handleCategories(w http.ResponseWriter, r *http.Request) {
return
}
if cats == nil {
cats = []store.CatCount{}
cats = []store.CategoryView{}
}
writeJSON(w, http.StatusOK, cats)
}
+3
View File
@@ -66,6 +66,9 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("GET /api/tags", s.auth(s.handleTags))
mux.HandleFunc("GET /api/categories", s.auth(s.handleCategories))
mux.HandleFunc("POST /api/categories", s.auth(s.handleCreateCategory))
mux.HandleFunc("PUT /api/categories/{id}", s.auth(s.handleUpdateCategory))
mux.HandleFunc("DELETE /api/categories/{id}", s.auth(s.handleDeleteCategory))
mux.HandleFunc("POST /api/render", s.auth(s.handleRender))
mux.HandleFunc("GET /{$}", s.serveIndex)