438 lines
11 KiB
Go
438 lines
11 KiB
Go
package nodes
|
|
|
|
import (
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"path/filepath"
|
|
"time"
|
|
|
|
"verstak/internal/core/storage"
|
|
"verstak/internal/core/templates"
|
|
"verstak/internal/core/util"
|
|
)
|
|
|
|
var (
|
|
// ErrNotFound is returned when a node cannot be located.
|
|
ErrNotFound = errors.New("node not found")
|
|
// ErrDuplicateSlug means slug uniqueness is violated within the parent.
|
|
ErrDuplicateSlug = errors.New("duplicate slug under the same parent")
|
|
)
|
|
|
|
// Repository provides CRUD for the nodes table.
|
|
type Repository struct {
|
|
db *storage.DB
|
|
}
|
|
|
|
// NewRepository wraps an open storage DB.
|
|
func NewRepository(db *storage.DB) *Repository {
|
|
return &Repository{db: db}
|
|
}
|
|
|
|
// DB returns the underlying storage (useful for transactions).
|
|
func (r *Repository) DB() *storage.DB {
|
|
return r.db
|
|
}
|
|
|
|
// tx is a small helper for one-statement anonymous function transactions.
|
|
func tx(db *storage.DB, fn func(*sql.Tx) error) error {
|
|
t, err := db.Begin()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer t.Rollback()
|
|
if err := fn(t); err != nil {
|
|
return err
|
|
}
|
|
return t.Commit()
|
|
}
|
|
|
|
// now returns an RFC3339 timestamp.
|
|
func now() string {
|
|
return time.Now().UTC().Format(time.RFC3339)
|
|
}
|
|
|
|
// columns used in all SELECT queries.
|
|
var nodeColumns = "id,parent_id,type,title,slug,template_id,fs_path,section,sort_order,archived,created_at,updated_at,deleted_at,revision,device_id"
|
|
|
|
// Create inserts a node. parentID may be nil for root-level nodes.
|
|
func (r *Repository) Create(parentID *string, typ, title string, sortOrder int, templateID, fsPath string) (*Node, error) {
|
|
if !IsValidType(typ) {
|
|
return nil, fmt.Errorf("invalid node type: %s", typ)
|
|
}
|
|
if title == "" {
|
|
return nil, errors.New("title is required")
|
|
}
|
|
|
|
n := &Node{
|
|
ID: util.UUID7(),
|
|
Type: typ,
|
|
Title: title,
|
|
Slug: Slugify(title),
|
|
TemplateID: templateID,
|
|
FsPath: fsPath,
|
|
SortOrder: sortOrder,
|
|
CreatedAt: time.Now().UTC(),
|
|
UpdatedAt: time.Now().UTC(),
|
|
Revision: 1,
|
|
}
|
|
if parentID != nil {
|
|
n.ParentID = parentID
|
|
}
|
|
|
|
err := r.insertNode(n)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if parentID != nil {
|
|
_ = r.touch(*parentID)
|
|
}
|
|
return n, nil
|
|
}
|
|
|
|
// touch updates a node's updated_at without changing other fields.
|
|
func (r *Repository) touch(id string) error {
|
|
t := now()
|
|
_, err := r.db.Exec(
|
|
`UPDATE nodes SET updated_at=?, revision=revision+1
|
|
WHERE id=? AND deleted_at IS NULL`, t, id)
|
|
return err
|
|
}
|
|
|
|
func (r *Repository) insertNode(n *Node) error {
|
|
var parent interface{}
|
|
if n.ParentID != nil {
|
|
parent = *n.ParentID
|
|
}
|
|
|
|
_, err := r.db.Exec(
|
|
`INSERT INTO nodes (id,parent_id,type,title,slug,template_id,fs_path,section,sort_order,archived,
|
|
created_at,updated_at,deleted_at,revision,device_id)
|
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
|
n.ID, parent, n.Type, n.Title, n.Slug, n.TemplateID, n.FsPath, n.Section,
|
|
n.SortOrder, n.Archived, n.CreatedAt.Format(time.RFC3339), n.UpdatedAt.Format(time.RFC3339),
|
|
n.DeletedAt, n.Revision, n.DeviceID,
|
|
)
|
|
return err
|
|
}
|
|
|
|
// Get returns a plain node (even if soft-deleted).
|
|
func (r *Repository) Get(id string) (*Node, error) {
|
|
row := r.db.QueryRow(
|
|
`SELECT `+nodeColumns+` FROM nodes WHERE id = ?`, id)
|
|
return scanNode(row)
|
|
}
|
|
|
|
// GetActive returns ErrNotFound if the node is soft-deleted or missing.
|
|
func (r *Repository) GetActive(id string) (*Node, error) {
|
|
n, err := r.Get(id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if n.IsDeleted() {
|
|
return nil, ErrNotFound
|
|
}
|
|
return n, nil
|
|
}
|
|
|
|
// ListChildren returns direct children ordered by sort_order, then title.
|
|
// IncludeDeleted lists soft-deleted children too.
|
|
func (r *Repository) ListChildren(parentID string, includeDeleted bool) ([]Node, error) {
|
|
q := `SELECT ` + nodeColumns + ` FROM nodes WHERE parent_id = ?`
|
|
if !includeDeleted {
|
|
q += " AND deleted_at IS NULL"
|
|
}
|
|
q += " ORDER BY sort_order, title"
|
|
|
|
rows, err := r.db.Query(q, parentID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
return scanNodes(rows)
|
|
}
|
|
|
|
// ListRoots returns nodes with no parent (top-level).
|
|
func (r *Repository) ListRoots(includeDeleted bool) ([]Node, error) {
|
|
q := `SELECT ` + nodeColumns + ` FROM nodes WHERE parent_id IS NULL`
|
|
if !includeDeleted {
|
|
q += " AND deleted_at IS NULL"
|
|
}
|
|
q += " ORDER BY sort_order, title"
|
|
|
|
rows, err := r.db.Query(q)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
return scanNodes(rows)
|
|
}
|
|
|
|
// ListByParent returns children as *Node pointers. parentID must not be empty.
|
|
func (r *Repository) ListByParent(parentID string) ([]*Node, error) {
|
|
rows, err := r.db.Query(
|
|
`SELECT `+nodeColumns+` FROM nodes WHERE parent_id = ? AND deleted_at IS NULL ORDER BY sort_order, title`, parentID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var out []*Node
|
|
for rows.Next() {
|
|
n, err := scanNode(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, n)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// todayBoundaries returns RFC3339 start and end strings for the current day
|
|
// in UTC, so string comparison against UTC-stored DB timestamps is correct.
|
|
func todayBoundaries() (string, string) {
|
|
now := time.Now()
|
|
y, m, d := now.Date()
|
|
start := time.Date(y, m, d, 0, 0, 0, 0, now.Location())
|
|
end := start.Add(24 * time.Hour)
|
|
return start.UTC().Format(time.RFC3339), end.UTC().Format(time.RFC3339)
|
|
}
|
|
|
|
// ListTodayNodes returns active root-level nodes created or updated today.
|
|
func (r *Repository) ListTodayNodes() ([]Node, error) {
|
|
start, end := todayBoundaries()
|
|
q := `SELECT ` + nodeColumns + ` FROM nodes
|
|
WHERE deleted_at IS NULL
|
|
AND parent_id IS NULL
|
|
AND (
|
|
(created_at >= ? AND created_at < ?)
|
|
OR
|
|
(updated_at >= ? AND updated_at < ?)
|
|
)
|
|
ORDER BY updated_at DESC, created_at DESC`
|
|
rows, err := r.db.Query(q, start, end, start, end)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
return scanNodes(rows)
|
|
}
|
|
|
|
// UpdateTitle changes title (slug is recomputed).
|
|
func (r *Repository) UpdateTitle(id, title string) error {
|
|
if title == "" {
|
|
return errors.New("title required")
|
|
}
|
|
slug := Slugify(title)
|
|
t := now()
|
|
res, err := r.db.Exec(
|
|
`UPDATE nodes SET title=?, slug=?, updated_at=?, revision=revision+1
|
|
WHERE id=? AND deleted_at IS NULL`,
|
|
title, slug, t, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
n, _ := res.RowsAffected()
|
|
if n == 0 {
|
|
return ErrNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// UpdateFsPath updates the fs_path of a single node.
|
|
func (r *Repository) UpdateFsPath(id, fsPath string) error {
|
|
t := now()
|
|
res, err := r.db.Exec(
|
|
`UPDATE nodes SET fs_path=?, updated_at=?, revision=revision+1
|
|
WHERE id=? AND deleted_at IS NULL`,
|
|
fsPath, t, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
n, _ := res.RowsAffected()
|
|
if n == 0 {
|
|
return ErrNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// UpdateFsPathRecursive updates fs_path for a node and all its descendants.
|
|
func (r *Repository) UpdateFsPathRecursive(id, newFsPath string) error {
|
|
if err := r.UpdateFsPath(id, newFsPath); err != nil {
|
|
return err
|
|
}
|
|
children, err := r.ListChildren(id, false)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, child := range children {
|
|
seg := templates.SafeDisplayNameToPathSegment(child.Title)
|
|
childPath := filepath.Join(newFsPath, seg)
|
|
if err := r.UpdateFsPathRecursive(child.ID, childPath); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Move changes the parent and/or sort order of a node.
|
|
// newParentID = nil means move to root.
|
|
func (r *Repository) Move(id string, newParentID *string, sortOrder int) error {
|
|
var parent interface{}
|
|
if newParentID != nil {
|
|
parent = *newParentID
|
|
}
|
|
t := now()
|
|
res, err := r.db.Exec(
|
|
`UPDATE nodes SET parent_id=?, sort_order=?, updated_at=?, revision=revision+1
|
|
WHERE id=? AND deleted_at IS NULL`,
|
|
parent, sortOrder, t, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
n, _ := res.RowsAffected()
|
|
if n == 0 {
|
|
return ErrNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SetArchived sets the archived flag on a node.
|
|
func (r *Repository) SetArchived(id string, archived bool) error {
|
|
t := now()
|
|
res, err := r.db.Exec(
|
|
`UPDATE nodes SET archived=?, updated_at=?, revision=revision+1
|
|
WHERE id=? AND deleted_at IS NULL`,
|
|
archived, t, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
n, _ := res.RowsAffected()
|
|
if n == 0 {
|
|
return ErrNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SoftDelete marks a node as deleted (does not touch its children).
|
|
func (r *Repository) SoftDelete(id string) error {
|
|
t := now()
|
|
res, err := r.db.Exec(
|
|
`UPDATE nodes SET deleted_at=?, updated_at=?
|
|
WHERE id=? AND deleted_at IS NULL`,
|
|
t, t, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
n, _ := res.RowsAffected()
|
|
if n == 0 {
|
|
return ErrNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// MetaSet sets or overwrites a key-value pair for a node.
|
|
func (r *Repository) MetaSet(nodeID, key, value string) error {
|
|
_, err := r.db.Exec(
|
|
`INSERT INTO node_meta (node_id,key,value) VALUES (?,?,?)
|
|
ON CONFLICT(node_id,key) DO UPDATE SET value=excluded.value`,
|
|
nodeID, key, value)
|
|
return err
|
|
}
|
|
|
|
// MetaGet returns a single meta value, ok=false if missing.
|
|
func (r *Repository) MetaGet(nodeID, key string) (string, bool, error) {
|
|
var v string
|
|
err := r.db.QueryRow(
|
|
`SELECT value FROM node_meta WHERE node_id=? AND key=?`, nodeID, key).Scan(&v)
|
|
if err == sql.ErrNoRows {
|
|
return "", false, nil
|
|
}
|
|
if err != nil {
|
|
return "", false, err
|
|
}
|
|
return v, true, nil
|
|
}
|
|
|
|
// MetaList returns all meta for a node.
|
|
func (r *Repository) MetaList(nodeID string) ([]Meta, error) {
|
|
rows, err := r.db.Query(
|
|
`SELECT key,value FROM node_meta WHERE node_id=?`, nodeID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var out []Meta
|
|
for rows.Next() {
|
|
var m Meta
|
|
if err := rows.Scan(&m.Key, &m.Value); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, m)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// --- scanning helpers ---
|
|
|
|
type scanner interface {
|
|
Scan(dest ...interface{}) error
|
|
}
|
|
|
|
func scanNode(s scanner) (*Node, error) {
|
|
var n Node
|
|
var parentID, templateID, fsPath, section, deletedAt, deviceID sql.NullString
|
|
var archived int
|
|
var createdStr, updatedStr string
|
|
|
|
err := s.Scan(
|
|
&n.ID, &parentID, &n.Type, &n.Title, &n.Slug, &templateID, &fsPath,
|
|
§ion, &n.SortOrder, &archived, &createdStr, &updatedStr, &deletedAt,
|
|
&n.Revision, &deviceID,
|
|
)
|
|
if err == sql.ErrNoRows {
|
|
return nil, ErrNotFound
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if parentID.Valid {
|
|
n.ParentID = &parentID.String
|
|
}
|
|
if templateID.Valid {
|
|
n.TemplateID = templateID.String
|
|
}
|
|
if fsPath.Valid {
|
|
n.FsPath = fsPath.String
|
|
}
|
|
if section.Valid {
|
|
n.Section = section.String
|
|
}
|
|
if deletedAt.Valid {
|
|
t, _ := time.Parse(time.RFC3339, deletedAt.String)
|
|
n.DeletedAt = &t
|
|
}
|
|
if deviceID.Valid {
|
|
n.DeviceID = &deviceID.String
|
|
}
|
|
n.Archived = archived != 0
|
|
|
|
n.CreatedAt, _ = time.Parse(time.RFC3339, createdStr)
|
|
n.UpdatedAt, _ = time.Parse(time.RFC3339, updatedStr)
|
|
return &n, nil
|
|
}
|
|
|
|
func scanNodes(rows *sql.Rows) ([]Node, error) {
|
|
var out []Node
|
|
for rows.Next() {
|
|
n, err := scanNode(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, *n)
|
|
}
|
|
return out, rows.Err()
|
|
}
|