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:
1 parent
8b355e8edb
commit
3b91c1a509
8 files changed
+456
-57
No files matched your search
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user