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
342 lines
7.2 KiB
Go
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 ¬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
|
|
}
|