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
|
SELECT id, path, title, content, updated_at, size, published
|
||||||
FROM notes
|
FROM notes
|
||||||
WHERE lower(content) LIKE lower(?)
|
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 {
|
if err != nil {
|
||||||
return []*note.Note{}
|
return []*note.Note{}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -187,3 +187,27 @@ func TestSQLiteStorage_Tags(t *testing.T) {
|
|||||||
t.Fatalf("expected remaining tag rust, got %+v", tags)
|
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 {
|
for _, note := range ns.Index {
|
||||||
lowContent := strings.ToLower(string(note.Content))
|
lowContent := strings.ToLower(string(note.Content))
|
||||||
lowQuery := strings.ToLower(query)
|
lowQuery := strings.ToLower(query)
|
||||||
if strings.Contains(lowContent, lowQuery) {
|
if strings.Contains(lowContent, lowQuery) || tagsContain(note.Tags, lowQuery) {
|
||||||
results = append(results, note)
|
results = append(results, note)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return results
|
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 {
|
func (ns *NoteStorage) AddTag(noteID, tag string) error {
|
||||||
n, ok := ns.Index[noteID]
|
n, ok := ns.Index[noteID]
|
||||||
if !ok {
|
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)
|
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
|
RenderedNote template.HTML
|
||||||
SortBy string
|
SortBy string
|
||||||
SearchTerm string
|
SearchTerm string
|
||||||
|
TagFilter string
|
||||||
LastActive string
|
LastActive string
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -79,6 +80,7 @@ func (h *Handler) buildViewState(r *http.Request) (*ViewState, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
searchTerm := query.Get("search")
|
searchTerm := query.Get("search")
|
||||||
|
tagFilter := query.Get("tag")
|
||||||
|
|
||||||
// Get notes from service
|
// Get notes from service
|
||||||
var notes []*note.Note
|
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{
|
return &ViewState{
|
||||||
Notes: notes,
|
Notes: notes,
|
||||||
SortBy: sortBy,
|
SortBy: sortBy,
|
||||||
SearchTerm: searchTerm,
|
SearchTerm: searchTerm,
|
||||||
|
TagFilter: tagFilter,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -166,6 +173,24 @@ func (h *Handler) handleNotes(w http.ResponseWriter, r *http.Request) {
|
|||||||
h.templates.Render(w, "index", state)
|
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) {
|
func (h *Handler) handleTags(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
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)
|
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 {
|
.last-modified {
|
||||||
|
text-align: right;
|
||||||
font-size: 0.75em;
|
font-size: 0.75em;
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
@ -290,20 +291,36 @@
|
|||||||
gap: 0.4em;
|
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 {
|
.tag-remove-form {
|
||||||
display: inline;
|
display: inline;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tag {
|
.tag-remove {
|
||||||
background: var(--background2);
|
border: 0;
|
||||||
padding: 0.25em 0.6em;
|
background: var(--background3);
|
||||||
border-radius: 4px;
|
color: var(--text1);
|
||||||
color: var(--url);
|
width: 1.4em;
|
||||||
font-size: 0.8em;
|
height: 1.4em;
|
||||||
white-space: nowrap;
|
border-radius: 999px;
|
||||||
border: 1px solid var(--border-color);
|
line-height: 1;
|
||||||
}
|
}
|
||||||
.tag:hover {
|
|
||||||
|
.tag-remove:hover {
|
||||||
background: var(--background-hover);
|
background: var(--background-hover);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,9 +11,12 @@
|
|||||||
</form>
|
</form>
|
||||||
<div class="meta-tags">
|
<div class="meta-tags">
|
||||||
{{ range .Note.Tags }}
|
{{ range .Note.Tags }}
|
||||||
<form method="POST" action="/notes/{{ $.Note.ID }}/tags/{{ . }}" class="tag-remove-form">
|
<span class="tag-chip">
|
||||||
<button type="submit" class="tag">{{ . }} ×</button>
|
<a class="tag-link" href="/?tag={{ . | urlquery }}">{{ . }}</a>
|
||||||
</form>
|
<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 }}
|
{{ end }}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@ -23,6 +23,7 @@
|
|||||||
|
|
||||||
{{ define "renderSearch" }}
|
{{ define "renderSearch" }}
|
||||||
{{ if ne .SearchTerm "" }}<h2>Matching results for query '{{ .SearchTerm }}'</h2>{{ end }}
|
{{ 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">
|
<ul class="search-results">
|
||||||
{{ range .Notes }}
|
{{ range .Notes }}
|
||||||
<li {{ if eq .ID $.LastActive }}class="active-note"{{ end }}>
|
<li {{ if eq .ID $.LastActive }}class="active-note"{{ end }}>
|
||||||
|
|||||||
Reference in New Issue
Block a user