diff --git a/internal/storage/sqlite.go b/internal/storage/sqlite.go index 4ab457f..52f8209 100644 --- a/internal/storage/sqlite.go +++ b/internal/storage/sqlite.go @@ -155,7 +155,13 @@ func (s *SQLiteStorage) Search(query string) []*note.Note { SELECT id, path, title, content, updated_at, size, published FROM notes WHERE lower(content) LIKE lower(?) - `, pattern) + OR id IN ( + SELECT nt.note_id + FROM note_tags nt + JOIN tags t ON t.id = nt.tag_id + WHERE lower(t.name) LIKE lower(?) + ) + `, pattern, pattern) if err != nil { return []*note.Note{} } diff --git a/internal/storage/sqlite_test.go b/internal/storage/sqlite_test.go index aec2239..d3c26c9 100644 --- a/internal/storage/sqlite_test.go +++ b/internal/storage/sqlite_test.go @@ -187,3 +187,27 @@ func TestSQLiteStorage_Tags(t *testing.T) { t.Fatalf("expected remaining tag rust, got %+v", tags) } } + +func TestSQLiteStorage_SearchByTag(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", "no match", ts)); err != nil { + t.Fatalf("create note: %v", err) + } + if err := st.Create(sampleNote("n2", "notes/beta.md", "Beta", "content", ts)); err != nil { + t.Fatalf("create note: %v", err) + } + + if err := st.AddTag("n2", "Go"); err != nil { + t.Fatalf("add tag: %v", err) + } + + results := st.Search("go") + if len(results) != 1 { + t.Fatalf("expected 1 result, got %d", len(results)) + } + if results[0].ID != "n2" { + t.Fatalf("expected tag match to be n2") + } +} diff --git a/internal/storage/storage.go b/internal/storage/storage.go index 446a3b0..71a8120 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -67,13 +67,25 @@ func (ns *NoteStorage) Search(query string) []*note.Note { for _, note := range ns.Index { lowContent := strings.ToLower(string(note.Content)) lowQuery := strings.ToLower(query) - if strings.Contains(lowContent, lowQuery) { + if strings.Contains(lowContent, lowQuery) || tagsContain(note.Tags, lowQuery) { results = append(results, note) } } return results } +func tagsContain(tags []string, query string) bool { + if query == "" { + return false + } + for _, tag := range tags { + if strings.Contains(strings.ToLower(tag), query) { + return true + } + } + return false +} + func (ns *NoteStorage) AddTag(noteID, tag string) error { n, ok := ns.Index[noteID] if !ok { diff --git a/internal/storage/storage_test.go b/internal/storage/storage_test.go index 036477a..b56135f 100644 --- a/internal/storage/storage_test.go +++ b/internal/storage/storage_test.go @@ -51,3 +51,20 @@ func TestNoteStorageGetUpdate(t *testing.T) { t.Errorf("Updating a note should reflect it in storage. Wanted '%s', got '%s'\n", n1.Content, nn2.Content) } } + +func TestNoteStorageSearch_Tags(t *testing.T) { + ns = NewNoteStorage() + + n := note.NewNote() + n.Path = "note3.md" + n.ID = note.GenerateNoteID(n.Path) + n.Content = "no tag here" + n.Tags = []string{"devops", "go"} + + ns.Create(n) + + results := ns.Search("go") + if len(results) != 1 { + t.Fatalf("expected 1 result, got %d", len(results)) + } +} diff --git a/internal/web/handler.go b/internal/web/handler.go index b6321e5..8e91811 100644 --- a/internal/web/handler.go +++ b/internal/web/handler.go @@ -54,6 +54,7 @@ type ViewState struct { RenderedNote template.HTML SortBy string SearchTerm string + TagFilter string LastActive string } @@ -79,6 +80,7 @@ func (h *Handler) buildViewState(r *http.Request) (*ViewState, error) { } searchTerm := query.Get("search") + tagFilter := query.Get("tag") // Get notes from service var notes []*note.Note @@ -111,10 +113,15 @@ func (h *Handler) buildViewState(r *http.Request) (*ViewState, error) { } } + if tagFilter != "" { + notes = filterNotesByTag(notes, tagFilter) + } + return &ViewState{ Notes: notes, SortBy: sortBy, SearchTerm: searchTerm, + TagFilter: tagFilter, }, nil } @@ -166,6 +173,24 @@ func (h *Handler) handleNotes(w http.ResponseWriter, r *http.Request) { h.templates.Render(w, "index", state) } +func filterNotesByTag(notes []*note.Note, tag string) []*note.Note { + tag = strings.ToLower(strings.TrimSpace(tag)) + if tag == "" { + return notes + } + + filtered := make([]*note.Note, 0, len(notes)) + for _, n := range notes { + for _, t := range n.Tags { + if strings.EqualFold(t, tag) { + filtered = append(filtered, n) + break + } + } + } + return filtered +} + func (h *Handler) handleTags(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) diff --git a/internal/web/handler_test.go b/internal/web/handler_test.go index ab228ab..0e2974f 100644 --- a/internal/web/handler_test.go +++ b/internal/web/handler_test.go @@ -277,3 +277,31 @@ func TestHandlerTags_Remove(t *testing.T) { t.Fatalf("expected tag to be removed, got %+v", n.Tags) } } + +func TestHandlerRoot_TagFilter(t *testing.T) { + env := newTestEnv(t) + + if err := env.storage.AddTag("a1", "go"); err != nil { + t.Fatalf("seed tag: %v", err) + } + if err := env.storage.AddTag("b2", "rust"); err != nil { + t.Fatalf("seed tag: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/?tag=go", nil) + rec := httptest.NewRecorder() + + env.handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", rec.Code) + } + + body := rec.Body.String() + if strings.Contains(body, noteMarker("b2")) { + t.Fatalf("expected non-matching note to be excluded") + } + if !strings.Contains(body, noteMarker("a1")) { + t.Fatalf("expected matching note to be included") + } +} diff --git a/internal/web/static/css/main.css b/internal/web/static/css/main.css index d7076d2..dc7c25d 100644 --- a/internal/web/static/css/main.css +++ b/internal/web/static/css/main.css @@ -170,6 +170,7 @@ } .last-modified { + text-align: right; font-size: 0.75em; opacity: 0.7; } @@ -290,20 +291,36 @@ gap: 0.4em; } + .tag-chip { + display: inline-flex; + align-items: center; + gap: 0.35em; + background: var(--background2); + border: 1px solid var(--border-color); + border-radius: 999px; + padding: 0.2em 0.45em; + } + + .tag-link { + color: var(--url); + font-size: 0.8em; + } + .tag-remove-form { display: inline; } - .tag { - background: var(--background2); - padding: 0.25em 0.6em; - border-radius: 4px; - color: var(--url); - font-size: 0.8em; - white-space: nowrap; - border: 1px solid var(--border-color); + .tag-remove { + border: 0; + background: var(--background3); + color: var(--text1); + width: 1.4em; + height: 1.4em; + border-radius: 999px; + line-height: 1; } - .tag:hover { + + .tag-remove:hover { background: var(--background-hover); cursor: pointer; } diff --git a/internal/web/templates/metadata.tmpl b/internal/web/templates/metadata.tmpl index 864636c..65e478e 100644 --- a/internal/web/templates/metadata.tmpl +++ b/internal/web/templates/metadata.tmpl @@ -11,9 +11,12 @@
diff --git a/internal/web/templates/noteList.tmpl b/internal/web/templates/noteList.tmpl index 3d23c1e..36bfedf 100644 --- a/internal/web/templates/noteList.tmpl +++ b/internal/web/templates/noteList.tmpl @@ -23,6 +23,7 @@ {{ define "renderSearch" }} {{ if ne .SearchTerm "" }}