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:
commit
9bc5de4c56
22 files changed
+4239
No files matched your search
@@ -0,0 +1,10 @@
|
||||
.git
|
||||
.gitignore
|
||||
data/
|
||||
docs/
|
||||
.env
|
||||
.env.example
|
||||
*.md
|
||||
Dockerfile
|
||||
docker-compose.yml
|
||||
notas
|
||||
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
/notas
|
||||
/data/
|
||||
.env
|
||||
*.tmp
|
||||
+33
@@ -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"]
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
}
|
||||
@@ -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:
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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; }
|
||||
@@ -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
@@ -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`"></></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
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user