feat: filter and search by tag

This commit is contained in:
2026-02-03 09:53:08 +01:00
parent cb11e34798
commit 229223f77a
9 changed files with 147 additions and 14 deletions

View File

@ -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{}
}

View File

@ -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")
}
}

View File

@ -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 {

View File

@ -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))
}
}

View File

@ -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)

View File

@ -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")
}
}

View File

@ -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;
}

View File

@ -11,9 +11,12 @@
</form>
<div class="meta-tags">
{{ range .Note.Tags }}
<form method="POST" action="/notes/{{ $.Note.ID }}/tags/{{ . }}" class="tag-remove-form">
<button type="submit" class="tag">{{ . }} ×</button>
</form>
<span class="tag-chip">
<a class="tag-link" href="/?tag={{ . | urlquery }}">{{ . }}</a>
<form method="POST" action="/notes/{{ $.Note.ID }}/tags/{{ . | urlquery }}" class="tag-remove-form">
<button type="submit" class="tag-remove" aria-label="Remove tag {{ . }}">×</button>
</form>
</span>
{{ end }}
</div>
</section>

View File

@ -23,6 +23,7 @@
{{ define "renderSearch" }}
{{ if ne .SearchTerm "" }}<h2>Matching results for query '{{ .SearchTerm }}'</h2>{{ end }}
{{ if ne .TagFilter "" }}<h2>Filtered by tag '{{ .TagFilter }}'</h2>{{ end }}
<ul class="search-results">
{{ range .Notes }}
<li {{ if eq .ID $.LastActive }}class="active-note"{{ end }}>