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

+35 -11
View File
@@ -36,9 +36,20 @@ restauração) e autenticação por **token** (Bearer para API / cookie HttpOnly
o formato ativo (desabilitada em texto puro).
- **Modo Edição ↔ Preview**: Markdown é renderizado no servidor (goldmark, GFM, HTML cru escapado);
HTML é pré-visualizado em `<iframe sandbox>`; texto puro é exibido como está.
- **Categorias/pastas** no painel lateral e **tags `#tag`** extraídas automaticamente do conteúdo
(cabeçalhos Markdown como `# Título` não contam — só `#tag` sem espaço). Filtro por categoria,
por tag e busca em título/categoria/conteúdo.
- **Categorias como árvore de pastas**: o painel lateral traz um explorador de arquivos —
categorias aninhadas (subpastas), expansão/colapso, contagem de notas por pasta e menu de
contexto (Nova subpasta / Renomear / Excluir, sem ciclos). Uma nota pode pertencer a
**várias categorias** ao mesmo tempo (seletor multi-marca no topo do editor). Filtro por
pasta é recursivo por padrão (`?recursive=0` desliga); há também "Todas as notas" e
"Sem categoria". Dados antigos com o campo único `category` são migrados automaticamente
na abertura (vira categoria raiz de mesmo nome).
- **Tags `#tag`** extraídas automaticamente do conteúdo (cabeçalhos Markdown como `# Título`
não contam — só `#tag` sem espaço), com filtro por nuvem de tags e busca em
título/categorias/conteúdo.
- **Abas de arquivos**: abra várias notas ao mesmo tempo (clique na lista, `Ctrl+N` ou botão
✚); cada aba tem estado sujo próprio (●), botão ✕ com confirmação, fechar com clique do
meio, trocar com `Alt+←/→` ou `Alt+1..9`, fechar com `Alt+W`. As abas abertas (inclusive
rascunhos não salvos) são restauradas entre sessões via `localStorage`.
- **Preferências do editor** (menu Editar → Preferências… ou `Ctrl+,`, em diálogo próprio):
- **Números de linha** (gutter alinhado mesmo com quebra de linha; alternável pelo menu Exibir).
- **Marcador de coluna** customizável — padrão **80** (linha vertical no editor, exibida com
@@ -60,10 +71,15 @@ restauração) e autenticação por **token** (Bearer para API / cookie HttpOnly
- **Autenticação por token**: sem token, a interface exibe um diálogo pedindo o token; ele é
guardado em **cookie HttpOnly/SameSite=Lax** (30 dias). A API também aceita
`Authorization: Bearer <token>` para scripts.
- **Responsivo para celular** (≤760px): barra lateral vira gaveta com backdrop (☰ fecha ao
tocar uma nota), painel de versões ocupa a tela toda, toolbar e menus rolam horizontalmente
sem rótulos, alvos de toque ampliados, diálogos em largura total, fonte do editor com mínimo
de 16px (evita zoom automático do iOS), altura em `dvh` e `safe-area-inset` para notch.
- **Estilo Mousepad**: tema claro Greybird-like, menus nativos (Arquivo/Editar/Exibir/Ajuda),
atalhos `Ctrl+S`, `Ctrl+N`, `Ctrl+E/R` (editar/preview), `Ctrl+H` (histórico), `F9` (barra
lateral), `Ctrl+B/I/K` (formatação), `Ctrl+,` (preferências), barra de status com arquivo,
formato, linha/coluna, contagem de palavras e estado de salvamento.
lateral), `Ctrl+B/I/K` (formatação), `Ctrl+,` (preferências), `Alt+W`/`Alt+←/→`/`Alt+1..9`
(abas), barra de status com arquivo, formato, linha/coluna, contagem de palavras e estado de
salvamento.
## Estrutura do projeto
@@ -121,19 +137,25 @@ Todos os endpoints sob `/api/*` exigem o token, exceto `login/logout/me` e o fro
```bash
T=$NOTES_TOKEN; B=http://localhost:8080
curl -H "Authorization: Bearer $T" $B/api/notes # lista (filtros: ?category=&tag=&q=)
curl -H "Authorization: Bearer $T" $B/api/notes # lista (?category=<id|none>&recursive=0&tag=&q=)
curl -H "Authorization: Bearer $T" $B/api/notes/<id> # nota + conteúdo
curl -X POST -H "Authorization: Bearer $T" -H 'Content-Type: application/json' \
-d '{"title":"Olá","format":"md","category":"dev","content":"# Oi #linux"}' $B/api/notes
curl -s -X POST -H "Authorization: Bearer $T" -H 'Content-Type: application/json' \
-d '{"title":"Olá","format":"md","categories":["<id>"],"content":"# Oi #linux"}' $B/api/notes
curl -X PUT -H "Authorization: Bearer $T" -H 'Content-Type: application/json' \
-d '{"title":"Olá","format":"md","category":"dev","content":"editado","author":"lg","message":"revisão 2"}' \
-d '{"title":"Olá","format":"md","categories":["<id>"],"content":"editado","author":"lg","message":"revisão 2"}' \
$B/api/notes/<id>
curl -H "Authorization: Bearer $T" $B/api/notes/<id>/revisions # histórico
curl -H "Authorization: Bearer $T" $B/api/notes/<id>/revisions/<hash>/diff # diff unificado
curl -X POST -H "Authorization: Bearer $T" $B/api/notes/<id>/revisions/<hash>/restore \
-H 'Content-Type: application/json' -d '{"author":"lg"}' # restaurar
curl -H "Authorization: Bearer $T" $B/api/notes/<id>/raw # download .txt/.md/.html
curl -H "Authorization: Bearer $T" $B/api/tags $B/api/categories
curl -H "Authorization: Bearer $T" $B/api/tags $B/api/categories # agregações
# árvore de categorias (lista plana; parent="" é raiz)
curl -X POST -H "Authorization: Bearer $T" -H 'Content-Type: application/json' \
-d '{"name":"devops","parent":""}' $B/api/categories
curl -X PUT -H "Authorization: Bearer $T" -H 'Content-Type: application/json' \
-d '{"name":"infra","parent":"<novo-pai>"}' $B/api/categories/<id> # renomear/mover
curl -X DELETE -H "Authorization: Bearer $T" $B/api/categories/<id> # remove subtree
```
## Como funciona o versionamento
@@ -142,7 +164,9 @@ curl -H "Authorization: Bearer $T" $B/api/tags $B/api/categories
Git): conteúdo repetido ocupa um único blob.
- Cada nota tem `data/notes/<id>/meta.json` (estado atual) e `history.json` (lista ordenada de
revisões: `hash`, `parent`, `author`, `when`, `message`, `bytes`).
- Salvar sem alterar o conteúdo atualiza apenas os metadados (título/categoria/formato) — sem
- A árvore de categorias fica em `data/categories.json` (lista plana com `parent`; nomes únicos
entre irmãos; mover para dentro de si mesma é recusado).
- Salvar sem alterar o conteúdo atualiza apenas os metadados (título/categorias/formato) — sem
revisão nova. Restaurar cria uma revisão nova apontando para o blob antigo.
- Apagar uma nota faz GC dos blobs que ficaram sem referência.
+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)
+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 == "" {
+167 -33
View File
@@ -1,6 +1,7 @@
package store
import (
"encoding/json"
"errors"
"os"
"path/filepath"
@@ -17,10 +18,19 @@ func newTestStore(t *testing.T) *Store {
return s
}
func mustCat(t *testing.T, s *Store, name, parent string) string {
t.Helper()
c, err := s.CreateCategory(name, parent)
if err != nil {
t.Fatalf("CreateCategory(%q): %v", name, err)
}
return c.ID
}
func TestCreateExtractsTagsAndIgnoresHeadings(t *testing.T) {
s := newTestStore(t)
content := "# Título do post\n\nUso #Linux e #devops.\n#linux repetida, #Hardware\n"
n, err := s.Create("Boas vindas", FormatMD, "infra", content, "lg", "")
n, err := s.Create("Boas vindas", FormatMD, nil, content, "lg", "")
if err != nil {
t.Fatalf("Create: %v", err)
}
@@ -32,12 +42,6 @@ func TestCreateExtractsTagsAndIgnoresHeadings(t *testing.T) {
t.Errorf("tag %q ausente em %v", tag, n.Tags)
}
}
// cabeçalho "# Título" não deve virar tag ("Título" nem entraria, e "título" tem acento)
for _, tag := range n.Tags {
if strings.Contains(tag, "t") && strings.HasPrefix(tag, "tí") {
t.Errorf("cabeçalho vazou como tag: %v", n.Tags)
}
}
if n.Hash == "" || n.Bytes != len(content) {
t.Errorf("hash/bytes errados: %q %d", n.Hash, n.Bytes)
}
@@ -54,7 +58,8 @@ func contains(xs []string, x string) bool {
func TestUpdateCreatesRevisionOnlyOnContentChange(t *testing.T) {
s := newTestStore(t)
n, err := s.Create("nota", FormatTxt, "", "v1", "lg", "")
dev := mustCat(t, s, "dev", "")
n, err := s.Create("nota", FormatTxt, []string{dev}, "v1", "lg", "")
if err != nil {
t.Fatal(err)
}
@@ -63,16 +68,21 @@ func TestUpdateCreatesRevisionOnlyOnContentChange(t *testing.T) {
t.Fatalf("história inicial: %v %v", len(h1), err)
}
// Só metadados: nenhuma revisão nova.
if _, err := s.Update(n.ID, "renomeada", FormatTxt, "pasta", "v1", "lg", ""); err != nil {
// Só metadados: nenhuma revisão nova (troca de categoria/título).
house := mustCat(t, s, "casa", "")
if _, err := s.Update(n.ID, "renomeada", FormatTxt, []string{dev, house}, "v1", "lg", ""); err != nil {
t.Fatal(err)
}
if h, _ := s.History(n.ID); len(h) != 1 {
t.Fatalf("conteúdo inalterado não deveria criar revisão; história: %+v", h)
}
got, _ := s.Get(n.ID)
if len(got.Categories) != 2 {
t.Errorf("múltiplas categorias não persistidas: %+v", got.Categories)
}
// Conteúdo novo: segunda revisão com parent correto.
n2, err := s.Update(n.ID, "renomeada", FormatTxt, "pasta", "v2", "lg", "ajuste final")
n2, err := s.Update(n.ID, "renomeada", FormatTxt, []string{dev}, "v2", "lg", "ajuste final")
if err != nil {
t.Fatal(err)
}
@@ -94,8 +104,8 @@ func TestUpdateCreatesRevisionOnlyOnContentChange(t *testing.T) {
func TestRestoreAppendsRevisionWithOldContent(t *testing.T) {
s := newTestStore(t)
n, _ := s.Create("r", FormatMD, "", "primeiro", "", "")
if _, err := s.Update(n.ID, "r", FormatMD, "", "segundo", "", ""); err != nil {
n, _ := s.Create("r", FormatMD, nil, "primeiro", "", "")
if _, err := s.Update(n.ID, "r", FormatMD, nil, "segundo", "", ""); err != nil {
t.Fatal(err)
}
first, err := s.History(n.ID)
@@ -117,36 +127,164 @@ func TestRestoreAppendsRevisionWithOldContent(t *testing.T) {
if len(h) != 3 || !strings.HasPrefix(h[2].Message, "Restauração da versão") {
t.Fatalf("história pós-resturo: %+v", h)
}
// Restaurar a versão já atual devolve ErrUnchanged.
if _, err := s.Restore(n.ID, first[0].Hash, "lg"); !errors.Is(err, ErrUnchanged) {
t.Errorf("esperava ErrUnchanged, obtive %v", err)
}
// Hash desconhecido devolve ErrRevision.
if _, err := s.Restore(n.ID, strings.Repeat("f", 64), "lg"); !errors.Is(err, ErrRevision) {
t.Errorf("esperava ErrRevision, obtive %v", err)
}
}
func TestCategoryTreeOperations(t *testing.T) {
s := newTestStore(t)
devops := mustCat(t, s, "devops", "")
k8s := mustCat(t, s, "kubernetes", devops)
pessoal := mustCat(t, s, "pessoal", "")
// nomes duplicados entre irmãos são recusados (case-insensitive)
if _, err := s.CreateCategory("DevOps", ""); !errors.Is(err, ErrInvalid) {
t.Errorf("duplicata de raiz deveria falhar: %v", err)
}
// mas o mesmo nome em pais diferentes é ok
mustCat(t, s, "notes", k8s)
n1, _ := s.Create("deploy", FormatMD, []string{k8s}, "conteúdo", "", "")
n2, _ := s.Create("compras", FormatTxt, []string{pessoal}, "oi", "", "")
n3, _ := s.Create("shell", FormatTxt, []string{devops, pessoal}, "duas categorias", "", "")
// filtro recursivo pela raiz devops: só n1 (k8s) e n3 (devops direto)
rec, err := s.List(Filter{Category: devops, Recursive: true})
if err != nil || len(rec) != 2 {
t.Fatalf("filtro recursivo: %d notas %v", len(rec), err)
}
// não-recursivo: só n3
nonrec, _ := s.List(Filter{Category: devops})
if len(nonrec) != 1 || nonrec[0].ID != n3.ID {
t.Errorf("filtro não-recursivo: %+v", nonrec)
}
// múltiplas categorias: n3 aparece nos dois filtros
if r, _ := s.List(Filter{Category: pessoal}); len(r) != 2 {
t.Errorf("filtro pessoal (n2+n3): %+v", r)
}
// none
if r, _ := s.List(Filter{Category: "none"}); len(r) != 0 {
t.Errorf("todas têm categoria: %+v", r)
}
n4, _ := s.Create("livre", FormatTxt, nil, "x", "", "")
if r, _ := s.List(Filter{Category: "none"}); len(r) != 1 || r[0].ID != n4.ID {
t.Errorf("filtro none: %+v", r)
}
// mover k8s para pessoal (sem ciclo)
if _, err := s.UpdateCategory(k8s, "", ptr(pessoal)); err != nil {
t.Fatalf("move: %v", err)
}
if r, _ := s.List(Filter{Category: devops, Recursive: true}); len(r) != 1 {
t.Errorf("após mover k8s, devops recursivo deveria ter só n3: %+v", r)
}
// ciclo: mover devops para dentro de si mesmo
if _, err := s.UpdateCategory(devops, "", ptr(devops)); !errors.Is(err, ErrInvalid) {
t.Errorf("ciclo direto deveria falhar: %v", err)
}
// ciclo via descendente: mover pessoal para k8s (k8s é filho de pessoal)
if _, err := s.UpdateCategory(pessoal, "", ptr(k8s)); !errors.Is(err, ErrInvalid) {
t.Errorf("ciclo via descendente deveria falhar: %v", err)
}
// renomear
upd, err := s.UpdateCategory(devops, "infra", nil)
if err != nil || upd.Name != "infra" {
t.Fatalf("rename: %+v %v", upd, err)
}
// excluir categoria-pai remove subtree e limpa vínculos das notas
if err := s.DeleteCategory(pessoal); err != nil {
t.Fatalf("delete: %v", err)
}
got2, _ := s.Get(n2.ID)
if len(got2.Categories) != 0 {
t.Errorf("n2 deveria perder a categoria excluída: %+v", got2.Categories)
}
got3, _ := s.Get(n3.ID)
if len(got3.Categories) != 1 || got3.Categories[0] != upd.ID {
t.Errorf("n3 deveria manter só infra: %+v", got3.Categories)
}
cats, _ := s.Categories()
for _, c := range cats {
if c.ID == pessoal || c.ID == k8s {
t.Errorf("subtree não removida: %+v", cats)
}
}
// categoria inexistente em nota → ErrInvalid
if _, err := s.Update(n1.ID, "deploy", FormatMD, []string{"naoexiste1"}, "x", "", ""); !errors.Is(err, ErrInvalid) {
t.Errorf("categoria fantasma: %v", err)
}
// busca por nome de categoria: só n3 ainda é "infra" (n1 ficou órfã com a exclusão)
r, _ := s.List(Filter{Query: "infra"})
if len(r) != 1 || r[0].ID != n3.ID {
t.Errorf("busca por nome de categoria: %+v", ids(r))
}
}
func ptr(s string) *string { return &s }
func TestLegacyCategoryMigration(t *testing.T) {
dir := t.TempDir()
noteDir := filepath.Join(dir, "notes", "abcd12345678")
if err := os.MkdirAll(noteDir, 0o750); err != nil {
t.Fatal(err)
}
legacy := map[string]any{
"id": "abcd12345678", "title": "velha", "format": "txt",
"category": "hardware", "tags": []string{}, "hash": strings.Repeat("0", 64),
"bytes": 0, "created_at": "2024-01-01T00:00:00Z", "updated_at": "2024-01-01T00:00:00Z",
}
data, _ := json.Marshal(legacy)
if err := os.WriteFile(filepath.Join(noteDir, "meta.json"), data, 0o640); err != nil {
t.Fatal(err)
}
s2, err := New(dir)
if err != nil {
t.Fatalf("New pós-legado: %v", err)
}
n, err := s2.Get("abcd12345678")
if err != nil {
t.Fatal(err)
}
if len(n.Categories) != 1 {
t.Fatalf("migração não criou categoria: %+v", n)
}
cats, _ := s2.Categories()
if len(cats) != 1 || cats[0].Name != "hardware" || cats[0].Parent != "" || cats[0].NoteCount != 1 {
t.Errorf("categoria legada errada: %+v", cats)
}
if r, _ := s2.List(Filter{Category: cats[0].ID}); len(r) != 1 {
t.Errorf("nota não filtrável pela categoria migrada")
}
}
func TestListFiltersAndSorts(t *testing.T) {
s := newTestStore(t)
a, _ := s.Create("Alpha linux", FormatMD, "dev", "conteúdo #linux", "", "")
s.Update(a.ID, "Alpha linux", FormatMD, "dev", "conteúdo #linux editado", "", "")
b, _ := s.Create("Beta", FormatTxt, "casa", "compras", "", "")
c, _ := s.Create("Gamma docker", FormatHTML, "dev", "<p>#devops #linux</p>", "", "")
dev := mustCat(t, s, "dev", "")
a, _ := s.Create("Alpha linux", FormatMD, []string{dev}, "conteúdo #linux", "", "")
s.Update(a.ID, "Alpha linux", FormatMD, []string{dev}, "conteúdo #linux editado", "", "")
b, _ := s.Create("Beta", FormatTxt, nil, "compras", "", "")
c, _ := s.Create("Gamma docker", FormatHTML, []string{dev}, "<p>#devops #linux</p>", "", "")
all, err := s.List(Filter{})
if err != nil || len(all) != 3 {
t.Fatalf("list tudo: %d notas, %v", len(all), err)
}
if all[0].ID != c.ID { // mais recente primeiro (gamma criada por último)
if all[0].ID != c.ID { // mais recente primeiro
t.Errorf("ordenação por UpdatedAt errada: %v", ids(all))
}
byCat, _ := s.List(Filter{Category: "dev"})
byCat, _ := s.List(Filter{Category: dev})
if len(byCat) != 2 {
t.Errorf("categoria dev: %+v", ids(byCat))
}
byTag, _ := s.List(Filter{Tag: "#linux"})
if len(byTag) != 2 { // alpha (md) e gamma (html) contêm #linux
if len(byTag) != 2 { // alpha e gamma contêm #linux
t.Errorf("tag linux: %+v", ids(byTag))
}
byQ, _ := s.List(Filter{Query: "compras"})
@@ -157,10 +295,6 @@ func TestListFiltersAndSorts(t *testing.T) {
if len(tags) != 2 || tags[0].Tag != "linux" || tags[0].Count != 2 {
t.Errorf("agregação de tags: %+v", tags)
}
cats, _ := s.Categories()
if len(cats) != 2 || cats[0].Category != "casa" || cats[1].Category != "dev" {
t.Errorf("agregação de categorias: %+v", cats)
}
}
func ids(ns []*Note) []string {
@@ -173,7 +307,7 @@ func ids(ns []*Note) []string {
func TestDeleteGCsOrphanBlobs(t *testing.T) {
s := newTestStore(t)
n, _ := s.Create("gc", FormatTxt, "", "conteúdo único para o blob", "", "")
n, _ := s.Create("gc", FormatTxt, nil, "conteúdo único para o blob", "", "")
blob := filepath.Join(s.dir, "blobs", n.Hash[:2], n.Hash)
if _, err := os.Stat(blob); err != nil {
t.Fatalf("blob deveria existir: %v", err)
@@ -194,8 +328,8 @@ func TestDeleteGCsOrphanBlobs(t *testing.T) {
func TestSharedBlobSurvivesDeleteOfOneNote(t *testing.T) {
s := newTestStore(t)
a, _ := s.Create("a", FormatTxt, "", "mesmo", "", "")
b, _ := s.Create("b", FormatTxt, "", "mesmo", "", "") // mesmo hash
a, _ := s.Create("a", FormatTxt, nil, "mesmo", "", "")
b, _ := s.Create("b", FormatTxt, nil, "mesmo", "", "") // mesmo hash
if a.Hash != b.Hash {
t.Fatal("blobs deveriam ser deduplicados por conteúdo")
}
@@ -210,20 +344,20 @@ func TestSharedBlobSurvivesDeleteOfOneNote(t *testing.T) {
func TestInvalidFormatAndOversizeContent(t *testing.T) {
s := newTestStore(t)
if _, err := s.Create("x", "rtf", "", "", "", ""); !errors.Is(err, ErrInvalid) {
if _, err := s.Create("x", "rtf", nil, "", "", ""); !errors.Is(err, ErrInvalid) {
t.Errorf("formato inválido: %v", err)
}
if _, err := s.Update("naoexiste1", "x", FormatTxt, "", "a", "", ""); !errors.Is(err, ErrNotFound) {
if _, err := s.Update("naoexiste1", "x", FormatTxt, nil, "a", "", ""); !errors.Is(err, ErrNotFound) {
t.Errorf("update em nota ausente: %v", err)
}
if _, err := s.Create("x", FormatTxt, "", strings.Repeat("a", MaxContentBytes+1), "", ""); !errors.Is(err, ErrTooLarge) {
if _, err := s.Create("x", FormatTxt, nil, strings.Repeat("a", MaxContentBytes+1), "", ""); !errors.Is(err, ErrTooLarge) {
t.Errorf("conteúdo gigante: %v", err)
}
}
func TestFilenameAndRevisionContent(t *testing.T) {
s := newTestStore(t)
n, _ := s.Create("minha/nota: ruim", FormatMD, "", "oi #t", "", "")
n, _ := s.Create("minha/nota: ruim", FormatMD, nil, "oi #t", "", "")
if got := n.Filename(); got != "minhanota ruim.md" {
t.Errorf("filename: %q", got)
}
+216 -16
View File
@@ -62,7 +62,7 @@ body {
.muted { color: var(--muted); font-size: 12px; }
code { font-family: var(--mono); font-size: 0.92em; }
#app { display: flex; flex-direction: column; height: 100vh; }
#app { display: flex; flex-direction: column; height: 100vh; height: 100dvh; }
/* ------------------------------------------------------------------ menus */
#menubar {
@@ -209,7 +209,7 @@ body.sidebar-off #sidebar { display: none; }
color: var(--muted);
margin-bottom: 6px;
}
#sidebar input[type="search"], #sidebar select {
#sidebar input[type="search"] {
width: 100%;
font: inherit;
font-size: 13px;
@@ -270,16 +270,7 @@ body.sidebar-off #sidebar { display: none; }
background: var(--panel-2);
color: var(--fg);
}
#note-title:focus, .meta-cat:focus { outline: 2px solid var(--accent); outline-offset: -2px; }
.meta-cat {
flex: 0 0 220px;
font-size: 13px;
border: 1px solid var(--border);
border-radius: 3px;
padding: 4px 8px;
background: var(--panel);
color: var(--fg);
}
#note-title:focus { outline: 2px solid var(--accent); outline-offset: -2px; }
#editor-host { flex: 1 1 auto; display: flex; min-height: 0; position: relative; }
@@ -545,10 +536,212 @@ body.no-gutter #gutter { display: none; }
box-shadow: 0 6px 18px rgba(0,0,0,.3);
}
/* responsivo simples -----------------------------------------------------*/
@media (max-width: 940px) {
#sidebar { position: absolute; left: 0; top: 68px; bottom: 26px; z-index: 30; box-shadow: 4px 0 14px rgba(0,0,0,.12); }
#versions-panel { position: absolute; right: 0; top: 68px; bottom: 26px; z-index: 30; box-shadow: -4px 0 14px rgba(0,0,0,.12); }
/* ------------------------------------------------------------- abas */
#tabbar {
display: flex;
align-items: stretch;
background: var(--panel-2);
border-bottom: 1px solid var(--border);
flex: none;
min-height: 32px;
}
#tabs { display: flex; overflow-x: auto; scrollbar-width: none; flex: 1 1 auto; }
#tabs::-webkit-scrollbar { display: none; }
.tab {
display: flex;
align-items: center;
gap: 6px;
border: 0;
border-right: 1px solid var(--border-soft);
background: var(--panel-2);
font: inherit;
font-size: 12.5px;
color: var(--muted);
padding: 6px 8px 6px 12px;
cursor: pointer;
white-space: nowrap;
max-width: 240px;
}
.tab.active {
background: var(--panel);
color: var(--fg);
font-weight: 600;
box-shadow: inset 0 2px 0 var(--accent);
}
.tab.dirty .tab-label::after { content: ' ●'; color: var(--warn); }
.tab-label { overflow: hidden; text-overflow: ellipsis; }
.tab-close {
flex: none;
width: 16px;
height: 16px;
line-height: 15px;
text-align: center;
border-radius: 3px;
font-size: 10px;
}
.tab-close:hover { background: var(--danger); color: #fff; }
.tab-new { margin: 3px 4px; }
/* ------------------------------------------------- árvore de categorias */
#cat-tree { max-height: 240px; overflow-y: auto; }
.tree-row {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
border-radius: 3px;
cursor: pointer;
font-size: 13px;
color: var(--fg);
}
.tree-row:hover { background: #e9e6e2; }
.tree-row.active { background: #cfe2fa; box-shadow: inset 3px 0 0 var(--accent); }
.tree-caret {
border: 0;
background: none;
width: 14px;
padding: 0;
color: var(--muted);
cursor: pointer;
font-size: 10px;
}
.tree-icon { flex: none; }
.tree-name { flex: 1 1 auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tree-count {
font-size: 10px;
color: var(--muted);
background: var(--panel);
border: 1px solid var(--border-soft);
border-radius: 8px;
padding: 0 5px;
}
.tree-menu {
border: 0;
background: none;
color: var(--muted);
cursor: pointer;
padding: 0 4px;
border-radius: 3px;
visibility: hidden;
}
.tree-row:hover .tree-menu, .tree-row.active .tree-menu { visibility: visible; }
.tree-menu:hover { background: #dcd7d2; color: var(--fg); }
/* ------------------------------------------------- popover de categorias */
#note-meta { position: relative; }
#btn-cats {
flex: none;
max-width: 240px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cat-picker {
position: absolute;
top: 100%;
right: 8px;
z-index: 45;
width: min(300px, 90vw);
max-height: 300px;
overflow-y: auto;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 0 0 5px 5px;
box-shadow: 0 10px 26px rgba(0, 0, 0, .25);
padding: 6px;
}
.picker-row {
display: flex;
align-items: center;
gap: 6px;
padding: 5px 6px;
border-radius: 3px;
font-size: 13px;
cursor: pointer;
}
.picker-row:hover { background: var(--panel-2); }
.picker-empty { padding: 8px; font-size: 12px; }
/* ------------------------------------------------- menu de contexto */
.ctx-menu {
position: fixed;
z-index: 70;
min-width: 180px;
margin: 0;
padding: 4px 0;
list-style: none;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 4px;
box-shadow: 0 8px 22px rgba(0, 0, 0, .25);
}
.ctx-menu li { padding: 7px 14px; font-size: 13px; cursor: pointer; }
.ctx-menu li:hover { background: var(--accent); color: #fff; }
.ctx-danger { color: var(--danger); }
.ctx-danger:hover { color: #fff; }
/* ------------------------------------------------- backdrop */
#backdrop { position: fixed; inset: 0; z-index: 55; background: rgba(0, 0, 0, .4); }
.btn, .menu-btn, .tab, .tag-chip, .tree-row, .seg { touch-action: manipulation; }
/* ------------------------------------------------- responsivo (celular) */
@media (max-width: 760px) {
#menubar { padding: 0 2px; overflow-x: auto; scrollbar-width: none; }
#menubar::-webkit-scrollbar { display: none; }
.menu-btn { padding: 0 9px; }
.menu-dropdown li { padding: 9px 14px; }
#toolbar { overflow-x: auto; scrollbar-width: none; padding: 0 6px; }
#toolbar::-webkit-scrollbar { display: none; }
.tb-label, .tb-caption { display: none; }
.btn-icon { padding: 6px 9px; min-height: 32px; }
.segmented .seg { padding: 7px 12px; }
/* sidebar vira gaveta */
#sidebar {
position: fixed;
top: 0;
left: 0;
bottom: 0;
width: min(320px, 86vw);
z-index: 60;
transform: translateX(-105%);
transition: transform .18s ease;
box-shadow: 6px 0 20px rgba(0, 0, 0, .25);
padding-top: env(safe-area-inset-top);
}
body.sidebar-off #sidebar { display: flex; } /* F9 é atalho de desktop */
body.sidebar-open #sidebar { transform: none; }
/* painel de versões ocupa a tela toda */
#versions-panel {
position: fixed;
inset: 0;
width: auto;
z-index: 60;
box-shadow: none;
padding-top: env(safe-area-inset-top);
}
#statusbar {
height: 32px;
padding-bottom: env(safe-area-inset-bottom);
font-size: 11.5px;
}
#st-stats, #st-format { display: none; }
.st-item { padding: 0 8px; }
#note-meta { flex-wrap: wrap; }
#note-title { flex: 1 1 100%; }
.tab { padding: 9px 10px; }
/* 16px evita zoom automático do iOS ao focar o textarea */
#editor, #hl, .ln { font-size: max(var(--editor-size), 16px); }
}
@media (min-width: 761px) {
body.sidebar-open #sidebar { display: flex; } /* classe mobile não vaza no desktop */
}
/* ---------------------------------------------------- temas / esquemas */
@@ -702,3 +895,10 @@ html[data-theme="solar-escuro"] #revisions-list li:hover { background: #0c4450;
color: var(--fg);
}
.dialog .num { width: 74px; flex: none; }
/* diálogos no celular (depois das regras base de .dialog) */
@media (max-width: 760px) {
.dialog .row { font-size: 15px; }
.dialog input { font-size: 16px; }
.tree-menu { visibility: visible; } /* toque não tem hover */
}
+23 -7
View File
@@ -2,7 +2,7 @@
<html lang="pt-BR">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="color-scheme" content="light">
<title>Notas — editor estilo Mousepad</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><rect width='16' height='16' rx='2' fill='%233584e4'/><path d='M3 4h10M3 7h10M3 10h7' stroke='white' stroke-width='1.5' stroke-linecap='round'/></svg>">
@@ -78,6 +78,13 @@
</div>
</div>
<!-- Menu de contexto das categorias (Nova subpasta / Renomear / Excluir) -->
<ul id="ctx-menu" class="ctx-menu hidden">
<li data-ctx="sub">Nova subpasta…</li>
<li data-ctx="rename">Renomear…</li>
<li data-ctx="delete" class="ctx-danger">Excluir…</li>
</ul>
<div id="app">
<!-- Barra de menus (estilo Mousepad) -->
@@ -93,6 +100,7 @@
<li data-action="save">Salvar<span class="accel">Ctrl+S</span></li>
<li data-action="export">Baixar arquivo (.txt/.md/.html)…</li>
<li class="menu-sep"></li>
<li data-action="close-tab">Fechar aba<span class="accel">Alt+W</span></li>
<li data-action="delete-note">Excluir esta nota…</li>
</ul>
</div>
@@ -177,11 +185,11 @@
<input id="search-input" type="search" placeholder="Buscar notas…" autocomplete="off">
</div>
<div class="side-section">
<label class="side-label" for="category-select">Categoria</label>
<select id="category-select">
<option value="*">Todas as categorias</option>
<option value="">Sem categoria</option>
</select>
<div class="side-listhead">
<span class="side-label">Categorias</span>
<button type="button" class="btn btn-small" id="btn-new-cat" title="Nova categoria raiz">+ pasta</button>
</div>
<div id="cat-tree"></div>
</div>
<div class="side-section">
<span class="side-label">Tags</span>
@@ -197,9 +205,15 @@
</aside>
<section id="editor-pane">
<!-- Abas de arquivos abertos -->
<div id="tabbar" role="tablist">
<div id="tabs"></div>
<button type="button" class="btn btn-icon tab-new" id="btn-tab-new" title="Nova aba (Ctrl+N)">✚</button>
</div>
<div id="note-meta">
<input id="note-title" type="text" placeholder="Título da nota" maxlength="200" autocomplete="off">
<input id="note-category" type="text" placeholder="categoria (opcional)" maxlength="100" autocomplete="off" class="meta-cat">
<button type="button" class="btn" id="btn-cats" title="Atribuir categorias">🗀 Sem categoria</button>
<div id="cat-picker" class="cat-picker hidden"></div>
</div>
<div id="editor-host">
<div id="editor-wrap">
@@ -249,6 +263,8 @@
<div id="toast" class="hidden"></div>
</div>
<div id="backdrop" class="hidden"></div>
<script src="/js/app.js"></script>
</body>
</html>
+771 -321
View File
File diff suppressed because it is too large. Load diff