Files
donniemarko/internal/storage/sqlite.go
adminoo b571588b15 feat(release): v0.2.0
commit 78d6c27c8940da32a6de8e64327c86f74fdaa2eb
Author: adminoo <git@kadath.corp>
Date:   Wed Feb 4 12:59:22 2026 +0100

    feat: freebsd log rotation config thingie

commit 55af4e6c70122e679272ed247c26e04b1247f694
Author: adminoo <git@kadath.corp>
Date:   Wed Feb 4 12:58:43 2026 +0100

    feat: embed templates, static resolution

commit 29c917f929a7378ec29c54315ee2e9f420747787
Author: adminoo <git@kadath.corp>
Date:   Wed Feb 4 10:44:34 2026 +0100

    feat: set log file path

commit 294fd3d1549979eab63587ceec6ff5d0978e9afc
Author: adminoo <git@kadath.corp>
Date:   Wed Feb 4 10:23:53 2026 +0100

    feat: logging HTTP request

commit c9ae80b240d58e1abed7ae3b7b2c3b283a31f1a1
Author: adminoo <git@kadath.corp>
Date:   Wed Feb 4 09:54:05 2026 +0100

    feat: freebsd-specific compile target and scripts

commit 86ca154dedd19aa1fe5f571c445dcf17a8396bfa
Author: adminoo <git@kadath.corp>
Date:   Wed Feb 4 09:25:16 2026 +0100

    feat: mobile friendly CSS

commit 199f4319e0b08a4b6d595d7eb3effb6db6c7beec
Author: adminoo <git@kadath.corp>
Date:   Wed Feb 4 09:25:03 2026 +0100

    feat: persisting rendered note

commit 865e258237e45d7c542685a4653bcad3c5af259d
Author: adminoo <git@kadath.corp>
Date:   Wed Feb 4 08:06:38 2026 +0100

    fix: grouping notes by folder

commit 242d1d074c92461f38212b033c7a9e383f9dc550
Author: adminoo <git@kadath.corp>
Date:   Tue Feb 3 16:52:50 2026 +0100

    feat: storage layer logic

    - Prune notes from db not matching current folder structure at start
    - Detect file system deletion on start by comparing in-db notes
    - Prevent updating of in-db notes at start if modification time is not
      newer
    - Delete by path

commit d75d46bc1ab22bd990d0fdc307e571fe52f0dd99
Author: adminoo <git@kadath.corp>
Date:   Tue Feb 3 15:27:07 2026 +0100

    feat: group notes by root folders

commit e1e25a938e717599332f7b40a449d9bb854b673a
Author: adminoo <git@kadath.corp>
Date:   Tue Feb 3 14:24:37 2026 +0100

    feat: size in kilobytes

commit 61220272a2df2b66c2b8e356ba359ed01de3bd12
Author: adminoo <git@kadath.corp>
Date:   Tue Feb 3 14:19:40 2026 +0100

    feat: styling inputs
2026-02-04 13:17:16 +01:00

342 lines
7.2 KiB
Go

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
WHERE excluded.updated_at > notes.updated_at
`,
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) DeleteByPath(path string) {
_, _ = s.db.Exec(`DELETE FROM notes WHERE path = ?`, path)
}
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 &note.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
}