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:
1 parent
3b91c1a509
commit
a55591d208
9 files changed
+543
-15
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
@@ -10,6 +11,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
|
||||
"github.com/lgcosta/notas-online/internal/api"
|
||||
"github.com/lgcosta/notas-online/internal/store"
|
||||
@@ -377,6 +379,81 @@ func TestSaveConflictViaAPI(t *testing.T) {
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
func TestPresenceAndSSEEvents(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
call := func(method, path, body string) *http.Response {
|
||||
return do(t, srv, method, path, body, testToken, "")
|
||||
}
|
||||
|
||||
// abre o stream SSE
|
||||
req, err := http.NewRequest("GET", srv.URL+"/api/events?session=s1", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+testToken)
|
||||
streamResp, err := srv.Client().Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer streamResp.Body.Close()
|
||||
if ct := streamResp.Header.Get("Content-Type"); ct != "text/event-stream" {
|
||||
t.Fatalf("content-type SSE: %q", ct)
|
||||
}
|
||||
sc := bufio.NewScanner(streamResp.Body)
|
||||
|
||||
readEvent := func(contains string) string {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for sc.Scan() && time.Now().Before(deadline) {
|
||||
line := sc.Text()
|
||||
if strings.HasPrefix(line, "data: ") && strings.Contains(line, contains) {
|
||||
return line
|
||||
}
|
||||
}
|
||||
if err := sc.Err(); err != nil {
|
||||
t.Fatalf("scanner SSE: %v", err)
|
||||
}
|
||||
t.Fatalf("evento contendo %q não chegou", contains)
|
||||
return ""
|
||||
}
|
||||
|
||||
// hello inicial com snapshot de presença
|
||||
readEvent(`"type":"hello"`)
|
||||
|
||||
// heartbeat de presença → broadcast presence com s2
|
||||
resp := call("POST", "/api/presence", `{"session":"s2","notes":["nota1"]}`)
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("presence: %s", resp.Status)
|
||||
}
|
||||
readEvent(`"type":"presence"`)
|
||||
|
||||
// presença sem session → 400
|
||||
resp = call("POST", "/api/presence", `{"notes":[]}`)
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("presence sem session: %s", resp.Status)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
// criar nota com session → evento saved para os ouvintes
|
||||
resp = call("POST", "/api/notes", `{"title":"E","format":"txt","categories":[],"content":"x","author":"","message":"","session":"s3"}`)
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("create: %s", resp.Status)
|
||||
}
|
||||
var created noteView
|
||||
decode(t, resp, &created)
|
||||
ev := readEvent(`"type":"saved"`)
|
||||
if !strings.Contains(ev, `"session":"s3"`) || !strings.Contains(ev, created.ID) {
|
||||
t.Errorf("evento saved incompleto: %s", ev)
|
||||
}
|
||||
|
||||
// SSE sem token → 401
|
||||
resp = do(t, srv, "GET", "/api/events", "", "", "")
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("SSE sem auth: %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, "")
|
||||
|
||||
+11
-1
@@ -30,6 +30,7 @@ type noteInput struct {
|
||||
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)
|
||||
Session string `json:"session"` // id da sessão do cliente (presença/eventos; opcional)
|
||||
}
|
||||
|
||||
func (in noteInput) fields() (string, string, []string, string, string, string, string) {
|
||||
@@ -77,6 +78,7 @@ func (s *Server) handleCreateNote(w http.ResponseWriter, r *http.Request) {
|
||||
mapStoreError(w, err)
|
||||
return
|
||||
}
|
||||
s.publishSaved(n, author, in.Session, false)
|
||||
writeJSON(w, http.StatusCreated, noteView{n, content})
|
||||
}
|
||||
|
||||
@@ -110,14 +112,22 @@ func (s *Server) handleUpdateNote(w http.ResponseWriter, r *http.Request) {
|
||||
mapStoreError(w, err)
|
||||
return
|
||||
}
|
||||
s.publishSaved(n, author, in.Session, false)
|
||||
writeJSON(w, http.StatusOK, noteView{n, content})
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteNote(w http.ResponseWriter, r *http.Request) {
|
||||
if err := s.st.Delete(r.PathValue("id")); err != nil {
|
||||
id := r.PathValue("id")
|
||||
n, err := s.st.Get(id)
|
||||
if err != nil {
|
||||
mapStoreError(w, err)
|
||||
return
|
||||
}
|
||||
if err := s.st.Delete(id); err != nil {
|
||||
mapStoreError(w, err)
|
||||
return
|
||||
}
|
||||
s.publishSaved(n, "", "", true)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/lgcosta/notas-online/internal/store"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hub SSE: transmite eventos (saved/presence) para todas as conexões /api/events
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type hub struct {
|
||||
mu sync.Mutex
|
||||
clients map[chan string]struct{}
|
||||
}
|
||||
|
||||
func newHub() *hub { return &hub{clients: map[chan string]struct{}{}} }
|
||||
|
||||
func (h *hub) register() chan string {
|
||||
c := make(chan string, 32)
|
||||
h.mu.Lock()
|
||||
h.clients[c] = struct{}{}
|
||||
h.mu.Unlock()
|
||||
return c
|
||||
}
|
||||
|
||||
func (h *hub) unregister(c chan string) {
|
||||
h.mu.Lock()
|
||||
delete(h.clients, c)
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
// broadcast entrega sem bloquear; cliente lento perde eventos (o estado
|
||||
// completo volta no próximo heartbeat/presence snapshot).
|
||||
func (h *hub) broadcast(data string) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
for c := range h.clients {
|
||||
select {
|
||||
case c <- data:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// publish serializa v e envia ao hub.
|
||||
func (s *Server) publish(v any) {
|
||||
data, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
s.hub.broadcast(string(data))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Registro de presença: sessão → notas abertas (TTL por heartbeat)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const presenceTTL = 30 * time.Second
|
||||
|
||||
type presenceEntry struct {
|
||||
notes []string
|
||||
when time.Time
|
||||
}
|
||||
|
||||
type presenceReg struct {
|
||||
mu sync.Mutex
|
||||
m map[string]presenceEntry
|
||||
}
|
||||
|
||||
func newPresenceReg() *presenceReg {
|
||||
return &presenceReg{m: map[string]presenceEntry{}}
|
||||
}
|
||||
|
||||
// set grava o estado da sessão; devolve true se o conjunto de notas mudou.
|
||||
func (p *presenceReg) set(session string, notes []string) bool {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
old, ok := p.m[session]
|
||||
p.m[session] = presenceEntry{notes: notes, when: time.Now()}
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
if len(old.notes) != len(notes) {
|
||||
return true
|
||||
}
|
||||
for i := range notes {
|
||||
if old.notes[i] != notes[i] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (p *presenceReg) remove(session string) bool {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
_, ok := p.m[session]
|
||||
delete(p.m, session)
|
||||
return ok
|
||||
}
|
||||
|
||||
// snapshot devolve as sessões vivas (podando as expiradas).
|
||||
func (p *presenceReg) snapshot() []presenceSession {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
now := time.Now()
|
||||
out := make([]presenceSession, 0, len(p.m))
|
||||
for sid, e := range p.m {
|
||||
if now.Sub(e.when) > presenceTTL {
|
||||
delete(p.m, sid)
|
||||
continue
|
||||
}
|
||||
notes := e.notes
|
||||
if notes == nil {
|
||||
notes = []string{}
|
||||
}
|
||||
out = append(out, presenceSession{Session: sid, Notes: notes})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Eventos (payloads)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type savedEvent struct {
|
||||
Type string `json:"type"` // "saved"
|
||||
NoteID string `json:"note_id"`
|
||||
Hash string `json:"hash"`
|
||||
Title string `json:"title"`
|
||||
Author string `json:"author"`
|
||||
Session string `json:"session"` // quem salvou (o próprio cliente ignora)
|
||||
Deleted bool `json:"deleted,omitempty"`
|
||||
}
|
||||
|
||||
type presenceSession struct {
|
||||
Session string `json:"session"`
|
||||
Notes []string `json:"notes"`
|
||||
}
|
||||
|
||||
type presenceEvent struct {
|
||||
Type string `json:"type"` // "presence" | "hello"
|
||||
Sessions []presenceSession `json:"sessions"`
|
||||
}
|
||||
|
||||
// publishPresence anuncia o snapshot atual a todos os clientes SSE.
|
||||
func (s *Server) publishPresence() {
|
||||
s.publish(presenceEvent{Type: "presence", Sessions: s.pres.snapshot()})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// handleEvents é o stream SSE (EventSource). Auth via cookie (o EventSource
|
||||
// do navegador envia cookies same-origin automaticamente).
|
||||
func (s *Server) handleEvents(w http.ResponseWriter, r *http.Request) {
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
writeError(w, http.StatusInternalServerError, "streaming não suportado")
|
||||
return
|
||||
}
|
||||
session := truncate(r.URL.Query().Get("session"), 64)
|
||||
|
||||
ch := s.hub.register()
|
||||
defer func() {
|
||||
s.hub.unregister(ch)
|
||||
if session != "" && s.pres.remove(session) {
|
||||
s.publishPresence() // sumiu o heartbeat/sessão → atualiza todos
|
||||
}
|
||||
}()
|
||||
|
||||
h := w.Header()
|
||||
h.Set("Content-Type", "text/event-stream")
|
||||
h.Set("Cache-Control", "no-store")
|
||||
h.Set("X-Accel-Buffering", "no")
|
||||
|
||||
hello, _ := json.Marshal(presenceEvent{Type: "hello", Sessions: s.pres.snapshot()})
|
||||
fmt.Fprintf(w, "data: %s\n\n", hello)
|
||||
flusher.Flush()
|
||||
|
||||
keepalive := time.NewTicker(25 * time.Second)
|
||||
defer keepalive.Stop()
|
||||
for {
|
||||
select {
|
||||
case msg := <-ch:
|
||||
fmt.Fprintf(w, "data: %s\n\n", msg)
|
||||
flusher.Flush()
|
||||
case <-keepalive.C:
|
||||
_, _ = io.WriteString(w, ": ping\n\n")
|
||||
flusher.Flush()
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type presenceInput struct {
|
||||
Session string `json:"session"`
|
||||
Notes []string `json:"notes"`
|
||||
}
|
||||
|
||||
// handlePresence recebe o heartbeat {session, notes:[ids abertos]}.
|
||||
func (s *Server) handlePresence(w http.ResponseWriter, r *http.Request) {
|
||||
var in presenceInput
|
||||
if err := decodeJSON(r, &in, 1<<16); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "corpo JSON inválido")
|
||||
return
|
||||
}
|
||||
in.Session = truncate(in.Session, 64)
|
||||
if in.Session == "" {
|
||||
writeError(w, http.StatusBadRequest, "session é obrigatória")
|
||||
return
|
||||
}
|
||||
notes := in.Notes
|
||||
if len(notes) > 64 {
|
||||
notes = notes[:64]
|
||||
}
|
||||
for _, n := range notes {
|
||||
if len(n) > 32 {
|
||||
writeError(w, http.StatusBadRequest, "id de nota inválido")
|
||||
return
|
||||
}
|
||||
}
|
||||
if changed := s.pres.set(in.Session, notes); changed {
|
||||
s.publishPresence()
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// publishSaved anuncia uma alteração de nota às demais sessões.
|
||||
func (s *Server) publishSaved(n *store.Note, author, session string, deleted bool) {
|
||||
s.publish(savedEvent{
|
||||
Type: "saved", NoteID: n.ID, Hash: n.Hash, Title: n.Title,
|
||||
Author: author, Session: session, Deleted: deleted,
|
||||
})
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
if len(s) > n {
|
||||
return s[:n]
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -75,7 +75,8 @@ func (s *Server) handleRevisionDiff(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
type restoreInput struct {
|
||||
Author string `json:"author"`
|
||||
Author string `json:"author"`
|
||||
Session string `json:"session"`
|
||||
}
|
||||
|
||||
func (s *Server) handleRestore(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -101,6 +102,7 @@ func (s *Server) handleRestore(w http.ResponseWriter, r *http.Request) {
|
||||
mapStoreError(w, err)
|
||||
return
|
||||
}
|
||||
s.publishSaved(n, in.Author, in.Session, false)
|
||||
writeJSON(w, http.StatusOK, noteView{n, content})
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,8 @@ type Server struct {
|
||||
token string
|
||||
webFS fs.FS
|
||||
md goldmark.Markdown
|
||||
hub *hub
|
||||
pres *presenceReg
|
||||
}
|
||||
|
||||
// New cria o servidor da API. webFS é o filesystem embutido do frontend
|
||||
@@ -42,6 +44,8 @@ func New(st *store.Store, token string, webFS fs.FS) *Server {
|
||||
goldmark.WithExtensions(extension.GFM),
|
||||
goldmark.WithRendererOptions(htmlr.WithHardWraps()),
|
||||
),
|
||||
hub: newHub(),
|
||||
pres: newPresenceReg(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,6 +74,8 @@ func (s *Server) Handler() http.Handler {
|
||||
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 /api/events", s.auth(s.handleEvents))
|
||||
mux.HandleFunc("POST /api/presence", s.auth(s.handlePresence))
|
||||
|
||||
mux.HandleFunc("GET /{$}", s.serveIndex)
|
||||
mux.Handle("GET /", http.FileServerFS(s.webFS))
|
||||
|
||||
@@ -919,3 +919,17 @@ html[data-theme="solar-escuro"] #revisions-list li:hover { background: #0c4450;
|
||||
}
|
||||
#wysiwyg:empty::before { content: 'Comece a escrever…'; color: var(--muted); }
|
||||
#wysiwyg [contenteditable="true"]:focus { outline: none; }
|
||||
|
||||
/* ------------------------------------------------- presença e autosave */
|
||||
.presence-badge {
|
||||
align-self: center;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--warn);
|
||||
white-space: nowrap;
|
||||
flex: none;
|
||||
}
|
||||
.tab .tab-pres { color: var(--warn); font-size: 10px; flex: none; }
|
||||
#st-saved.pending { color: var(--accent); font-weight: 600; }
|
||||
@@ -34,6 +34,7 @@
|
||||
<label class="row"><input type="checkbox" id="pf-colmark"> Marcar coluna
|
||||
<input type="number" id="pf-col" min="20" max="320" step="1" class="num"> (padrão 80)</label>
|
||||
<label class="row"><input type="checkbox" id="pf-hl"> Colorir sintaxe (Markdown e HTML)</label>
|
||||
<label class="row"><input type="checkbox" id="pf-autosave"> Autosave — salva automaticamente ~1,5 s após parar de digitar</label>
|
||||
<label class="row">Tecla Tab
|
||||
<select id="pf-tabmode" class="compact">
|
||||
<option value="spaces">insere espaços</option>
|
||||
@@ -222,6 +223,7 @@
|
||||
</div>
|
||||
<div id="note-meta">
|
||||
<input id="note-title" type="text" placeholder="Título da nota" maxlength="200" autocomplete="off">
|
||||
<span id="presence-badge" class="presence-badge hidden" title="Outras sessões com esta nota aberta"></span>
|
||||
<button type="button" class="btn" id="btn-cats" title="Atribuir categorias">🗀 Sem categoria</button>
|
||||
<div id="cat-picker" class="cat-picker hidden"></div>
|
||||
</div>
|
||||
|
||||
+154
-13
@@ -17,6 +17,7 @@
|
||||
notesList: $('#notes-list'), notesCount: $('#notes-count'),
|
||||
tabs: $('#tabs'), btnTabNew: $('#btn-tab-new'),
|
||||
noteTitle: $('#note-title'), btnCats: $('#btn-cats'), catPicker: $('#cat-picker'),
|
||||
presenceBadge: $('#presence-badge'),
|
||||
editor: $('#editor'), editorWrap: $('#editor-wrap'), gutter: $('#gutter'),
|
||||
gutterInner: $('#gutter-inner'), hl: $('#hl'), colMarker: $('#col-marker'),
|
||||
preview: $('#preview'), previewFrame: $('#preview-frame'), wysiwyg: $('#wysiwyg'),
|
||||
@@ -33,6 +34,16 @@
|
||||
const EXPAND_KEY = 'notas_expanded';
|
||||
const DRAFT_MAX = 200 * 1024;
|
||||
|
||||
// id de sessão por aba do navegador (sessionStorage): presença + eventos
|
||||
const SID = (() => {
|
||||
let v = sessionStorage.getItem('notas_sid');
|
||||
if (!v) {
|
||||
v = (crypto.randomUUID ? crypto.randomUUID() : 's' + Date.now() + '-' + Math.random().toString(36).slice(2));
|
||||
sessionStorage.setItem('notas_sid', v);
|
||||
}
|
||||
return v;
|
||||
})();
|
||||
|
||||
const state = {
|
||||
tabs: [], // abas abertas (nota ou rascunho)
|
||||
activeKey: null,
|
||||
@@ -42,6 +53,8 @@
|
||||
expanded: new Set(JSON.parse(localStorage.getItem(EXPAND_KEY) || '[]')),
|
||||
selectedRev: null,
|
||||
ctxCat: null,
|
||||
presence: {}, // noteID → [session,...] (exclui a própria sessão)
|
||||
booted: false,
|
||||
};
|
||||
let tabSeq = 0;
|
||||
|
||||
@@ -183,10 +196,91 @@
|
||||
}
|
||||
});
|
||||
|
||||
/* --------------------------------------------- presença + SSE (multi-sessão) */
|
||||
|
||||
const openNoteIds = () => state.tabs.filter((t) => t.id).map((t) => t.id);
|
||||
|
||||
function sendPresence() {
|
||||
fetch('/api/presence', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ session: SID, notes: openNoteIds() }),
|
||||
}).catch(() => { /* offline: heartbeat tenta de novo */ });
|
||||
}
|
||||
|
||||
let es = null;
|
||||
function connectEvents() {
|
||||
if (es) es.close();
|
||||
es = new EventSource('/api/events?session=' + encodeURIComponent(SID));
|
||||
es.onmessage = (e) => {
|
||||
let ev;
|
||||
try { ev = JSON.parse(e.data); } catch { return; }
|
||||
handleServerEvent(ev);
|
||||
};
|
||||
// onerror: EventSource reconecta sozinho; o checkFreshness no focus é a rede de segurança
|
||||
}
|
||||
|
||||
function handleServerEvent(ev) {
|
||||
if (ev.type === 'hello' || ev.type === 'presence') { applyPresence(ev.sessions || []); return; }
|
||||
if (ev.type !== 'saved') return;
|
||||
const t = state.tabs.find((x) => x.id === ev.note_id);
|
||||
if (!t || ev.session === SID) return; // nota não aberta aqui, ou fui eu que salvei
|
||||
if (ev.deleted) {
|
||||
if (!t.dirty) {
|
||||
closeTab(t.key, true);
|
||||
toast('“' + (ev.title || 'Sem título') + '” foi excluída por outra sessão.');
|
||||
} else {
|
||||
toast('Cuidado: outra sessão excluiu esta nota; o texto local não salvo não poderá ser gravado.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (t.hash === ev.hash) return;
|
||||
t.remoteHash = ev.hash;
|
||||
if (!t.dirty) {
|
||||
// aba limpa: acompanha o servidor em tempo real
|
||||
api('GET', '/api/notes/' + t.id)
|
||||
.then((d) => {
|
||||
applyNoteToTab(t, d);
|
||||
if (t.key === state.activeKey) {
|
||||
toast('“' + (d.title || 'Sem título') + '” atualizada por outra sessão — recarregada');
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
} else if (t.key === state.activeKey) {
|
||||
resolveConflict(t, null);
|
||||
} else {
|
||||
renderTabs();
|
||||
}
|
||||
}
|
||||
|
||||
function applyPresence(sessions) {
|
||||
const map = {};
|
||||
for (const s of sessions) {
|
||||
if (s.session === SID) continue;
|
||||
for (const n of s.notes || []) (map[n] = map[n] || []).push(s.session);
|
||||
}
|
||||
state.presence = map;
|
||||
renderTabs();
|
||||
renderNotesList();
|
||||
updatePresenceBadge();
|
||||
}
|
||||
|
||||
function updatePresenceBadge() {
|
||||
const t = activeTab();
|
||||
const others = t && t.id ? (state.presence[t.id] || []) : [];
|
||||
E.presenceBadge.classList.toggle('hidden', !others.length);
|
||||
if (others.length) {
|
||||
E.presenceBadge.textContent = others.length === 1
|
||||
? '👥 outra sessão aberta aqui'
|
||||
: `👥 ${others.length} outras sessões abertas aqui`;
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------- abas */
|
||||
|
||||
function addTab(tab, activate = true) {
|
||||
state.tabs.push(tab);
|
||||
sendPresence();
|
||||
if (activate) activateTab(tab.key);
|
||||
else renderTabs();
|
||||
}
|
||||
@@ -223,11 +317,12 @@
|
||||
closeDrawer();
|
||||
}
|
||||
|
||||
async function closeTab(key) {
|
||||
async function closeTab(key, force) {
|
||||
const idx = state.tabs.findIndex((t) => t.key === key);
|
||||
if (idx < 0) return;
|
||||
if (!(await confirmDiscardTab(state.tabs[idx]))) return;
|
||||
if (!force && !(await confirmDiscardTab(state.tabs[idx]))) return;
|
||||
state.tabs.splice(idx, 1);
|
||||
sendPresence();
|
||||
if (!state.tabs.length) { addTab(newTab()); return; }
|
||||
if (state.activeKey === key) {
|
||||
const next = state.tabs[Math.min(idx, state.tabs.length - 1)];
|
||||
@@ -256,10 +351,17 @@
|
||||
const label = document.createElement('span');
|
||||
label.className = 'tab-label';
|
||||
label.textContent = (t.title || 'Sem título') + EXT[t.format];
|
||||
el.appendChild(label);
|
||||
if (t.id && state.presence[t.id] && state.presence[t.id].length) {
|
||||
const pres = document.createElement('span');
|
||||
pres.className = 'tab-pres';
|
||||
pres.textContent = '👥';
|
||||
pres.title = 'outra(s) sessão(ões) com esta nota aberta';
|
||||
el.appendChild(pres);
|
||||
}
|
||||
const close = document.createElement('span');
|
||||
close.className = 'tab-close';
|
||||
close.textContent = '✕';
|
||||
el.appendChild(label);
|
||||
el.appendChild(close);
|
||||
el.addEventListener('click', (e) => {
|
||||
if (e.target === close) { e.stopPropagation(); closeTab(t.key); }
|
||||
@@ -381,6 +483,7 @@
|
||||
const bits = [relTime(n.updated_at)];
|
||||
if (n.categories && n.categories.length) bits.push(n.categories.map(catName).join(' · '));
|
||||
if (n.tags && n.tags.length) bits.push(n.tags.map((t) => '#' + t).join(' '));
|
||||
if (state.presence[n.id] && state.presence[n.id].length) bits.push('👥 aberta por outra sessão');
|
||||
sub.textContent = bits.join(' · ');
|
||||
li.appendChild(title);
|
||||
li.appendChild(sub);
|
||||
@@ -648,6 +751,7 @@
|
||||
updateCatButton();
|
||||
updateToolbar();
|
||||
updateStatus();
|
||||
updatePresenceBadge();
|
||||
renderEditorVisual();
|
||||
if (modeState.mode === 'wysiwyg') enterWysiwyg();
|
||||
else refreshPreviewIfOpen();
|
||||
@@ -665,6 +769,17 @@
|
||||
if (t) t.dirty = true;
|
||||
updateStatus();
|
||||
persistTabs();
|
||||
scheduleAutosave();
|
||||
}
|
||||
|
||||
let autosaveTimer = 0;
|
||||
function scheduleAutosave() {
|
||||
if (!prefs.autosave) return;
|
||||
clearTimeout(autosaveTimer);
|
||||
autosaveTimer = setTimeout(() => {
|
||||
const t = activeTab();
|
||||
if (t && t.dirty && !t.conflictPending) save(true);
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
E.editor.addEventListener('input', () => {
|
||||
@@ -832,7 +947,7 @@
|
||||
/* ------------------------------------------------------- preferências */
|
||||
|
||||
const PREFS_KEY = 'notas_prefs';
|
||||
const PREFS_DEFAULT = { gutter: true, wrap: true, colmark: true, col: 80, hl: true, tabSpaces: true, tabSize: 4, font: 'mono', size: 14, theme: 'claro' };
|
||||
const PREFS_DEFAULT = { gutter: true, wrap: true, colmark: true, col: 80, hl: true, autosave: true, tabSpaces: true, tabSize: 4, font: 'mono', size: 14, theme: 'claro' };
|
||||
const THEMES = ['claro', 'escuro', 'solar-claro', 'solar-escuro'];
|
||||
const FONT_STACKS = {
|
||||
mono: '"DejaVu Sans Mono", "Ubuntu Mono", "Cascadia Code", Consolas, monospace',
|
||||
@@ -1081,6 +1196,7 @@
|
||||
const PF = {
|
||||
overlay: $('#prefs-overlay'), form: $('#prefs-form'), gutter: $('#pf-gutter'),
|
||||
wrap: $('#pf-wrap'), colmark: $('#pf-colmark'), col: $('#pf-col'), hl: $('#pf-hl'),
|
||||
autosave: $('#pf-autosave'),
|
||||
tabmode: $('#pf-tabmode'), tabsize: $('#pf-tabsize'),
|
||||
font: $('#pf-font'), size: $('#pf-size'), theme: $('#pf-theme'),
|
||||
reset: $('#pf-reset'), cancel: $('#pf-cancel'),
|
||||
@@ -1093,6 +1209,7 @@
|
||||
PF.colmark.checked = prefs.colmark;
|
||||
PF.col.value = prefs.col;
|
||||
PF.hl.checked = prefs.hl;
|
||||
PF.autosave.checked = prefs.autosave;
|
||||
PF.tabmode.value = prefs.tabSpaces ? 'spaces' : 'tab';
|
||||
PF.tabsize.value = prefs.tabSize;
|
||||
PF.font.value = prefs.font;
|
||||
@@ -1106,6 +1223,7 @@
|
||||
prefs.colmark = PF.colmark.checked;
|
||||
prefs.col = Math.min(320, Math.max(20, parseInt(PF.col.value, 10) || 80));
|
||||
prefs.hl = PF.hl.checked;
|
||||
prefs.autosave = PF.autosave.checked;
|
||||
prefs.tabSpaces = PF.tabmode.value !== 'tab';
|
||||
prefs.tabSize = Math.min(16, Math.max(1, parseInt(PF.tabsize.value, 10) || 4));
|
||||
prefs.font = FONT_STACKS[PF.font.value] ? PF.font.value : 'mono';
|
||||
@@ -1475,25 +1593,26 @@
|
||||
|
||||
/* ------------------------------------------------------------ salvar */
|
||||
|
||||
async function save() {
|
||||
async function save(auto) {
|
||||
const t = activeTab();
|
||||
if (!t) return;
|
||||
if (!t || t.conflictPending) return;
|
||||
clearTimeout(autosaveTimer);
|
||||
const body = {
|
||||
title: currentTitle(),
|
||||
format: currentFormat(),
|
||||
categories: t.categories,
|
||||
content: t.content,
|
||||
author: E.authorInput.value.trim(),
|
||||
message: E.revMessage.value.trim(),
|
||||
message: E.revMessage.value.trim() || (auto ? 'autosave' : ''),
|
||||
base_hash: t.hash || '',
|
||||
session: SID,
|
||||
};
|
||||
E.stSaved.textContent = 'salvando…';
|
||||
E.stSaved.className = 'st-item pending';
|
||||
let data;
|
||||
try {
|
||||
if (t.id) {
|
||||
body.base_hash = t.hash;
|
||||
data = await api('PUT', '/api/notes/' + t.id, body);
|
||||
} else {
|
||||
data = await api('POST', '/api/notes', body);
|
||||
}
|
||||
if (t.id) data = await api('PUT', '/api/notes/' + t.id, body);
|
||||
else data = await api('POST', '/api/notes', body);
|
||||
} catch (err) {
|
||||
if (err.status === 409 && err.data && err.data.server_hash) {
|
||||
t.remoteHash = err.data.server_hash;
|
||||
@@ -1501,10 +1620,13 @@
|
||||
return;
|
||||
}
|
||||
toast('Falha ao salvar: ' + err.message);
|
||||
updateStatus();
|
||||
return;
|
||||
}
|
||||
const wasDraft = !t.id;
|
||||
applyNoteToTab(t, data);
|
||||
E.revMessage.value = '';
|
||||
if (wasDraft) sendPresence(); // rascunho virou nota: presença passa a reportá-la
|
||||
loadNotes().catch(() => {});
|
||||
loadAggregates().catch(() => {});
|
||||
if (!E.versionsPanel.classList.contains('hidden')) loadRevisions().catch(() => {});
|
||||
@@ -1513,6 +1635,8 @@
|
||||
|
||||
// Conflito: o servidor mudou desde a base da aba. Recarregar ou sobrescrever.
|
||||
async function resolveConflict(t, body) {
|
||||
if (t.conflictPending) return;
|
||||
t.conflictPending = true;
|
||||
if (!body) {
|
||||
body = {
|
||||
title: t.title || currentTitle(),
|
||||
@@ -1521,6 +1645,7 @@
|
||||
content: t.content,
|
||||
author: E.authorInput.value.trim(),
|
||||
message: E.revMessage.value.trim(),
|
||||
session: SID,
|
||||
};
|
||||
}
|
||||
const reload = await modal({
|
||||
@@ -1546,6 +1671,8 @@
|
||||
}
|
||||
} catch (err) {
|
||||
toast('Conflito: ' + err.message);
|
||||
} finally {
|
||||
t.conflictPending = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1560,6 +1687,7 @@
|
||||
toast('Nota excluída.');
|
||||
const idx = state.tabs.indexOf(t);
|
||||
state.tabs.splice(idx, 1);
|
||||
sendPresence();
|
||||
if (!state.tabs.length) addTab(newTab());
|
||||
else activateTab(state.tabs[Math.min(idx, state.tabs.length - 1)].key);
|
||||
loadNotes().catch(() => {});
|
||||
@@ -1798,8 +1926,21 @@
|
||||
await restoreTabs();
|
||||
updateStatus();
|
||||
state.booted = true;
|
||||
connectEvents();
|
||||
sendPresence();
|
||||
setInterval(sendPresence, 10000); // heartbeat de presença
|
||||
}
|
||||
|
||||
window.addEventListener('pagehide', () => {
|
||||
// remove a sessão do registro assim que a aba fecha (keepalive sobrevive ao unload)
|
||||
fetch('/api/presence', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ session: SID, notes: [] }),
|
||||
keepalive: true,
|
||||
}).catch(() => {});
|
||||
});
|
||||
|
||||
window.addEventListener('unhandledrejection', (e) => {
|
||||
if (e.reason instanceof ApiError && e.reason.status === 401) return; // login já exibido
|
||||
console.error(e.reason);
|
||||
|
||||
Reference in new issue
Block a user