feat(release): v0.1.0
commit06ed2c3cbeAuthor: adminoo <git@kadath.corp> Date: Tue Feb 3 11:34:24 2026 +0100 fix: changed detected by scanner but no updated by render layer commit01dcaf882aAuthor: adminoo <git@kadath.corp> Date: Tue Feb 3 10:19:05 2026 +0100 feat: VERSION bumb commit229223f77aAuthor: adminoo <git@kadath.corp> Date: Tue Feb 3 09:53:08 2026 +0100 feat: filter and search by tag commitcb11e34798Author: adminoo <git@kadath.corp> Date: Tue Feb 3 09:41:03 2026 +0100 feat: tag system commit3f5cf0d673Author: adminoo <git@kadath.corp> Date: Tue Feb 3 09:15:29 2026 +0100 feat: sqlite storage draft commitd6617cec02Author: adminoo <git@kadath.corp> Date: Tue Feb 3 09:04:11 2026 +0100 feat: metadata draft commit7238d02a13Author: adminoo <git@kadath.corp> Date: Mon Feb 2 10:18:42 2026 +0100 fix: body overflowing commit16ff836274Author: adminoo <git@kadath.corp> Date: Mon Feb 2 10:09:01 2026 +0100 feat: tests for http handlers and render package commit36ac3f03aaAuthor: adminoo <git@kadath.corp> Date: Mon Feb 2 09:45:29 2026 +0100 feat: Dark theme, placeholder metadata panel commite6923fa4f5Author: adminoo <git@kadath.corp> Date: Sun Feb 1 18:26:59 2026 +0100 fix: uneeded func + uneeded bogus note creation logic commit4458ba2d15Author: adminoo <git@kadath.corp> Date: Sun Feb 1 18:26:21 2026 +0100 feat: log when changing note states commit92a6f84540Author: adminoo <git@kadath.corp> Date: Sun Feb 1 16:55:40 2026 +0100 possibly first working draft commite27aadc603Author: adminoo <git@kadath.corp> Date: Sun Feb 1 11:55:16 2026 +0100 draft shits
This commit is contained in:
336
internal/storage/sqlite.go
Normal file
336
internal/storage/sqlite.go
Normal file
@ -0,0 +1,336 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"donniemarko/internal/note"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
type SQLiteStorage struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
const sqliteSchema = `
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS notes (
|
||||
id TEXT PRIMARY KEY,
|
||||
path TEXT NOT NULL UNIQUE,
|
||||
title TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
size INTEGER NOT NULL,
|
||||
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) {
|
||||
db, err := sql.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open sqlite: %w", err)
|
||||
}
|
||||
|
||||
if _, err := db.Exec(sqliteSchema); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, fmt.Errorf("init schema: %w", err)
|
||||
}
|
||||
|
||||
return &SQLiteStorage{db: db}, nil
|
||||
}
|
||||
|
||||
func (s *SQLiteStorage) Close() error {
|
||||
if s.db == nil {
|
||||
return nil
|
||||
}
|
||||
return s.db.Close()
|
||||
}
|
||||
|
||||
func (s *SQLiteStorage) GetAll() []*note.Note {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT id, path, title, content, updated_at, size, published
|
||||
FROM notes
|
||||
`)
|
||||
if err != nil {
|
||||
return []*note.Note{}
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var notes []*note.Note
|
||||
for rows.Next() {
|
||||
n, err := scanNote(rows)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
n.Tags = s.GetTags(n.ID)
|
||||
notes = append(notes, n)
|
||||
}
|
||||
|
||||
return notes
|
||||
}
|
||||
|
||||
func (s *SQLiteStorage) Get(id string) (*note.Note, error) {
|
||||
row := s.db.QueryRow(`
|
||||
SELECT id, path, title, content, updated_at, size, published
|
||||
FROM notes
|
||||
WHERE id = ?
|
||||
`, id)
|
||||
|
||||
n, err := scanNote(row)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, fmt.Errorf("No note with id '%s'", id)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
n.Tags = s.GetTags(n.ID)
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (s *SQLiteStorage) Create(n *note.Note) error {
|
||||
_, err := s.db.Exec(`
|
||||
INSERT INTO notes (id, path, title, content, updated_at, size, published)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
path = excluded.path,
|
||||
title = excluded.title,
|
||||
content = excluded.content,
|
||||
updated_at = excluded.updated_at,
|
||||
size = excluded.size,
|
||||
published = excluded.published
|
||||
`,
|
||||
n.ID,
|
||||
n.Path,
|
||||
n.Title,
|
||||
n.Content,
|
||||
toUnix(n.UpdatedAt),
|
||||
n.Size,
|
||||
boolToInt(n.Published),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert note: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SQLiteStorage) Delete(id string) {
|
||||
_, _ = s.db.Exec(`DELETE FROM notes WHERE id = ?`, id)
|
||||
}
|
||||
|
||||
func (s *SQLiteStorage) Update(id string, n *note.Note) {
|
||||
_, _ = s.db.Exec(`
|
||||
UPDATE notes
|
||||
SET path = ?, title = ?, content = ?, updated_at = ?, size = ?, published = ?
|
||||
WHERE id = ?
|
||||
`,
|
||||
n.Path,
|
||||
n.Title,
|
||||
n.Content,
|
||||
toUnix(n.UpdatedAt),
|
||||
n.Size,
|
||||
boolToInt(n.Published),
|
||||
id,
|
||||
)
|
||||
}
|
||||
|
||||
func (s *SQLiteStorage) Search(query string) []*note.Note {
|
||||
pattern := "%" + query + "%"
|
||||
rows, err := s.db.Query(`
|
||||
SELECT id, path, title, content, updated_at, size, published
|
||||
FROM notes
|
||||
WHERE lower(content) LIKE lower(?)
|
||||
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 {
|
||||
return []*note.Note{}
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var notes []*note.Note
|
||||
for rows.Next() {
|
||||
n, err := scanNote(rows)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
n.Tags = s.GetTags(n.ID)
|
||||
notes = append(notes, n)
|
||||
}
|
||||
|
||||
return notes
|
||||
}
|
||||
|
||||
func (s *SQLiteStorage) Count() int {
|
||||
row := s.db.QueryRow(`SELECT COUNT(*) FROM notes`)
|
||||
var count int
|
||||
if err := row.Scan(&count); err != nil {
|
||||
return 0
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
func scanNote(row scanner) (*note.Note, error) {
|
||||
var (
|
||||
id string
|
||||
path string
|
||||
title string
|
||||
content string
|
||||
updated int64
|
||||
size int64
|
||||
published int
|
||||
)
|
||||
|
||||
if err := row.Scan(&id, &path, &title, &content, &updated, &size, &published); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return ¬e.Note{
|
||||
ID: id,
|
||||
Path: path,
|
||||
Title: title,
|
||||
Content: content,
|
||||
UpdatedAt: fromUnix(updated),
|
||||
Size: size,
|
||||
Published: published != 0,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func toUnix(t time.Time) int64 {
|
||||
if t.IsZero() {
|
||||
return 0
|
||||
}
|
||||
return t.UTC().Unix()
|
||||
}
|
||||
|
||||
func fromUnix(v int64) time.Time {
|
||||
if v == 0 {
|
||||
return time.Time{}
|
||||
}
|
||||
return time.Unix(v, 0).UTC()
|
||||
}
|
||||
|
||||
func boolToInt(v bool) int {
|
||||
if v {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
238
internal/storage/sqlite_test.go
Normal file
238
internal/storage/sqlite_test.go
Normal file
@ -0,0 +1,238 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"donniemarko/internal/note"
|
||||
)
|
||||
|
||||
func newSQLiteStorage(t *testing.T) *SQLiteStorage {
|
||||
t.Helper()
|
||||
|
||||
dbPath := filepath.Join(t.TempDir(), "notes.db")
|
||||
st, err := NewSQLiteStorage(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("new sqlite storage: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = st.Close()
|
||||
})
|
||||
|
||||
return st
|
||||
}
|
||||
|
||||
func sampleNote(id, path, title, content string, tstamp time.Time) *note.Note {
|
||||
return ¬e.Note{
|
||||
ID: id,
|
||||
Path: path,
|
||||
Title: title,
|
||||
Content: content,
|
||||
UpdatedAt: tstamp.Add(2 * time.Hour),
|
||||
Size: int64(len(content)),
|
||||
Published: true,
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteStorage_CreateGet(t *testing.T) {
|
||||
st := newSQLiteStorage(t)
|
||||
|
||||
ts := time.Date(2026, 2, 3, 12, 0, 0, 0, time.UTC)
|
||||
n := sampleNote("n1", "notes/alpha.md", "Alpha", "# Alpha", ts)
|
||||
|
||||
if err := st.Create(n); err != nil {
|
||||
t.Fatalf("create note: %v", err)
|
||||
}
|
||||
|
||||
got, err := st.Get("n1")
|
||||
if err != nil {
|
||||
t.Fatalf("get note: %v", err)
|
||||
}
|
||||
|
||||
if got.Title != n.Title || got.Path != n.Path || got.Content != n.Content {
|
||||
t.Fatalf("unexpected note fields: %+v", got)
|
||||
}
|
||||
if !got.UpdatedAt.Equal(n.UpdatedAt) || got.Size != n.Size {
|
||||
t.Fatalf("unexpected time fields: %+v", got)
|
||||
}
|
||||
if !got.Published {
|
||||
t.Fatalf("expected published to be true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteStorage_GetAll(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.Create(sampleNote("n2", "notes/beta.md", "Beta", "two", ts)); err != nil {
|
||||
t.Fatalf("create note: %v", err)
|
||||
}
|
||||
|
||||
all := st.GetAll()
|
||||
if len(all) != 2 {
|
||||
t.Fatalf("expected 2 notes, got %d", len(all))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteStorage_Update(t *testing.T) {
|
||||
st := newSQLiteStorage(t)
|
||||
|
||||
ts := time.Date(2026, 2, 3, 12, 0, 0, 0, time.UTC)
|
||||
n := sampleNote("n1", "notes/alpha.md", "Alpha", "one", ts)
|
||||
if err := st.Create(n); err != nil {
|
||||
t.Fatalf("create note: %v", err)
|
||||
}
|
||||
|
||||
updated := sampleNote("n1", "notes/alpha.md", "Alpha Updated", "two", ts.Add(24*time.Hour))
|
||||
st.Update("n1", updated)
|
||||
|
||||
got, err := st.Get("n1")
|
||||
if err != nil {
|
||||
t.Fatalf("get note: %v", err)
|
||||
}
|
||||
if got.Title != "Alpha Updated" || got.Content != "two" {
|
||||
t.Fatalf("update did not persist: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteStorage_Delete(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)
|
||||
}
|
||||
|
||||
st.Delete("n1")
|
||||
|
||||
if st.Count() != 0 {
|
||||
t.Fatalf("expected count 0 after delete")
|
||||
}
|
||||
|
||||
if _, err := st.Get("n1"); err == nil {
|
||||
t.Fatalf("expected error for missing note")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteStorage_Search(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", "Rust tips", ts)); err != nil {
|
||||
t.Fatalf("create note: %v", err)
|
||||
}
|
||||
if err := st.Create(sampleNote("n2", "notes/beta.md", "Beta", "Golang tutorial", ts)); err != nil {
|
||||
t.Fatalf("create note: %v", err)
|
||||
}
|
||||
|
||||
results := st.Search("rust")
|
||||
if len(results) != 1 {
|
||||
t.Fatalf("expected 1 result, got %d", len(results))
|
||||
}
|
||||
if results[0].ID != "n1" {
|
||||
t.Fatalf("expected rust match to be n1")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteStorage_Count(t *testing.T) {
|
||||
st := newSQLiteStorage(t)
|
||||
|
||||
if st.Count() != 0 {
|
||||
t.Fatalf("expected empty count to be 0")
|
||||
}
|
||||
|
||||
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.Create(sampleNote("n2", "notes/beta.md", "Beta", "two", ts)); err != nil {
|
||||
t.Fatalf("create note: %v", err)
|
||||
}
|
||||
|
||||
if st.Count() != 2 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteStorage_Create_Upsert(t *testing.T) {
|
||||
st := newSQLiteStorage(t)
|
||||
|
||||
ts := time.Date(2026, 2, 3, 12, 0, 0, 0, time.UTC)
|
||||
n := sampleNote("n1", "notes/alpha.md", "Alpha", "one", ts)
|
||||
if err := st.Create(n); err != nil {
|
||||
t.Fatalf("create note: %v", err)
|
||||
}
|
||||
|
||||
n.Content = "updated"
|
||||
n.Title = "Alpha Updated"
|
||||
n.UpdatedAt = ts.Add(2 * time.Hour)
|
||||
if err := st.Create(n); err != nil {
|
||||
t.Fatalf("upsert note: %v", err)
|
||||
}
|
||||
|
||||
got, err := st.Get("n1")
|
||||
if err != nil {
|
||||
t.Fatalf("get note: %v", err)
|
||||
}
|
||||
if got.Title != "Alpha Updated" || got.Content != "updated" {
|
||||
t.Fatalf("expected note to be updated, got %+v", got)
|
||||
}
|
||||
}
|
||||
130
internal/storage/storage.go
Normal file
130
internal/storage/storage.go
Normal file
@ -0,0 +1,130 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"donniemarko/internal/note"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Storage interface {
|
||||
GetAll() []*note.Note
|
||||
Get(id string) (*note.Note, error)
|
||||
Create(n *note.Note) error
|
||||
Delete(id string)
|
||||
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 {
|
||||
Index map[string]*note.Note
|
||||
}
|
||||
|
||||
func NewNoteStorage() *NoteStorage {
|
||||
return &NoteStorage{Index: make(map[string]*note.Note)}
|
||||
}
|
||||
|
||||
// GetAll returns all notes stored in the index
|
||||
func (ns *NoteStorage) GetAll() []*note.Note {
|
||||
var notes []*note.Note
|
||||
|
||||
for _, value := range ns.Index {
|
||||
notes = append(notes, value)
|
||||
}
|
||||
return notes
|
||||
}
|
||||
|
||||
func (ns *NoteStorage) Get(id string) (*note.Note, error) {
|
||||
n, ok := ns.Index[id]
|
||||
if ok {
|
||||
return n, nil
|
||||
}
|
||||
return nil, fmt.Errorf("No note with id '%s'", id)
|
||||
}
|
||||
|
||||
func (ns *NoteStorage) Create(n *note.Note) error {
|
||||
ns.Index[n.ID] = n
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ns *NoteStorage) Count() int {
|
||||
return len(ns.Index)
|
||||
}
|
||||
|
||||
func (ns *NoteStorage) Delete(id string) {
|
||||
delete(ns.Index, id)
|
||||
}
|
||||
|
||||
func (ns *NoteStorage) Update(id string, n *note.Note) {
|
||||
ns.Index[id] = n
|
||||
}
|
||||
|
||||
func (ns *NoteStorage) Search(query string) []*note.Note {
|
||||
results := []*note.Note{}
|
||||
for _, note := range ns.Index {
|
||||
lowContent := strings.ToLower(string(note.Content))
|
||||
lowQuery := strings.ToLower(query)
|
||||
if strings.Contains(lowContent, lowQuery) || tagsContain(note.Tags, lowQuery) {
|
||||
results = append(results, note)
|
||||
}
|
||||
}
|
||||
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 {
|
||||
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
|
||||
}
|
||||
70
internal/storage/storage_test.go
Normal file
70
internal/storage/storage_test.go
Normal file
@ -0,0 +1,70 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"donniemarko/internal/note"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var ns *NoteStorage
|
||||
var n1, n2 *note.Note
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
ns = NewNoteStorage()
|
||||
n1 = note.NewNote()
|
||||
n1.Path = "test/note1.md"
|
||||
n1.ID = note.GenerateNoteID(n1.Path)
|
||||
n1.Content = "# hola amigo"
|
||||
|
||||
n2 = note.NewNote()
|
||||
n2.Path = "note2.md"
|
||||
n2.ID = note.GenerateNoteID(n2.Path)
|
||||
n2.Content = "# ah si ?"
|
||||
m.Run()
|
||||
}
|
||||
|
||||
func TestNoteStorageCreate(t *testing.T) {
|
||||
ns.Create(n1)
|
||||
ns.Create(n2)
|
||||
|
||||
if len(ns.Index) < 2 {
|
||||
t.Errorf("Creating notes should add them to the storage. Wanted 2, got '%v'", len(ns.Index))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoteStorageDelete(t *testing.T) {
|
||||
ns.Delete(n1.ID)
|
||||
|
||||
if len(ns.Index) > 1 {
|
||||
t.Errorf("Deleting notes should remove them from to the storage. Wanted 1, got '%v'", len(ns.Index))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoteStorageGetUpdate(t *testing.T) {
|
||||
ns.Update(n2.ID, n1)
|
||||
|
||||
nn2, err := ns.Get(n2.ID)
|
||||
if err != nil {
|
||||
t.Errorf("Error retrieving note with id '%s': '%v'", n2.ID, err)
|
||||
}
|
||||
|
||||
if nn2.Content != n1.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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user