feat: tag system
This commit is contained in:
@ -77,6 +77,9 @@ func (tm *TemplateManager) Render(w http.ResponseWriter, name string, data any)
|
||||
// Include noteList template (used by index)
|
||||
files = append(files, tm.buildTemplatePath("noteList"))
|
||||
|
||||
// Include metadata template (used by index)
|
||||
files = append(files, tm.buildTemplatePath("metadata"))
|
||||
|
||||
// Include metadata template
|
||||
files = append(files, tm.buildTemplatePath("metadata"))
|
||||
|
||||
|
||||
@ -99,6 +99,7 @@ func TestTemplateManagerRender(t *testing.T) {
|
||||
baseDir := t.TempDir()
|
||||
writeTemplate(t, baseDir, "base.tmpl", `{{ define "base" }}<html>{{ template "content" . }}</html>{{ end }}`)
|
||||
writeTemplate(t, baseDir, "noteList.tmpl", `{{ define "noteList" }}notes{{ end }}`)
|
||||
writeTemplate(t, baseDir, "metadata.tmpl", `{{ define "metadata" }}meta{{ end }}`)
|
||||
writeTemplate(t, baseDir, "index.tmpl", `{{ define "content" }}hello{{ end }}`)
|
||||
writeTemplate(t, baseDir, "metadata.tmpl", `{{ define "noteList" }}notes{{ end }}`)
|
||||
|
||||
@ -127,6 +128,7 @@ func TestTemplateManagerRender_MissingTemplate(t *testing.T) {
|
||||
baseDir := t.TempDir()
|
||||
writeTemplate(t, baseDir, "base.tmpl", `{{ define "base" }}<html>{{ template "content" . }}</html>{{ end }}`)
|
||||
writeTemplate(t, baseDir, "noteList.tmpl", `{{ define "noteList" }}notes{{ end }}`)
|
||||
writeTemplate(t, baseDir, "metadata.tmpl", `{{ define "metadata" }}meta{{ end }}`)
|
||||
writeTemplate(t, baseDir, "metadata.tmpl", `{{ define "noteList" }}notes{{ end }}`)
|
||||
|
||||
tm := NewTemplateManager(baseDir)
|
||||
|
||||
@ -4,6 +4,7 @@ import (
|
||||
"donniemarko/internal/note"
|
||||
"donniemarko/internal/storage"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type NotesService struct {
|
||||
@ -91,3 +92,33 @@ func (s *NotesService) GetNoteByHash(hash string) (*note.Note, error) {
|
||||
func (s *NotesService) GetNotes() []*note.Note {
|
||||
return s.storage.GetAll()
|
||||
}
|
||||
|
||||
func (s *NotesService) AddTag(noteID, tag string) error {
|
||||
tag = normalizeTag(tag)
|
||||
if tag == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if _, err := s.storage.Get(noteID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.storage.AddTag(noteID, tag)
|
||||
}
|
||||
|
||||
func (s *NotesService) RemoveTag(noteID, tag string) error {
|
||||
tag = normalizeTag(tag)
|
||||
if tag == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if _, err := s.storage.Get(noteID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.storage.RemoveTag(noteID, tag)
|
||||
}
|
||||
|
||||
func normalizeTag(tag string) string {
|
||||
return strings.ToLower(strings.TrimSpace(tag))
|
||||
}
|
||||
|
||||
@ -46,3 +46,31 @@ func TestQueryNotes_WithSearch(t *testing.T) {
|
||||
t.Error("wrong note returned")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTags_NormalizationAndRemove(t *testing.T) {
|
||||
err := service.AddTag("test1", " Go ")
|
||||
if err != nil {
|
||||
t.Fatalf("add tag: %v", err)
|
||||
}
|
||||
|
||||
n, err := service.GetNoteByHash("test1")
|
||||
if err != nil {
|
||||
t.Fatalf("get note: %v", err)
|
||||
}
|
||||
if len(n.Tags) != 1 || n.Tags[0] != "go" {
|
||||
t.Fatalf("expected normalized tag, got %+v", n.Tags)
|
||||
}
|
||||
|
||||
err = service.RemoveTag("test1", "GO")
|
||||
if err != nil {
|
||||
t.Fatalf("remove tag: %v", err)
|
||||
}
|
||||
|
||||
n, err = service.GetNoteByHash("test1")
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -27,8 +27,23 @@ CREATE TABLE IF NOT EXISTS notes (
|
||||
published INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tags (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS note_tags (
|
||||
note_id TEXT NOT NULL,
|
||||
tag_id INTEGER NOT NULL,
|
||||
UNIQUE(note_id, tag_id),
|
||||
FOREIGN KEY(note_id) REFERENCES notes(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(tag_id) REFERENCES tags(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS notes_updated_at_idx ON notes(updated_at);
|
||||
CREATE INDEX IF NOT EXISTS notes_path_idx ON notes(path);
|
||||
CREATE INDEX IF NOT EXISTS note_tags_note_idx ON note_tags(note_id);
|
||||
CREATE INDEX IF NOT EXISTS note_tags_tag_idx ON note_tags(tag_id);
|
||||
`
|
||||
|
||||
func NewSQLiteStorage(dbPath string) (*SQLiteStorage, error) {
|
||||
@ -68,6 +83,7 @@ func (s *SQLiteStorage) GetAll() []*note.Note {
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
n.Tags = s.GetTags(n.ID)
|
||||
notes = append(notes, n)
|
||||
}
|
||||
|
||||
@ -89,6 +105,7 @@ func (s *SQLiteStorage) Get(id string) (*note.Note, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
n.Tags = s.GetTags(n.ID)
|
||||
return n, nil
|
||||
}
|
||||
|
||||
@ -150,6 +167,7 @@ func (s *SQLiteStorage) Search(query string) []*note.Note {
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
n.Tags = s.GetTags(n.ID)
|
||||
notes = append(notes, n)
|
||||
}
|
||||
|
||||
@ -165,6 +183,94 @@ func (s *SQLiteStorage) Count() int {
|
||||
return count
|
||||
}
|
||||
|
||||
func (s *SQLiteStorage) AddTag(noteID, tag string) error {
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(`INSERT OR IGNORE INTO tags (name) VALUES (?)`, tag); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return fmt.Errorf("insert tag: %w", err)
|
||||
}
|
||||
|
||||
var tagID int64
|
||||
if err := tx.QueryRow(`SELECT id FROM tags WHERE name = ?`, tag).Scan(&tagID); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return fmt.Errorf("lookup tag: %w", err)
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(`INSERT OR IGNORE INTO note_tags (note_id, tag_id) VALUES (?, ?)`, noteID, tagID); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return fmt.Errorf("attach tag: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SQLiteStorage) RemoveTag(noteID, tag string) error {
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
|
||||
var tagID int64
|
||||
err = tx.QueryRow(`SELECT id FROM tags WHERE name = ?`, tag).Scan(&tagID)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
_ = tx.Rollback()
|
||||
return nil
|
||||
}
|
||||
_ = tx.Rollback()
|
||||
return fmt.Errorf("lookup tag: %w", err)
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(`DELETE FROM note_tags WHERE note_id = ? AND tag_id = ?`, noteID, tagID); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return fmt.Errorf("detach tag: %w", err)
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(`DELETE FROM tags WHERE id = ? AND NOT EXISTS (SELECT 1 FROM note_tags WHERE tag_id = ?)`, tagID, tagID); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return fmt.Errorf("cleanup tag: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SQLiteStorage) GetTags(noteID string) []string {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT t.name
|
||||
FROM tags t
|
||||
JOIN note_tags nt ON nt.tag_id = t.id
|
||||
WHERE nt.note_id = ?
|
||||
ORDER BY t.name
|
||||
`, noteID)
|
||||
if err != nil {
|
||||
return []string{}
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var tags []string
|
||||
for rows.Next() {
|
||||
var name string
|
||||
if err := rows.Scan(&name); err != nil {
|
||||
continue
|
||||
}
|
||||
tags = append(tags, name)
|
||||
}
|
||||
|
||||
return tags
|
||||
}
|
||||
|
||||
type scanner interface {
|
||||
Scan(dest ...interface{}) error
|
||||
}
|
||||
|
||||
@ -157,3 +157,33 @@ func TestSQLiteStorage_Count(t *testing.T) {
|
||||
t.Fatalf("expected count 2")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteStorage_Tags(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", "one", ts)); err != nil {
|
||||
t.Fatalf("create note: %v", err)
|
||||
}
|
||||
|
||||
if err := st.AddTag("n1", "go"); err != nil {
|
||||
t.Fatalf("add tag: %v", err)
|
||||
}
|
||||
if err := st.AddTag("n1", "rust"); err != nil {
|
||||
t.Fatalf("add tag: %v", err)
|
||||
}
|
||||
|
||||
tags := st.GetTags("n1")
|
||||
if len(tags) != 2 {
|
||||
t.Fatalf("expected 2 tags, got %d", len(tags))
|
||||
}
|
||||
|
||||
if err := st.RemoveTag("n1", "go"); err != nil {
|
||||
t.Fatalf("remove tag: %v", err)
|
||||
}
|
||||
|
||||
tags = st.GetTags("n1")
|
||||
if len(tags) != 1 || tags[0] != "rust" {
|
||||
t.Fatalf("expected remaining tag rust, got %+v", tags)
|
||||
}
|
||||
}
|
||||
|
||||
@ -14,6 +14,9 @@ type Storage interface {
|
||||
Update(id string, n *note.Note)
|
||||
Search(query string) []*note.Note
|
||||
Count() int
|
||||
AddTag(noteID, tag string) error
|
||||
RemoveTag(noteID, tag string) error
|
||||
GetTags(noteID string) []string
|
||||
}
|
||||
|
||||
type NoteStorage struct {
|
||||
@ -70,3 +73,46 @@ func (ns *NoteStorage) Search(query string) []*note.Note {
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
func (ns *NoteStorage) AddTag(noteID, tag string) error {
|
||||
n, ok := ns.Index[noteID]
|
||||
if !ok {
|
||||
return fmt.Errorf("No note with id '%s'", noteID)
|
||||
}
|
||||
|
||||
for _, existing := range n.Tags {
|
||||
if existing == tag {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
n.Tags = append(n.Tags, tag)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ns *NoteStorage) RemoveTag(noteID, tag string) error {
|
||||
n, ok := ns.Index[noteID]
|
||||
if !ok {
|
||||
return fmt.Errorf("No note with id '%s'", noteID)
|
||||
}
|
||||
|
||||
updated := n.Tags[:0]
|
||||
for _, existing := range n.Tags {
|
||||
if existing != tag {
|
||||
updated = append(updated, existing)
|
||||
}
|
||||
}
|
||||
n.Tags = updated
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ns *NoteStorage) GetTags(noteID string) []string {
|
||||
n, ok := ns.Index[noteID]
|
||||
if !ok {
|
||||
return []string{}
|
||||
}
|
||||
|
||||
tags := make([]string, len(n.Tags))
|
||||
copy(tags, n.Tags)
|
||||
return tags
|
||||
}
|
||||
|
||||
@ -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