feat: tag system

This commit is contained in:
2026-02-03 09:41:03 +01:00
parent 3f5cf0d673
commit cb11e34798
11 changed files with 397 additions and 10 deletions

View File

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

View File

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

View File

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

View File

@ -5,10 +5,16 @@
<section class="meta-block">
<h3>Tags</h3>
<form method="POST" action="/notes/{{ .Note.ID }}/tags" class="tag-form">
<input type="text" name="tag" class="tag-input" placeholder="Add tag">
<input type="submit" value="add" class="tag-submit" />
</form>
<div class="meta-tags">
<span class="tag">emacs</span>
<span class="tag">tramp</span>
<span class="tag">editing</span>
{{ range .Note.Tags }}
<form method="POST" action="/notes/{{ $.Note.ID }}/tags/{{ . }}" class="tag-remove-form">
<button type="submit" class="tag">{{ . }} ×</button>
</form>
{{ end }}
</div>
</section>