feat: tag system
This commit is contained in:
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user