- manifest.webmanifest: standalone, theme/background color, ícones 192/512 any+maskable+monochrome (PNGs em web/icons) e atalho 'Nova nota' (?new=1) - sw.js: cache versionado (notas-shell-v1); navegação network-first com fallback ao shell (offline), estáticos stale-while-revalidate, /api/* e SSE sempre passthrough; activate limpa caches e notifica janelas - app.js: registro do SW, beforeinstallprompt com item 'Instalar Aplicativo…' no menu Ajuda (escondido em standalone/após instalar), toast de atualização, aviso offline/online, deep-link ?new=1 - index.html: meta tags theme-color/mobile-web-app/apple-touch-icon/manifest - server: staticHandler com MIME application/manifest+json e Cache-Control no-cache para /sw.js; embed inclui manifest, sw e ícones - README: seção PWA + orientação de serving (Go atual e equivalente Rust)
21 KiB
Notas — editor de notas web estilo Mousepad
Aplicação web completa para gerenciamento de notas inspirada no visual sóbrio e minimalista do Mousepad (editor de texto do XFCE): barra de menus superior, barra de ferramentas, área de texto limpa e barra de status na parte inferior.
Backend único em Go (binário estático de ~7 MB com o frontend já embutido), armazenamento em disco no estilo Git (blobs endereçados por SHA-256 + histórico de revisões com diff e restauração) e autenticação por token (Bearer para API / cookie HttpOnly para o navegador).
┌──────────────────────────────────────────────────────────────────────────┐
│ ☰ Notas Arquivo Editar Exibir Ajuda │ ← barra de menus
├──────────────────────────────────────────────────────────────────────────┤
│ ✚Nova 💾Salvar ⬇Baixar │ B I S H1 H2 ≡• 1 ❝ 🔗 </> { } │ Formato ▾ │ ← barra de ferramentas
│ [ Editar | Preview ] 🕘Versões │
├─────────────────┬────────────────────────────────────────────────────────┤
│ Buscar… │ ┌ nota.md ● ─┬─ outra.md ─┐ ✚ │ ← abas │
│ CATEGORIAS +pasta├─────────────┴─────────────┴──────────┴────────────────┤
│ 🗂 Todas as notas│ Título da nota 🗀 devops +1 ▾ │
│ 🗄 Sem categoria ├────────────────────────────────────────────────────────┤
│ 📂 devops 1 │ 1 │ # Placas │
│ · 📁 k8s 2 │ 2 │ │
│ TAGS │ 3 │ **nvme** em `/dev/nvme0` │ ← números de linha, │
│ #linux 2 #devops1│ 4 │ - controladora #linux │ cores, marcador │
│ NOTAS 3 │ 5 │ … ┊ │ de coluna (80) │
│ nota1 .md │ │
│ nota2 .txt │ │
├─────────────────┴────────────────────────────────────────────────────────┤
│ nota.md .md Ln 4, Col 12 128 caracteres · 20 palavras ● não salvo│ ← barra de status
└──────────────────────────────────────────────────────────────────────────┘
No celular (≤760px) a barra lateral vira gaveta com backdrop e o painel de versões ocupa a tela inteira; menus e toolbar rolam horizontalmente com botões só de ícone.
Funcionalidades
- 3 formatos por nota: Texto puro (
.txt), Markdown (.md) e HTML (.html), alternáveis no editor; a nota é baixada com a extensão correspondente. - Barra de ferramentas de formatação: negrito, itálico, tachado, títulos H1/H2, listas, citação, link, código inline e bloco de código — inserindo sintaxe Markdown ou HTML conforme o formato ativo (desabilitada em texto puro).
- Modo Edição ↔ WYSIWYG ↔ Preview (
Ctrl+E/M/R): o WYSIWYG (Ctrl+M) renderiza a nota numcontenteditablee sincroniza com o fonte a cada tecla — Markdown vira HTML editável (negrito, títulos, listas, citação, código, links, tabelas) e volta a Markdown ao editar/alternar; HTML é editado visualmente com tags/atributos preservados (scripts eon*são removidos ao abrir). O Preview renderiza Markdown no servidor (goldmark, GFM, HTML cru escapado); HTML em<iframe sandbox>; texto puro como está. Em texto puro o WYSIWYG não se aplica. - Categorias como árvore de pastas: o painel lateral traz um explorador de arquivos —
categorias aninhadas (subpastas), expansão/colapso, contagem de notas por pasta e menu de
contexto (Nova subpasta / Renomear / Excluir, sem ciclos). Uma nota pode pertencer a
várias categorias ao mesmo tempo (seletor multi-marca no topo do editor). Filtro por
pasta é recursivo por padrão (
?recursive=0desliga); há também "Todas as notas" e "Sem categoria". Dados antigos com o campo únicocategorysão migrados automaticamente na abertura (vira categoria raiz de mesmo nome). - Tags
#tagextraídas automaticamente do conteúdo (cabeçalhos Markdown como# Títulonão contam — só#tagsem espaço), com filtro por nuvem de tags e busca em título/categorias/conteúdo. - Abas de arquivos: abra várias notas ao mesmo tempo (clique na lista,
Ctrl+Nou botão ✚); cada aba tem estado sujo próprio (●), botão ✕ com confirmação, fechar com clique do meio, trocar comAlt+←/→ouAlt+1..9, fechar comAlt+W. As abas abertas (inclusive rascunhos não salvos) são restauradas entre sessões vialocalStorage. - Preferências do editor (menu Editar → Preferências… ou
Ctrl+,, em diálogo próprio):- Números de linha (gutter alinhado mesmo com quebra de linha; alternável pelo menu Exibir).
- Marcador de coluna customizável — padrão 80 (linha vertical no editor, exibida com quebra de linha desligada, como no Mousepad/gedit).
- Quebrar linha ligado/desligável (alternável também pelo menu Exibir).
- Tecla Tab: insere N espaços (padrão 4, configurável 1–16) ou o caractere de
tabulação
\t; o valor também define a largura exibida do\t(tab-size) no editor e no realce, mantendo as camadas alinhadas. - Fonte (DejaVu Sans Mono, Ubuntu Mono, Cascadia Code, Liberation Mono, Courier New ou monoespaçada do sistema) e tamanho (10–32 px).
- Esquema de cores / tema (21 opções, em grupos no diálogo): nativos — Claro (Greybird),
Escuro (Adwaita), Solarizado claro/escuro; iTerm2-Color-Schemes (projeto
mbadolato/iTerm2-Color-Schemes, MIT) —
escuros: Dracula, Nord, Atom One Dark, Monokai Pro, Gruvbox Dark, Tokyo Night, Catppuccin
Mocha, Tomorrow Night, Ayu Mirage, Kanagawa Wave; claros: Catppuccin Latte, Gruvbox Light,
Atom One Light, Nord Light, Carbon Paper, Tomorrow, Ayu Light. As cores de UI e dos
tokens de sintaxe são derivadas do Background/Foreground + ANSI 0–15 de cada arquivo
.itermcolors; cada tema também declaracolor-scheme(scrollbars/controles nativos acompanham). Aplicam-se a toda a interface, incluindo preview e diffs. - Colorização de sintaxe: Markdown (títulos, negrito/itálico, código inline e blocos
```, listas, citações, links, tags#tag, URLs) e HTML (tags, atributos, strings, comentários, entidades) renderizados por overlay sobre o textarea; desligável. - Tudo persistido no navegador (
localStorage), com pré-visualização ao vivo no diálogo, botões Cancelar (reverte) e Restaurar padrões.
- Versionamento estilo Git: cada salvamento de conteúdo cria uma revisão imutável (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 doEventSourceé automática e o fallback continua sendo a checagem no foco da janela.
- Presença: cada janela/aba do navegador é uma sessão (id aleatório em
- Autosave por ociosidade: só grava após um período sem nenhuma alteração — padrão
10 s, configurável (5/10/30/60 s em Preferências); cada tecla reinicia a contagem, então
digitação contínua nunca é interrompida por gravações. Silencioso (sem toast — apenas o
status "salvando…/salvo"; toast continua no
Ctrl+Smanual). A gravação também é feita ao trocar de aba do app ou ao minimizar/trocar de janela (visibilitychange/pagehide), para não perder edição pendente. Conflitos 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: Recarregar do servidor (descarta o texto local) ou Salvar por cima (a versão remota fica no histórico e é restaurável). - Abas sem edição pendente são recarregadas automaticamente quando a janela volta ao foco
(
visibilitychange/focus, com debounce), com aviso discreto.
- Cada aba lembra o hash em que se baseia (
- Autenticação por token: sem token, a interface exibe um diálogo pedindo o token; ele é
guardado em cookie HttpOnly/SameSite=Lax (30 dias). A API também aceita
Authorization: Bearer <token>para scripts. - Responsivo para celular (≤760px): barra lateral vira gaveta com backdrop (☰ fecha ao
tocar uma nota), painel de versões ocupa a tela toda, toolbar e menus rolam horizontalmente
sem rótulos, dropdowns dos menus posicionados como
fixed(ooverflow-xda menubar não os corta mais em telas pequenas), alvos de toque ampliados, diálogos em largura total, fonte do editor com mínimo de 16px (evita zoom automático do iOS), altura emdvhesafe-area-insetpara notch. - Estilo Mousepad: tema claro Greybird-like, menus nativos (Arquivo/Editar/Exibir/Ajuda),
atalhos
Ctrl+S,Ctrl+N,Ctrl+E/M/R(edição/wysiwyg/preview),Ctrl+H(histórico),F9(barra lateral),Ctrl+B/I/K(formatação),Ctrl+,(preferências),Alt+W/Alt+←/→/Alt+1..9(abas), barra de status com arquivo, formato, linha/coluna, contagem de palavras e estado de salvamento.
Estrutura do projeto
.
├── cmd/notas/ # main: flags, env, graceful shutdown
├── internal/
│ ├── store/ # persistência em disco, revisões (estilo Git) e árvore de categorias
│ ├── diffx/ # diff unificado (gotextdiff/Myers)
│ └── api/ # rotas HTTP, auth, CRUD de categorias, render de Markdown
├── web/ # frontend estático (go:embed no binário)
│ ├── index.html css/app.css js/app.js
├── Dockerfile # multi-stage: golang:alpine → scratch
├── docker-compose.yml
└── .env.example
Como rodar com Docker (recomendado)
cp .env.example .env
# edite .env e defina o token (gere um forte):
# openssl rand -hex 24
docker compose up -d
# abra http://localhost:8080 e informe o token na tela de login
Se a porta 8080 estiver ocupada: NOTAS_PORT=9090 docker compose up -d.
Com Podman: tudo acima funciona igualmente com podman-compose.
Os dados ficam no diretório local ./data (bind mount, ao lado do
docker-compose.yml) — já ignorado pelo .gitignore. Backup = copiar a pasta;
docker compose down não apaga nada.
Permissões do ./data (a imagem roda como uid 65534, non-root):
- Podman rootless: adicione
NOTAS_USER=0:0ao.env— dentro do container isso é mapeado para o seu usuário no host, que é dono de./data. - Docker (root):
sudo chown -R 65534:65534 data(ou rode o container comNOTAS_USER=$(id -u):$(id -g)e ajuste a dona da pasta em conformidade).
Como rodar sem Docker
export NOTES_TOKEN=$(openssl rand -hex 24)
go run ./cmd/notas # escuta :8080, dados em ./data
# ou o binário:
go build -trimpath -ldflags="-s -w" -o notas ./cmd/notas
./notas -addr :8080 -data ./data
Configuração (variáveis de ambiente / flags)
| Variável | Flag | Padrão | Descrição |
|---|---|---|---|
NOTES_TOKEN |
— | (sem) | Obrigatório. Token de acesso. |
NOTES_DATA_DIR |
-data |
./data |
Diretório de dados (notas + blobs). |
NOTES_ADDR |
-addr |
:8080 |
Endereço de escuta. |
API (para scripts)
Todos os endpoints sob /api/* exigem o token, exceto login/logout/me e o frontend.
T=$NOTES_TOKEN; B=http://localhost:8080
curl -H "Authorization: Bearer $T" $B/api/notes # lista (?category=<id|none>&recursive=0&tag=&q=)
curl -H "Authorization: Bearer $T" $B/api/notes/<id> # nota + conteúdo
curl -s -X POST -H "Authorization: Bearer $T" -H 'Content-Type: application/json' \
-d '{"title":"Olá","format":"md","categories":["<id>"],"content":"# Oi #linux"}' $B/api/notes
curl -X PUT -H "Authorization: Bearer $T" -H 'Content-Type: application/json' \
-d '{"title":"Olá","format":"md","categories":["<id>"],"content":"editado","author":"lg","message":"revisão 2","base_hash":"<hash carregado>"}' \
$B/api/notes/<id>
# base_hash é opcional: se enviado e diferente do atual → 409 {error, server_hash, updated_at}
# (omitir/enviar vazio = força o salvamento)
curl -H "Authorization: Bearer $T" $B/api/notes/<id>/revisions # histórico
curl -H "Authorization: Bearer $T" $B/api/notes/<id>/revisions/<hash>/diff # diff unificado
curl -X POST -H "Authorization: Bearer $T" $B/api/notes/<id>/revisions/<hash>/restore \
-H 'Content-Type: application/json' -d '{"author":"lg"}' # restaurar
curl -H "Authorization: Bearer $T" $B/api/notes/<id>/raw # download .txt/.md/.html
curl -H "Authorization: Bearer $T" $B/api/tags $B/api/categories # agregações
# árvore de categorias (lista plana; parent="" é raiz)
curl -X POST -H "Authorization: Bearer $T" -H 'Content-Type: application/json' \
-d '{"name":"devops","parent":""}' $B/api/categories
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)
PWA — instalável e offline
O app é um PWA completo: instalável no Linux/Windows/macOS/Android (menu Ajuda →
Instalar Aplicativo…, ou o ícone da barra de endereço), com janela standalone sem chrome
do navegador e atalho de área de trabalho "Nova nota" (/?new=1).
web/manifest.webmanifest—name/short_name/description/start_url/scope/theme_color/ background_color,display: standalone(+display_overridecom window-controls-overlay), ícones 192/512 emany,maskableemonochrome(PNGs reais emweb/icons/) e atalho.web/sw.js— Service Worker com cache versionado (notas-shell-v1):installpré-cacheia o shell eskipWaiting;activateexpurga caches antigos,clients.claim()e avisa as janelas (notas:updated→ toast "App atualizado");- navegação: network-first com fallback ao shell (abre offline);
- estáticos: stale-while-revalidate (carregamento instantâneo);
/api/*nunca é interceptado — dados sempre frescos e o SSE de tempo real não quebra.- Ao publicar mudanças de assets, bump na constante
CACHEdosw.js.
- Meta tags em
index.html:theme-color,mobile-web-app-capable,apple-mobile-web-app-*,apple-touch-icone<link rel="manifest">. - Offline UX: shell + interface funcionam sem rede; ações que precisam do servidor mostram toast claro ("Sem conexão — o app abre offline; salvar precisa do servidor").
Servindo os arquivos (backend Go atual)
Já implementado em internal/api/server.go (staticHandler):
sw.js→Cache-Control: no-cache, no-store, must-revalidate+Content-Type: text/javascript(a detecção de update do SW é por bytes — nunca cacheie!);manifest.webmanifest→Content-Type: application/manifest+json; charset=utf-8(o Go não conhece a extensão.webmanifestpor padrão);- tudo vem do
go:embed(web/embed.goincluimanifest.webmanifest sw.js icons). - O Service Worker só registra em origem segura: HTTPS ou
localhost/127.0.0.1— em IP de LAN puro (http://192.168.x.x) o navegador bloqueia SW; use um túnel/reverse proxy TLS para testar instalação real fora de localhost.
Equivalente em Rust (axum, caso o backend migre)
// MIME do manifest + no-cache no SW
async fn manifest() -> impl IntoResponse {
([(header::CONTENT_TYPE, "application/manifest+json")], include_str!("../web/manifest.webmanifest"))
}
async fn sw() -> impl IntoResponse {
[
(header::CONTENT_TYPE, "text/javascript"),
(header::CACHE_CONTROL, "no-cache, no-store, must-revalidate"),
]
}
No actix-web, o fs::Files já define MIME por extensão, mas registre antes:
mime::BABFILE não — use mime_guess::init::detect_EXTENSION("webmanifest", "application/manifest+json") e um wrap_fn que sobrepõe Cache-Control quando o caminho for
/sw.js. (No tower-http SetResponseHeaderLayer com Match por path é o caminho limpo.)
Como funciona o versionamento
- O conteúdo é guardado em
data/blobs/<aa>/<hash-sha256>(content-addressed, como objetos do Git): conteúdo repetido ocupa um único blob. - Cada nota tem
data/notes/<id>/meta.json(estado atual) ehistory.json(lista ordenada de revisões:hash,parent,author,when,message,bytes). - A árvore de categorias fica em
data/categories.json(lista plana comparent; nomes únicos entre irmãos; mover para dentro de si mesma é recusado). - Salvar sem alterar o conteúdo atualiza apenas os metadados (título/categorias/formato) — sem revisão nova. Restaurar cria uma revisão nova apontando para o blob antigo.
- Apagar uma nota faz GC dos blobs que ficaram sem referência.
Segurança
- Token verificado em tempo constante; nunca é registrado nos logs.
- Sessão do navegador em cookie HttpOnly + SameSite=Lax (mitiga CSRF); o token em
localStoragefoi preterido de propósito por ser exposto a XSS. - CSP restritiva (
default-src 'self'),X-Frame-Options: DENY,nosniff; preview de HTML roda em iframesandboxsem mesma origem; Markdown renderiza com HTML cru escapado. - Limite de 2 MiB por nota; corpo HTTP limitado a 4 MiB.
- Rode atrás de TLS (reverse proxy) em produção — o cookie marca
Secureautomaticamente quando a requisição chega por HTTPS/X-Forwarded-Proto. O modelo é token único compartilhado (sem usuários múltiplos); a imagem final éscratchnon-root (uid 65534) — sem shell paraexecde 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
go test ./... # store (revisões/GC/tags/árvore de categorias/migração),
# diffx (diff unificado), api (fluxo HTTP completo + CRUD de categorias)
go vet ./...
node --check web/js/app.js