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

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

View File

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

View File

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