feat: filter and search by tag
This commit is contained in:
@ -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{}
|
||||
}
|
||||
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
<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>
|
||||
|
||||
@ -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 }}>
|
||||
|
||||
Reference in New Issue
Block a user