From cb11e34798469966fdb7af00c59b9d487863b613 Mon Sep 17 00:00:00 2001 From: adminoo Date: Tue, 3 Feb 2026 09:41:03 +0100 Subject: [PATCH] feat: tag system --- internal/render/render.go | 3 + internal/render/render_test.go | 2 + internal/service/note.go | 31 ++++++++ internal/service/note_test.go | 28 +++++++ internal/storage/sqlite.go | 106 +++++++++++++++++++++++++++ internal/storage/sqlite_test.go | 30 ++++++++ internal/storage/storage.go | 46 ++++++++++++ internal/web/handler.go | 63 ++++++++++++++++ internal/web/handler_test.go | 59 +++++++++++++-- internal/web/static/css/main.css | 27 +++++++ internal/web/templates/metadata.tmpl | 12 ++- 11 files changed, 397 insertions(+), 10 deletions(-) diff --git a/internal/render/render.go b/internal/render/render.go index 639e08e..b096643 100644 --- a/internal/render/render.go +++ b/internal/render/render.go @@ -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")) diff --git a/internal/render/render_test.go b/internal/render/render_test.go index deed064..1b07bd0 100644 --- a/internal/render/render_test.go +++ b/internal/render/render_test.go @@ -99,6 +99,7 @@ func TestTemplateManagerRender(t *testing.T) { baseDir := t.TempDir() writeTemplate(t, baseDir, "base.tmpl", `{{ define "base" }}{{ template "content" . }}{{ 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" }}{{ template "content" . }}{{ 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) diff --git a/internal/service/note.go b/internal/service/note.go index 681dd0d..fa5a6b3 100644 --- a/internal/service/note.go +++ b/internal/service/note.go @@ -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)) +} diff --git a/internal/service/note_test.go b/internal/service/note_test.go index 78dad8e..883738f 100644 --- a/internal/service/note_test.go +++ b/internal/service/note_test.go @@ -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) + } +} diff --git a/internal/storage/sqlite.go b/internal/storage/sqlite.go index a28c89c..4ab457f 100644 --- a/internal/storage/sqlite.go +++ b/internal/storage/sqlite.go @@ -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 } diff --git a/internal/storage/sqlite_test.go b/internal/storage/sqlite_test.go index bfee33c..aec2239 100644 --- a/internal/storage/sqlite_test.go +++ b/internal/storage/sqlite_test.go @@ -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) + } +} diff --git a/internal/storage/storage.go b/internal/storage/storage.go index 8e381c8..446a3b0 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -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 +} diff --git a/internal/web/handler.go b/internal/web/handler.go index 1c11ff4..b6321e5 100644 --- a/internal/web/handler.go +++ b/internal/web/handler.go @@ -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 +} diff --git a/internal/web/handler_test.go b/internal/web/handler_test.go index 01227ed..ab228ab 100644 --- a/internal/web/handler_test.go +++ b/internal/web/handler_test.go @@ -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) + } +} diff --git a/internal/web/static/css/main.css b/internal/web/static/css/main.css index 213c0e9..d7076d2 100644 --- a/internal/web/static/css/main.css +++ b/internal/web/static/css/main.css @@ -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; diff --git a/internal/web/templates/metadata.tmpl b/internal/web/templates/metadata.tmpl index a9ab09d..864636c 100644 --- a/internal/web/templates/metadata.tmpl +++ b/internal/web/templates/metadata.tmpl @@ -5,10 +5,16 @@

Tags

+
+ + +
- emacs - tramp - editing + {{ range .Note.Tags }} +
+ +
+ {{ end }}