feat: aplicação inicial — gerenciador de notas web estilo Mousepad

- Backend Go: API REST, auth por token (Bearer + cookie HttpOnly), storage em disco
  com revisões content-addressed (estilo Git: hash, autor, data, diff, restauração)
- Editor txt/md/html com preview, tags #tag, categorias, painel de histórico
- Preferências: números de linha, marcador de coluna (padrão 80), quebra de linha,
  fonte/tamanho, 4 temas e colorização de sintaxe (overlay no textarea)
- Frontend vanilla embutido via go:embed; Dockerfile multi-stage + docker-compose
This commit is contained in:
lgcosta committed 2026-11-04 09:41:46 -03:00
commit 9bc5de4c56
22 files changed
+4239

No files matched your search

+10
View File
@@ -0,0 +1,10 @@
.git
.gitignore
data/
docs/
.env
.env.example
*.md
Dockerfile
docker-compose.yml
notas
+5
View File
@@ -0,0 +1,5 @@
# Copie para .env e defina o seu token: cp .env.example .env
# Gere um token forte: openssl rand -hex 24
NOTES_TOKEN=troque-este-token
# Porta do host (opcional):
# NOTAS_PORT=9090
+4
View File
@@ -0,0 +1,4 @@
/notas
/data/
.env
*.tmp
+33
View File
@@ -0,0 +1,33 @@
# syntax=docker/dockerfile:1
# ---------------------------------------------------------------------------
# Estágio 1: compila o binário estático (sem CGO) com o frontend embutido.
# ---------------------------------------------------------------------------
FROM golang:1.24-alpine AS build
WORKDIR /src
# Dependências primeiro, para aproveitar o cache de camadas.
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/notas ./cmd/notas \
&& mkdir -p /out/data \
&& chown -R 65534:65534 /out/data
# ---------------------------------------------------------------------------
# Estágio 2: imagem final mínima (scratch), apenas o binário + diretório de dados.
# ---------------------------------------------------------------------------
FROM scratch
COPY --from=build /out/notas /notas
COPY --from=build --chown=65534:65534 /out/data /data
USER 65534:65534
EXPOSE 8080
ENV NOTES_DATA_DIR=/data \
NOTES_ADDR=:8080
VOLUME /data
ENTRYPOINT ["/notas"]
+168
View File
@@ -0,0 +1,168 @@
# 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 ~11 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… │ Título da nota categoria │ HISTÓRICO DE │
│ CATEGORIA ▾ ├──────────────────────────────────────┤ │ REVISÕES │
│ TAGS │ # Placas │ │ hash · autor │
│ #linux 2 │ **nvme** em `/dev/nvme0` │ │ mensagem · data│
│ NOTAS │ - controladora #linux │ ├────────────────┤
│ nota1 .md │ … │ │ diff unificado │
│ nota2 .txt │ │ │ +restaurar │
├──────────────┴──────────────────────────────────────┴────────────────┤
│ nota.md .md Ln 4, Col 12 128 caracteres · 20 palavras ●● │ ← barra de status
└──────────────────────────────────────────────────────────────────────┘
```
## 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 ↔ Preview**: Markdown é renderizado no servidor (goldmark, GFM, HTML cru escapado);
HTML é pré-visualizado em `<iframe sandbox>`; texto puro é exibido como está.
- **Categorias/pastas** no painel lateral e **tags `#tag`** extraídas automaticamente do conteúdo
(cabeçalhos Markdown como `# Título` não contam — só `#tag` sem espaço). Filtro por categoria,
por tag e busca em título/categoria/conteúdo.
- **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).
- **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**: Claro (Greybird), Escuro (Adwaita), Solarizado claro e
Solarizado escuro — 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).
- **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.
- **Estilo Mousepad**: tema claro Greybird-like, menus nativos (Arquivo/Editar/Exibir/Ajuda),
atalhos `Ctrl+S`, `Ctrl+N`, `Ctrl+E/R` (editar/preview), `Ctrl+H` (histórico), `F9` (barra
lateral), `Ctrl+B/I/K` (formatação), `Ctrl+,` (preferências), 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)
│ ├── diffx/ # diff unificado (gotextdiff/Myers)
│ └── api/ # rotas HTTP, auth, 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)
```bash
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 volume nomeado `notas-data` — `docker compose down` não os apaga
(não use `down -v` se quiser preservá-los). Faça backup copiando o volume.
## Como rodar sem Docker
```bash
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.
```bash
T=$NOTES_TOKEN; B=http://localhost:8080
curl -H "Authorization: Bearer $T" $B/api/notes # lista (filtros: ?category=&tag=&q=)
curl -H "Authorization: Bearer $T" $B/api/notes/<id> # nota + conteúdo
curl -X POST -H "Authorization: Bearer $T" -H 'Content-Type: application/json' \
-d '{"title":"Olá","format":"md","category":"dev","content":"# Oi #linux"}' $B/api/notes
curl -X PUT -H "Authorization: Bearer $T" -H 'Content-Type: application/json' \
-d '{"title":"Olá","format":"md","category":"dev","content":"editado","author":"lg","message":"revisão 2"}' \
$B/api/notes/<id>
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
```
## 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) e `history.json` (lista ordenada de
revisões: `hash`, `parent`, `author`, `when`, `message`, `bytes`).
- Salvar sem alterar o conteúdo atualiza apenas os metadados (título/categoria/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
`localStorage` foi preterido de propósito por ser exposto a XSS.
- CSP restritiva (`default-src 'self'`), `X-Frame-Options: DENY`, `nosniff`; preview de HTML roda
em iframe `sandbox` sem 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 `Secure` automaticamente quando
a requisição chega por HTTPS/`X-Forwarded-Proto`. O modelo é **token único compartilhado**
(sem usuários múltiplos); a imagem final é `scratch` non-root (uid 65534) — sem shell para
`exec` de depuração, por design.
## Testes
```bash
go test ./... # store (revisões/GC/tags), diffx (diff unificado), api (fluxo HTTP completo)
go vet ./...
node --check web/js/app.js
```
+86
View File
@@ -0,0 +1,86 @@
// Command notas roda o servidor web de notas estilo Mousepad.
//
// Configuração por variáveis de ambiente:
//
// NOTES_TOKEN token de acesso (obrigatório)
// NOTES_DATA_DIR diretório de dados (padrão ./data)
// NOTES_ADDR endereço de escuta (padrão :8080)
package main
import (
"context"
"errors"
"flag"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/lgcosta/notas-online/internal/api"
"github.com/lgcosta/notas-online/internal/store"
"github.com/lgcosta/notas-online/web"
)
func main() {
if err := run(); err != nil {
slog.Error("fatal", "err", err)
os.Exit(1)
}
}
func run() error {
addr := flag.String("addr", envOr("NOTES_ADDR", ":8080"), "endereço de escuta")
dataDir := flag.String("data", envOr("NOTES_DATA_DIR", "./data"), "diretório de dados")
flag.Parse()
token := os.Getenv("NOTES_TOKEN")
if token == "" {
return errors.New("NOTES_TOKEN não definido — defina o token de acesso " +
"(ex.: NOTES_TOKEN=$(openssl rand -hex 24) docker compose up -d)")
}
st, err := store.New(*dataDir)
if err != nil {
return err
}
handler := api.New(st, token, web.FS).Handler()
srv := &http.Server{
Addr: *addr,
Handler: handler,
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 60 * time.Second,
IdleTimeout: 120 * time.Second,
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
errCh := make(chan error, 1)
go func() {
slog.Info("notas: servidor iniciando", "addr", *addr, "data", *dataDir)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
errCh <- err
}
}()
select {
case err := <-errCh:
return err
case <-ctx.Done():
slog.Info("encerrando…")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
return srv.Shutdown(shutdownCtx)
}
}
func envOr(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
+20
View File
@@ -0,0 +1,20 @@
services:
notas:
build: .
image: notas-online:latest
container_name: notas
ports:
# Troque a porta do host com NOTAS_PORT (ex.: NOTAS_PORT=9090 docker compose up -d)
- "${NOTAS_PORT:-8080}:8080"
environment:
# Obrigatório: defina em .env (veja .env.example) ou exporte no shell.
NOTES_TOKEN: ${NOTES_TOKEN:?defina NOTES_TOKEN em .env ou no ambiente}
NOTES_DATA_DIR: /data
volumes:
- notas-data:/data
restart: unless-stopped
security_opt:
- no-new-privileges:true
volumes:
notas-data:
+8
View File
@@ -0,0 +1,8 @@
module github.com/lgcosta/notas-online
go 1.23
require (
github.com/hexops/gotextdiff v1.0.3
github.com/yuin/goldmark v1.8.7
)
+4
View File
@@ -0,0 +1,4 @@
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/yuin/goldmark v1.8.7 h1:XT/tNqCIbabh0ln675w4x3dv04kQBKi8NwYqi9IJaxI=
github.com/yuin/goldmark v1.8.7/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
+271
View File
@@ -0,0 +1,271 @@
package api_test
import (
"bytes"
"encoding/json"
"io"
"io/fs"
"net/http"
"net/http/httptest"
"strings"
"testing"
"testing/fstest"
"github.com/lgcosta/notas-online/internal/api"
"github.com/lgcosta/notas-online/internal/store"
)
const testToken = "s3cr3t-token"
func newTestServer(t *testing.T) *httptest.Server {
t.Helper()
st, err := store.New(t.TempDir())
if err != nil {
t.Fatal(err)
}
webFS := fs.FS(fstest.MapFS{"index.html": &fstest.MapFile{Data: []byte("<html>MOUSEPAD-SHELL</html>")}})
srv := httptest.NewServer(api.New(st, testToken, webFS).Handler())
t.Cleanup(srv.Close)
return srv
}
func do(t *testing.T, srv *httptest.Server, method, path, body, bearer, cookie string) *http.Response {
t.Helper()
var rdr io.Reader
if body != "" {
rdr = strings.NewReader(body)
}
req, err := http.NewRequest(method, srv.URL+path, rdr)
if err != nil {
t.Fatal(err)
}
if bearer != "" {
req.Header.Set("Authorization", "Bearer "+bearer)
}
if cookie != "" {
req.Header.Add("Cookie", cookie)
}
resp, err := srv.Client().Do(req)
if err != nil {
t.Fatal(err)
}
return resp
}
func decode(t *testing.T, resp *http.Response, v any) {
t.Helper()
defer resp.Body.Close()
if err := json.NewDecoder(resp.Body).Decode(v); err != nil {
t.Fatalf("decodificando resposta %s %s: %v", resp.Status, resp.Request.URL.Path, err)
}
}
type noteView struct {
ID string `json:"id"`
Title string `json:"title"`
Format string `json:"format"`
Category string `json:"category"`
Tags []string `json:"tags"`
Hash string `json:"hash"`
Content string `json:"content"`
}
func TestAuthRequiredAndLoginFlow(t *testing.T) {
srv := newTestServer(t)
// Sem token: API bloqueia.
if resp := do(t, srv, "GET", "/api/notes", "", "", ""); resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("esperava 401 sem token, obtive %s", resp.Status)
}
resp := do(t, srv, "GET", "/api/notes", "", "token-errado", "")
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("bearer errado deveria dar 401, obtive %s", resp.Status)
}
// Login errado.
resp = do(t, srv, "POST", "/api/login", `{"token":"nope"}`, "", "")
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("login errado: %s", resp.Status)
}
// Login correto: cookie HttpOnly emitido.
resp = do(t, srv, "POST", "/api/login", `{"token":"`+testToken+`"}`, "", "")
if resp.StatusCode != http.StatusOK {
t.Fatalf("login: %s", resp.Status)
}
sc := resp.Header.Get("Set-Cookie")
if !strings.Contains(sc, "notas_token="+testToken) || !strings.Contains(sc, "HttpOnly") ||
strings.Contains(sc, "Max-Age=0") {
t.Errorf("Set-Cookie inadequado: %q", sc)
}
cookieValue := strings.SplitN(strings.TrimPrefix(sc, "notas_token="), ";", 2)[0]
// /api/me com o cookie.
resp = do(t, srv, "GET", "/api/me", "", "", "notas_token="+cookieValue)
if resp.StatusCode != http.StatusOK {
t.Fatalf("/api/me com cookie: %s", resp.Status)
}
// Logout limpa o cookie.
resp = do(t, srv, "POST", "/api/logout", "", "", "notas_token="+cookieValue)
resp.Body.Close()
sc = resp.Header.Get("Set-Cookie")
if !strings.Contains(sc, "Max-Age=0") && !strings.Contains(sc, "Expires=Thu, 01 Jan 1970") {
t.Errorf("logout deveria expirar o cookie: %q", sc)
}
// Index é público e servido do FS embutido.
resp = do(t, srv, "GET", "/", "", "", "")
var buf bytes.Buffer
_, _ = buf.ReadFrom(resp.Body)
resp.Body.Close()
if !strings.Contains(buf.String(), "MOUSEPAD-SHELL") {
t.Errorf("index não servido: %q", buf.String())
}
}
func TestNoteLifecycleWithHistoryAndRestore(t *testing.T) {
srv := newTestServer(t)
call := func(method, path, body string) *http.Response {
return do(t, srv, method, path, body, testToken, "")
}
// Criar.
resp := call("POST", "/api/notes",
`{"title":"Servidor","format":"md","category":"devops","content":"# Notas\n\n#linux #systemd","author":"lg","message":""}`)
if resp.StatusCode != http.StatusCreated {
t.Fatalf("create: %s", resp.Status)
}
var created noteView
decode(t, resp, &created)
if created.ID == "" || len(created.Tags) != 2 {
t.Fatalf("payload criado: %+v", created)
}
id := created.ID
// Listar com filtro de tag.
resp = call("GET", "/api/notes?tag=linux", "")
var list []noteView
decode(t, resp, &list)
if len(list) != 1 || list[0].Content != "" { // listagem não carrega conteúdo
t.Fatalf("list: %+v", list)
}
// Ler completo.
resp = call("GET", "/api/notes/"+id, "")
var got noteView
decode(t, resp, &got)
if !strings.Contains(got.Content, "#systemd") {
t.Fatalf("get: %+v", got)
}
// Atualizar: nova revisão.
resp = call("PUT", "/api/notes/"+id,
`{"title":"Servidor","format":"md","category":"devops","content":"# Notas\n\n#linux","author":"lg","message":"removi systemd"}`)
var updated noteView
decode(t, resp, &updated)
if updated.Hash == created.Hash {
t.Fatal("hash não mudou após edição de conteúdo")
}
// Histórico: mais recente primeiro, com parent e mensagem.
resp = call("GET", "/api/notes/"+id+"/revisions", "")
var revs []store.Revision
decode(t, resp, &revs)
if len(revs) != 2 || revs[0].Message != "removi systemd" || revs[0].Parent != created.Hash {
t.Fatalf("revisões: %+v", revs)
}
// Diff da revisão atual.
resp = call("GET", "/api/notes/"+id+"/revisions/"+revs[0].Hash+"/diff", "")
var dv struct {
Diff string `json:"diff"`
Added int `json:"added"`
Removed int `json:"removed"`
}
decode(t, resp, &dv)
if !strings.Contains(dv.Diff, "-#linux #systemd") || !strings.Contains(dv.Diff, "+#linux") {
t.Fatalf("diff: %q", dv.Diff)
}
if dv.Added != 1 || dv.Removed != 1 {
t.Fatalf("stats do diff: +%d -%d", dv.Added, dv.Removed)
}
// Restaurar a primeira revisão.
resp = call("POST", "/api/notes/"+id+"/revisions/"+created.Hash+"/restore", `{"author":"lg"}`)
if resp.StatusCode != http.StatusOK {
t.Fatalf("restore: %s", resp.Status)
}
var restored noteView
decode(t, resp, &restored)
if !strings.Contains(restored.Content, "#systemd") {
t.Fatalf("restore não trouxe conteúdo antigo: %+v", restored)
}
// Restaurar de novo → 409.
resp = call("POST", "/api/notes/"+id+"/revisions/"+created.Hash+"/restore", `{"author":"lg"}`)
if resp.StatusCode != http.StatusConflict {
t.Fatalf("esperava 409, obtive %s", resp.Status)
}
resp.Body.Close()
// Download bruto com Content-Disposition.
resp = call("GET", "/api/notes/"+id+"/raw", "")
if cd := resp.Header.Get("Content-Disposition"); !strings.Contains(cd, `attachment; filename="Servidor.md"`) {
t.Errorf("Content-Disposition: %q", cd)
}
resp.Body.Close()
// Deletar → 204, e GET passa a 404.
resp = call("DELETE", "/api/notes/"+id, "")
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("delete: %s", resp.Status)
}
resp = call("GET", "/api/notes/"+id, "")
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("get pós-delete: %s", resp.Status)
}
}
func TestRenderMarkdownIsSafe(t *testing.T) {
srv := newTestServer(t)
resp := do(t, srv, "POST", "/api/render",
`{"content":"**negrito** <script>alert(1)</script>\nlinha1\nlinha2","format":"md"}`, testToken, "")
var out struct{ HTML string `json:"html"` }
decode(t, resp, &out)
if !strings.Contains(out.HTML, "<strong>negrito</strong>") {
t.Errorf("markdown não renderizado: %q", out.HTML)
}
if strings.Contains(out.HTML, "<script>") {
t.Errorf("HTML cru deveria ser escapado: %q", out.HTML)
}
if !strings.Contains(out.HTML, "<br") {
t.Errorf("quebra de linha simples deveria virar <br>: %q", out.HTML)
}
// html não é renderizado no servidor.
resp = do(t, srv, "POST", "/api/render", `{"content":"<b>x</b>","format":"html"}`, testToken, "")
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("formato html deveria retornar 400, obtive %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, "")
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("formato inválido: %s", resp.Status)
}
resp.Body.Close()
resp = do(t, srv, "POST", "/api/notes", `{"token":"extra"}`, testToken, "")
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("campo desconhecido deveria falhar (DisallowUnknownFields): %s", resp.Status)
}
resp.Body.Close()
resp = do(t, srv, "GET", "/api/notes/z0zzzzzzzzzz/revisions", "", testToken, "")
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("nota inexistente: %s", resp.Status)
}
resp.Body.Close()
}
+165
View File
@@ -0,0 +1,165 @@
package api
import (
"io/fs"
"net/http"
"strings"
"github.com/lgcosta/notas-online/internal/store"
)
// serveIndex entrega o shell da aplicação.
func (s *Server) serveIndex(w http.ResponseWriter, r *http.Request) {
data, err := fs.ReadFile(s.webFS, "index.html")
if err != nil {
http.Error(w, "frontend ausente no binário", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache")
_, _ = w.Write(data)
}
// noteInput é o corpo aceito por POST/PUT de notas.
type noteInput struct {
Title string `json:"title"`
Format string `json:"format"`
Category string `json:"category"`
Content string `json:"content"`
Author string `json:"author"`
Message string `json:"message"`
}
func (in noteInput) fields() (string, string, string, string, string, string) {
f := in.Format
if f == "" {
f = store.FormatTxt
}
return in.Title, f, in.Category, in.Content, in.Author, in.Message
}
// noteView é a representação completa (meta + conteúdo) de uma nota.
type noteView struct {
*store.Note
Content string `json:"content"`
}
func (s *Server) handleListNotes(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
notes, err := s.st.List(store.Filter{
Category: q.Get("category"),
Tag: q.Get("tag"),
Query: q.Get("q"),
})
if err != nil {
mapStoreError(w, err)
return
}
if notes == nil {
notes = []*store.Note{}
}
writeJSON(w, http.StatusOK, notes)
}
func (s *Server) handleCreateNote(w http.ResponseWriter, r *http.Request) {
var in noteInput
if err := decodeJSON(r, &in, maxBody); err != nil {
writeError(w, http.StatusBadRequest, "corpo JSON inválido")
return
}
title, format, category, content, author, message := in.fields()
n, err := s.st.Create(title, format, category, content, author, message)
if err != nil {
mapStoreError(w, err)
return
}
writeJSON(w, http.StatusCreated, noteView{n, content})
}
func (s *Server) handleGetNote(w http.ResponseWriter, r *http.Request) {
content, n, err := s.st.Content(r.PathValue("id"))
if err != nil {
mapStoreError(w, err)
return
}
writeJSON(w, http.StatusOK, noteView{n, content})
}
func (s *Server) handleUpdateNote(w http.ResponseWriter, r *http.Request) {
var in noteInput
if err := decodeJSON(r, &in, maxBody); err != nil {
writeError(w, http.StatusBadRequest, "corpo JSON inválido")
return
}
title, format, category, content, author, message := in.fields()
n, err := s.st.Update(r.PathValue("id"), title, format, category, content, author, message)
if err != nil {
mapStoreError(w, err)
return
}
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 {
mapStoreError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// handleRaw baixa a nota como arquivo (Content-Disposition attachment).
func (s *Server) handleRaw(w http.ResponseWriter, r *http.Request) {
content, n, err := s.st.Content(r.PathValue("id"))
if err != nil {
mapStoreError(w, err)
return
}
ctype := "text/plain; charset=utf-8"
switch n.Format {
case store.FormatMD:
ctype = "text/markdown; charset=utf-8"
case store.FormatHTML:
ctype = "text/html; charset=utf-8"
}
w.Header().Set("Content-Type", ctype)
w.Header().Set("Content-Disposition",
`attachment; filename="`+sanitizeFilename(n.Filename())+`"`)
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write([]byte(content))
}
// sanitizeFilename elimina caracteres que quebrariam o header.
func sanitizeFilename(name string) string {
return strings.Map(func(r rune) rune {
switch r {
case '"', '\\', '\n', '\r', '/':
return '_'
}
return r
}, name)
}
func (s *Server) handleTags(w http.ResponseWriter, r *http.Request) {
tags, err := s.st.Tags()
if err != nil {
mapStoreError(w, err)
return
}
if tags == nil {
tags = []store.TagCount{}
}
writeJSON(w, http.StatusOK, tags)
}
func (s *Server) handleCategories(w http.ResponseWriter, r *http.Request) {
cats, err := s.st.Categories()
if err != nil {
mapStoreError(w, err)
return
}
if cats == nil {
cats = []store.CatCount{}
}
writeJSON(w, http.StatusOK, cats)
}
+35
View File
@@ -0,0 +1,35 @@
package api
import (
"net/http"
"strings"
)
const maxBody = 4 << 20 // folga sobre o limite de conteúdo da store (2 MiB)
type renderInput struct {
Content string `json:"content"`
Format string `json:"format"`
}
// handleRender converte Markdown em HTML seguro (raw HTML do markdown é
// escapado pelo goldmark; a nota em si é sempre renderizada via innerHTML).
func (s *Server) handleRender(w http.ResponseWriter, r *http.Request) {
var in renderInput
if err := decodeJSON(r, &in, maxBody); err != nil {
writeError(w, http.StatusBadRequest, "corpo JSON inválido")
return
}
switch in.Format {
case "html":
// O preview de HTML usa iframe com sandbox no cliente; não renderiza aqui.
writeError(w, http.StatusBadRequest, "formato html usa preview em iframe")
return
}
var buf strings.Builder
if err := s.md.Convert([]byte(in.Content), &buf); err != nil {
writeError(w, http.StatusInternalServerError, "falha ao renderizar markdown")
return
}
writeJSON(w, http.StatusOK, map[string]string{"html": buf.String()})
}
+112
View File
@@ -0,0 +1,112 @@
package api
import (
"net/http"
"regexp"
"slices"
"github.com/lgcosta/notas-online/internal/diffx"
"github.com/lgcosta/notas-online/internal/store"
)
var hashRe = regexp.MustCompile(`^[0-9a-f]{64}$`)
func (s *Server) handleListRevisions(w http.ResponseWriter, r *http.Request) {
revs, err := s.st.History(r.PathValue("id"))
if err != nil {
mapStoreError(w, err)
return
}
// Mais recente primeiro: ordem natural para um painel de histórico.
slices.Reverse(revs)
if revs == nil {
revs = []store.Revision{}
}
writeJSON(w, http.StatusOK, revs)
}
func (s *Server) handleGetRevision(w http.ResponseWriter, r *http.Request) {
hash := r.PathValue("hash")
if !hashRe.MatchString(hash) {
writeError(w, http.StatusNotFound, "revisão não encontrada")
return
}
rc, err := s.st.RevisionWithContent(r.PathValue("id"), hash)
if err != nil {
mapStoreError(w, err)
return
}
writeJSON(w, http.StatusOK, rc)
}
type diffView struct {
From string `json:"from"`
To string `json:"to"`
Author string `json:"author"`
When string `json:"when"`
Message string `json:"message"`
Diff string `json:"diff"`
Added int `json:"added"`
Removed int `json:"removed"`
}
func (s *Server) handleRevisionDiff(w http.ResponseWriter, r *http.Request) {
hash := r.PathValue("hash")
if !hashRe.MatchString(hash) {
writeError(w, http.StatusNotFound, "revisão não encontrada")
return
}
before, after, rev, err := s.st.DiffBetween(r.PathValue("id"), hash)
if err != nil {
mapStoreError(w, err)
return
}
from := "criação"
if rev.Parent != "" {
from = short(rev.Parent)
}
patch := diffx.Unified(from, short(hash), before, after)
added, removed := diffx.Stats(patch)
writeJSON(w, http.StatusOK, diffView{
From: from, To: short(hash), Author: rev.Author,
When: rev.When.Format("2006-01-02 15:04:05 MST"), Message: rev.Message,
Diff: patch, Added: added, Removed: removed,
})
}
type restoreInput struct {
Author string `json:"author"`
}
func (s *Server) handleRestore(w http.ResponseWriter, r *http.Request) {
hash := r.PathValue("hash")
if !hashRe.MatchString(hash) {
writeError(w, http.StatusNotFound, "revisão não encontrada")
return
}
var in restoreInput
if r.ContentLength > 0 {
if err := decodeJSON(r, &in, 1<<16); err != nil {
writeError(w, http.StatusBadRequest, "corpo JSON inválido")
return
}
}
n, err := s.st.Restore(r.PathValue("id"), hash, in.Author)
if err != nil {
mapStoreError(w, err)
return
}
content, _, err := s.st.Content(n.ID)
if err != nil {
mapStoreError(w, err)
return
}
writeJSON(w, http.StatusOK, noteView{n, content})
}
func short(hash string) string {
if len(hash) > 8 {
return hash[:8]
}
return hash
}
+211
View File
@@ -0,0 +1,211 @@
// Package api expõe a camada HTTP da aplicação de notas.
package api
import (
"crypto/subtle"
"encoding/json"
"errors"
"io/fs"
"log/slog"
"net/http"
"strings"
"time"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/extension"
htmlr "github.com/yuin/goldmark/renderer/html"
"github.com/lgcosta/notas-online/internal/store"
)
// CookieName é o cookie HttpOnly onde o token de sessão é guardado.
const CookieName = "notas_token"
const cookieMaxAge = 30 * 24 * time.Hour
// Server agrupa dependências e rotas.
type Server struct {
st *store.Store
token string
webFS fs.FS
md goldmark.Markdown
}
// New cria o servidor da API. webFS é o filesystem embutido do frontend
// (raiz contém index.html, css/, js/).
func New(st *store.Store, token string, webFS fs.FS) *Server {
return &Server{
st: st,
token: token,
webFS: webFS,
md: goldmark.New(
goldmark.WithExtensions(extension.GFM),
goldmark.WithRendererOptions(htmlr.WithHardWraps()),
),
}
}
// Handler monta o mux completo (rotas públicas, autenticadas e estáticas).
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("POST /api/login", s.handleLogin)
mux.HandleFunc("POST /api/logout", s.handleLogout)
mux.HandleFunc("GET /api/me", s.handleMe)
mux.HandleFunc("GET /api/notes", s.auth(s.handleListNotes))
mux.HandleFunc("POST /api/notes", s.auth(s.handleCreateNote))
mux.HandleFunc("GET /api/notes/{id}", s.auth(s.handleGetNote))
mux.HandleFunc("PUT /api/notes/{id}", s.auth(s.handleUpdateNote))
mux.HandleFunc("DELETE /api/notes/{id}", s.auth(s.handleDeleteNote))
mux.HandleFunc("GET /api/notes/{id}/raw", s.auth(s.handleRaw))
mux.HandleFunc("GET /api/notes/{id}/revisions", s.auth(s.handleListRevisions))
mux.HandleFunc("GET /api/notes/{id}/revisions/{hash}", s.auth(s.handleGetRevision))
mux.HandleFunc("GET /api/notes/{id}/revisions/{hash}/diff", s.auth(s.handleRevisionDiff))
mux.HandleFunc("POST /api/notes/{id}/revisions/{hash}/restore", s.auth(s.handleRestore))
mux.HandleFunc("GET /api/tags", s.auth(s.handleTags))
mux.HandleFunc("GET /api/categories", s.auth(s.handleCategories))
mux.HandleFunc("POST /api/render", s.auth(s.handleRender))
mux.HandleFunc("GET /{$}", s.serveIndex)
mux.Handle("GET /", http.FileServerFS(s.webFS))
return s.wrap(mux)
}
// wrap aplica middlewares globais: recuperação, headers de segurança e log.
func (s *Server) wrap(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
slog.Error("panic atendendo requisição", "path", r.URL.Path, "panic", rec)
http.Error(w, `{"error":"erro interno"}`, http.StatusInternalServerError)
}
}()
h := w.Header()
h.Set("X-Content-Type-Options", "nosniff")
h.Set("X-Frame-Options", "DENY")
h.Set("Referrer-Policy", "no-referrer")
h.Set("Content-Security-Policy",
"default-src 'self'; img-src 'self' data:; style-src 'self'; "+
"script-src 'self'; frame-src 'self'; connect-src 'self'")
next.ServeHTTP(w, r)
})
}
// ---------------------------------------------------------------------------
// Autenticação
// ---------------------------------------------------------------------------
func (s *Server) presentedToken(r *http.Request) string {
if v, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer "); ok {
return strings.TrimSpace(v)
}
if c, err := r.Cookie(CookieName); err == nil {
return c.Value
}
return ""
}
func (s *Server) validToken(candidate string) bool {
if s.token == "" || candidate == "" {
return false
}
return subtle.ConstantTimeCompare([]byte(candidate), []byte(s.token)) == 1
}
func (s *Server) auth(h http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !s.validToken(s.presentedToken(r)) {
writeError(w, http.StatusUnauthorized, "token inválido ou ausente")
return
}
h(w, r)
}
}
type loginRequest struct {
Token string `json:"token"`
}
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
var req loginRequest
if err := decodeJSON(r, &req, 1<<16); err != nil {
writeError(w, http.StatusBadRequest, "corpo JSON inválido")
return
}
if !s.validToken(strings.TrimSpace(req.Token)) {
writeError(w, http.StatusUnauthorized, "token inválido")
return
}
secure := r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https"
http.SetCookie(w, &http.Cookie{
Name: CookieName,
Value: strings.TrimSpace(req.Token),
Path: "/",
MaxAge: int(cookieMaxAge.Seconds()),
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteLaxMode,
})
writeJSON(w, http.StatusOK, map[string]any{"authed": true})
}
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{Name: CookieName, Value: "", Path: "/", MaxAge: -1,
HttpOnly: true, SameSite: http.SameSiteLaxMode})
writeJSON(w, http.StatusOK, map[string]any{"authed": false})
}
func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
if !s.validToken(s.presentedToken(r)) {
writeError(w, http.StatusUnauthorized, "não autenticado")
return
}
writeJSON(w, http.StatusOK, map[string]any{"authed": true})
}
// ---------------------------------------------------------------------------
// JSON helpers
// ---------------------------------------------------------------------------
func decodeJSON(r *http.Request, v any, maxBytes int64) error {
defer r.Body.Close()
dec := json.NewDecoder(http.MaxBytesReader(nil, r.Body, maxBytes))
dec.DisallowUnknownFields()
if err := dec.Decode(v); err != nil {
return err
}
return nil
}
func writeJSON(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(v)
}
func writeError(w http.ResponseWriter, code int, msg string) {
writeJSON(w, code, map[string]string{"error": msg})
}
// mapStoreError converte erros da store para códigos HTTP.
func mapStoreError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, store.ErrNotFound):
writeError(w, http.StatusNotFound, "nota não encontrada")
case errors.Is(err, store.ErrRevision):
writeError(w, http.StatusNotFound, "revisão não encontrada")
case errors.Is(err, store.ErrInvalid):
writeError(w, http.StatusBadRequest, err.Error())
case errors.Is(err, store.ErrTooLarge):
writeError(w, http.StatusRequestEntityTooLarge, "conteúdo excede 2 MiB")
case errors.Is(err, store.ErrUnchanged):
writeError(w, http.StatusConflict, "o conteúdo dessa revisão já está ativo")
default:
slog.Error("erro interno da store", "err", err)
writeError(w, http.StatusInternalServerError, "erro interno")
}
}
+56
View File
@@ -0,0 +1,56 @@
// Package diffx gera diffs unificados no estilo Git a partir de strings.
package diffx
import (
"fmt"
"github.com/hexops/gotextdiff"
"github.com/hexops/gotextdiff/myers"
"github.com/hexops/gotextdiff/span"
)
// Unified devolve o diff unificado (contexto padrão de 3 linhas) entre
// before e after, rotulando os lados como oldName e newName.
func Unified(oldName, newName, before, after string) string {
if before == after {
return ""
}
edits := myers.ComputeEdits(span.URIFromPath(oldName), before, after)
unified := gotextdiff.ToUnified(oldName, newName, before, edits)
return fmt.Sprintf("%s", unified)
}
// Stats conta linhas adicionadas e removidas em um diff unificado já gerado.
func Stats(unified string) (added, removed int) {
for _, line := range splitLines(unified) {
switch {
case len(line) == 0:
case line[0] == '+' && !isHeader(line):
added++
case line[0] == '-' && !isHeader(line):
removed++
}
}
return added, removed
}
func isHeader(line string) bool {
// cabeçalhos de arquivo no formato "--- nome" / "+++ nome" começam com
// dois sinais; uma linha de conteúdo "++foo" é contada como adição.
return len(line) >= 3 && line[0] == line[1] && (line[0] == '+' || line[0] == '-')
}
func splitLines(s string) []string {
var out []string
start := 0
for i := range len(s) {
if s[i] == '\n' {
out = append(out, s[start:i])
start = i + 1
}
}
if start < len(s) {
out = append(out, s[start:])
}
return out
}
+44
View File
@@ -0,0 +1,44 @@
package diffx
import (
"strings"
"testing"
)
func TestUnifiedEmptyWhenEqual(t *testing.T) {
if got := Unified("a.txt", "a.txt", "mesmo", "mesmo"); got != "" {
t.Fatalf("esperava diff vazio, veio %q", got)
}
}
func TestUnifiedCreateLooksLikeGit(t *testing.T) {
got := Unified("(sem conteúdo)", "nota.md", "", "# Ola\n\nlinha\n")
for _, want := range []string{"--- (sem conteúdo)", "+++ nota.md", "@@", "+# Ola", "+linha"} {
if !strings.Contains(got, want) {
t.Errorf("diff deveria conter %q; diff:\n%s", want, got)
}
}
if strings.Contains(got, "-linha") {
t.Errorf("não deveria haver remoção em criação pura:\n%s", got)
}
}
func TestUnifiedEditHasAddAndRemove(t *testing.T) {
before := "alpha\nbravo\ncharlie\n"
after := "alpha\nBRAVO change\ncharlie\ndelta\n"
got := Unified("n", "n", before, after)
added, removed := Stats(got)
if removed != 1 {
t.Errorf("esperava 1 remoção, obtive %d (diff:\n%s)", removed, got)
}
if added != 2 {
t.Errorf("esperava 2 adições, obtive %d (diff:\n%s)", added, got)
}
}
func TestStatsIgnoresFileHeaders(t *testing.T) {
added, removed := Stats("--- a\n+++ b\n@@ -1 +1 @@\n+x\n")
if added != 1 || removed != 0 {
t.Fatalf("added=%d removed=%d, esperava 1/0", added, removed)
}
}
+718
View File
@@ -0,0 +1,718 @@
// Package store implementa a persistência de notas em disco no estilo
// Git: conteúdo é armazenado como blobs endereçados por hash SHA-256 e
// cada salvamento cria uma revisão imutável apontando para seu pai.
package store
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"slices"
"sort"
"strings"
"sync"
"time"
"unicode"
)
// Formatos aceitos para uma nota.
const (
FormatTxt = "txt"
FormatMD = "md"
FormatHTML = "html"
)
var validFormats = map[string]string{
FormatTxt: ".txt",
FormatMD: ".md",
FormatHTML: ".html",
}
const (
// MaxContentBytes limita o tamanho do conteúdo de uma nota.
MaxContentBytes = 2 << 20 // 2 MiB
maxTitleLen = 200
maxCategoryLen = 100
maxTagLen = 64
)
// Erros sentinel da camada de armazenamento.
var (
ErrNotFound = errors.New("nota não encontrada")
ErrRevision = errors.New("revisão não encontrada")
ErrInvalid = errors.New("dados inválidos")
ErrTooLarge = errors.New("conteúdo excede o limite de tamanho")
ErrUnchanged = errors.New("conteúdo idêntico à versão atual")
)
// Note é o estado atual de uma nota (sem o conteúdo).
type Note struct {
ID string `json:"id"`
Title string `json:"title"`
Format string `json:"format"`
Category string `json:"category"`
Tags []string `json:"tags"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Hash string `json:"hash"` // blob do conteúdo atual
Bytes int `json:"bytes"`
}
// Filename devolve "titulo.ext" sanitizado para download.
func (n *Note) Filename() string {
base := n.Title
if base == "" {
base = "sem-titulo"
}
var b strings.Builder
for _, r := range base {
switch {
case unicode.IsLetter(r) || unicode.IsDigit(r) || r == '-' || r == '_' || r == ' ':
b.WriteRune(r)
case r == '.':
// permite extensão dupla acidental; mantém apenas um ponto
if b.Len() > 0 && b.String()[b.Len()-1] != '.' {
b.WriteRune(r)
}
}
}
base = strings.TrimSpace(b.String())
if base == "" {
base = "sem-titulo"
}
return base + validFormats[n.Format]
}
// Revision é uma entrada imutável do histórico de uma nota.
type Revision struct {
Hash string `json:"hash"` // SHA-256 do conteúdo (hex)
Parent string `json:"parent"` // hash da revisão anterior; "" na primeira
Author string `json:"author"`
When time.Time `json:"when"`
Message string `json:"message"`
Bytes int `json:"bytes"`
}
// RevisionWithContent é uma revisão com seu conteúdo, para o painel de histórico.
type RevisionWithContent struct {
Revision
Content string `json:"content"`
}
// Filter restringe a Listagem de notas.
type Filter struct {
Category string // "" = todas
Tag string // "" = todas (sem #)
Query string // busca em título, categoria e conteúdo (case-insensitive)
}
// Store é seguro para concorrência.
type Store struct {
dir string
mu sync.RWMutex
}
// New abre (ou cria) o diretório raiz de dados.
func New(dir string) (*Store, error) {
for _, sub := range []string{"notes", "blobs"} {
if err := os.MkdirAll(filepath.Join(dir, sub), 0o750); err != nil {
return nil, err
}
}
return &Store{dir: dir}, nil
}
// ---------------------------------------------------------------------------
// Tipos internos persistidos
// ---------------------------------------------------------------------------
func (s *Store) noteDir(id string) string { return filepath.Join(s.dir, "notes", id) }
func (s *Store) metaPath(id string) string { return filepath.Join(s.noteDir(id), "meta.json") }
func (s *Store) histPath(id string) string { return filepath.Join(s.noteDir(id), "history.json") }
func (s *Store) blobPath(hash string) string {
return filepath.Join(s.dir, "blobs", hash[:2], hash)
}
func hashContent(content string) string {
sum := sha256.Sum256([]byte(content))
return hex.EncodeToString(sum[:])
}
func newID() string {
var buf [8]byte
if _, err := rand.Read(buf[:]); err != nil { // improvável; fallback temporal
return fmt.Sprintf("%012x", time.Now().UnixNano()&0xffffffffffff)
}
return hex.EncodeToString(buf[:])[:12]
}
// writeJSONAtomic grava v em JSON de forma atômica (tmp + rename).
func writeJSONAtomic(path string, v any) error {
data, err := json.MarshalIndent(v, "", " ")
if err != nil {
return err
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, append(data, '\n'), 0o640); err != nil {
return err
}
return os.Rename(tmp, path)
}
func readJSON(path string, v any) error {
data, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
return ErrNotFound
}
if err != nil {
return err
}
return json.Unmarshal(data, v)
}
// putBlobArmazenado grava o conteúdo se o blob ainda não existe.
func (s *Store) putBlob(content string) (string, error) {
hash := hashContent(content)
p := s.blobPath(hash)
if _, err := os.Stat(p); err == nil {
return hash, nil
} else if !errors.Is(err, os.ErrNotExist) {
return "", err
}
if err := os.MkdirAll(filepath.Dir(p), 0o750); err != nil {
return "", err
}
tmp := p + ".tmp"
if err := os.WriteFile(tmp, []byte(content), 0o640); err != nil {
return "", err
}
return hash, os.Rename(tmp, p)
}
func (s *Store) getBlob(hash string) (string, error) {
data, err := os.ReadFile(s.blobPath(hash))
if errors.Is(err, os.ErrNotExist) {
return "", ErrNotFound
}
return string(data), err
}
// ---------------------------------------------------------------------------
// Validação e tags
// ---------------------------------------------------------------------------
// tagRe casa #tag: # seguido de letra e então letras/dígitos/_/-.
// Cabeçalhos Markdown ("# Título") têm espaço após # e ficam de fora.
var tagRe = regexp.MustCompile(`(^|[^\p{L}\p{N}_/])#(\p{L}[\p{L}\p{N}_-]{0,63})`)
// ExtractTags normaliza (minúsculas) e deduplica as tags presentes no conteúdo.
func ExtractTags(content string) []string {
seen := map[string]bool{}
var out []string
for _, m := range tagRe.FindAllStringSubmatch(content, -1) {
t := strings.ToLower(m[2])
t = strings.Trim(t, "-_") // "#dev-" e "#dev_" viram "#dev"
if t == "" || seen[t] {
continue
}
seen[t] = true
out = append(out, t)
}
slices.Sort(out)
return out
}
func normalizeFormat(f string) (string, error) {
f = strings.ToLower(strings.TrimPrefix(f, "."))
if _, ok := validFormats[f]; !ok {
return "", fmt.Errorf("%w: formato %q", ErrInvalid, f)
}
return f, nil
}
func cleanNoteFields(title, category *string, format *string) error {
*title = strings.TrimSpace(*title)
if r := []rune(*title); len(r) > maxTitleLen {
*title = string(r[:maxTitleLen])
}
*category = strings.TrimSpace(*category)
if r := []rune(*category); len(r) > maxCategoryLen {
*category = string(r[:maxCategoryLen])
}
f, err := normalizeFormat(*format)
if err != nil {
return err
}
*format = f
return nil
}
func validateContent(content string) error {
if len(content) > MaxContentBytes {
return ErrTooLarge
}
return nil
}
var idRe = regexp.MustCompile(`^[a-z0-9]{6,32}$`)
// ---------------------------------------------------------------------------
// Operações públicas
// ---------------------------------------------------------------------------
// Create grava uma nova nota com sua primeira revisão.
func (s *Store) Create(title, format, category, content, author, message string) (*Note, error) {
if err := cleanNoteFields(&title, &category, &format); err != nil {
return nil, err
}
if err := validateContent(content); err != nil {
return nil, err
}
s.mu.Lock()
defer s.mu.Unlock()
id := newID()
now := time.Now().UTC()
if err := os.MkdirAll(s.noteDir(id), 0o750); err != nil {
return nil, err
}
hash, err := s.putBlob(content)
if err != nil {
return nil, err
}
n := &Note{
ID: id, Title: title, Format: format, Category: category,
Tags: ExtractTags(content), CreatedAt: now, UpdatedAt: now,
Hash: hash, Bytes: len(content),
}
rev := Revision{Hash: hash, Parent: "", Author: fallback(author, "desconhecido"),
When: now, Message: fallback(message, "Criação da nota"), Bytes: len(content)}
if err := writeJSONAtomic(s.metaPath(id), n); err != nil {
return nil, err
}
if err := writeJSONAtomic(s.histPath(id), []Revision{rev}); err != nil {
return nil, err
}
return n, nil
}
// Get devolve os metadados da nota id.
func (s *Store) Get(id string) (*Note, error) {
s.mu.RLock()
defer s.mu.RUnlock()
return s.get(id)
}
func (s *Store) get(id string) (*Note, error) {
if !idRe.MatchString(id) {
return nil, ErrNotFound
}
var n Note
if err := readJSON(s.metaPath(id), &n); err != nil {
if errors.Is(err, ErrNotFound) {
return nil, ErrNotFound
}
return nil, err
}
return &n, nil
}
// Content devolve o conteúdo atual da nota.
func (s *Store) Content(id string) (string, *Note, error) {
s.mu.RLock()
defer s.mu.RUnlock()
n, err := s.get(id)
if err != nil {
return "", nil, err
}
c, err := s.getBlob(n.Hash)
return c, n, err
}
// Update salva metadados e, se o conteúdo mudou, uma nova revisão.
// Um conteúdo inalterado atualiza apenas título/categoria/formato.
func (s *Store) Update(id, title, format, category, content, author, message string) (*Note, error) {
if err := cleanNoteFields(&title, &category, &format); err != nil {
return nil, err
}
if err := validateContent(content); err != nil {
return nil, err
}
s.mu.Lock()
defer s.mu.Unlock()
n, err := s.get(id)
if err != nil {
return nil, err
}
newHash := hashContent(content)
contentChanged := newHash != n.Hash
if contentChanged {
if _, err := s.putBlob(content); err != nil {
return nil, err
}
}
hist, err := s.history(id)
if err != nil {
return nil, err
}
if contentChanged {
rev := Revision{Hash: newHash, Parent: n.Hash, Author: fallback(author, "desconhecido"),
When: time.Now().UTC(), Message: fallback(message, "Edição via editor"), Bytes: len(content)}
hist = append(hist, rev)
if err := writeJSONAtomic(s.histPath(id), hist); err != nil {
return nil, err
}
}
tags := n.Tags
if contentChanged {
tags = ExtractTags(content)
}
now := time.Now().UTC()
meta := &Note{ID: n.ID, Title: title, Format: format, Category: category, Tags: tags,
CreatedAt: n.CreatedAt, UpdatedAt: now, Hash: newHash, Bytes: len(content)}
if err := writeJSONAtomic(s.metaPath(id), meta); err != nil {
return nil, err
}
return meta, nil
}
// Delete remove a nota e faz GC dos blobs que ficaram órfãos.
func (s *Store) Delete(id string) error {
s.mu.Lock()
defer s.mu.Unlock()
if !idRe.MatchString(id) {
return ErrNotFound
}
if _, err := os.Stat(s.noteDir(id)); err != nil {
if errors.Is(err, os.ErrNotExist) {
return ErrNotFound
}
return err
}
if err := os.RemoveAll(s.noteDir(id)); err != nil {
return err
}
return s.gcBlobs()
}
// gcBlobs apaga blobs não referenciados por nenhuma história restante.
func (s *Store) gcBlobs() error {
live := map[string]bool{}
ids, err := s.noteIDs()
if err != nil {
return err
}
for _, id := range ids {
hist, err := s.history(id)
if err != nil {
return err
}
for _, r := range hist {
live[r.Hash] = true
}
}
root := filepath.Join(s.dir, "blobs")
shards, err := os.ReadDir(root)
if err != nil {
return err
}
for _, shard := range shards {
if !shard.IsDir() {
continue
}
dir := filepath.Join(root, shard.Name())
entries, err := os.ReadDir(dir)
if err != nil {
return err
}
for _, e := range entries {
if !live[e.Name()] {
if err := os.Remove(filepath.Join(dir, e.Name())); err != nil {
return err
}
}
}
if remaining, err := os.ReadDir(dir); err == nil && len(remaining) == 0 {
_ = os.Remove(dir)
}
}
return nil
}
func (s *Store) noteIDs() ([]string, error) {
entries, err := os.ReadDir(filepath.Join(s.dir, "notes"))
if err != nil {
return nil, err
}
var ids []string
for _, e := range entries {
if e.IsDir() && idRe.MatchString(e.Name()) {
ids = append(ids, e.Name())
}
}
return ids, nil
}
// List devolve notas conforme f, ordenadas por atualização mais recente.
func (s *Store) List(f Filter) ([]*Note, error) {
f.Category = strings.TrimSpace(f.Category)
f.Tag = strings.ToLower(strings.TrimPrefix(f.Tag, "#"))
f.Query = strings.ToLower(strings.TrimSpace(f.Query))
s.mu.RLock()
defer s.mu.RUnlock()
ids, err := s.noteIDs()
if err != nil {
return nil, err
}
out := make([]*Note, 0, len(ids))
for _, id := range ids {
n, err := s.get(id)
if err != nil {
continue
}
if f.Category != "" && n.Category != f.Category {
continue
}
if f.Tag != "" && !slices.Contains(n.Tags, f.Tag) {
continue
}
if f.Query != "" && !s.matchesQuery(n, f.Query) {
continue
}
out = append(out, n)
}
sort.Slice(out, func(i, j int) bool { return out[i].UpdatedAt.After(out[j].UpdatedAt) })
return out, nil
}
func (s *Store) matchesQuery(n *Note, q string) bool {
if strings.Contains(strings.ToLower(n.Title), q) ||
strings.Contains(strings.ToLower(n.Category), q) ||
slices.ContainsFunc(n.Tags, func(t string) bool { return strings.Contains(t, q) }) {
return true
}
content, err := s.getBlob(n.Hash)
if err != nil {
return false
}
return strings.Contains(strings.ToLower(content), q)
}
// History devolve as revisões da nota, da mais antiga para a mais nova.
func (s *Store) History(id string) ([]Revision, error) {
s.mu.RLock()
defer s.mu.RUnlock()
if _, err := s.get(id); err != nil {
return nil, err
}
return s.history(id)
}
func (s *Store) history(id string) ([]Revision, error) {
var hist []Revision
if err := readJSON(s.histPath(id), &hist); err != nil {
if errors.Is(err, ErrNotFound) {
return nil, fmt.Errorf("história ausente para %s: %w", id, ErrNotFound)
}
return nil, err
}
return hist, nil
}
// RevisionWithContent devolve uma revisão específica com seu conteúdo.
func (s *Store) RevisionWithContent(id, hash string) (*RevisionWithContent, error) {
s.mu.RLock()
defer s.mu.RUnlock()
hist, err := s.history(id)
if err != nil {
return nil, err
}
for _, r := range hist {
if r.Hash == hash {
content, err := s.getBlob(hash)
if err != nil {
return nil, err
}
return &RevisionWithContent{Revision: r, Content: content}, nil
}
}
return nil, ErrRevision
}
// DiffBetween devolve (conteúdo antigo, conteúdo novo) entre uma revisão e
// seu pai. Para a primeira revisão, o antigo é vazio.
func (s *Store) DiffBetween(id, hash string) (before, after string, rev Revision, err error) {
s.mu.RLock()
defer s.mu.RUnlock()
hist, err := s.history(id)
if err != nil {
return "", "", rev, err
}
for _, r := range hist {
if r.Hash == hash {
after, err = s.getBlob(r.Hash)
if err != nil {
return
}
if r.Parent != "" {
before, err = s.getBlob(r.Parent)
if err != nil {
return
}
}
rev = r
return
}
}
return "", "", rev, ErrRevision
}
// Restore cria uma nova revisão com o conteúdo de uma revisão anterior.
// Se o conteúdo já é o atual, devolve ErrUnchanged.
func (s *Store) Restore(id, hash, author string) (*Note, error) {
s.mu.Lock()
defer s.mu.Unlock()
n, err := s.get(id)
if err != nil {
return nil, err
}
content, err := s.getBlob(hash)
if err != nil {
if errors.Is(err, ErrNotFound) {
return nil, ErrRevision
}
return nil, err
}
hist, err := s.history(id)
if err != nil {
return nil, err
}
found := false
for _, r := range hist {
if r.Hash == hash {
found = true
break
}
}
if !found {
return nil, ErrRevision
}
if hash == n.Hash {
return nil, ErrUnchanged
}
now := time.Now().UTC()
newHash, err := s.putBlob(content) // já existe (mesmo hash), no-op
if err != nil {
return nil, err
}
short := hash
if len(short) > 8 {
short = short[:8]
}
rev := Revision{Hash: newHash, Parent: n.Hash, Author: fallback(author, "desconhecido"),
When: now, Message: fmt.Sprintf("Restauração da versão %s", short), Bytes: len(content)}
hist = append(hist, rev)
if err := writeJSONAtomic(s.histPath(id), hist); err != nil {
return nil, err
}
meta := *n
meta.Hash = newHash
meta.Bytes = len(content)
meta.Tags = ExtractTags(content)
meta.UpdatedAt = now
if err := writeJSONAtomic(s.metaPath(id), &meta); err != nil {
return nil, err
}
return &meta, nil
}
// TagCount é uma tag agregada com número de notas.
type TagCount struct {
Tag string `json:"tag"`
Count int `json:"count"`
}
// Tags devolve todas as tags em uso, ordenadas por contagem desc e nome.
func (s *Store) Tags() ([]TagCount, error) {
s.mu.RLock()
defer s.mu.RUnlock()
counts := map[string]int{}
ids, err := s.noteIDs()
if err != nil {
return nil, err
}
for _, id := range ids {
n, err := s.get(id)
if err != nil {
continue
}
for _, t := range n.Tags {
counts[t]++
}
}
out := make([]TagCount, 0, len(counts))
for t, c := range counts {
out = append(out, TagCount{Tag: t, Count: c})
}
sort.Slice(out, func(i, j int) bool {
if out[i].Count != out[j].Count {
return out[i].Count > out[j].Count
}
return out[i].Tag < out[j].Tag
})
return out, nil
}
// CatCount é uma categoria agregada com número de notas. "" = sem categoria.
type CatCount struct {
Category string `json:"category"`
Count int `json:"count"`
}
// Categories devolve as categorias em uso.
func (s *Store) Categories() ([]CatCount, error) {
s.mu.RLock()
defer s.mu.RUnlock()
counts := map[string]int{}
ids, err := s.noteIDs()
if err != nil {
return nil, err
}
for _, id := range ids {
n, err := s.get(id)
if err != nil {
continue
}
counts[n.Category]++
}
out := make([]CatCount, 0, len(counts))
for c, k := range counts {
out = append(out, CatCount{Category: c, Count: k})
}
sort.Slice(out, func(i, j int) bool {
if (out[i].Category == "") != (out[j].Category == "") {
return out[j].Category == "" // sem categoria por último
}
return out[i].Category < out[j].Category
})
return out, nil
}
func fallback(v, def string) string {
v = strings.TrimSpace(v)
if v == "" {
return def
}
if r := []rune(v); len(r) > 80 {
v = string(r[:80])
}
return v
}
+241
View File
@@ -0,0 +1,241 @@
package store
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
)
func newTestStore(t *testing.T) *Store {
t.Helper()
s, err := New(t.TempDir())
if err != nil {
t.Fatalf("New: %v", err)
}
return s
}
func TestCreateExtractsTagsAndIgnoresHeadings(t *testing.T) {
s := newTestStore(t)
content := "# Título do post\n\nUso #Linux e #devops.\n#linux repetida, #Hardware\n"
n, err := s.Create("Boas vindas", FormatMD, "infra", content, "lg", "")
if err != nil {
t.Fatalf("Create: %v", err)
}
if len(n.Tags) != 3 {
t.Fatalf("esperava 3 tags (devops, hardware, linux), obtive %v", n.Tags)
}
for _, tag := range []string{"devops", "hardware", "linux"} {
if !contains(n.Tags, tag) {
t.Errorf("tag %q ausente em %v", tag, n.Tags)
}
}
// cabeçalho "# Título" não deve virar tag ("Título" nem entraria, e "título" tem acento)
for _, tag := range n.Tags {
if strings.Contains(tag, "t") && strings.HasPrefix(tag, "tí") {
t.Errorf("cabeçalho vazou como tag: %v", n.Tags)
}
}
if n.Hash == "" || n.Bytes != len(content) {
t.Errorf("hash/bytes errados: %q %d", n.Hash, n.Bytes)
}
}
func contains(xs []string, x string) bool {
for _, v := range xs {
if v == x {
return true
}
}
return false
}
func TestUpdateCreatesRevisionOnlyOnContentChange(t *testing.T) {
s := newTestStore(t)
n, err := s.Create("nota", FormatTxt, "", "v1", "lg", "")
if err != nil {
t.Fatal(err)
}
h1, err := s.History(n.ID)
if err != nil || len(h1) != 1 {
t.Fatalf("história inicial: %v %v", len(h1), err)
}
// Só metadados: nenhuma revisão nova.
if _, err := s.Update(n.ID, "renomeada", FormatTxt, "pasta", "v1", "lg", ""); err != nil {
t.Fatal(err)
}
if h, _ := s.History(n.ID); len(h) != 1 {
t.Fatalf("conteúdo inalterado não deveria criar revisão; história: %+v", h)
}
// Conteúdo novo: segunda revisão com parent correto.
n2, err := s.Update(n.ID, "renomeada", FormatTxt, "pasta", "v2", "lg", "ajuste final")
if err != nil {
t.Fatal(err)
}
h2, err := s.History(n.ID)
if err != nil || len(h2) != 2 {
t.Fatalf("esperava 2 revisões, obtive %d (%v)", len(h2), err)
}
if h2[1].Parent != h1[0].Hash || h2[1].Hash != n2.Hash {
t.Errorf("corrente de revisões quebrada: %+v", h2)
}
if h2[1].Message != "ajuste final" {
t.Errorf("mensagem: %q", h2[1].Message)
}
content, _, err := s.Content(n.ID)
if err != nil || content != "v2" {
t.Fatalf("content=%q err=%v", content, err)
}
}
func TestRestoreAppendsRevisionWithOldContent(t *testing.T) {
s := newTestStore(t)
n, _ := s.Create("r", FormatMD, "", "primeiro", "", "")
if _, err := s.Update(n.ID, "r", FormatMD, "", "segundo", "", ""); err != nil {
t.Fatal(err)
}
first, err := s.History(n.ID)
if err != nil {
t.Fatal(err)
}
restored, err := s.Restore(n.ID, first[0].Hash, "lg")
if err != nil {
t.Fatalf("Restore: %v", err)
}
if restored.Hash != first[0].Hash {
t.Errorf("nota restaurada deveria apontar para o blob antigo: %+v", restored)
}
content, _, err := s.Content(n.ID)
if err != nil || content != "primeiro" {
t.Fatalf("pós-resturo content=%q err=%v", content, err)
}
h, _ := s.History(n.ID)
if len(h) != 3 || !strings.HasPrefix(h[2].Message, "Restauração da versão") {
t.Fatalf("história pós-resturo: %+v", h)
}
// Restaurar a versão já atual devolve ErrUnchanged.
if _, err := s.Restore(n.ID, first[0].Hash, "lg"); !errors.Is(err, ErrUnchanged) {
t.Errorf("esperava ErrUnchanged, obtive %v", err)
}
// Hash desconhecido devolve ErrRevision.
if _, err := s.Restore(n.ID, strings.Repeat("f", 64), "lg"); !errors.Is(err, ErrRevision) {
t.Errorf("esperava ErrRevision, obtive %v", err)
}
}
func TestListFiltersAndSorts(t *testing.T) {
s := newTestStore(t)
a, _ := s.Create("Alpha linux", FormatMD, "dev", "conteúdo #linux", "", "")
s.Update(a.ID, "Alpha linux", FormatMD, "dev", "conteúdo #linux editado", "", "")
b, _ := s.Create("Beta", FormatTxt, "casa", "compras", "", "")
c, _ := s.Create("Gamma docker", FormatHTML, "dev", "<p>#devops #linux</p>", "", "")
all, err := s.List(Filter{})
if err != nil || len(all) != 3 {
t.Fatalf("list tudo: %d notas, %v", len(all), err)
}
if all[0].ID != c.ID { // mais recente primeiro (gamma criada por último)
t.Errorf("ordenação por UpdatedAt errada: %v", ids(all))
}
byCat, _ := s.List(Filter{Category: "dev"})
if len(byCat) != 2 {
t.Errorf("categoria dev: %+v", ids(byCat))
}
byTag, _ := s.List(Filter{Tag: "#linux"})
if len(byTag) != 2 { // alpha (md) e gamma (html) contêm #linux
t.Errorf("tag linux: %+v", ids(byTag))
}
byQ, _ := s.List(Filter{Query: "compras"})
if len(byQ) != 1 || byQ[0].ID != b.ID {
t.Errorf("busca em conteúdo 'compras': %+v", ids(byQ))
}
tags, _ := s.Tags()
if len(tags) != 2 || tags[0].Tag != "linux" || tags[0].Count != 2 {
t.Errorf("agregação de tags: %+v", tags)
}
cats, _ := s.Categories()
if len(cats) != 2 || cats[0].Category != "casa" || cats[1].Category != "dev" {
t.Errorf("agregação de categorias: %+v", cats)
}
}
func ids(ns []*Note) []string {
var out []string
for _, n := range ns {
out = append(out, n.ID)
}
return out
}
func TestDeleteGCsOrphanBlobs(t *testing.T) {
s := newTestStore(t)
n, _ := s.Create("gc", FormatTxt, "", "conteúdo único para o blob", "", "")
blob := filepath.Join(s.dir, "blobs", n.Hash[:2], n.Hash)
if _, err := os.Stat(blob); err != nil {
t.Fatalf("blob deveria existir: %v", err)
}
if err := s.Delete(n.ID); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(blob); !errors.Is(err, os.ErrNotExist) {
t.Errorf("blob órfão não coletado")
}
if _, err := s.Get(n.ID); !errors.Is(err, ErrNotFound) {
t.Errorf("Get pós-delete: %v", err)
}
if err := s.Delete(n.ID); !errors.Is(err, ErrNotFound) {
t.Errorf("Delete duplo deveria dar ErrNotFound, obtive %v", err)
}
}
func TestSharedBlobSurvivesDeleteOfOneNote(t *testing.T) {
s := newTestStore(t)
a, _ := s.Create("a", FormatTxt, "", "mesmo", "", "")
b, _ := s.Create("b", FormatTxt, "", "mesmo", "", "") // mesmo hash
if a.Hash != b.Hash {
t.Fatal("blobs deveriam ser deduplicados por conteúdo")
}
if err := s.Delete(a.ID); err != nil {
t.Fatal(err)
}
content, _, err := s.Content(b.ID)
if err != nil || content != "mesmo" {
t.Fatalf("nota sobrevivente perdeu conteúdo: %q %v", content, err)
}
}
func TestInvalidFormatAndOversizeContent(t *testing.T) {
s := newTestStore(t)
if _, err := s.Create("x", "rtf", "", "", "", ""); !errors.Is(err, ErrInvalid) {
t.Errorf("formato inválido: %v", err)
}
if _, err := s.Update("naoexiste1", "x", FormatTxt, "", "a", "", ""); !errors.Is(err, ErrNotFound) {
t.Errorf("update em nota ausente: %v", err)
}
if _, err := s.Create("x", FormatTxt, "", strings.Repeat("a", MaxContentBytes+1), "", ""); !errors.Is(err, ErrTooLarge) {
t.Errorf("conteúdo gigante: %v", err)
}
}
func TestFilenameAndRevisionContent(t *testing.T) {
s := newTestStore(t)
n, _ := s.Create("minha/nota: ruim", FormatMD, "", "oi #t", "", "")
if got := n.Filename(); got != "minhanota ruim.md" {
t.Errorf("filename: %q", got)
}
rc, err := s.RevisionWithContent(n.ID, n.Hash)
if err != nil || rc.Content != "oi #t" {
t.Fatalf("RevisionWithContent: %+v %v", rc, err)
}
if _, err := s.RevisionWithContent(n.ID, strings.Repeat("0", 64)); !errors.Is(err, ErrRevision) {
t.Errorf("hash inexistente: %v", err)
}
before, after, _, err := s.DiffBetween(n.ID, n.Hash)
if err != nil || before != "" || after != "oi #t" {
t.Fatalf("DiffBetween primeira revisão: %q %q %v", before, after, err)
}
}
+704
View File
@@ -0,0 +1,704 @@
/* Notas — tema claro sóbrio inspirado no Mousepad (XFCE/Greybird). */
:root {
--bg: #f6f5f4;
--panel: #ffffff;
--panel-2: #f0eeeb;
--border: #cdc7c2;
--border-soft: #dedad6;
--fg: #2e3436;
--muted: #787d80;
--accent: #3584e4;
--accent-dark: #1c71d8;
--danger: #c01c28;
--success: #26a269;
--warn: #e5a50a;
--diff-add: #d8f0dd;
--diff-del: #f8d7da;
--diff-hunk: #dce8fa;
--mono: "DejaVu Sans Mono", "Ubuntu Mono", "Cascadia Code", Consolas, monospace;
--sans: "Cantarell", "Noto Sans", "DejaVu Sans", "Segoe UI", sans-serif;
/* editor overlay */
--editor-font: var(--mono);
--editor-size: 14px;
--line-h: 22px;
--pad-x: 16px;
--pad-y: 14px;
/* cores de tokens (tema claro Greybird) */
--tk-heading: #1c5d99;
--tk-strong: #8f3f00;
--tk-em: #613583;
--tk-strike: #7c4a03;
--tk-code: #0f6b46;
--tk-code-bg: #eef1ee;
--tk-fence: #5e5c64;
--tk-link: #1a5fb4;
--tk-url: #57636e;
--tk-tag: #1c71d8;
--tk-mark: #c35c00;
--tk-quote: #57636e;
--tk-html: #a51d2d;
--tk-attr: #8b5a00;
--tk-str: #26734d;
--tk-comment: #888a85;
--tk-punc: #5e5c64;
--tk-ent: #9859ae;
--marker: #e5a50a;
}
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0;
font-family: var(--sans);
font-size: 14px;
color: var(--fg);
background: var(--bg);
}
.hidden { display: none !important; }
.muted { color: var(--muted); font-size: 12px; }
code { font-family: var(--mono); font-size: 0.92em; }
#app { display: flex; flex-direction: column; height: 100vh; }
/* ------------------------------------------------------------------ menus */
#menubar {
display: flex;
align-items: stretch;
height: 30px;
background: var(--panel-2);
border-bottom: 1px solid var(--border);
padding: 0 6px;
flex: none;
}
.menu-brand { display: flex; align-items: center; gap: 6px; padding-right: 8px; }
.brand-title { font-weight: 700; font-size: 13px; letter-spacing: .2px; }
.menu { position: relative; display: flex; }
.menu-btn {
border: 0;
background: none;
font: inherit;
font-size: 13px;
color: var(--fg);
padding: 0 11px;
height: 100%;
cursor: pointer;
border-radius: 0;
}
.menu-btn:hover, .menu.open .menu-btn { background: var(--accent); color: #fff; }
#btn-sidebar { width: 34px; border-radius: 3px; margin: 3px 0; }
#btn-sidebar:hover { background: #e0dcd8; }
.menu-dropdown {
display: none;
position: absolute;
top: 100%;
left: 0;
z-index: 40;
min-width: 240px;
margin: 0;
padding: 4px 0;
list-style: none;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 0 0 4px 4px;
box-shadow: 0 6px 18px rgba(0,0,0,.18);
}
.menu.open .menu-dropdown { display: block; }
.menu-dropdown li {
padding: 5px 14px;
font-size: 13px;
cursor: pointer;
display: flex;
justify-content: space-between;
gap: 18px;
white-space: nowrap;
}
.menu-dropdown li:hover { background: var(--accent); color: #fff; }
.menu-dropdown li:hover .accel { color: #e5effb; }
.menu-dropdown .accel { color: var(--muted); font-size: 11px; }
.menu-sep { height: 0; padding: 0; margin: 4px 8px; border-top: 1px solid var(--border-soft); cursor: default; }
.menu-sep:hover { background: none; }
/* --------------------------------------------------------------- toolbar */
#toolbar {
display: flex;
align-items: center;
gap: 4px;
height: 38px;
padding: 0 8px;
background: var(--panel);
border-bottom: 1px solid var(--border);
flex: none;
}
.tb-group { display: flex; align-items: center; gap: 2px; }
.tb-caption { font-size: 12px; color: var(--muted); margin-right: 4px; }
.tb-sep { width: 1px; height: 22px; background: var(--border-soft); margin: 0 6px; }
.tb-grow { flex: 1; }
.tb-label { font-size: 12px; margin-left: 4px; }
.btn {
font: inherit;
font-size: 13px;
color: var(--fg);
background: var(--panel-2);
border: 1px solid var(--border);
border-radius: 3px;
padding: 3px 10px;
cursor: pointer;
}
.btn:hover { background: #e6e2dd; }
.btn:active { border-color: var(--accent-dark); }
.btn:disabled { opacity: .45; cursor: default; }
.btn-icon { display: inline-flex; align-items: center; padding: 3px 8px; }
.btn-primary { background: var(--accent); border-color: var(--accent-dark); color: #fff; }
.btn-primary:hover { background: var(--accent-dark); }
.btn-danger { background: var(--danger); border-color: #9c1720; color: #fff; }
.btn-small { font-size: 12px; padding: 2px 8px; }
.fmt { min-width: 30px; justify-content: center; font-size: 13px; }
#fmt-tools.disabled { opacity: .45; pointer-events: none; }
#toolbar select {
font: inherit;
font-size: 13px;
color: var(--fg);
background: var(--panel);
border: 1px solid var(--border);
border-radius: 3px;
padding: 3px 6px;
}
.segmented { display: inline-flex; border: 1px solid var(--border); border-radius: 4px; overflow: hidden; }
.segmented .seg {
border: 0;
background: var(--panel-2);
font: inherit;
font-size: 12px;
padding: 4px 12px;
cursor: pointer;
color: var(--fg);
}
.segmented .seg + .seg { border-left: 1px solid var(--border); }
.segmented .seg.active { background: var(--accent); color: #fff; }
/* ---------------------------------------------------------------- layout */
#layout { display: flex; flex: 1 1 auto; min-height: 0; }
/* sidebar ------------------------------------------------------------- */
#sidebar {
width: 272px;
flex: none;
display: flex;
flex-direction: column;
background: var(--panel-2);
border-right: 1px solid var(--border);
overflow-y: auto;
}
body.sidebar-off #sidebar { display: none; }
.side-section { padding: 8px 10px; border-bottom: 1px solid var(--border-soft); }
.side-label {
display: block;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: .4px;
color: var(--muted);
margin-bottom: 6px;
}
#sidebar input[type="search"], #sidebar select {
width: 100%;
font: inherit;
font-size: 13px;
padding: 5px 8px;
border: 1px solid var(--border);
border-radius: 3px;
background: var(--panel);
color: var(--fg);
}
#tag-cloud { display: flex; flex-wrap: wrap; gap: 4px; }
.tag-chip {
font: inherit;
font-size: 12px;
border: 1px solid var(--border);
background: var(--panel);
color: var(--accent-dark);
border-radius: 10px;
padding: 1px 8px;
cursor: pointer;
}
.tag-chip:hover { border-color: var(--accent); }
.tag-chip.active { background: var(--accent); border-color: var(--accent-dark); color: #fff; }
.tag-chip .tag-n { color: var(--muted); margin-left: 3px; font-size: 10px; }
.tag-chip.active .tag-n { color: #dbe9fb; }
.side-notes { flex: 1 1 auto; border-bottom: 0; display: flex; flex-direction: column; }
.side-listhead { display: flex; justify-content: space-between; align-items: baseline; }
#notes-count { font-size: 11px; }
#notes-list { list-style: none; margin: 0; padding: 0; overflow-y: auto; }
#notes-list li {
padding: 7px 10px;
border-bottom: 1px solid var(--border-soft);
cursor: pointer;
}
#notes-list li:hover { background: #e9e6e2; }
#notes-list li.active { background: #cfe2fa; box-shadow: inset 3px 0 0 var(--accent); }
.n-title { font-size: 13px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.n-sub { font-size: 11px; color: var(--muted); margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.n-ext { color: var(--accent-dark); font-family: var(--mono); font-size: 10px; }
/* editor ------------------------------------------------------------ */
#editor-pane { flex: 1 1 auto; display: flex; flex-direction: column; min-width: 0; background: var(--panel); }
#note-meta {
display: flex;
gap: 8px;
padding: 8px 10px;
border-bottom: 1px solid var(--border-soft);
background: var(--panel);
flex: none;
}
#note-title {
flex: 1 1 auto;
font-size: 15px;
font-weight: 600;
border: 1px solid transparent;
border-radius: 3px;
padding: 4px 8px;
background: var(--panel-2);
color: var(--fg);
}
#note-title:focus, .meta-cat:focus { outline: 2px solid var(--accent); outline-offset: -2px; }
.meta-cat {
flex: 0 0 220px;
font-size: 13px;
border: 1px solid var(--border);
border-radius: 3px;
padding: 4px 8px;
background: var(--panel);
color: var(--fg);
}
#editor-host { flex: 1 1 auto; display: flex; min-height: 0; position: relative; }
/* editor com overlay de realce (textarea transparente sobre #hl) */
#editor-wrap { flex: 1 1 auto; display: flex; min-width: 0; min-height: 0; }
#gutter {
flex: none;
overflow: hidden;
background: var(--panel-2);
border-right: 1px solid var(--border-soft);
user-select: none;
}
body.no-gutter #gutter { display: none; }
#gutter-inner { padding: var(--pad-y) 0; }
.ln {
height: var(--line-h);
font-family: var(--editor-font);
font-size: var(--editor-size);
line-height: var(--line-h);
text-align: right;
color: var(--muted);
padding: 0 9px 0 12px;
min-width: 42px;
}
#code-area { position: relative; flex: 1 1 auto; min-width: 0; }
#hl {
position: absolute;
inset: 0;
z-index: 1;
overflow: hidden;
padding: var(--pad-y) var(--pad-x);
font-family: var(--editor-font);
font-size: var(--editor-size);
line-height: var(--line-h);
color: var(--fg);
white-space: pre-wrap;
overflow-wrap: break-word;
pointer-events: none;
tab-size: 4;
}
#hl.nowrap { white-space: pre; overflow-wrap: normal; }
.hl-line { min-height: var(--line-h); }
#col-marker {
position: absolute;
top: 0;
bottom: 0;
width: 1px;
background: var(--marker);
opacity: .6;
z-index: 2;
pointer-events: none;
}
#editor {
position: absolute;
inset: 0;
z-index: 3;
width: 100%;
height: 100%;
border: 0;
resize: none;
overflow: auto;
padding: var(--pad-y) var(--pad-x);
font-family: var(--editor-font);
font-size: var(--editor-size);
line-height: var(--line-h);
background: transparent;
color: transparent;
caret-color: var(--fg);
tab-size: 4;
}
#editor:focus { outline: none; }
#editor::selection { background: rgba(53, 132, 228, .30); }
#editor::placeholder { color: var(--muted); }
/* tokens de colorização */
.tk-h { color: var(--tk-heading); font-weight: 700; }
.tk-strong { color: var(--tk-strong); font-weight: 700; }
.tk-em { color: var(--tk-em); font-style: italic; }
.tk-strike { color: var(--tk-strike); text-decoration: line-through; }
.tk-code { color: var(--tk-code); background: var(--tk-code-bg); }
.tk-fence { color: var(--tk-fence); }
.tk-fline { color: var(--tk-code); }
.tk-link { color: var(--tk-link); text-decoration: underline; }
.tk-url { color: var(--tk-url); text-decoration: underline; }
.tk-tag { color: var(--tk-tag); font-weight: 600; }
.tk-mark { color: var(--tk-mark); font-weight: 700; }
.tk-quote { color: var(--tk-quote); font-style: italic; }
.tk-html { color: var(--tk-html); }
.tk-attr { color: var(--tk-attr); }
.tk-str { color: var(--tk-str); }
.tk-comment{ color: var(--tk-comment); font-style: italic; }
.tk-punc { color: var(--tk-punc); }
.tk-ent { color: var(--tk-ent); }
#preview {
flex: 1 1 auto;
overflow-y: auto;
padding: 16px 22px;
background: var(--panel);
white-space: pre-wrap; /* usado no modo txt; sobrescrito pelas regras md */
}
#preview-frame { flex: 1 1 auto; width: 100%; border: 0; background: #fff; }
/* preview markdown --------------------------------------------------- */
#preview.md { white-space: normal; line-height: 1.55; }
#preview.md h1, #preview.md h2, #preview.md h3 { margin: 18px 0 8px; line-height: 1.25; }
#preview.md h1 { font-size: 1.7em; border-bottom: 1px solid var(--border-soft); padding-bottom: 4px; }
#preview.md h2 { font-size: 1.35em; }
#preview.md p { margin: 8px 0; }
#preview.md ul, #preview.md ol { margin: 8px 0; padding-left: 26px; }
#preview.md blockquote {
margin: 10px 0; padding: 4px 14px; color: var(--muted);
border-left: 3px solid var(--accent); background: var(--panel-2);
}
#preview.md pre {
background: var(--panel-2); border: 1px solid var(--border-soft);
border-radius: 4px; padding: 10px 12px; overflow-x: auto;
}
#preview.md code { background: var(--panel-2); padding: 1px 4px; border-radius: 3px; }
#preview.md pre code { background: none; padding: 0; }
#preview.md table { border-collapse: collapse; margin: 10px 0; }
#preview.md th, #preview.md td { border: 1px solid var(--border); padding: 4px 10px; }
#preview.md a { color: var(--accent-dark); }
#preview.md hr { border: 0; border-top: 1px solid var(--border); margin: 16px 0; }
/* painel de versões --------------------------------------------------- */
#versions-panel {
width: 350px;
flex: none;
display: flex;
flex-direction: column;
background: var(--panel-2);
border-left: 1px solid var(--border);
min-height: 0;
}
.panel-head {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 10px;
border-bottom: 1px solid var(--border-soft);
}
.panel-head .side-label { margin: 0; }
#revisions-list { list-style: none; margin: 0; padding: 0; overflow-y: auto; flex: 0 1 40%; }
#revisions-list li { padding: 7px 10px; border-bottom: 1px solid var(--border-soft); cursor: pointer; }
#revisions-list li:hover { background: #e9e6e2; }
#revisions-list li.active { background: #cfe2fa; box-shadow: inset 3px 0 0 var(--accent); }
.rev-top { display: flex; justify-content: space-between; font-size: 12px; gap: 8px; }
.rev-hash { font-family: var(--mono); color: var(--accent-dark); font-size: 11px; }
.rev-msg { font-size: 12px; margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rev-when { font-size: 11px; color: var(--muted); }
#version-detail {
flex: 1 1 auto;
display: flex;
flex-direction: column;
min-height: 0;
border-top: 2px solid var(--border);
background: var(--panel);
}
.diff-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
padding: 8px 10px;
border-bottom: 1px solid var(--border-soft);
}
#version-info { font-size: 12px; }
#version-diff {
flex: 1 1 auto;
margin: 0;
overflow: auto;
padding: 8px 10px;
font-family: var(--mono);
font-size: 11.5px;
line-height: 1.5;
background: var(--panel);
white-space: pre;
}
.d-add { display: block; background: var(--diff-add); }
.d-del { display: block; background: var(--diff-del); }
.d-hunk { display: block; color: var(--accent-dark); background: var(--diff-hunk); }
.d-head { display: block; color: var(--muted); }
.panel-foot { padding: 8px 10px; border-top: 1px solid var(--border-soft); display: flex; flex-direction: column; gap: 6px; }
.panel-foot input {
font: inherit;
font-size: 12.5px;
padding: 5px 8px;
border: 1px solid var(--border);
border-radius: 3px;
background: var(--panel);
color: var(--fg);
}
/* barra de status ------------------------------------------------------ */
#statusbar {
display: flex;
align-items: center;
gap: 4px;
height: 26px;
padding: 0 8px;
background: var(--panel-2);
border-top: 1px solid var(--border);
font-size: 12px;
color: var(--muted);
flex: none;
}
.st-item {
padding: 0 10px;
border-right: 1px solid var(--border-soft);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.st-grow { flex: 1; }
#st-saved.dirty { color: var(--warn); font-weight: 600; }
#st-saved.ok { color: var(--success); }
/* overlays / diálogos --------------------------------------------------- */
.overlay {
position: fixed;
inset: 0;
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
background: rgba(20, 20, 20, .45);
}
.dialog {
width: min(440px, 92vw);
background: var(--panel);
border: 1px solid var(--border);
border-radius: 6px;
box-shadow: 0 12px 40px rgba(0,0,0,.35);
padding: 18px 20px;
}
.dialog h1 { margin: 0 0 6px; font-size: 18px; }
.dialog-hint { margin: 0 0 12px; font-size: 13px; color: var(--muted); }
.dialog input {
width: 100%;
font: inherit;
font-size: 14px;
padding: 7px 10px;
border: 1px solid var(--border);
border-radius: 4px;
}
.dialog-error { color: var(--danger); font-size: 12.5px; margin: 8px 0 0; }
.dialog-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; }
#modal-body { font-size: 13px; line-height: 1.6; }
#modal-body code { background: var(--panel-2); padding: 1px 5px; border-radius: 3px; }
#toast {
position: fixed;
left: 50%;
bottom: 40px;
transform: translateX(-50%);
z-index: 120;
background: #2e3436;
color: #fff;
font-size: 13px;
padding: 8px 18px;
border-radius: 4px;
box-shadow: 0 6px 18px rgba(0,0,0,.3);
}
/* responsivo simples -----------------------------------------------------*/
@media (max-width: 940px) {
#sidebar { position: absolute; left: 0; top: 68px; bottom: 26px; z-index: 30; box-shadow: 4px 0 14px rgba(0,0,0,.12); }
#versions-panel { position: absolute; right: 0; top: 68px; bottom: 26px; z-index: 30; box-shadow: -4px 0 14px rgba(0,0,0,.12); }
}
/* ---------------------------------------------------- temas / esquemas */
html[data-theme="escuro"] {
--bg: #232323;
--panel: #1e1e1e;
--panel-2: #262626;
--border: #454545;
--border-soft: #383838;
--fg: #dbdbdb;
--muted: #9a9a9a;
--accent: #3584e4;
--accent-dark: #62a0ea;
--danger: #f66151;
--success: #33d17a;
--warn: #f5c211;
--diff-add: #22352a;
--diff-del: #3d2629;
--diff-hunk: #24334d;
--tk-heading: #73c2ff;
--tk-strong: #ffa348;
--tk-em: #c58fe0;
--tk-strike: #e8b07d;
--tk-code: #57e389;
--tk-code-bg: #2a3330;
--tk-fence: #b0b0b0;
--tk-link: #78aeff;
--tk-url: #a5a5a5;
--tk-tag: #78aeff;
--tk-mark: #ffb84d;
--tk-quote: #9a9a9a;
--tk-html: #ff7b6b;
--tk-attr: #ffc27d;
--tk-str: #6bd490;
--tk-comment: #6f6f6f;
--tk-punc: #b0b0b0;
--tk-ent: #d99ce8;
--marker: #a86f00;
}
html[data-theme="escuro"] #editor::selection { background: rgba(120, 174, 255, .28); }
html[data-theme="escuro"] #notes-list li.active,
html[data-theme="escuro"] #revisions-list li.active { background: #2b3f5c; }
html[data-theme="escuro"] #notes-list li:hover,
html[data-theme="escuro"] #revisions-list li:hover { background: #303030; }
html[data-theme="solar-claro"] {
--bg: #eee8d5;
--panel: #fdf6e3;
--panel-2: #f4efdc;
--border: #d9cfb0;
--border-soft: #e4dcc2;
--fg: #586e75;
--muted: #93a1a1;
--accent: #268bd2;
--accent-dark: #1a6bab;
--danger: #dc322f;
--success: #859900;
--warn: #b58900;
--diff-add: #e6edc9;
--diff-del: #f6ddd8;
--diff-hunk: #d8e6f3;
--tk-heading: #268bd2;
--tk-strong: #cb4b16;
--tk-em: #6c71c4;
--tk-strike: #b58900;
--tk-code: #2aa198;
--tk-code-bg: #efe9d4;
--tk-fence: #93a1a1;
--tk-link: #268bd2;
--tk-url: #93a1a1;
--tk-tag: #268bd2;
--tk-mark: #cb4b16;
--tk-quote: #93a1a1;
--tk-html: #dc322f;
--tk-attr: #b58900;
--tk-str: #859900;
--tk-comment: #aaa79b;
--tk-punc: #93a1a1;
--tk-ent: #d33682;
--marker: #b58900;
}
html[data-theme="solar-escuro"] {
--bg: #073642;
--panel: #002b36;
--panel-2: #0a3d4a;
--border: #2b5662;
--border-soft: #1b4552;
--fg: #93a1a1;
--muted: #657b83;
--accent: #268bd2;
--accent-dark: #2aa198;
--danger: #dc322f;
--success: #859900;
--warn: #b58900;
--diff-add: #0d4440;
--diff-del: #3c2a2e;
--diff-hunk: #0a3d5c;
--tk-heading: #268bd2;
--tk-strong: #cb4b16;
--tk-em: #6c71c4;
--tk-strike: #b58900;
--tk-code: #2aa198;
--tk-code-bg: #0a3d4a;
--tk-fence: #586e75;
--tk-link: #268bd2;
--tk-url: #657b83;
--tk-tag: #2aa198;
--tk-mark: #cb4b16;
--tk-quote: #657b83;
--tk-html: #dc322f;
--tk-attr: #b58900;
--tk-str: #859900;
--tk-comment: #586e75;
--tk-punc: #657b83;
--tk-ent: #d33682;
--marker: #b58900;
}
html[data-theme="solar-escuro"] #editor::selection { background: rgba(38, 139, 210, .30); }
html[data-theme="solar-escuro"] #notes-list li.active,
html[data-theme="solar-escuro"] #revisions-list li.active { background: #0a4a63; }
html[data-theme="solar-escuro"] #notes-list li:hover,
html[data-theme="solar-escuro"] #revisions-list li:hover { background: #0c4450; }
/* ------------------------------------------------- diálogo preferências */
.dialog-wide { width: min(560px, 92vw); }
.dialog fieldset {
border: 1px solid var(--border-soft);
border-radius: 4px;
margin: 12px 0 0;
padding: 8px 14px 12px;
}
.dialog legend { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .4px; color: var(--muted); padding: 0 6px; }
.dialog .row {
display: flex;
align-items: center;
gap: 8px;
margin: 8px 0 0;
font-size: 13.5px;
color: var(--fg);
}
.dialog .row input[type="checkbox"] { width: auto; margin: 0; }
.dialog .row select {
flex: 1 1 auto;
font: inherit;
font-size: 13px;
padding: 4px 8px;
border: 1px solid var(--border);
border-radius: 3px;
background: var(--panel);
color: var(--fg);
}
.dialog .num { width: 74px; flex: none; }
+9
View File
@@ -0,0 +1,9 @@
// Package web embute o frontend estático no binário Go.
package web
import "embed"
// FS contém index.html, css/ e js/ na raiz.
//
//go:embed index.html css js
var FS embed.FS
+254
View File
@@ -0,0 +1,254 @@
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light">
<title>Notas — editor estilo Mousepad</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><rect width='16' height='16' rx='2' fill='%233584e4'/><path d='M3 4h10M3 7h10M3 10h7' stroke='white' stroke-width='1.5' stroke-linecap='round'/></svg>">
<link rel="stylesheet" href="/css/app.css">
</head>
<body>
<!-- Diálogo de login (token) -->
<div id="login-overlay" class="overlay hidden">
<form id="login-form" class="dialog" autocomplete="off">
<h1>Notas</h1>
<p class="dialog-hint">Digite o token de acesso (variável <code>NOTES_TOKEN</code> do servidor).</p>
<input id="login-token" type="password" placeholder="token de acesso" autofocus required>
<p id="login-error" class="dialog-error hidden"></p>
<div class="dialog-actions">
<button type="submit" id="login-submit" class="btn btn-primary">Entrar</button>
</div>
</form>
</div>
<!-- Diálogo de Preferências do editor -->
<div id="prefs-overlay" class="overlay hidden">
<form id="prefs-form" class="dialog dialog-wide" autocomplete="off">
<h1>Preferências do editor</h1>
<fieldset>
<legend>Exibição</legend>
<label class="row"><input type="checkbox" id="pf-gutter"> Mostrar números de linha</label>
<label class="row"><input type="checkbox" id="pf-wrap"> Quebrar linha automaticamente</label>
<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>
</fieldset>
<fieldset>
<legend>Fonte e tema</legend>
<label class="row">Fonte
<select id="pf-font">
<option value="mono">DejaVu Sans Mono (padrão)</option>
<option value="ubuntu">Ubuntu Mono</option>
<option value="cascadia">Cascadia Code</option>
<option value="liberation">Liberation Mono</option>
<option value="courier">Courier New</option>
<option value="system">Monoespaçada do sistema</option>
</select>
</label>
<label class="row">Tamanho <input type="number" id="pf-size" min="10" max="32" step="1" class="num"> px</label>
<label class="row">Esquema de cores
<select id="pf-theme">
<option value="claro">Claro (Greybird — padrão)</option>
<option value="escuro">Escuro (Adwaita)</option>
<option value="solar-claro">Solarizado claro</option>
<option value="solar-escuro">Solarizado escuro</option>
</select>
</label>
</fieldset>
<div class="dialog-actions">
<button type="button" id="pf-reset" class="btn">Restaurar padrões</button>
<span class="st-grow"></span>
<button type="button" id="pf-cancel" class="btn">Cancelar</button>
<button type="submit" class="btn btn-primary">Aplicar</button>
</div>
</form>
</div>
<!-- Modal genérico (Sobre / confirmar) -->
<div id="modal-overlay" class="overlay hidden">
<div class="dialog">
<h1 id="modal-title"></h1>
<div id="modal-body"></div>
<div class="dialog-actions">
<button type="button" id="modal-ok" class="btn btn-primary">OK</button>
<button type="button" id="modal-cancel" class="btn hidden">Cancelar</button>
</div>
</div>
</div>
<div id="app">
<!-- Barra de menus (estilo Mousepad) -->
<header id="menubar" role="menubar">
<div class="menu-brand">
<button type="button" class="menu-btn" id="btn-sidebar" title="Mostrar/ocultar barra lateral">☰</button>
<span class="brand-title">Notas</span>
</div>
<div class="menu" data-menu="arquivo">
<button type="button" class="menu-btn">Arquivo</button>
<ul class="menu-dropdown">
<li data-action="new-note">Nova nota<span class="accel">Ctrl+N</span></li>
<li data-action="save">Salvar<span class="accel">Ctrl+S</span></li>
<li data-action="export">Baixar arquivo (.txt/.md/.html)…</li>
<li class="menu-sep"></li>
<li data-action="delete-note">Excluir esta nota…</li>
</ul>
</div>
<div class="menu" data-menu="editar">
<button type="button" class="menu-btn">Editar</button>
<ul class="menu-dropdown">
<li data-action="undo">Desfazer<span class="accel">Ctrl+Z</span></li>
<li data-action="redo">Refazer<span class="accel">Ctrl+Y</span></li>
<li class="menu-sep"></li>
<li data-action="select-all">Selecionar tudo<span class="accel">Ctrl+A</span></li>
<li class="menu-sep"></li>
<li data-action="prefs">Preferências…<span class="accel">Ctrl+,</span></li>
</ul>
</div>
<div class="menu" data-menu="exibir">
<button type="button" class="menu-btn">Exibir</button>
<ul class="menu-dropdown">
<li data-action="toggle-sidebar">Barra lateral<span class="accel">F9</span></li>
<li data-action="toggle-gutter">Números de linha</li>
<li data-action="toggle-wrap">Quebrar linha</li>
<li class="menu-sep"></li>
<li data-action="toggle-edit">Modo edição<span class="accel">Ctrl+E</span></li>
<li data-action="toggle-preview">Modo visualização<span class="accel">Ctrl+R</span></li>
<li class="menu-sep"></li>
<li data-action="toggle-versions">Painel de versões<span class="accel">Ctrl+H</span></li>
</ul>
</div>
<div class="menu" data-menu="ajuda">
<button type="button" class="menu-btn">Ajuda</button>
<ul class="menu-dropdown">
<li data-action="about">Sobre o Notas</li>
<li data-action="logout">Sair (esquecer token)</li>
</ul>
</div>
</header>
<!-- Barra de ferramentas -->
<div id="toolbar">
<div class="tb-group">
<button type="button" class="btn btn-icon" id="btn-new" title="Nova nota (Ctrl+N)">✚<span class="tb-label">Nova</span></button>
<button type="button" class="btn btn-icon" id="btn-save" title="Salvar (Ctrl+S)">💾<span class="tb-label">Salvar</span></button>
<button type="button" class="btn btn-icon" id="btn-export" title="Baixar arquivo">⬇<span class="tb-label">Baixar</span></button>
</div>
<span class="tb-sep"></span>
<div class="tb-group" id="fmt-tools">
<button type="button" class="btn btn-icon fmt" data-fmt="bold" title="Negrito (Ctrl+B)"><b>B</b></button>
<button type="button" class="btn btn-icon fmt" data-fmt="italic" title="Itálico (Ctrl+I)"><i>I</i></button>
<button type="button" class="btn btn-icon fmt" data-fmt="strike" title="Tachado"><s>S</s></button>
<button type="button" class="btn btn-icon fmt" data-fmt="h1" title="Título 1">H1</button>
<button type="button" class="btn btn-icon fmt" data-fmt="h2" title="Título 2">H2</button>
<button type="button" class="btn btn-icon fmt" data-fmt="list-ul" title="Lista com marcadores">≡•</button>
<button type="button" class="btn btn-icon fmt" data-fmt="list-ol" title="Lista numerada">≡1</button>
<button type="button" class="btn btn-icon fmt" data-fmt="quote" title="Citação">❝</button>
<button type="button" class="btn btn-icon fmt" data-fmt="link" title="Link (Ctrl+K)">🔗</button>
<button type="button" class="btn btn-icon fmt" data-fmt="code" title="Código `inline`">&lt;/&gt;</button>
<button type="button" class="btn btn-icon fmt" data-fmt="block" title="Bloco de código">{ }</button>
</div>
<span class="tb-sep"></span>
<div class="tb-group">
<label class="tb-caption" for="format-select">Formato</label>
<select id="format-select" title="Formato da nota">
<option value="txt">Texto puro .txt</option>
<option value="md" selected>Markdown .md</option>
<option value="html">HTML .html</option>
</select>
</div>
<span class="tb-grow"></span>
<div class="tb-group">
<div class="segmented" id="mode-toggle">
<button type="button" class="seg active" data-mode="edit" title="Editar (Ctrl+E)">Editar</button>
<button type="button" class="seg" data-mode="preview" title="Visualizar (Ctrl+R)">Preview</button>
</div>
<button type="button" class="btn btn-icon" id="btn-versions" title="Painel de versões (Ctrl+H)">🕘<span class="tb-label">Versões</span></button>
</div>
</div>
<!-- Corpo: sidebar + editor + painel de versões -->
<main id="layout">
<aside id="sidebar">
<div class="side-section">
<input id="search-input" type="search" placeholder="Buscar notas…" autocomplete="off">
</div>
<div class="side-section">
<label class="side-label" for="category-select">Categoria</label>
<select id="category-select">
<option value="*">Todas as categorias</option>
<option value="">Sem categoria</option>
</select>
</div>
<div class="side-section">
<span class="side-label">Tags</span>
<div id="tag-cloud"><span class="muted">nenhuma tag ainda</span></div>
</div>
<div class="side-section side-notes">
<div class="side-listhead">
<span class="side-label">Notas</span>
<span id="notes-count" class="muted"></span>
</div>
<ul id="notes-list"></ul>
</div>
</aside>
<section id="editor-pane">
<div id="note-meta">
<input id="note-title" type="text" placeholder="Título da nota" maxlength="200" autocomplete="off">
<input id="note-category" type="text" placeholder="categoria (opcional)" maxlength="100" autocomplete="off" class="meta-cat">
</div>
<div id="editor-host">
<div id="editor-wrap">
<div id="gutter" aria-hidden="true"><div id="gutter-inner"></div></div>
<div id="code-area">
<div id="hl" aria-hidden="true"></div>
<div id="col-marker" class="hidden" aria-hidden="true"></div>
<textarea id="editor" spellcheck="false" placeholder="Comece a escrever… use #tags para organizar (ex.: #linux #devops)"></textarea>
</div>
</div>
<div id="preview" class="hidden"></div>
<iframe id="preview-frame" class="hidden" sandbox="allow-scripts" title="Pré-visualização HTML"></iframe>
</div>
</section>
<aside id="versions-panel" class="hidden">
<div class="panel-head">
<span class="side-label">Histórico de revisões</span>
<button type="button" class="btn btn-icon" id="btn-close-versions" title="Fechar painel">✕</button>
</div>
<ul id="revisions-list"></ul>
<div id="version-detail" class="hidden">
<div class="diff-head">
<span id="version-info"></span>
<button type="button" class="btn btn-primary btn-small" id="btn-restore">Restaurar</button>
</div>
<pre id="version-diff"></pre>
</div>
<div class="panel-foot">
<input id="rev-message" type="text" placeholder="Mensagem da próxima revisão (opcional)" maxlength="500" autocomplete="off">
<input id="author-input" type="text" placeholder="autor (ex.: lg)" maxlength="80" autocomplete="off">
</div>
</aside>
</main>
<!-- Barra de status (estilo Mousepad) -->
<footer id="statusbar">
<span id="st-file" class="st-item">sem nota aberta</span>
<span id="st-format" class="st-item">.txt</span>
<span id="st-pos" class="st-item">Ln 1, Col 1</span>
<span class="st-grow"></span>
<span id="st-stats" class="st-item">0 caracteres</span>
<span id="st-saved" class="st-item">—</span>
</footer>
<div id="toast" class="hidden"></div>
</div>
<script src="/js/app.js"></script>
</body>
</html>
+1081
View File
File diff suppressed because it is too large. Load diff