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
@@ -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
@@ -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, ¬e)
|
||||
|
||||
// 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, ¬e)
|
||||
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, "")
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user