Phase 5h slice 4 — adds the star button on each tile that flips Pinned on the projax item via POST /dashboard/pin. Backend: - store.SetPinned(ids, pinned bool) — minimal-write helper that mirrors SetPublic, only touching the pinned column. - web/dashboard_pin.go — handleDashboardPin parses id + pin from form, calls SetPinned, invalidates the entire dashboard cache (pin affects sort order across every view/scope/filter combo), then re-renders by delegating to handleDashboard so HTMX receives the updated #dashboard-section HTML. - Route: POST /dashboard/pin (sibling of /dashboard/task/*). Frontend: - Tile template now leads with a <form class="tile-pin-form"> that POSTs id + the inverted pin state. Button glyph is ☆ when unpinned, ★ when pinned; aria-label flips accordingly. - HTMX swaps the entire #dashboard-section so the tile moves to the pinned-first position (or back to alphabetical) without a full reload. - CSS: .tile-pin (transparent button, muted color, accent on hover); .tile-pin.pinned for the filled-star state. Test helper: server_test.go gains a post() helper paired with the existing get() — form-encoded POSTs for writeback tests. Tests (dashboard_pin_test.go): - TestDashboardPinTogglesItem — POST pin=true flips the row, and the re-render shows the .tile-pinned class on the tile <article>. - TestDashboardPinUnpinsItem — POST pin=false on a pinned row unpins. - TestDashboardPinRequiresID — missing id returns 400. - TestDashboardPinInvalidatesCache — primes with unpinned cache, POSTs pin, asserts the next GET reflects the pinned class (proving the prior cache entry was busted).
922 lines
30 KiB
Go
922 lines
30 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"slices"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
// Item is the unified row shape served by projax.items_unified. Phase 1.5
|
|
// collapsed the area/project distinction (kind keeps the slot for future
|
|
// types but 'area' is no longer a special value) and extended the tree to
|
|
// a DAG: an item can have zero or more parents and surface under multiple
|
|
// paths simultaneously.
|
|
type Item struct {
|
|
ID string
|
|
Kind []string
|
|
Title string
|
|
Slug string
|
|
Paths []string // sorted, deduped — one entry per ancestor lineage
|
|
ParentIDs []string
|
|
ContentMD string
|
|
Aliases []string
|
|
Metadata map[string]any
|
|
Status string
|
|
Pinned bool
|
|
Archived bool
|
|
StartTime *time.Time
|
|
EndTime *time.Time
|
|
Source string // always "projax" after Phase 1.5; kept for forward-compat
|
|
SourceRefID *string // mai.projects.id when a 'mai-project' item_links row exists
|
|
Tags []string
|
|
Management []string
|
|
// Phase 4d public-listing fields. Source of truth for the portfolio
|
|
// flexsiebels.de + any future public consumer renders via MCP.
|
|
Public bool
|
|
PublicDescription string
|
|
PublicLiveURL string
|
|
PublicSourceURL string
|
|
PublicScreenshots []string
|
|
// Phase 4f timeline-exclude. Per-item array of kinds to hide from
|
|
// /timeline aggregation. Values: 'todos' | 'events' | 'docs' | 'creation'.
|
|
// Empty array (default) = nothing excluded = current behaviour.
|
|
TimelineExclude []string
|
|
CreatedAt time.Time
|
|
UpdatedAt time.Time
|
|
}
|
|
|
|
// ExcludesTimelineKind reports whether this item's timeline_exclude array
|
|
// names the given kind. The aggregator uses the singular form ("todo",
|
|
// "event", "doc", "creation"); the persisted values use the plural form
|
|
// ("todos", "events", "docs", "creation") per the task brief and so the
|
|
// UI labels read naturally. This translates between the two so callers
|
|
// can pass either.
|
|
func (it *Item) ExcludesTimelineKind(kind string) bool {
|
|
plural := kind
|
|
switch kind {
|
|
case "todo":
|
|
plural = "todos"
|
|
case "event":
|
|
plural = "events"
|
|
case "doc":
|
|
plural = "docs"
|
|
}
|
|
for _, k := range it.TimelineExclude {
|
|
if k == kind || k == plural {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// IsRoot reports whether this item sits at the top of the DAG (no parents).
|
|
func (it *Item) IsRoot() bool { return len(it.ParentIDs) == 0 }
|
|
|
|
// PrimaryPath returns the first path (alphabetically) for routing & display.
|
|
// Empty string when paths is empty (defensive — every persisted row has at
|
|
// least one path).
|
|
func (it *Item) PrimaryPath() string {
|
|
if len(it.Paths) == 0 {
|
|
return ""
|
|
}
|
|
return it.Paths[0]
|
|
}
|
|
|
|
// OtherPaths returns all paths except the primary one, for the "also at: …"
|
|
// breadcrumb on the detail page.
|
|
func (it *Item) OtherPaths() []string {
|
|
if len(it.Paths) <= 1 {
|
|
return nil
|
|
}
|
|
return it.Paths[1:]
|
|
}
|
|
|
|
// HasManagement reports whether the given mode (e.g. "mai") is set on the item.
|
|
func (it *Item) HasManagement(mode string) bool { return slices.Contains(it.Management, mode) }
|
|
|
|
// HasTag reports whether the item carries the given tag.
|
|
func (it *Item) HasTag(tag string) bool { return slices.Contains(it.Tags, tag) }
|
|
|
|
// Editable is preserved for template forward-compat. All rows are editable
|
|
// in projax after the mai.projects unification.
|
|
func (it *Item) Editable() bool { return true }
|
|
|
|
// SourceRefDeref returns the source ref id (empty string if nil) for templates.
|
|
func (it *Item) SourceRefDeref() string {
|
|
if it.SourceRefID == nil {
|
|
return ""
|
|
}
|
|
return *it.SourceRefID
|
|
}
|
|
|
|
// Store wraps a pgx pool with the queries projax needs.
|
|
type Store struct {
|
|
Pool *pgxpool.Pool
|
|
}
|
|
|
|
func New(pool *pgxpool.Pool) *Store { return &Store{Pool: pool} }
|
|
|
|
var ErrNotFound = errors.New("projax: item not found")
|
|
|
|
const itemsUnifiedCols = `id, kind, title, slug, paths, parent_ids, content_md, aliases,
|
|
metadata, status, pinned, archived, start_time, end_time, source, source_ref_id,
|
|
tags, management, public, public_description, public_live_url, public_source_url,
|
|
public_screenshots, timeline_exclude, created_at, updated_at`
|
|
|
|
func scanItem(row pgx.Row) (*Item, error) {
|
|
var it Item
|
|
if err := row.Scan(
|
|
&it.ID, &it.Kind, &it.Title, &it.Slug, &it.Paths, &it.ParentIDs, &it.ContentMD,
|
|
&it.Aliases, &it.Metadata, &it.Status, &it.Pinned, &it.Archived,
|
|
&it.StartTime, &it.EndTime, &it.Source, &it.SourceRefID,
|
|
&it.Tags, &it.Management,
|
|
&it.Public, &it.PublicDescription, &it.PublicLiveURL, &it.PublicSourceURL, &it.PublicScreenshots,
|
|
&it.TimelineExclude,
|
|
&it.CreatedAt, &it.UpdatedAt,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
return &it, nil
|
|
}
|
|
|
|
func scanItems(rows pgx.Rows) ([]*Item, error) {
|
|
defer rows.Close()
|
|
var out []*Item
|
|
for rows.Next() {
|
|
var it Item
|
|
if err := rows.Scan(
|
|
&it.ID, &it.Kind, &it.Title, &it.Slug, &it.Paths, &it.ParentIDs, &it.ContentMD,
|
|
&it.Aliases, &it.Metadata, &it.Status, &it.Pinned, &it.Archived,
|
|
&it.StartTime, &it.EndTime, &it.Source, &it.SourceRefID,
|
|
&it.Tags, &it.Management,
|
|
&it.Public, &it.PublicDescription, &it.PublicLiveURL, &it.PublicSourceURL, &it.PublicScreenshots,
|
|
&it.TimelineExclude,
|
|
&it.CreatedAt, &it.UpdatedAt,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, &it)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// ListAll returns every visible row from items_unified. Caller groups by tree.
|
|
func (s *Store) ListAll(ctx context.Context) ([]*Item, error) {
|
|
rows, err := s.Pool.Query(ctx,
|
|
`select `+itemsUnifiedCols+` from projax.items_unified order by paths[1]`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return scanItems(rows)
|
|
}
|
|
|
|
// GetByPath looks up a single item by any of its paths. Multi-parent items
|
|
// can be accessed via /i/work.paliad or /i/dev.paliad interchangeably.
|
|
func (s *Store) GetByPath(ctx context.Context, path string) (*Item, error) {
|
|
row := s.Pool.QueryRow(ctx,
|
|
`select `+itemsUnifiedCols+` from projax.items_unified where $1 = any(paths) limit 1`, path)
|
|
it, err := scanItem(row)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, ErrNotFound
|
|
}
|
|
return nil, err
|
|
}
|
|
return it, nil
|
|
}
|
|
|
|
// GetByID looks up a single projax-native item by uuid.
|
|
func (s *Store) GetByID(ctx context.Context, id string) (*Item, error) {
|
|
row := s.Pool.QueryRow(ctx,
|
|
`select `+itemsUnifiedCols+` from projax.items_unified where id = $1`, id)
|
|
it, err := scanItem(row)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, ErrNotFound
|
|
}
|
|
return nil, err
|
|
}
|
|
return it, nil
|
|
}
|
|
|
|
// Roots returns the top-level items (no parents), ordered by slug.
|
|
func (s *Store) Roots(ctx context.Context) ([]*Item, error) {
|
|
rows, err := s.Pool.Query(ctx,
|
|
`select `+itemsUnifiedCols+` from projax.items_unified
|
|
where cardinality(parent_ids) = 0
|
|
order by slug`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return scanItems(rows)
|
|
}
|
|
|
|
// MaiOrphans lists mai-managed items that landed at root and need m to
|
|
// re-parent them. This includes both backfilled items that the heuristic
|
|
// misplaced and brand-new mai.projects rows created by mai code (which the
|
|
// reverse sync trigger drops at root by design).
|
|
func (s *Store) MaiOrphans(ctx context.Context) ([]*Item, error) {
|
|
rows, err := s.Pool.Query(ctx,
|
|
`select `+itemsUnifiedCols+` from projax.items_unified
|
|
where cardinality(parent_ids) = 0 and 'mai' = any(management)
|
|
order by slug`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return scanItems(rows)
|
|
}
|
|
|
|
// CreateInput captures the editable surface of a projax-native item.
|
|
type CreateInput struct {
|
|
Kind []string
|
|
Title string
|
|
Slug string
|
|
ParentIDs []string
|
|
ContentMD string
|
|
Status string
|
|
Pinned bool
|
|
StartTime *time.Time
|
|
EndTime *time.Time
|
|
Tags []string
|
|
Management []string
|
|
Metadata map[string]any
|
|
}
|
|
|
|
func (s *Store) Create(ctx context.Context, in CreateInput) (*Item, error) {
|
|
if len(in.Kind) == 0 {
|
|
return nil, errors.New("kind required")
|
|
}
|
|
if in.Title == "" {
|
|
return nil, errors.New("title required")
|
|
}
|
|
if in.Slug == "" {
|
|
return nil, errors.New("slug required")
|
|
}
|
|
if in.Status == "" {
|
|
in.Status = "active"
|
|
}
|
|
if in.Tags == nil {
|
|
in.Tags = []string{}
|
|
}
|
|
if in.Management == nil {
|
|
in.Management = []string{}
|
|
}
|
|
if in.ParentIDs == nil {
|
|
in.ParentIDs = []string{}
|
|
}
|
|
metadata := in.Metadata
|
|
if metadata == nil {
|
|
metadata = map[string]any{}
|
|
}
|
|
var id string
|
|
err := s.Pool.QueryRow(ctx, `
|
|
insert into projax.items
|
|
(kind, title, slug, parent_ids, content_md, status, pinned, start_time, end_time,
|
|
tags, management, metadata)
|
|
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
|
returning id`,
|
|
in.Kind, in.Title, in.Slug, in.ParentIDs, in.ContentMD, in.Status, in.Pinned, in.StartTime, in.EndTime,
|
|
in.Tags, in.Management, metadata,
|
|
).Scan(&id)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("insert: %w", err)
|
|
}
|
|
return s.GetByID(ctx, id)
|
|
}
|
|
|
|
// UpdateInput captures the editable surface of an existing projax-native item.
|
|
type UpdateInput struct {
|
|
Title string
|
|
Slug string
|
|
ParentIDs []string
|
|
ContentMD string
|
|
Status string
|
|
Pinned bool
|
|
Archived bool
|
|
StartTime *time.Time
|
|
EndTime *time.Time
|
|
Tags []string
|
|
Management []string
|
|
// Phase 4d public-listing fields. Full-replace semantics on Update —
|
|
// callers that want partial-update use UpdatePublic instead.
|
|
Public bool
|
|
PublicDescription string
|
|
PublicLiveURL string
|
|
PublicSourceURL string
|
|
PublicScreenshots []string
|
|
// Phase 4f timeline-exclude. Full-replace; values 'todos' / 'events' /
|
|
// 'docs' / 'creation'.
|
|
TimelineExclude []string
|
|
}
|
|
|
|
func (s *Store) Update(ctx context.Context, id string, in UpdateInput) (*Item, error) {
|
|
if in.Tags == nil {
|
|
in.Tags = []string{}
|
|
}
|
|
if in.Management == nil {
|
|
in.Management = []string{}
|
|
}
|
|
if in.ParentIDs == nil {
|
|
in.ParentIDs = []string{}
|
|
}
|
|
if in.PublicScreenshots == nil {
|
|
in.PublicScreenshots = []string{}
|
|
}
|
|
if in.TimelineExclude == nil {
|
|
in.TimelineExclude = []string{}
|
|
}
|
|
_, err := s.Pool.Exec(ctx, `
|
|
update projax.items
|
|
set title=$2, slug=$3, parent_ids=$4, content_md=$5,
|
|
status=$6, pinned=$7, archived=$8, start_time=$9, end_time=$10,
|
|
tags=$11, management=$12,
|
|
public=$13, public_description=$14, public_live_url=$15,
|
|
public_source_url=$16, public_screenshots=$17,
|
|
timeline_exclude=$18
|
|
where id=$1 and deleted_at is null`,
|
|
id, in.Title, in.Slug, in.ParentIDs, in.ContentMD,
|
|
in.Status, in.Pinned, in.Archived, in.StartTime, in.EndTime,
|
|
in.Tags, in.Management,
|
|
in.Public, in.PublicDescription, in.PublicLiveURL,
|
|
in.PublicSourceURL, in.PublicScreenshots,
|
|
in.TimelineExclude,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("update: %w", err)
|
|
}
|
|
return s.GetByID(ctx, id)
|
|
}
|
|
|
|
// SetPublic flips just the public boolean on selected items. Used by the
|
|
// /admin/bulk "Make public" / "Make private" actions so callers don't have
|
|
// to round-trip every other field.
|
|
func (s *Store) SetPublic(ctx context.Context, ids []string, public bool) error {
|
|
if len(ids) == 0 {
|
|
return nil
|
|
}
|
|
_, err := s.Pool.Exec(ctx,
|
|
`update projax.items set public = $2 where id = any($1::uuid[]) and deleted_at is null`,
|
|
ids, public)
|
|
return err
|
|
}
|
|
|
|
// SetPinned flips just the pinned boolean on selected items. Used by the
|
|
// dashboard Tiles view's star toggle so a tile flip doesn't have to
|
|
// re-send every other item field. Same minimal-write shape as SetPublic.
|
|
func (s *Store) SetPinned(ctx context.Context, ids []string, pinned bool) error {
|
|
if len(ids) == 0 {
|
|
return nil
|
|
}
|
|
_, err := s.Pool.Exec(ctx,
|
|
`update projax.items set pinned = $2 where id = any($1::uuid[]) and deleted_at is null`,
|
|
ids, pinned)
|
|
return err
|
|
}
|
|
|
|
// Reparent replaces parent_ids entirely with the given set. Used by the
|
|
// classify page's inline form and any "move to under X" action.
|
|
func (s *Store) Reparent(ctx context.Context, id string, parentIDs []string) (*Item, error) {
|
|
if parentIDs == nil {
|
|
parentIDs = []string{}
|
|
}
|
|
_, err := s.Pool.Exec(ctx,
|
|
`update projax.items set parent_ids = $2 where id = $1 and deleted_at is null`,
|
|
id, parentIDs,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("reparent: %w", err)
|
|
}
|
|
return s.GetByID(ctx, id)
|
|
}
|
|
|
|
// AddParent appends a parent without disturbing existing ones — used by the
|
|
// multi-parent UI to surface a project under a second branch.
|
|
func (s *Store) AddParent(ctx context.Context, id, parentID string) (*Item, error) {
|
|
_, err := s.Pool.Exec(ctx, `
|
|
update projax.items
|
|
set parent_ids = case
|
|
when $2::uuid = any(parent_ids) then parent_ids
|
|
else array_append(parent_ids, $2::uuid)
|
|
end
|
|
where id = $1 and deleted_at is null`,
|
|
id, parentID,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("add parent: %w", err)
|
|
}
|
|
return s.GetByID(ctx, id)
|
|
}
|
|
|
|
// ItemLink mirrors a projax.item_links row — external pointer attached to
|
|
// an item (calendar URL, gitea repo, mai project id, …).
|
|
type ItemLink struct {
|
|
ID string
|
|
ItemID string
|
|
RefType string
|
|
RefID string
|
|
Rel string
|
|
Note *string
|
|
Metadata map[string]any
|
|
CreatedAt time.Time
|
|
// EventDate, when non-nil, anchors the link to a calendar day — the
|
|
// backing slot for the YYMMDD segment of the PER standard. Day
|
|
// granularity by design; time-of-day is intentionally out of scope.
|
|
EventDate *time.Time
|
|
}
|
|
|
|
// LinksByType returns every item_link of the given ref_type for one item.
|
|
func (s *Store) LinksByType(ctx context.Context, itemID, refType string) ([]*ItemLink, error) {
|
|
rows, err := s.Pool.Query(ctx, `
|
|
select id, item_id, ref_type, ref_id, rel, note, metadata, created_at, event_date
|
|
from projax.item_links
|
|
where item_id = $1 and ref_type = $2
|
|
order by created_at`, itemID, refType)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []*ItemLink
|
|
for rows.Next() {
|
|
var l ItemLink
|
|
if err := rows.Scan(&l.ID, &l.ItemID, &l.RefType, &l.RefID, &l.Rel, &l.Note, &l.Metadata, &l.CreatedAt, &l.EventDate); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, &l)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// LinksByRefType returns every item_link of the given ref_type across the
|
|
// whole schema. Used by /admin/caldav to find already-linked calendars.
|
|
func (s *Store) LinksByRefType(ctx context.Context, refType string) ([]*ItemLink, error) {
|
|
rows, err := s.Pool.Query(ctx, `
|
|
select id, item_id, ref_type, ref_id, rel, note, metadata, created_at, event_date
|
|
from projax.item_links
|
|
where ref_type = $1
|
|
order by created_at`, refType)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []*ItemLink
|
|
for rows.Next() {
|
|
var l ItemLink
|
|
if err := rows.Scan(&l.ID, &l.ItemID, &l.RefType, &l.RefID, &l.Rel, &l.Note, &l.Metadata, &l.CreatedAt, &l.EventDate); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, &l)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// AddLink inserts an item_link. ON CONFLICT (item_id, ref_type, ref_id, rel)
|
|
// the existing row is returned untouched.
|
|
func (s *Store) AddLink(ctx context.Context, itemID, refType, refID, rel string, metadata map[string]any) (*ItemLink, error) {
|
|
if rel == "" {
|
|
rel = "contains"
|
|
}
|
|
if metadata == nil {
|
|
metadata = map[string]any{}
|
|
}
|
|
var id string
|
|
err := s.Pool.QueryRow(ctx, `
|
|
insert into projax.item_links (item_id, ref_type, ref_id, rel, metadata)
|
|
values ($1, $2, $3, $4, $5)
|
|
on conflict (item_id, ref_type, ref_id, rel) do update set metadata = excluded.metadata
|
|
returning id`,
|
|
itemID, refType, refID, rel, metadata,
|
|
).Scan(&id)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("add link: %w", err)
|
|
}
|
|
row := s.Pool.QueryRow(ctx, `
|
|
select id, item_id, ref_type, ref_id, rel, note, metadata, created_at, event_date
|
|
from projax.item_links where id = $1`, id)
|
|
var l ItemLink
|
|
if err := row.Scan(&l.ID, &l.ItemID, &l.RefType, &l.RefID, &l.Rel, &l.Note, &l.Metadata, &l.CreatedAt, &l.EventDate); err != nil {
|
|
return nil, err
|
|
}
|
|
return &l, nil
|
|
}
|
|
|
|
// AddLinkDated is AddLink + an event_date + an explicit note. Existing
|
|
// AddLink callers leave the date unset; the new MCP add_link tool and the
|
|
// Documents UI pass it through.
|
|
func (s *Store) AddLinkDated(ctx context.Context, itemID, refType, refID, rel string, note *string, eventDate *time.Time, metadata map[string]any) (*ItemLink, error) {
|
|
if rel == "" {
|
|
rel = "contains"
|
|
}
|
|
if metadata == nil {
|
|
metadata = map[string]any{}
|
|
}
|
|
var id string
|
|
err := s.Pool.QueryRow(ctx, `
|
|
insert into projax.item_links (item_id, ref_type, ref_id, rel, note, metadata, event_date)
|
|
values ($1, $2, $3, $4, $5, $6, $7)
|
|
on conflict (item_id, ref_type, ref_id, rel) do update
|
|
set metadata = excluded.metadata,
|
|
note = coalesce(excluded.note, projax.item_links.note),
|
|
event_date = coalesce(excluded.event_date, projax.item_links.event_date)
|
|
returning id`,
|
|
itemID, refType, refID, rel, note, metadata, eventDate,
|
|
).Scan(&id)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("add link (dated): %w", err)
|
|
}
|
|
row := s.Pool.QueryRow(ctx, `
|
|
select id, item_id, ref_type, ref_id, rel, note, metadata, created_at, event_date
|
|
from projax.item_links where id = $1`, id)
|
|
var l ItemLink
|
|
if err := row.Scan(&l.ID, &l.ItemID, &l.RefType, &l.RefID, &l.Rel, &l.Note, &l.Metadata, &l.CreatedAt, &l.EventDate); err != nil {
|
|
return nil, err
|
|
}
|
|
return &l, nil
|
|
}
|
|
|
|
// RecentDocuments returns every dated item_link across the whole schema with
|
|
// event_date in [since, now], newest-first. Used by the /dashboard "Recent
|
|
// documents" card. Soft-deleted items are excluded — 0013's cascade trigger
|
|
// removes their links, so the join against projax.items is technically
|
|
// redundant but kept defensively to match the items_unified guarantee.
|
|
func (s *Store) RecentDocuments(ctx context.Context, since time.Time, limit int) ([]*ItemLinkWithItem, error) {
|
|
if limit <= 0 {
|
|
limit = 30
|
|
}
|
|
rows, err := s.Pool.Query(ctx, `
|
|
select l.id, l.item_id, l.ref_type, l.ref_id, l.rel, l.note, l.metadata, l.created_at, l.event_date,
|
|
i.slug, i.title, i.paths
|
|
from projax.item_links l
|
|
join projax.items i on i.id = l.item_id and i.deleted_at is null
|
|
where l.event_date is not null
|
|
and l.event_date >= $1
|
|
order by l.event_date desc, l.created_at desc
|
|
limit $2`, since, limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
out := []*ItemLinkWithItem{}
|
|
for rows.Next() {
|
|
var x ItemLinkWithItem
|
|
if err := rows.Scan(
|
|
&x.Link.ID, &x.Link.ItemID, &x.Link.RefType, &x.Link.RefID, &x.Link.Rel,
|
|
&x.Link.Note, &x.Link.Metadata, &x.Link.CreatedAt, &x.Link.EventDate,
|
|
&x.ItemSlug, &x.ItemTitle, &x.ItemPaths,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, &x)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// ItemLinkWithItem bundles an item_link with a thin slice of its parent
|
|
// item's fields — enough for the dashboard "Recent documents" row to render
|
|
// without a second store hop.
|
|
type ItemLinkWithItem struct {
|
|
Link ItemLink
|
|
ItemSlug string
|
|
ItemTitle string
|
|
ItemPaths []string
|
|
}
|
|
|
|
// PrimaryPath returns the first path of the bundled item, mirroring Item.PrimaryPath.
|
|
func (x *ItemLinkWithItem) PrimaryPath() string {
|
|
if len(x.ItemPaths) == 0 {
|
|
return ""
|
|
}
|
|
return x.ItemPaths[0]
|
|
}
|
|
|
|
// DatedLinksRange returns every dated item_link with event_date in [from, to)
|
|
// across the whole schema, joined to its parent item — same shape as
|
|
// RecentDocuments but with explicit bounds. Used by the /timeline view so
|
|
// dated docs/letters/URLs appear under their anchor date. Ordered by event_date
|
|
// asc (timeline groups by ascending day then we reorder at render time).
|
|
func (s *Store) DatedLinksRange(ctx context.Context, from, to time.Time) ([]*ItemLinkWithItem, error) {
|
|
rows, err := s.Pool.Query(ctx, `
|
|
select l.id, l.item_id, l.ref_type, l.ref_id, l.rel, l.note, l.metadata, l.created_at, l.event_date,
|
|
i.slug, i.title, i.paths
|
|
from projax.item_links l
|
|
join projax.items i on i.id = l.item_id and i.deleted_at is null
|
|
where l.event_date is not null
|
|
and l.event_date >= $1
|
|
and l.event_date < $2
|
|
order by l.event_date asc, l.created_at asc`, from, to)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
out := []*ItemLinkWithItem{}
|
|
for rows.Next() {
|
|
var x ItemLinkWithItem
|
|
if err := rows.Scan(
|
|
&x.Link.ID, &x.Link.ItemID, &x.Link.RefType, &x.Link.RefID, &x.Link.Rel,
|
|
&x.Link.Note, &x.Link.Metadata, &x.Link.CreatedAt, &x.Link.EventDate,
|
|
&x.ItemSlug, &x.ItemTitle, &x.ItemPaths,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, &x)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// ItemsCreatedInRange returns every live projax.item created between [from, to).
|
|
// Used by the /timeline view to render small "added X to projax" markers in
|
|
// the spine.
|
|
func (s *Store) ItemsCreatedInRange(ctx context.Context, from, to time.Time) ([]*Item, error) {
|
|
rows, err := s.Pool.Query(ctx,
|
|
`select `+itemsUnifiedCols+` from projax.items_unified
|
|
where created_at >= $1 and created_at < $2
|
|
order by created_at asc`, from, to)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return scanItems(rows)
|
|
}
|
|
|
|
// DatedLinks returns every item_link with an event_date set, ordered
|
|
// newest-first then by insertion order. Used by the detail-page Documents
|
|
// section.
|
|
func (s *Store) DatedLinks(ctx context.Context, itemID string) ([]*ItemLink, error) {
|
|
rows, err := s.Pool.Query(ctx, `
|
|
select id, item_id, ref_type, ref_id, rel, note, metadata, created_at, event_date
|
|
from projax.item_links
|
|
where item_id = $1 and event_date is not null
|
|
order by event_date desc, created_at`, itemID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []*ItemLink
|
|
for rows.Next() {
|
|
var l ItemLink
|
|
if err := rows.Scan(&l.ID, &l.ItemID, &l.RefType, &l.RefID, &l.Rel, &l.Note, &l.Metadata, &l.CreatedAt, &l.EventDate); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, &l)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// DeleteLink removes a single item_link by id.
|
|
func (s *Store) DeleteLink(ctx context.Context, id string) error {
|
|
_, err := s.Pool.Exec(ctx, `delete from projax.item_links where id = $1`, id)
|
|
return err
|
|
}
|
|
|
|
// AllTags returns the deduplicated tag vocabulary in alphabetical order.
|
|
// Used by the tree page filter chips.
|
|
func (s *Store) AllTags(ctx context.Context) ([]string, error) {
|
|
rows, err := s.Pool.Query(ctx,
|
|
`select distinct unnest(tags) as tag from projax.items where deleted_at is null order by tag`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []string
|
|
for rows.Next() {
|
|
var t string
|
|
if err := rows.Scan(&t); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, t)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// SoftDelete marks a projax-native item deleted_at = now().
|
|
func (s *Store) SoftDelete(ctx context.Context, id string) error {
|
|
_, err := s.Pool.Exec(ctx, `update projax.items set deleted_at = now() where id = $1`, id)
|
|
return err
|
|
}
|
|
|
|
// ErrHasLiveChildren is returned by SoftDeleteCascade when the caller did not
|
|
// request cascade=true and the item has at least one undeleted descendant.
|
|
var ErrHasLiveChildren = errors.New("projax: item has live children — pass cascade=true to soft-delete the whole subtree")
|
|
|
|
// SoftDeleteCascade soft-deletes the item and, if cascade is true, every
|
|
// descendant (any row whose paths array contains a prefix matching this
|
|
// item's primary path). Without cascade, it refuses when there is at least
|
|
// one live descendant.
|
|
func (s *Store) SoftDeleteCascade(ctx context.Context, id string, cascade bool) error {
|
|
it, err := s.GetByID(ctx, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
primary := it.PrimaryPath()
|
|
// Children are any other live row that names this id in their parent_ids
|
|
// (direct children) or has a path with the primary path as a `.`-prefix
|
|
// (transitive descendants).
|
|
var childCount int
|
|
err = s.Pool.QueryRow(ctx, `
|
|
select count(*) from projax.items
|
|
where deleted_at is null
|
|
and id <> $1
|
|
and ($1::uuid = any(parent_ids)
|
|
or exists (select 1 from unnest(paths) p where p like $2 || '.%'))
|
|
`, id, primary).Scan(&childCount)
|
|
if err != nil {
|
|
return fmt.Errorf("count children: %w", err)
|
|
}
|
|
if childCount > 0 && !cascade {
|
|
return ErrHasLiveChildren
|
|
}
|
|
if childCount > 0 && cascade {
|
|
_, err := s.Pool.Exec(ctx, `
|
|
update projax.items set deleted_at = now()
|
|
where deleted_at is null
|
|
and ($1::uuid = any(parent_ids)
|
|
or exists (select 1 from unnest(paths) p where p like $2 || '.%'))`,
|
|
id, primary)
|
|
if err != nil {
|
|
return fmt.Errorf("cascade soft-delete: %w", err)
|
|
}
|
|
}
|
|
return s.SoftDelete(ctx, id)
|
|
}
|
|
|
|
// GetByPathOrSlug resolves a single item by either a dot path (any entry in
|
|
// paths) or a bare root slug. Slug-only inputs match items whose paths array
|
|
// contains exactly the slug (i.e. root items) as well as a fallback unique
|
|
// slug match — useful for MCP callers that don't know the path.
|
|
func (s *Store) GetByPathOrSlug(ctx context.Context, key string) (*Item, error) {
|
|
key = sanitizeKey(key)
|
|
if key == "" {
|
|
return nil, ErrNotFound
|
|
}
|
|
row := s.Pool.QueryRow(ctx, `select `+itemsUnifiedCols+`
|
|
from projax.items_unified u
|
|
where ($1 = any(u.paths) or u.slug = $1)
|
|
order by case when $1 = any(u.paths) then 0 else 1 end
|
|
limit 1`, key)
|
|
it, err := scanItem(row)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, ErrNotFound
|
|
}
|
|
return nil, err
|
|
}
|
|
return it, nil
|
|
}
|
|
|
|
// SearchFilters narrows ListByFilters. Each field is treated independently;
|
|
// the predicates AND together. Empty fields are no-ops.
|
|
type SearchFilters struct {
|
|
ParentPath string // any item whose paths array contains a path beginning with this prefix
|
|
Tags []string // ALL must be present on the item
|
|
Management []string // ALL must be present on the item
|
|
Kind []string // ANY must be present on the item
|
|
Status string // exact match (active|done|archived)
|
|
Q string // ILIKE prefix-match on title/slug/aliases/content_md
|
|
HasRepo *bool // if non-nil, item must (or must not) have a gitea-repo link
|
|
HasCalDAV *bool // if non-nil, item must (or must not) have a caldav-list link
|
|
Public *bool // if non-nil, item must (or must not) be marked public — Phase 4d
|
|
Limit int // 0 → no limit
|
|
}
|
|
|
|
// ListByFilters returns items_unified rows matching all supplied predicates.
|
|
// Used by the MCP list_items tool.
|
|
func (s *Store) ListByFilters(ctx context.Context, f SearchFilters) ([]*Item, error) {
|
|
conds := []string{"true"}
|
|
args := []any{}
|
|
addArg := func(v any) string {
|
|
args = append(args, v)
|
|
return fmt.Sprintf("$%d", len(args))
|
|
}
|
|
if f.ParentPath != "" {
|
|
p := addArg(f.ParentPath)
|
|
// Path equals or starts with `<parent>.`
|
|
conds = append(conds, fmt.Sprintf("exists (select 1 from unnest(u.paths) pp where pp = %s or pp like %s || '.%%')", p, p))
|
|
}
|
|
if len(f.Tags) > 0 {
|
|
conds = append(conds, fmt.Sprintf("u.tags @> %s::text[]", addArg(f.Tags)))
|
|
}
|
|
if len(f.Management) > 0 {
|
|
conds = append(conds, fmt.Sprintf("u.management @> %s::text[]", addArg(f.Management)))
|
|
}
|
|
if len(f.Kind) > 0 {
|
|
conds = append(conds, fmt.Sprintf("u.kind && %s::text[]", addArg(f.Kind)))
|
|
}
|
|
if f.Status != "" {
|
|
conds = append(conds, fmt.Sprintf("u.status = %s", addArg(f.Status)))
|
|
}
|
|
if f.Q != "" {
|
|
q := addArg("%" + f.Q + "%")
|
|
conds = append(conds, fmt.Sprintf("(u.title ilike %s or u.slug ilike %s or u.content_md ilike %s or exists (select 1 from unnest(u.aliases) a where a ilike %s))", q, q, q, q))
|
|
}
|
|
if f.HasRepo != nil {
|
|
op := ""
|
|
if *f.HasRepo {
|
|
op = "exists"
|
|
} else {
|
|
op = "not exists"
|
|
}
|
|
conds = append(conds, fmt.Sprintf("%s (select 1 from projax.item_links l where l.item_id = u.id and l.ref_type = 'gitea-repo')", op))
|
|
}
|
|
if f.HasCalDAV != nil {
|
|
op := ""
|
|
if *f.HasCalDAV {
|
|
op = "exists"
|
|
} else {
|
|
op = "not exists"
|
|
}
|
|
conds = append(conds, fmt.Sprintf("%s (select 1 from projax.item_links l where l.item_id = u.id and l.ref_type = 'caldav-list')", op))
|
|
}
|
|
if f.Public != nil {
|
|
conds = append(conds, fmt.Sprintf("u.public = %s", addArg(*f.Public)))
|
|
}
|
|
q := `select ` + itemsUnifiedCols + ` from projax.items_unified u where ` + joinAnd(conds) + ` order by u.paths[1] nulls last, u.slug`
|
|
if f.Limit > 0 {
|
|
q += fmt.Sprintf(" limit %d", f.Limit)
|
|
}
|
|
rows, err := s.Pool.Query(ctx, q, args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
return scanItems(rows)
|
|
}
|
|
|
|
// Search returns ranked items_unified rows matching the query. Match buckets:
|
|
// (0) exact slug, (1) title ILIKE prefix, (2) title contains, (3) alias hit,
|
|
// (4) content_md contains. Within each bucket rows are sorted by primary path.
|
|
func (s *Store) Search(ctx context.Context, q string, limit int) ([]*Item, error) {
|
|
q = sanitizeKey(q)
|
|
if q == "" {
|
|
return nil, nil
|
|
}
|
|
if limit <= 0 || limit > 200 {
|
|
limit = 50
|
|
}
|
|
// Ranking is computed in SQL with a virtual `match_rank`, then we re-select
|
|
// just the canonical column set so scanItems handles the row.
|
|
sql := `with ranked as (
|
|
select u.*,
|
|
case
|
|
when u.slug = $1 then 0
|
|
when u.title ilike $1 || '%' then 1
|
|
when u.title ilike '%' || $1 || '%' then 2
|
|
when exists (select 1 from unnest(u.aliases) a where a ilike '%' || $1 || '%') then 3
|
|
when u.content_md ilike '%' || $1 || '%' then 4
|
|
else 5
|
|
end as match_rank
|
|
from projax.items_unified u
|
|
where true
|
|
and (
|
|
u.slug = $1
|
|
or u.title ilike '%' || $1 || '%'
|
|
or u.content_md ilike '%' || $1 || '%'
|
|
or exists (select 1 from unnest(u.aliases) a where a ilike '%' || $1 || '%')
|
|
)
|
|
)
|
|
select ` + itemsUnifiedCols + `
|
|
from ranked
|
|
order by match_rank, paths[1] nulls last, slug
|
|
limit $2`
|
|
rows, err := s.Pool.Query(ctx, sql, q, limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return scanItems(rows)
|
|
}
|
|
|
|
// sanitizeKey trims and rejects NUL / control characters that the planner
|
|
// would otherwise have to deal with.
|
|
func sanitizeKey(s string) string {
|
|
s = trimSpace(s)
|
|
for _, r := range s {
|
|
if r == 0 || (r < 0x20 && r != '\t') {
|
|
return ""
|
|
}
|
|
}
|
|
return s
|
|
}
|
|
|
|
func trimSpace(s string) string {
|
|
for len(s) > 0 && (s[0] == ' ' || s[0] == '\t' || s[0] == '\n' || s[0] == '\r') {
|
|
s = s[1:]
|
|
}
|
|
for len(s) > 0 && (s[len(s)-1] == ' ' || s[len(s)-1] == '\t' || s[len(s)-1] == '\n' || s[len(s)-1] == '\r') {
|
|
s = s[:len(s)-1]
|
|
}
|
|
return s
|
|
}
|
|
|
|
func joinAnd(parts []string) string {
|
|
out := ""
|
|
for i, p := range parts {
|
|
if i > 0 {
|
|
out += " and "
|
|
}
|
|
out += p
|
|
}
|
|
return out
|
|
}
|