summaryrefslogtreecommitdiffstats
path: root/internal/db
diff options
context:
space:
mode:
authorClaude <noreply@anthropic.com>2026-03-31 12:11:18 +0200
committerClaude <noreply@anthropic.com>2026-03-31 12:11:18 +0200
commit3cb7c82cf7c4e050148f69be23590a7fbe587a27 (patch)
treed2b6506db2de72b3a6982cfbe69925b88936de90 /internal/db
parent33f214f6cd9729473bb55fd7b3b923d5d960bb98 (diff)
downloadnebbet.no-3cb7c82cf7c4e050148f69be23590a7fbe587a27.tar.xz
nebbet.no-3cb7c82cf7c4e050148f69be23590a7fbe587a27.zip
Add static site builder: SQLite-backed MD→HTML pipeline
- cmd/nebbet: CLI with build [--watch] and user add/passwd/delete/list - internal/builder: markdown→HTML, component injection via HTML comments, auto importmap from lib/, fsnotify watch with 150ms debounce - internal/db: meta.db (page index, tag queries) + search.db (FTS5) - internal/sqlitedrv: minimal CGO database/sql driver for system libsqlite3 - internal/auth: htpasswd-compatible bcrypt password file management - templates/base.html + admin.html, styles/main.css + admin.css - nginx.conf with auth_basic for /admin, clean URLs, gzip - nebbet.service systemd unit for watch daemon - Example content/index.md and components/site-greeting.js https://claude.ai/code/session_01HTc1BCBCiMTEB54XQP1Wz9
Diffstat (limited to 'internal/db')
-rw-r--r--internal/db/meta.go117
-rw-r--r--internal/db/search.go113
2 files changed, 230 insertions, 0 deletions
diff --git a/internal/db/meta.go b/internal/db/meta.go
new file mode 100644
index 0000000..4857234
--- /dev/null
+++ b/internal/db/meta.go
@@ -0,0 +1,117 @@
+package db
+
+import (
+ "database/sql"
+ "encoding/json"
+ "strings"
+ "time"
+
+ _ "nebbet.no/internal/sqlitedrv"
+)
+
+type MetaDB struct {
+ db *sql.DB
+}
+
+type PageMeta struct {
+ Path string
+ HTMLPath string
+ Title string
+ Date string
+ Tags []string
+ UpdatedAt time.Time
+}
+
+func OpenMeta(path string) (*MetaDB, error) {
+ db, err := sql.Open("sqlite", path)
+ if err != nil {
+ return nil, err
+ }
+ _, err = db.Exec(`
+ CREATE TABLE IF NOT EXISTS pages (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ path TEXT NOT NULL UNIQUE,
+ html_path TEXT NOT NULL,
+ title TEXT NOT NULL DEFAULT '',
+ date TEXT DEFAULT '',
+ tags TEXT DEFAULT '[]',
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
+ );
+ CREATE INDEX IF NOT EXISTS idx_pages_path ON pages(path);
+ CREATE INDEX IF NOT EXISTS idx_pages_date ON pages(date);
+ `)
+ if err != nil {
+ return nil, err
+ }
+ return &MetaDB{db: db}, nil
+}
+
+func (m *MetaDB) Close() error { return m.db.Close() }
+
+func (m *MetaDB) UpsertPage(p PageMeta) error {
+ tags, _ := json.Marshal(p.Tags)
+ _, err := m.db.Exec(`
+ INSERT INTO pages (path, html_path, title, date, tags, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?)
+ ON CONFLICT(path) DO UPDATE SET
+ html_path = excluded.html_path,
+ title = excluded.title,
+ date = excluded.date,
+ tags = excluded.tags,
+ updated_at = excluded.updated_at
+ `, p.Path, p.HTMLPath, p.Title, p.Date, string(tags), p.UpdatedAt.UTC())
+ return err
+}
+
+func (m *MetaDB) DeletePage(path string) error {
+ _, err := m.db.Exec(`DELETE FROM pages WHERE path = ?`, path)
+ return err
+}
+
+func (m *MetaDB) GetPage(path string) (*PageMeta, error) {
+ row := m.db.QueryRow(
+ `SELECT path, html_path, title, date, tags FROM pages WHERE path = ?`, path)
+ var p PageMeta
+ var tagsJSON string
+ if err := row.Scan(&p.Path, &p.HTMLPath, &p.Title, &p.Date, &tagsJSON); err != nil {
+ return nil, err
+ }
+ _ = json.Unmarshal([]byte(tagsJSON), &p.Tags)
+ return &p, nil
+}
+
+func (m *MetaDB) ListPages() ([]PageMeta, error) {
+ rows, err := m.db.Query(
+ `SELECT path, html_path, title, date, tags FROM pages ORDER BY date DESC, path`)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ return scanPages(rows)
+}
+
+func (m *MetaDB) ListByTag(tag string) ([]PageMeta, error) {
+ // JSON array contains check via LIKE — sufficient for simple tag strings.
+ needle := `%"` + strings.ReplaceAll(tag, `"`, `\"`) + `"%`
+ rows, err := m.db.Query(
+ `SELECT path, html_path, title, date, tags FROM pages WHERE tags LIKE ? ORDER BY date DESC`, needle)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ return scanPages(rows)
+}
+
+func scanPages(rows *sql.Rows) ([]PageMeta, error) {
+ var pages []PageMeta
+ for rows.Next() {
+ var p PageMeta
+ var tagsJSON string
+ if err := rows.Scan(&p.Path, &p.HTMLPath, &p.Title, &p.Date, &tagsJSON); err != nil {
+ return nil, err
+ }
+ _ = json.Unmarshal([]byte(tagsJSON), &p.Tags)
+ pages = append(pages, p)
+ }
+ return pages, rows.Err()
+}
diff --git a/internal/db/search.go b/internal/db/search.go
new file mode 100644
index 0000000..b2c9b49
--- /dev/null
+++ b/internal/db/search.go
@@ -0,0 +1,113 @@
+package db
+
+import (
+ "database/sql"
+
+ _ "nebbet.no/internal/sqlitedrv"
+)
+
+type SearchDB struct {
+ db *sql.DB
+}
+
+type SearchPage struct {
+ Path string
+ Title string
+ Content string
+}
+
+type SearchResult struct {
+ Path string
+ Title string
+ Snippet string
+}
+
+func OpenSearch(path string) (*SearchDB, error) {
+ db, err := sql.Open("sqlite", path)
+ if err != nil {
+ return nil, err
+ }
+ _, err = db.Exec(`
+ CREATE TABLE IF NOT EXISTS indexed_pages (
+ path TEXT NOT NULL PRIMARY KEY,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
+ );
+ CREATE VIRTUAL TABLE IF NOT EXISTS pages_fts USING fts5(
+ path UNINDEXED,
+ title,
+ content,
+ tokenize = 'porter unicode61'
+ );
+ `)
+ if err != nil {
+ return nil, err
+ }
+ return &SearchDB{db: db}, nil
+}
+
+func (s *SearchDB) Close() error { return s.db.Close() }
+
+func (s *SearchDB) IndexPage(p SearchPage) error {
+ tx, err := s.db.Begin()
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback()
+
+ if _, err = tx.Exec(`DELETE FROM pages_fts WHERE path = ?`, p.Path); err != nil {
+ return err
+ }
+ if _, err = tx.Exec(
+ `INSERT INTO pages_fts (path, title, content) VALUES (?, ?, ?)`,
+ p.Path, p.Title, p.Content,
+ ); err != nil {
+ return err
+ }
+ if _, err = tx.Exec(`
+ INSERT INTO indexed_pages (path, updated_at) VALUES (?, CURRENT_TIMESTAMP)
+ ON CONFLICT(path) DO UPDATE SET updated_at = CURRENT_TIMESTAMP
+ `, p.Path); err != nil {
+ return err
+ }
+ return tx.Commit()
+}
+
+func (s *SearchDB) DeletePage(path string) error {
+ tx, err := s.db.Begin()
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback()
+ if _, err = tx.Exec(`DELETE FROM pages_fts WHERE path = ?`, path); err != nil {
+ return err
+ }
+ if _, err = tx.Exec(`DELETE FROM indexed_pages WHERE path = ?`, path); err != nil {
+ return err
+ }
+ return tx.Commit()
+}
+
+// Search runs a full-text query and returns up to 20 results with snippets.
+func (s *SearchDB) Search(query string) ([]SearchResult, error) {
+ rows, err := s.db.Query(`
+ SELECT path, title,
+ snippet(pages_fts, 2, '<mark>', '</mark>', '...', 20)
+ FROM pages_fts
+ WHERE pages_fts MATCH ?
+ ORDER BY rank
+ LIMIT 20
+ `, query)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var results []SearchResult
+ for rows.Next() {
+ var r SearchResult
+ if err := rows.Scan(&r.Path, &r.Title, &r.Snippet); err != nil {
+ return nil, err
+ }
+ results = append(results, r)
+ }
+ return results, rows.Err()
+}