feat: presença por sessão, eventos SSE em tempo real e autosave

- presence.go: hub SSE + registro de presença (TTL 30s por heartbeat)
- GET /api/events (EventSource, auth cookie/Bearer) transmite saved/presence;
  POST /api/presence publica {session, notes[]}; create/update/restore/delete
  publicam savedEvent com a session de origem (eco ignorado pelo autor)
- PUT/POST/restore aceitam campo opcional session
- UI: id de sessão em sessionStorage; selo 👥 na aba, na lista de notas e no
  topo do editor quando outra sessão tem a mesma nota aberta; aba LIMPA
  recarrega sozinha ao receber saved; aba SUJA abre o diálogo de conflito
  automaticamente (conflictPending evita diálogos empilhados)
- Autosave: debounce de 1,5s em qualquer alteração (rascunho vira nota),
  mensagem de revisão 'autosave', status 'salvando…', toggle nas Preferências
- Testes: SSE hello/presence/saved + presença 400/401 via httptest
This commit is contained in:
lgcosta committed 2026-11-04 13:35:33 -03:00
1 parent 3b91c1a509
commit a55591d208
9 files changed
+543 -15

No files matched your search

+25
View File
@@ -82,6 +82,20 @@ ocupa a tela inteira; menus e toolbar rolam horizontalmente com botões só de
(autor, data, mensagem, hash SHA-256 e parent). O painel de histórico mostra o **diff unificado**
de cada revisão e permite **restaurar** qualquer versão anterior (a restauração vira uma nova
revisão — nada se perde). Conteúdo idêntico é deduplicado (content-addressed).
- **Colaboração em tempo real entre dispositivos/sessões** (SSE, sem recarregar página):
- **Presença**: cada janela/aba do navegador é uma *sessão* (id aleatório em `sessionStorage`)
que envia heartbeat das notas abertas (`POST /api/presence`). Quando outra sessão está com a
mesma nota aberta, aparece **👥** na aba, na lista de notas e um selo
*"outra sessão aberta aqui"* no topo do editor.
- **Propagação de salvamento**: criar/salvar/restaurar/excluir publica um evento
`{type:"saved", note_id, hash, session}` no stream SSE (`GET /api/events`). Instância com a
nota **limpa** recarrega o conteúdo sozinha (toast discreto); com a nota **suja**, abre
automaticamente o diálogo *Conflito de edição* (recarregar | salvar por cima). A própria
sessão que salvou ignora o evento; reconexão do `EventSource` é automática e o fallback
continua sendo a checagem no foco da janela.
- **Autosave**: ligado por padrão — após ~1,5 s sem digitar, a alteração é salva como revisão
`autosave` (rascunho novo vira nota criada automaticamente; status *"salvando…"* na barra).
Desligável em Preferências. Conflitos durante autosave pausam o ciclo até a resolução.
- **Sincronia entre dispositivos** (celular + desktop no mesmo servidor):
- Cada aba lembra o hash em que se baseia (`base_hash`). Ao salvar, se o servidor mudou desde
então, a API responde **409** e a UI abre o diálogo **Conflito de edição** com as duas saídas:
@@ -189,6 +203,14 @@ curl -X POST -H "Authorization: Bearer $T" -H 'Content-Type: application/json' \
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
# tempo real (SSE): stream de eventos saved/presence (auth por cookie no navegador;
# para scripts, leia o stream com o header Bearer)
curl -N -H "Authorization: Bearer $T" "$B/api/events?session=meu-id"
# heartbeat de presença: informa quais notas a sessão tem abertas
curl -X POST -H "Authorization: Bearer $T" -H 'Content-Type: application/json' \
-d '{"session":"meu-id","notes":["<id>"]}' $B/api/presence
# PUT/POST/restore aceitam campo opcional "session" — o eco vai no evento saved
# (clientes que ignoram o evento podem sobrescrever a própria edição sem querer)
```
## Como funciona o versionamento
@@ -217,6 +239,9 @@ curl -X DELETE -H "Authorization: Bearer $T" $B/api/categories/<id> #
`exec` de depuração, por design.
- Preferências do editor e abas abertas ficam em `localStorage` (dados não sensíveis, por
dispositivo/navegador); o token nunca vai para lá.
- Presença/SSE: o stream exige a mesma autenticação das rotas `/api/*` (cookie HttpOnly no
navegador, Bearer em scripts). O registro de presença guarda apenas id de sessão aleatório +
ids de notas abertas — nada de conteúdo trafega no canal de presença.
## Testes