feat: tag system

This commit is contained in:
2026-02-03 09:41:03 +01:00
parent 3f5cf0d673
commit cb11e34798
11 changed files with 397 additions and 10 deletions

View File

@ -77,6 +77,9 @@ func (tm *TemplateManager) Render(w http.ResponseWriter, name string, data any)
// Include noteList template (used by index)
files = append(files, tm.buildTemplatePath("noteList"))
// Include metadata template (used by index)
files = append(files, tm.buildTemplatePath("metadata"))
// Include metadata template
files = append(files, tm.buildTemplatePath("metadata"))

View File

@ -99,6 +99,7 @@ func TestTemplateManagerRender(t *testing.T) {
baseDir := t.TempDir()
writeTemplate(t, baseDir, "base.tmpl", `{{ define "base" }}<html>{{ template "content" . }}</html>{{ end }}`)
writeTemplate(t, baseDir, "noteList.tmpl", `{{ define "noteList" }}notes{{ end }}`)
writeTemplate(t, baseDir, "metadata.tmpl", `{{ define "metadata" }}meta{{ end }}`)
writeTemplate(t, baseDir, "index.tmpl", `{{ define "content" }}hello{{ end }}`)
writeTemplate(t, baseDir, "metadata.tmpl", `{{ define "noteList" }}notes{{ end }}`)
@ -127,6 +128,7 @@ func TestTemplateManagerRender_MissingTemplate(t *testing.T) {
baseDir := t.TempDir()
writeTemplate(t, baseDir, "base.tmpl", `{{ define "base" }}<html>{{ template "content" . }}</html>{{ end }}`)
writeTemplate(t, baseDir, "noteList.tmpl", `{{ define "noteList" }}notes{{ end }}`)
writeTemplate(t, baseDir, "metadata.tmpl", `{{ define "metadata" }}meta{{ end }}`)
writeTemplate(t, baseDir, "metadata.tmpl", `{{ define "noteList" }}notes{{ end }}`)
tm := NewTemplateManager(baseDir)

View File

@ -4,6 +4,7 @@ import (
"donniemarko/internal/note"
"donniemarko/internal/storage"
"sort"
"strings"
)
type NotesService struct {
@ -91,3 +92,33 @@ func (s *NotesService) GetNoteByHash(hash string) (*note.Note, error) {
func (s *NotesService) GetNotes() []*note.Note {
return s.storage.GetAll()
}
func (s *NotesService) AddTag(noteID, tag string) error {
tag = normalizeTag(tag)
if tag == "" {
return nil
}
if _, err := s.storage.Get(noteID); err != nil {
return err
}
return s.storage.AddTag(noteID, tag)
}
func (s *NotesService) RemoveTag(noteID, tag string) error {
tag = normalizeTag(tag)
if tag == "" {
return nil
}
if _, err := s.storage.Get(noteID); err != nil {
return err
}
return s.storage.RemoveTag(noteID, tag)
}
func normalizeTag(tag string) string {
return strings.ToLower(strings.TrimSpace(tag))
}

View File

@ -46,3 +46,31 @@ func TestQueryNotes_WithSearch(t *testing.T) {
t.Error("wrong note returned")
}
}
func TestTags_NormalizationAndRemove(t *testing.T) {
err := service.AddTag("test1", " Go ")
if err != nil {
t.Fatalf("add tag: %v", err)
}
n, err := service.GetNoteByHash("test1")
if err != nil {
t.Fatalf("get note: %v", err)
}
if len(n.Tags) != 1 || n.Tags[0] != "go" {
t.Fatalf("expected normalized tag, got %+v", n.Tags)
}
err = service.RemoveTag("test1", "GO")
if err != nil {
t.Fatalf("remove tag: %v", err)
}
n, err = service.GetNoteByHash("test1")
if err != nil {
t.Fatalf("get note: %v", err)
}
if len(n.Tags) != 0 {
t.Fatalf("expected tag to be removed, got %+v", n.Tags)
}
}

View File

@ -27,8 +27,23 @@ CREATE TABLE IF NOT EXISTS notes (
published INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS tags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE
);
CREATE TABLE IF NOT EXISTS note_tags (
note_id TEXT NOT NULL,
tag_id INTEGER NOT NULL,
UNIQUE(note_id, tag_id),
FOREIGN KEY(note_id) REFERENCES notes(id) ON DELETE CASCADE,
FOREIGN KEY(tag_id) REFERENCES tags(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS notes_updated_at_idx ON notes(updated_at);
CREATE INDEX IF NOT EXISTS notes_path_idx ON notes(path);
CREATE INDEX IF NOT EXISTS note_tags_note_idx ON note_tags(note_id);
CREATE INDEX IF NOT EXISTS note_tags_tag_idx ON note_tags(tag_id);
`
func NewSQLiteStorage(dbPath string) (*SQLiteStorage, error) {
@ -68,6 +83,7 @@ func (s *SQLiteStorage) GetAll() []*note.Note {
if err != nil {
continue
}
n.Tags = s.GetTags(n.ID)
notes = append(notes, n)
}
@ -89,6 +105,7 @@ func (s *SQLiteStorage) Get(id string) (*note.Note, error) {
return nil, err
}
n.Tags = s.GetTags(n.ID)
return n, nil
}
@ -150,6 +167,7 @@ func (s *SQLiteStorage) Search(query string) []*note.Note {
if err != nil {
continue
}
n.Tags = s.GetTags(n.ID)
notes = append(notes, n)
}
@ -165,6 +183,94 @@ func (s *SQLiteStorage) Count() int {
return count
}
func (s *SQLiteStorage) AddTag(noteID, tag string) error {
tx, err := s.db.Begin()
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
if _, err := tx.Exec(`INSERT OR IGNORE INTO tags (name) VALUES (?)`, tag); err != nil {
_ = tx.Rollback()
return fmt.Errorf("insert tag: %w", err)
}
var tagID int64
if err := tx.QueryRow(`SELECT id FROM tags WHERE name = ?`, tag).Scan(&tagID); err != nil {
_ = tx.Rollback()
return fmt.Errorf("lookup tag: %w", err)
}
if _, err := tx.Exec(`INSERT OR IGNORE INTO note_tags (note_id, tag_id) VALUES (?, ?)`, noteID, tagID); err != nil {
_ = tx.Rollback()
return fmt.Errorf("attach tag: %w", err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit: %w", err)
}
return nil
}
func (s *SQLiteStorage) RemoveTag(noteID, tag string) error {
tx, err := s.db.Begin()
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
var tagID int64
err = tx.QueryRow(`SELECT id FROM tags WHERE name = ?`, tag).Scan(&tagID)
if err != nil {
if err == sql.ErrNoRows {
_ = tx.Rollback()
return nil
}
_ = tx.Rollback()
return fmt.Errorf("lookup tag: %w", err)
}
if _, err := tx.Exec(`DELETE FROM note_tags WHERE note_id = ? AND tag_id = ?`, noteID, tagID); err != nil {
_ = tx.Rollback()
return fmt.Errorf("detach tag: %w", err)
}
if _, err := tx.Exec(`DELETE FROM tags WHERE id = ? AND NOT EXISTS (SELECT 1 FROM note_tags WHERE tag_id = ?)`, tagID, tagID); err != nil {
_ = tx.Rollback()
return fmt.Errorf("cleanup tag: %w", err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit: %w", err)
}
return nil
}
func (s *SQLiteStorage) GetTags(noteID string) []string {
rows, err := s.db.Query(`
SELECT t.name
FROM tags t
JOIN note_tags nt ON nt.tag_id = t.id
WHERE nt.note_id = ?
ORDER BY t.name
`, noteID)
if err != nil {
return []string{}
}
defer rows.Close()
var tags []string
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
continue
}
tags = append(tags, name)
}
return tags
}
type scanner interface {
Scan(dest ...interface{}) error
}

View File

@ -157,3 +157,33 @@ func TestSQLiteStorage_Count(t *testing.T) {
t.Fatalf("expected count 2")
}
}
func TestSQLiteStorage_Tags(t *testing.T) {
st := newSQLiteStorage(t)
ts := time.Date(2026, 2, 3, 12, 0, 0, 0, time.UTC)
if err := st.Create(sampleNote("n1", "notes/alpha.md", "Alpha", "one", ts)); err != nil {
t.Fatalf("create note: %v", err)
}
if err := st.AddTag("n1", "go"); err != nil {
t.Fatalf("add tag: %v", err)
}
if err := st.AddTag("n1", "rust"); err != nil {
t.Fatalf("add tag: %v", err)
}
tags := st.GetTags("n1")
if len(tags) != 2 {
t.Fatalf("expected 2 tags, got %d", len(tags))
}
if err := st.RemoveTag("n1", "go"); err != nil {
t.Fatalf("remove tag: %v", err)
}
tags = st.GetTags("n1")
if len(tags) != 1 || tags[0] != "rust" {
t.Fatalf("expected remaining tag rust, got %+v", tags)
}
}

View File

@ -14,6 +14,9 @@ type Storage interface {
Update(id string, n *note.Note)
Search(query string) []*note.Note
Count() int
AddTag(noteID, tag string) error
RemoveTag(noteID, tag string) error
GetTags(noteID string) []string
}
type NoteStorage struct {
@ -70,3 +73,46 @@ func (ns *NoteStorage) Search(query string) []*note.Note {
}
return results
}
func (ns *NoteStorage) AddTag(noteID, tag string) error {
n, ok := ns.Index[noteID]
if !ok {
return fmt.Errorf("No note with id '%s'", noteID)
}
for _, existing := range n.Tags {
if existing == tag {
return nil
}
}
n.Tags = append(n.Tags, tag)
return nil
}
func (ns *NoteStorage) RemoveTag(noteID, tag string) error {
n, ok := ns.Index[noteID]
if !ok {
return fmt.Errorf("No note with id '%s'", noteID)
}
updated := n.Tags[:0]
for _, existing := range n.Tags {
if existing != tag {
updated = append(updated, existing)
}
}
n.Tags = updated
return nil
}
func (ns *NoteStorage) GetTags(noteID string) []string {
n, ok := ns.Index[noteID]
if !ok {
return []string{}
}
tags := make([]string, len(n.Tags))
copy(tags, n.Tags)
return tags
}

View File

@ -6,6 +6,7 @@ import (
"donniemarko/internal/service"
"html/template"
"net/http"
"net/url"
"strings"
)
@ -34,6 +35,10 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Handle individual notes
if strings.HasPrefix(path, "/notes/") {
if strings.Contains(path, "/tags") {
h.handleTags(w, r)
return
}
h.handleNotes(w, r)
return
}
@ -160,3 +165,61 @@ func (h *Handler) handleNotes(w http.ResponseWriter, r *http.Request) {
h.templates.Render(w, "index", state)
}
func (h *Handler) handleTags(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
noteID, tag, isRemove := parseTagRoute(r.URL.Path)
if noteID == "" {
http.NotFound(w, r)
return
}
if isRemove {
if tag == "" {
http.Error(w, "Missing tag", http.StatusBadRequest)
return
}
if err := h.notesService.RemoveTag(noteID, tag); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
} else {
if err := r.ParseForm(); err != nil {
http.Error(w, "Invalid form", http.StatusBadRequest)
return
}
tag := r.FormValue("tag")
if tag == "" {
http.Error(w, "Missing tag", http.StatusBadRequest)
return
}
if err := h.notesService.AddTag(noteID, tag); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
http.Redirect(w, r, "/notes/"+noteID, http.StatusSeeOther)
}
func parseTagRoute(path string) (noteID string, tag string, isRemove bool) {
parts := strings.Split(strings.Trim(path, "/"), "/")
if len(parts) < 3 || parts[0] != "notes" || parts[2] != "tags" {
return "", "", false
}
noteID = parts[1]
if len(parts) >= 4 {
decoded, err := url.PathUnescape(parts[3])
if err != nil {
return noteID, "", true
}
return noteID, decoded, true
}
return noteID, "", false
}

View File

@ -3,6 +3,7 @@ package web
import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
@ -15,7 +16,7 @@ import (
type testEnv struct {
handler *Handler
notes map[string]*note.Note
storage *storage.NoteStorage
}
func newTestEnv(t *testing.T) *testEnv {
@ -56,14 +57,9 @@ func newTestEnv(t *testing.T) *testEnv {
tm := render.NewTemplateManager("templates")
handler := NewHandler(svc, tm)
noteMap := map[string]*note.Note{}
for _, n := range notes {
noteMap[n.ID] = n
}
return &testEnv{
handler: handler,
notes: noteMap,
storage: ns,
}
}
@ -232,3 +228,52 @@ func TestExtractHash(t *testing.T) {
})
}
}
func TestHandlerTags_Add(t *testing.T) {
env := newTestEnv(t)
form := url.Values{}
form.Set("tag", " Go ")
req := httptest.NewRequest(http.MethodPost, "/notes/b2/tags", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
env.handler.ServeHTTP(rec, req)
if rec.Code != http.StatusSeeOther {
t.Fatalf("expected status 303, got %d", rec.Code)
}
n, err := env.storage.Get("b2")
if err != nil {
t.Fatalf("get note: %v", err)
}
if len(n.Tags) != 1 || n.Tags[0] != "go" {
t.Fatalf("expected normalized tag on note, got %+v", n.Tags)
}
}
func TestHandlerTags_Remove(t *testing.T) {
env := newTestEnv(t)
if err := env.storage.AddTag("b2", "go"); err != nil {
t.Fatalf("seed tag: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/notes/b2/tags/go", nil)
rec := httptest.NewRecorder()
env.handler.ServeHTTP(rec, req)
if rec.Code != http.StatusSeeOther {
t.Fatalf("expected status 303, got %d", rec.Code)
}
n, err := env.storage.Get("b2")
if err != nil {
t.Fatalf("get note: %v", err)
}
if len(n.Tags) != 0 {
t.Fatalf("expected tag to be removed, got %+v", n.Tags)
}
}

View File

@ -261,12 +261,39 @@
}
/* Tags UI */
.tag-form {
display: flex;
gap: 0.5em;
margin-bottom: 0.8em;
}
.tag-input {
flex: 1;
padding: 0.35em 0.5em;
border: 1px solid var(--border-color);
border-radius: 4px;
background: var(--background2);
color: var(--text1);
}
.tag-submit {
padding: 0.35em 0.6em;
border: 1px solid var(--border-color);
border-radius: 4px;
background: var(--background2);
color: var(--text1);
}
.meta-tags {
display: flex;
flex-wrap: wrap;
gap: 0.4em;
}
.tag-remove-form {
display: inline;
}
.tag {
background: var(--background2);
padding: 0.25em 0.6em;

View File

@ -5,10 +5,16 @@
<section class="meta-block">
<h3>Tags</h3>
<form method="POST" action="/notes/{{ .Note.ID }}/tags" class="tag-form">
<input type="text" name="tag" class="tag-input" placeholder="Add tag">
<input type="submit" value="add" class="tag-submit" />
</form>
<div class="meta-tags">
<span class="tag">emacs</span>
<span class="tag">tramp</span>
<span class="tag">editing</span>
{{ range .Note.Tags }}
<form method="POST" action="/notes/{{ $.Note.ID }}/tags/{{ . }}" class="tag-remove-form">
<button type="submit" class="tag">{{ . }} ×</button>
</form>
{{ end }}
</div>
</section>