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,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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user