feat: menus mobile corrigidos, sincronia multi-dispositivo e editor WYSIWYG

- Menus: dropdowns agora com position:fixed posicionado via JS — no celular
  o overflow-x:auto da menubar cortava os itens (bug relatado)
- Sincronia celular/desktop: PUT aceita base_hash opcional; divergência →
  409 {server_hash} e diálogo "Conflito de edição" (recarregar | salvar por
  cima); abas limpas recarregam sozinhas no focus/visibilitychange (debounce)
- WYSIWYG (Ctrl+M): contenteditable com render md→html no servidor e
  serialização html→md própria (títulos, negrito/itálico/tachado, listas
  com task, citação, código, blocos, links, tabelas, entidades); HTML edita
  preservando tags/atributos, sanitizando script/on* na entrada; toolbar
  usa execCommand no modo rico; colar é sempre texto puro; txt não aplica
- store/api: ErrConflict + teste de conflito na store e na API
- README: WYSIWYG, sincronia, base_hash, nota do fix de menu
This commit is contained in:
lgcosta committed 2026-11-04 13:03:58 -03:00
1 parent 8b355e8edb
commit 3b91c1a509
8 files changed
+456 -57

No files matched your search

+42
View File
@@ -335,6 +335,48 @@ func TestCategoryCRUDViaAPI(t *testing.T) {
resp.Body.Close()
}
func TestSaveConflictViaAPI(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/notes", `{"title":"Sync","format":"txt","categories":[],"content":"v1","author":"","message":""}`)
var created noteView
decode(t, resp, &created)
// dispositivo B salva primeiro (sem base_hash = força)
resp = call("PUT", "/api/notes/"+created.ID,
`{"title":"Sync","format":"txt","categories":[],"content":"v2-B","author":"B","message":"B ganhou"}`)
if resp.StatusCode != http.StatusOK {
t.Fatalf("save B: %s", resp.Status)
}
var afterB noteView
decode(t, resp, &afterB)
// dispositivo A salva com base antiga → 409 + server_hash
resp = call("PUT", "/api/notes/"+created.ID,
`{"title":"Sync","format":"txt","categories":[],"content":"v2-A","author":"A","message":"A atrasado","base_hash":"`+created.Hash+`"}`)
if resp.StatusCode != http.StatusConflict {
t.Fatalf("esperava 409, obtive %s", resp.Status)
}
var conflict struct {
Error string `json:"error"`
ServerHash string `json:"server_hash"`
}
decode(t, resp, &conflict)
if conflict.ServerHash != afterB.Hash {
t.Errorf("409 deveria trazer o hash atual: %q != %q", conflict.ServerHash, afterB.Hash)
}
// A resolve: força sem base_hash
resp = call("PUT", "/api/notes/"+created.ID,
`{"title":"Sync","format":"txt","categories":[],"content":"v3-A forçado","author":"A","message":"force"}`)
if resp.StatusCode != http.StatusOK {
t.Fatalf("force: %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, "")
+16 -5
View File
@@ -1,6 +1,7 @@
package api
import (
"errors"
"io/fs"
"net/http"
"strings"
@@ -28,14 +29,15 @@ type noteInput struct {
Content string `json:"content"`
Author string `json:"author"`
Message string `json:"message"`
BaseHash string `json:"base_hash"` // hash em que a edição se baseia (opcional; evita sobrescrever mudanças remotas)
}
func (in noteInput) fields() (string, string, []string, string, string, string) {
func (in noteInput) fields() (string, string, []string, string, string, string, string) {
f := in.Format
if f == "" {
f = store.FormatTxt
}
return in.Title, f, in.Categories, in.Content, in.Author, in.Message
return in.Title, f, in.Categories, in.Content, in.Author, in.Message, in.BaseHash
}
// noteView é a representação completa (meta + conteúdo) de uma nota.
@@ -69,7 +71,7 @@ func (s *Server) handleCreateNote(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "corpo JSON inválido")
return
}
title, format, categories, content, author, message := in.fields()
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)
@@ -93,8 +95,17 @@ func (s *Server) handleUpdateNote(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "corpo JSON inválido")
return
}
title, format, categories, content, author, message := in.fields()
n, err := s.st.Update(r.PathValue("id"), title, format, categories, content, author, message)
title, format, categories, content, author, message, baseHash := in.fields()
n, err := s.st.Update(r.PathValue("id"), title, format, categories, content, author, message, baseHash)
if errors.Is(err, store.ErrConflict) {
// 409 com o estado atual: o cliente decide entre recarregar ou forçar.
writeJSON(w, http.StatusConflict, map[string]any{
"error": "a nota foi alterada em outro dispositivo",
"server_hash": n.Hash,
"updated_at": n.UpdatedAt,
})
return
}
if err != nil {
mapStoreError(w, err)
return
+7 -1
View File
@@ -49,6 +49,7 @@ var (
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")
ErrConflict = errors.New("a nota foi alterada desde a última sincronização")
)
// Note é o estado atual de uma nota (sem o conteúdo).
@@ -617,7 +618,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/categorias/formato.
func (s *Store) Update(id, title, format string, categories []string, content, author, message string) (*Note, error) {
// baseHash != "" ativa controle otimista de concorrência: se o servidor já
// mudou desde essa base, devolve ErrConflict (e a nota atual) em vez de sobrescrever.
func (s *Store) Update(id, title, format string, categories []string, content, author, message, baseHash string) (*Note, error) {
if err := cleanNoteFields(&title, &format); err != nil {
return nil, err
}
@@ -635,6 +638,9 @@ func (s *Store) Update(id, title, format string, categories []string, content, a
if err != nil {
return nil, err
}
if baseHash != "" && baseHash != n.Hash {
return n, ErrConflict
}
newHash := hashContent(content)
contentChanged := newHash != n.Hash
if contentChanged {
+32 -6
View File
@@ -70,7 +70,7 @@ func TestUpdateCreatesRevisionOnlyOnContentChange(t *testing.T) {
// 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 {
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 {
@@ -82,7 +82,7 @@ func TestUpdateCreatesRevisionOnlyOnContentChange(t *testing.T) {
}
// Conteúdo novo: segunda revisão com parent correto.
n2, err := s.Update(n.ID, "renomeada", FormatTxt, []string{dev}, "v2", "lg", "ajuste final")
n2, err := s.Update(n.ID, "renomeada", FormatTxt, []string{dev}, "v2", "lg", "ajuste final", "")
if err != nil {
t.Fatal(err)
}
@@ -105,7 +105,7 @@ func TestUpdateCreatesRevisionOnlyOnContentChange(t *testing.T) {
func TestRestoreAppendsRevisionWithOldContent(t *testing.T) {
s := newTestStore(t)
n, _ := s.Create("r", FormatMD, nil, "primeiro", "", "")
if _, err := s.Update(n.ID, "r", FormatMD, nil, "segundo", "", ""); err != nil {
if _, err := s.Update(n.ID, "r", FormatMD, nil, "segundo", "", "", ""); err != nil {
t.Fatal(err)
}
first, err := s.History(n.ID)
@@ -216,7 +216,7 @@ func TestCategoryTreeOperations(t *testing.T) {
}
// categoria inexistente em nota → ErrInvalid
if _, err := s.Update(n1.ID, "deploy", FormatMD, []string{"naoexiste1"}, "x", "", ""); !errors.Is(err, 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)
@@ -268,7 +268,7 @@ func TestListFiltersAndSorts(t *testing.T) {
s := newTestStore(t)
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", "", "")
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>", "", "")
@@ -347,7 +347,7 @@ func TestInvalidFormatAndOversizeContent(t *testing.T) {
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, nil, "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, nil, strings.Repeat("a", MaxContentBytes+1), "", ""); !errors.Is(err, ErrTooLarge) {
@@ -355,6 +355,32 @@ func TestInvalidFormatAndOversizeContent(t *testing.T) {
}
}
func TestUpdateBaseHashConflict(t *testing.T) {
s := newTestStore(t)
n, _ := s.Create("sync", FormatTxt, nil, "v1", "", "")
// outro dispositivo salva primeiro
other, err := s.Update(n.ID, "sync", FormatTxt, nil, "v2-outro", "", "", "")
if err != nil {
t.Fatal(err)
}
// meu save com base antiga → ErrConflict e nota atual devolvida
cur, err := s.Update(n.ID, "sync", FormatTxt, nil, "v2-meu", "", "", n.Hash)
if !errors.Is(err, ErrConflict) {
t.Fatalf("esperava ErrConflict, obtive %v", err)
}
if cur.Hash != other.Hash {
t.Errorf("ErrConflict deveria devolver a nota atual do servidor: %+v", cur)
}
content, _, _ := s.Content(n.ID)
if content != "v2-outro" {
t.Errorf("conteúdo não deveria ter mudado: %q", content)
}
// base vazio força (compat com clientes antigos)
if _, err := s.Update(n.ID, "sync", FormatTxt, nil, "v3-forçado", "", "", ""); err != nil {
t.Errorf("force save: %v", err)
}
}
func TestFilenameAndRevisionContent(t *testing.T) {
s := newTestStore(t)
n, _ := s.Create("minha/nota: ruim", FormatMD, nil, "oi #t", "", "")