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
+77
View File
@@ -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
View File
@@ -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)
}
+251
View File
@@ -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
}
+3 -1
View File
@@ -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})
}
+6
View File
@@ -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))
+14
View File
@@ -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; }
+2
View File
@@ -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
View File
@@ -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);