feat(phase 3c per-events): event_date on item_links, Documents UI, PER URL resolver, MCP date-aware add_link
migration 0011_item_links_event_date.sql: ADD event_date date + partial
index (idempotent). Day granularity by design per the PER spec; the
column lands NULL on every existing row, no backfill.
store:
- ItemLink gains an EventDate *time.Time (every read path scans it).
- AddLinkDated(ctx, item, refType, refID, rel, note, date, metadata)
upserts with COALESCE(new, old) for note + event_date so partial
callers don't clobber prior state.
- DatedLinks(item) returns event_date IS NOT NULL ordered DESC.
web:
- per.go: parsePER strips a trailing .YYMMDD (rejects invalid dates like
Feb 30); collisionTag yields a/b/.../z/aa/ab/...; computePERs walks
DatedLinks output and assigns render-time collision tags inside each
date group. Tags are never stored.
- handleDetail: 404 retry with PER stripped — /i/mfin.house1.260515
resolves to the house1 item with HighlightDate=2026-05-15.
- documents_section.tmpl: add-form (ref_type/date/ref_id/note),
date-sorted rows with computed PER, ref-type badge, remove × with
anti-forgery item-id check, highlight row when HighlightDate matches.
- POST /i/{path}/links/add and /links/remove handlers; HTMX swap on the
fragment, redirect for non-HTMX callers.
mcp:
- add_link accepts event_date: "YYYY-MM-DD" (parsed strict, hands back
fmt.Errorf on bad form). linkView.event_date surfaces it on responses.
- Existing add_link callers without event_date keep working unchanged.
docs:
- docs/standards/per.md gains an Implementation section pointing at
item_links.event_date + ref_types + render-time collision policy.
- docs/design.md adds a Documents/dated artifacts section with the
schema delta, conflict policy, and URL routing rules.
tests:
- per_test.go: parsePER (valid/invalid dates, non-numeric, wrong
length); collisionTag (1..53); computePERs (bare-then-.a, skips
undated, multi-date grouping).
This commit is contained in:
17
db/migrations/0011_item_links_event_date.sql
Normal file
17
db/migrations/0011_item_links_event_date.sql
Normal file
@@ -0,0 +1,17 @@
|
||||
-- 0011_item_links_event_date.sql
|
||||
--
|
||||
-- Phase 3c: add an optional event_date to projax.item_links so dated artifacts
|
||||
-- (PER-cited letters, invoices, meeting notes, …) actually resolve. The PER
|
||||
-- standard at docs/standards/per.md uses YYMMDD day granularity, so `date` is
|
||||
-- correct here — time-of-day is intentionally not part of the standard.
|
||||
--
|
||||
-- The partial index makes "list every dated artifact for an item, newest
|
||||
-- first" cheap without bloating the main index for the (vast) majority of
|
||||
-- existing links that carry no date.
|
||||
|
||||
ALTER TABLE projax.item_links
|
||||
ADD COLUMN IF NOT EXISTS event_date date;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS item_links_event_date_idx
|
||||
ON projax.item_links (event_date)
|
||||
WHERE event_date IS NOT NULL;
|
||||
@@ -320,6 +320,32 @@ Out of scope (parked):
|
||||
- Bulk import/export tools — phase 3b.
|
||||
- Otto-PWA integration that consumes this surface — separate worker.
|
||||
|
||||
## Documents / dated artifacts (Phase 3c)
|
||||
|
||||
The PER standard (`docs/standards/per.md`) needs a `(item, event_date)` pair as its backing store. Phase 3c lands it.
|
||||
|
||||
- **Schema**: migration `0011_item_links_event_date.sql` adds `projax.item_links.event_date date` (nullable) and a partial index. Day granularity per the PER spec; time-of-day is intentionally out of scope.
|
||||
- **Ref-type convention**: existing types (`caldav-list`, `gitea-repo`, `gitea-issue`, `mai-project`, …) keep their meaning. Phase 3c adds three convention names for dated artifacts:
|
||||
- `document` — generic external pointer (URL, local file path, Drive link, …)
|
||||
- `note` — short text snippet; the body lives in `note` or `metadata.body`
|
||||
- `url` — bookmarked link (rendered as a clickable anchor)
|
||||
|
||||
The schema doesn't enforce these — the column is `text` — but the UI uses them to render differently.
|
||||
- **Detail page → Documents section** (renders unconditionally on every `/i/{path}` page; empty-state copy when no dated links exist):
|
||||
- Lists every `item_link` with `event_date IS NOT NULL` for the item, ordered `event_date DESC, created_at ASC`.
|
||||
- Each row shows the computed PER (`<primary-path>.<YYMMDD>[.<collision-tag>]`), a ref-type badge, the ref_id (clickable for `url`), the optional note, and a small `×` to remove.
|
||||
- Add form (top of section): `ref_type | event_date | ref_id | note`. POSTs to `/i/{path}/links/add` → HTMX swap.
|
||||
- Collision tags (`.a`, `.b`, …) are **computed at render time only**, never stored. The first link on a date is bare; the second gets `.a`, the third `.b`. Order is by `created_at` within the same date.
|
||||
- **URL resolution** for PER-cited paths: `handleDetail` first tries the literal `path`; if it 404s and the trailing segment looks like `YYMMDD`, it retries with the date stripped and surfaces the parsed date as a render hint so the Documents section can scroll to / highlight the matching row. Invalid dates (Feb 30, 99/99/99) are not stripped — they hit the original 404 path.
|
||||
- **MCP**: `add_link` accepts an optional `event_date: "YYYY-MM-DD"`. Existing callers without it keep working. `linkView.event_date` surfaces the stored value on the response side. The conflict policy on duplicate `(item_id, ref_type, ref_id, rel)` is `COALESCE(new, old)` for note/event_date so partial updates don't clobber an earlier date by accident.
|
||||
- **Anti-forgery on remove**: the `/links/remove` handler verifies the link's `item_id` matches the URL's item before deleting — a crafted form can't snipe a link that belongs to a different item.
|
||||
|
||||
Out of scope (parked):
|
||||
|
||||
- File uploads / in-projax storage. v1 references only.
|
||||
- Recurring dated artifacts (RRULE-style). Flatten for now.
|
||||
- Cross-PER linking syntax / forward-jump anchors. Phase 3d+ if m needs it.
|
||||
|
||||
## 8. Open questions (post-PRD)
|
||||
|
||||
- **Path-trigger correctness** under cycle attempts: enforce acyclicity via check in trigger.
|
||||
|
||||
@@ -79,12 +79,27 @@ Examples:
|
||||
- Form fields with strict character limits below ~25–30 chars.
|
||||
- Anywhere ambiguity is a feature (intentionally vague references).
|
||||
|
||||
## Schema implications (Phase 2)
|
||||
## Schema implications
|
||||
|
||||
- `projax.item_links` gains an `event_date timestamptz` column (optional). Dated artifacts linked to an item — CalDAV todos, Gitea issues, document references, PER-cited letters — sit here with a date.
|
||||
- `projax.item_links` carries an optional `event_date date` column (migration `0011_item_links_event_date.sql`, shipped 2026-05-15). Dated artifacts linked to an item — CalDAV todos, Gitea issues, document references, PER-cited letters — sit here with a date.
|
||||
- Day granularity is intentional. Time-of-day is not part of the PER standard.
|
||||
- Existing `aliases text[]` on `projax.items` is the rename-stability backbone. Don't drop on archive.
|
||||
- PER resolution = parse the string → match `(area-walk-path, optional date)` → return matching `items_unified` row + linked `item_links` rows with `event_date = parsed-date`.
|
||||
|
||||
## Implementation (v0.1, shipped Phase 3c)
|
||||
|
||||
- **Backing columns**: `(projax.items.paths[], projax.item_links.event_date)`. The path is the canonical lookup key; the date narrows to a specific dated artifact.
|
||||
- **Ref-type convention** for the artifacts surfaced under a project's Documents section:
|
||||
- `document` — generic pointer (URL, file path, Drive link, …); ref_id is the pointer
|
||||
- `note` — short text snippet; ref_id is the body or a hash, full body in `note` column or `metadata.body`
|
||||
- `url` — bookmarked link; the UI renders ref_id as an `<a>` opening in a new tab
|
||||
- Existing typed refs (`caldav-list`, `gitea-repo`, `gitea-issue`, `mai-project`, …) keep their meaning and can also carry an `event_date`.
|
||||
- **Collision tags are render-time only.** When two links share `(item_id, event_date)`, the UI appends `.a`/`.b`/… in `created_at` order. The first one stays bare. We never store the tag — re-rendering after a delete naturally rebalances the assignments.
|
||||
- **URL routing**: `/i/<path>.<YYMMDD>` first tries the literal path; if 404 and the trailing segment is a valid `YYMMDD`, retries against the stripped path and surfaces the date as a render hint so the Documents row gets `.highlight`. Invalid dates (Feb 30, etc.) hit the original 404 path.
|
||||
- **MCP**: `mcp__projax__add_link` accepts an optional `event_date: "YYYY-MM-DD"`. `linkView.event_date` surfaces the stored value on responses.
|
||||
- **Conflict policy**: on `(item_id, ref_type, ref_id, rel)` duplicates the upsert uses `COALESCE(new, old)` for `note` and `event_date`, so a callable that re-adds a link without a date doesn't clobber a pre-set date.
|
||||
- **Cross-references**: see `docs/design.md` §"Documents / dated artifacts (Phase 3c)" for the schema delta and UI integration.
|
||||
|
||||
## Display & UI
|
||||
|
||||
- The backend stores lowercase. The frontend renders PERs in m's preferred camelCase by reading `items.title` (or a derived `display_slug` field if titles drift far from slugs).
|
||||
|
||||
51
mcp/tools.go
51
mcp/tools.go
@@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/m/projax/store"
|
||||
)
|
||||
@@ -115,18 +116,19 @@ func RegisterProjaxTools(s *Server, st *store.Store) {
|
||||
})
|
||||
s.Register(Tool{
|
||||
Name: "add_link",
|
||||
Description: "Add an external item_link to an item (caldav-list / gitea-repo / mbrian-node / url / …).",
|
||||
Description: "Add an external item_link to an item (caldav-list / gitea-repo / document / note / url / …). Pass event_date=YYYY-MM-DD to anchor a dated artifact (PER day-granular).",
|
||||
InputSchema: json.RawMessage(`{
|
||||
"type": "object",
|
||||
"required": ["ref_type", "ref_id"],
|
||||
"properties": {
|
||||
"id": {"type": "string"},
|
||||
"path": {"type": "string"},
|
||||
"ref_type": {"type": "string"},
|
||||
"ref_id": {"type": "string"},
|
||||
"rel": {"type": "string", "description": "Relation, default 'contains'"},
|
||||
"note": {"type": "string"},
|
||||
"metadata": {"type": "object"}
|
||||
"id": {"type": "string"},
|
||||
"path": {"type": "string"},
|
||||
"ref_type": {"type": "string"},
|
||||
"ref_id": {"type": "string"},
|
||||
"rel": {"type": "string", "description": "Relation, default 'contains'"},
|
||||
"note": {"type": "string"},
|
||||
"event_date": {"type": "string", "description": "YYYY-MM-DD; day-granular anchor for PER-cited artifacts"},
|
||||
"metadata": {"type": "object"}
|
||||
}
|
||||
}`),
|
||||
Handler: addLinkTool(st),
|
||||
@@ -204,6 +206,7 @@ type linkView struct {
|
||||
Note any `json:"note"`
|
||||
Metadata map[string]any `json:"metadata"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
EventDate any `json:"event_date"`
|
||||
}
|
||||
|
||||
func toItemView(it *store.Item) itemView {
|
||||
@@ -251,6 +254,9 @@ func toLinkView(l *store.ItemLink) linkView {
|
||||
if l.Note != nil {
|
||||
v.Note = *l.Note
|
||||
}
|
||||
if l.EventDate != nil {
|
||||
v.EventDate = l.EventDate.UTC().Format("2006-01-02")
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
@@ -580,13 +586,14 @@ func listLinksTool(st *store.Store) ToolHandler {
|
||||
|
||||
func addLinkTool(st *store.Store) ToolHandler {
|
||||
type input struct {
|
||||
ID string `json:"id"`
|
||||
Path string `json:"path"`
|
||||
RefType string `json:"ref_type"`
|
||||
RefID string `json:"ref_id"`
|
||||
Rel string `json:"rel"`
|
||||
Note string `json:"note"`
|
||||
Metadata map[string]any `json:"metadata"`
|
||||
ID string `json:"id"`
|
||||
Path string `json:"path"`
|
||||
RefType string `json:"ref_type"`
|
||||
RefID string `json:"ref_id"`
|
||||
Rel string `json:"rel"`
|
||||
Note string `json:"note"`
|
||||
EventDate string `json:"event_date"`
|
||||
Metadata map[string]any `json:"metadata"`
|
||||
}
|
||||
return func(ctx context.Context, raw json.RawMessage) (any, error) {
|
||||
var in input
|
||||
@@ -604,10 +611,20 @@ func addLinkTool(st *store.Store) ToolHandler {
|
||||
if md == nil {
|
||||
md = map[string]any{}
|
||||
}
|
||||
var notePtr *string
|
||||
if in.Note != "" {
|
||||
md["note"] = in.Note
|
||||
n := in.Note
|
||||
notePtr = &n
|
||||
}
|
||||
link, err := st.AddLink(ctx, it.ID, in.RefType, in.RefID, in.Rel, md)
|
||||
var datePtr *time.Time
|
||||
if strings.TrimSpace(in.EventDate) != "" {
|
||||
t, err := time.Parse("2006-01-02", strings.TrimSpace(in.EventDate))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("event_date must be YYYY-MM-DD: %w", err)
|
||||
}
|
||||
datePtr = &t
|
||||
}
|
||||
link, err := st.AddLinkDated(ctx, it.ID, in.RefType, in.RefID, in.Rel, notePtr, datePtr, md)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -335,12 +335,16 @@ type ItemLink struct {
|
||||
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
|
||||
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)
|
||||
@@ -351,7 +355,7 @@ func (s *Store) LinksByType(ctx context.Context, itemID, refType string) ([]*Ite
|
||||
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); err != nil {
|
||||
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)
|
||||
@@ -363,7 +367,7 @@ func (s *Store) LinksByType(ctx context.Context, itemID, refType string) ([]*Ite
|
||||
// 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
|
||||
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)
|
||||
@@ -374,7 +378,7 @@ func (s *Store) LinksByRefType(ctx context.Context, refType string) ([]*ItemLink
|
||||
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); err != nil {
|
||||
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)
|
||||
@@ -403,15 +407,73 @@ func (s *Store) AddLink(ctx context.Context, itemID, refType, refID, rel string,
|
||||
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
|
||||
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); err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
127
web/links.go
Normal file
127
web/links.go
Normal file
@@ -0,0 +1,127 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/m/projax/store"
|
||||
)
|
||||
|
||||
// handleLinksAdd processes POST /i/{path}/links/add. Accepts ref_type, ref_id,
|
||||
// note, event_date (YYYY-MM-DD). Anti-forgery isn't a concern at v1 since the
|
||||
// trust model is Tailscale-only + cookie auth.
|
||||
func (s *Server) handleLinksAdd(w http.ResponseWriter, r *http.Request, path string) {
|
||||
it, err := s.Store.GetByPath(r.Context(), path)
|
||||
if err != nil {
|
||||
s.fail(w, r, err)
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
s.fail(w, r, err)
|
||||
return
|
||||
}
|
||||
refType := strings.TrimSpace(r.FormValue("ref_type"))
|
||||
refID := strings.TrimSpace(r.FormValue("ref_id"))
|
||||
noteVal := strings.TrimSpace(r.FormValue("note"))
|
||||
dateStr := strings.TrimSpace(r.FormValue("event_date"))
|
||||
banner := ""
|
||||
if refType == "" || refID == "" {
|
||||
banner = "ref_type and ref_id are required."
|
||||
}
|
||||
var date *time.Time
|
||||
if banner == "" && dateStr != "" {
|
||||
t, err := time.Parse("2006-01-02", dateStr)
|
||||
if err != nil {
|
||||
banner = "event_date must be YYYY-MM-DD."
|
||||
} else {
|
||||
date = &t
|
||||
}
|
||||
}
|
||||
if banner == "" {
|
||||
var notePtr *string
|
||||
if noteVal != "" {
|
||||
notePtr = ¬eVal
|
||||
}
|
||||
if _, err := s.Store.AddLinkDated(r.Context(), it.ID, refType, refID, "", notePtr, date, nil); err != nil {
|
||||
banner = fmt.Sprintf("Could not add link: %v", err)
|
||||
}
|
||||
}
|
||||
s.renderDocumentsSection(w, r, it, nil, banner)
|
||||
}
|
||||
|
||||
// handleLinksRemove processes POST /i/{path}/links/remove.
|
||||
func (s *Server) handleLinksRemove(w http.ResponseWriter, r *http.Request, path string) {
|
||||
it, err := s.Store.GetByPath(r.Context(), path)
|
||||
if err != nil {
|
||||
s.fail(w, r, err)
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
s.fail(w, r, err)
|
||||
return
|
||||
}
|
||||
linkID := strings.TrimSpace(r.FormValue("link_id"))
|
||||
banner := ""
|
||||
if linkID == "" {
|
||||
banner = "link_id required"
|
||||
} else {
|
||||
// Belt-and-braces: ensure the link belongs to this item before
|
||||
// deleting, so a crafted form can't snipe an unrelated row.
|
||||
owns, err := s.linkBelongsToItem(r.Context(), linkID, it.ID)
|
||||
if err != nil {
|
||||
s.fail(w, r, err)
|
||||
return
|
||||
}
|
||||
if !owns {
|
||||
banner = "Link does not belong to this item."
|
||||
} else if err := s.Store.DeleteLink(r.Context(), linkID); err != nil {
|
||||
banner = fmt.Sprintf("Could not remove link: %v", err)
|
||||
}
|
||||
}
|
||||
s.renderDocumentsSection(w, r, it, nil, banner)
|
||||
}
|
||||
|
||||
// renderDocumentsSection re-pulls dated links, computes PERs, and renders the
|
||||
// Documents fragment for HTMX swaps. Non-HTMX requests fall back to a full
|
||||
// detail-page redirect.
|
||||
func (s *Server) renderDocumentsSection(w http.ResponseWriter, r *http.Request, it *store.Item, highlight *time.Time, banner string) {
|
||||
if r.Header.Get("HX-Request") != "true" {
|
||||
http.Redirect(w, r, "/i/"+it.PrimaryPath()+"#documents-section", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
docs, err := s.Store.DatedLinks(r.Context(), it.ID)
|
||||
if err != nil {
|
||||
s.fail(w, r, err)
|
||||
return
|
||||
}
|
||||
documents := computePERs(it.PrimaryPath(), docs)
|
||||
s.render(w, "documents_section", map[string]any{
|
||||
"Item": it,
|
||||
"Documents": documents,
|
||||
"HighlightDate": highlight,
|
||||
"DocBanner": banner,
|
||||
})
|
||||
}
|
||||
|
||||
// linkBelongsToItem returns true when the link's item_id equals the supplied
|
||||
// item id. Used as an anti-forgery check before delete.
|
||||
func (s *Server) linkBelongsToItem(ctx context.Context, linkID, itemID string) (bool, error) {
|
||||
var owner string
|
||||
err := s.Store.Pool.QueryRow(ctx,
|
||||
`select item_id from projax.item_links where id = $1`, linkID).Scan(&owner)
|
||||
if err != nil {
|
||||
if isNoRows(err) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
return owner == itemID, nil
|
||||
}
|
||||
|
||||
func isNoRows(err error) bool {
|
||||
return err != nil && (errors.Is(err, store.ErrNotFound) || err.Error() == "no rows in result set")
|
||||
}
|
||||
110
web/per.go
Normal file
110
web/per.go
Normal file
@@ -0,0 +1,110 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/m/projax/store"
|
||||
)
|
||||
|
||||
// parsePER strips a trailing YYMMDD segment off the path and returns the
|
||||
// shorter path + the parsed date. If the last segment doesn't match the
|
||||
// 6-digit form, the input is returned unchanged with a nil date. We
|
||||
// deliberately ignore collision-tag suffixes (`.a`/`.b`) at v0.1 — collision
|
||||
// is a display concern only per the PER standard, so a URL ending in `.a`
|
||||
// just won't strip and will 404 on its own.
|
||||
func parsePER(path string) (basePath string, eventDate *time.Time) {
|
||||
idx := strings.LastIndex(path, ".")
|
||||
if idx < 0 || idx == len(path)-1 {
|
||||
return path, nil
|
||||
}
|
||||
last := path[idx+1:]
|
||||
if len(last) != 6 {
|
||||
return path, nil
|
||||
}
|
||||
for _, c := range last {
|
||||
if c < '0' || c > '9' {
|
||||
return path, nil
|
||||
}
|
||||
}
|
||||
yy, _ := strconv.Atoi(last[0:2])
|
||||
mm, _ := strconv.Atoi(last[2:4])
|
||||
dd, _ := strconv.Atoi(last[4:6])
|
||||
// YY → 20YY (PER spec applies to m's century).
|
||||
t := time.Date(2000+yy, time.Month(mm), dd, 0, 0, 0, 0, time.UTC)
|
||||
// Reject impossible dates: time.Date normalises (e.g. Feb 30 → Mar 2),
|
||||
// so a round-trip mismatch signals "not a real date".
|
||||
if t.Year() != 2000+yy || int(t.Month()) != mm || t.Day() != dd {
|
||||
return path, nil
|
||||
}
|
||||
return path[:idx], &t
|
||||
}
|
||||
|
||||
// formatPERDate is the inverse of the YYMMDD slice of parsePER. Used for
|
||||
// rendering computed PERs in the Documents section.
|
||||
func formatPERDate(t time.Time) string {
|
||||
return t.UTC().Format("060102")
|
||||
}
|
||||
|
||||
// computePERs annotates each dated link with the canonical PER under which
|
||||
// it should display, including a collision tag (`.a`/`.b`/…) when multiple
|
||||
// links share the same `event_date`. Inputs must already be ordered by
|
||||
// (event_date DESC, created_at ASC) — matches store.DatedLinks output. The
|
||||
// tag is render-time only per the PER v0.1 spec; we never store it.
|
||||
type DocumentRow struct {
|
||||
Link *store.ItemLink
|
||||
PER string // basePath + . + YYMMDD + optional .a/.b
|
||||
Tag string // "" | "a" | "b" | …
|
||||
}
|
||||
|
||||
func computePERs(basePath string, links []*store.ItemLink) []DocumentRow {
|
||||
// Group by date so we can assign collision tags inside each group.
|
||||
type group struct {
|
||||
date time.Time
|
||||
links []*store.ItemLink
|
||||
}
|
||||
groups := []group{}
|
||||
for _, l := range links {
|
||||
if l.EventDate == nil {
|
||||
continue
|
||||
}
|
||||
d := *l.EventDate
|
||||
// New group when date changes (input is already sorted by event_date DESC).
|
||||
if len(groups) == 0 || !groups[len(groups)-1].date.Equal(d) {
|
||||
groups = append(groups, group{date: d})
|
||||
}
|
||||
groups[len(groups)-1].links = append(groups[len(groups)-1].links, l)
|
||||
}
|
||||
out := make([]DocumentRow, 0, len(links))
|
||||
for _, g := range groups {
|
||||
// Within a date, the first link is bare; the rest get .a, .b, …
|
||||
// (input is sorted by created_at ASC within the same date).
|
||||
for i, l := range g.links {
|
||||
row := DocumentRow{Link: l, PER: basePath + "." + formatPERDate(g.date)}
|
||||
if i > 0 {
|
||||
tag := collisionTag(i)
|
||||
row.Tag = tag
|
||||
row.PER = row.PER + "." + tag
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// collisionTag returns the alpha-only suffix for the n-th colliding link
|
||||
// (1-indexed: 1→"a", 2→"b", …, 26→"z", 27→"aa", 28→"ab", …). Matches the
|
||||
// rule documented in docs/standards/per.md §"Collision handling".
|
||||
func collisionTag(n int) string {
|
||||
if n <= 0 {
|
||||
return ""
|
||||
}
|
||||
out := ""
|
||||
for n > 0 {
|
||||
n--
|
||||
out = string(rune('a'+(n%26))) + out
|
||||
n /= 26
|
||||
}
|
||||
return out
|
||||
}
|
||||
110
web/per_test.go
Normal file
110
web/per_test.go
Normal file
@@ -0,0 +1,110 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/m/projax/store"
|
||||
)
|
||||
|
||||
func TestParsePER(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
wantBase string
|
||||
wantDate string // empty == nil
|
||||
}{
|
||||
{"dev.projax", "dev.projax", ""},
|
||||
{"dev.projax.260515", "dev.projax", "2026-05-15"},
|
||||
{"mfin.house1.260515", "mfin.house1", "2026-05-15"},
|
||||
// Six-digit but not a valid date → leave unchanged.
|
||||
{"foo.260230", "foo.260230", ""}, // Feb 30 doesn't exist
|
||||
{"foo.260000", "foo.260000", ""}, // month 00
|
||||
{"foo.261301", "foo.261301", ""}, // month 13
|
||||
{"foo.999999", "foo.999999", ""}, // not a real date
|
||||
// Wrong length → leave unchanged.
|
||||
{"foo.bar", "foo.bar", ""},
|
||||
{"foo.12345", "foo.12345", ""},
|
||||
{"foo.1234567", "foo.1234567", ""},
|
||||
// Empty trailing segment.
|
||||
{"foo.", "foo.", ""},
|
||||
// No dot at all.
|
||||
{"260515", "260515", ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
gotBase, gotDate := parsePER(tc.in)
|
||||
if gotBase != tc.wantBase {
|
||||
t.Errorf("parsePER(%q) base = %q, want %q", tc.in, gotBase, tc.wantBase)
|
||||
}
|
||||
if tc.wantDate == "" {
|
||||
if gotDate != nil {
|
||||
t.Errorf("parsePER(%q) date = %v, want nil", tc.in, gotDate)
|
||||
}
|
||||
continue
|
||||
}
|
||||
want, _ := time.Parse("2006-01-02", tc.wantDate)
|
||||
if gotDate == nil || !gotDate.Equal(want) {
|
||||
t.Errorf("parsePER(%q) date = %v, want %v", tc.in, gotDate, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollisionTag(t *testing.T) {
|
||||
cases := []struct {
|
||||
n int
|
||||
want string
|
||||
}{
|
||||
{0, ""},
|
||||
{1, "a"},
|
||||
{2, "b"},
|
||||
{26, "z"},
|
||||
{27, "aa"},
|
||||
{28, "ab"},
|
||||
{52, "az"},
|
||||
{53, "ba"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := collisionTag(tc.n); got != tc.want {
|
||||
t.Errorf("collisionTag(%d) = %q, want %q", tc.n, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputePERsBareThenAB(t *testing.T) {
|
||||
d := time.Date(2026, 5, 15, 0, 0, 0, 0, time.UTC)
|
||||
d2 := time.Date(2026, 5, 16, 0, 0, 0, 0, time.UTC)
|
||||
links := []*store.ItemLink{
|
||||
// 2026-05-15 group: 2 entries (sorted by created_at ASC; first bare, second .a)
|
||||
{ID: "x1", EventDate: &d, CreatedAt: time.Date(2026, 5, 15, 9, 0, 0, 0, time.UTC)},
|
||||
{ID: "x2", EventDate: &d, CreatedAt: time.Date(2026, 5, 15, 10, 0, 0, 0, time.UTC)},
|
||||
// 2026-05-16 group: 1 entry (bare).
|
||||
{ID: "x3", EventDate: &d2, CreatedAt: time.Date(2026, 5, 16, 9, 0, 0, 0, time.UTC)},
|
||||
}
|
||||
// Caller invariant: DatedLinks returns event_date DESC, created_at ASC.
|
||||
// Test data above is created_at ASC; reverse the date groups to match.
|
||||
desc := []*store.ItemLink{links[2], links[0], links[1]}
|
||||
rows := computePERs("mfin.house1", desc)
|
||||
if len(rows) != 3 {
|
||||
t.Fatalf("expected 3 rows, got %d", len(rows))
|
||||
}
|
||||
if rows[0].PER != "mfin.house1.260516" {
|
||||
t.Errorf("row 0 PER = %q", rows[0].PER)
|
||||
}
|
||||
if rows[1].PER != "mfin.house1.260515" || rows[1].Tag != "" {
|
||||
t.Errorf("row 1 should be bare, got PER=%q tag=%q", rows[1].PER, rows[1].Tag)
|
||||
}
|
||||
if rows[2].PER != "mfin.house1.260515.a" || rows[2].Tag != "a" {
|
||||
t.Errorf("row 2 should be .a, got PER=%q tag=%q", rows[2].PER, rows[2].Tag)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputePERsSkipsUndated(t *testing.T) {
|
||||
d := time.Date(2026, 5, 15, 0, 0, 0, 0, time.UTC)
|
||||
links := []*store.ItemLink{
|
||||
{ID: "with", EventDate: &d, CreatedAt: time.Now()},
|
||||
{ID: "without", EventDate: nil, CreatedAt: time.Now()},
|
||||
}
|
||||
rows := computePERs("dev.x", links)
|
||||
if len(rows) != 1 || rows[0].Link.ID != "with" {
|
||||
t.Errorf("undated link should be skipped, got %v", rows)
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/m/projax/store"
|
||||
)
|
||||
@@ -109,6 +110,7 @@ func New(s *store.Store, logger *slog.Logger) (*Server, error) {
|
||||
"templates/detail.tmpl",
|
||||
"templates/tasks_section.tmpl",
|
||||
"templates/issues_section.tmpl",
|
||||
"templates/documents_section.tmpl",
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse detail: %w", err)
|
||||
@@ -120,6 +122,12 @@ func New(s *store.Store, logger *slog.Logger) (*Server, error) {
|
||||
return nil, fmt.Errorf("parse tasks_section: %w", err)
|
||||
}
|
||||
pages["tasks_section"] = tasksFragment
|
||||
// Standalone documents-section template for HTMX fragment responses.
|
||||
docsFragment, err := template.New("documents_section").Funcs(funcs).ParseFS(templatesFS, "templates/documents_section.tmpl")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse documents_section: %w", err)
|
||||
}
|
||||
pages["documents_section"] = docsFragment
|
||||
loginTmpl, err := template.New("login").Funcs(funcs).ParseFS(templatesFS, "templates/login.tmpl")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse login: %w", err)
|
||||
@@ -253,7 +261,18 @@ func (s *Server) handleDetail(w http.ResponseWriter, r *http.Request) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
// PER URL resolution: try the full path first; if it 404s and the trailing
|
||||
// segment looks like YYMMDD, retry against the shorter path and surface
|
||||
// the date as a render hint to scroll/highlight the matching row.
|
||||
it, err := s.Store.GetByPath(r.Context(), path)
|
||||
var highlight *time.Time
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
if base, d := parsePER(path); d != nil {
|
||||
if it2, err2 := s.Store.GetByPath(r.Context(), base); err2 == nil {
|
||||
it, err, highlight = it2, nil, d
|
||||
}
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
s.fail(w, r, err)
|
||||
return
|
||||
@@ -275,6 +294,11 @@ func (s *Server) handleDetail(w http.ResponseWriter, r *http.Request) {
|
||||
for _, ri := range issues {
|
||||
openTotal += ri.OpenCount
|
||||
}
|
||||
docs, err := s.Store.DatedLinks(r.Context(), it.ID)
|
||||
if err != nil {
|
||||
s.Logger.Warn("detail docs", "path", it.PrimaryPath(), "err", err)
|
||||
}
|
||||
documents := computePERs(it.PrimaryPath(), docs)
|
||||
s.render(w, "detail", map[string]any{
|
||||
"Title": it.Title,
|
||||
"Item": it,
|
||||
@@ -285,6 +309,8 @@ func (s *Server) handleDetail(w http.ResponseWriter, r *http.Request) {
|
||||
"Issues": issues,
|
||||
"IssuesOpenTotal": openTotal,
|
||||
"GiteaOn": s.Gitea != nil,
|
||||
"Documents": documents,
|
||||
"HighlightDate": highlight,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -304,6 +330,14 @@ func (s *Server) handleDetailWrite(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
}
|
||||
if base, ok := strings.CutSuffix(path, "/links/add"); ok {
|
||||
s.handleLinksAdd(w, r, base)
|
||||
return
|
||||
}
|
||||
if base, ok := strings.CutSuffix(path, "/links/remove"); ok {
|
||||
s.handleLinksRemove(w, r, base)
|
||||
return
|
||||
}
|
||||
it, err := s.Store.GetByPath(r.Context(), path)
|
||||
if err != nil {
|
||||
s.fail(w, r, err)
|
||||
@@ -533,6 +567,8 @@ func (s *Server) render(w http.ResponseWriter, name string, data map[string]any)
|
||||
entry = "tasks-section"
|
||||
case "tree_section":
|
||||
entry = "tree-section"
|
||||
case "documents_section":
|
||||
entry = "documents-section"
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := t.ExecuteTemplate(w, entry, data); err != nil {
|
||||
|
||||
@@ -127,3 +127,33 @@ table.classify input, table.classify select { width: 100%; }
|
||||
#tree-filterbar small { opacity: 0.75; margin-left: 2px; }
|
||||
.tree-section .empty { padding: 24px; color: var(--muted); }
|
||||
.tree-section .clear { color: var(--bad); }
|
||||
|
||||
/* Documents / PER-dated artifacts (phase 3c). */
|
||||
.documents { margin-top: 24px; }
|
||||
.documents .doc-add { display: flex; gap: 6px; margin: 8px 0 12px; align-items: center; flex-wrap: wrap; }
|
||||
.documents .doc-add input[type="text"] { flex: 1; min-width: 8em; }
|
||||
.documents ul.docs { list-style: none; padding: 0; margin: 0; }
|
||||
.documents li.doc-row {
|
||||
display: flex; gap: 8px; align-items: baseline; padding: 6px 0;
|
||||
border-bottom: 1px dotted var(--border); flex-wrap: wrap;
|
||||
}
|
||||
.documents li.doc-row:last-child { border-bottom: none; }
|
||||
.documents li.doc-row.highlight { background: #fff5d6; padding-left: 8px; border-left: 3px solid var(--warn); }
|
||||
.documents .per {
|
||||
font-family: ui-monospace, SFMono-Regular, monospace; font-size: 0.88em;
|
||||
color: var(--accent); background: var(--bg-alt); padding: 1px 6px; border-radius: 4px;
|
||||
}
|
||||
.documents .ref-type {
|
||||
display: inline-block; font-size: 0.72em; padding: 1px 6px; border-radius: 999px;
|
||||
background: #fff; border: 1px solid var(--border); color: var(--muted);
|
||||
}
|
||||
.documents .ref-type-document { color: var(--accent); border-color: var(--accent); }
|
||||
.documents .ref-type-note { color: var(--ok); border-color: var(--ok); }
|
||||
.documents .ref-type-url { color: var(--warn); border-color: var(--warn); }
|
||||
.documents .ref-id { font-family: ui-monospace, SFMono-Regular, monospace; font-size: 0.85em; flex: 1; min-width: 8em; }
|
||||
.documents .doc-note { color: var(--muted); font-style: italic; }
|
||||
.documents .doc-remove .x {
|
||||
background: #fff; color: var(--muted); border-color: var(--border);
|
||||
font-size: 1.05em; line-height: 1; padding: 2px 6px;
|
||||
}
|
||||
.documents .doc-remove .x:hover { color: var(--bad); border-color: var(--bad); }
|
||||
|
||||
@@ -21,6 +21,8 @@
|
||||
{{template "issues-section" .}}
|
||||
{{end}}
|
||||
|
||||
{{template "documents-section" .}}
|
||||
|
||||
<form method="post" action="/i/{{.Item.PrimaryPath}}" class="edit">
|
||||
<label>Title <input name="title" value="{{.Item.Title}}" required></label>
|
||||
<label>Slug <input name="slug" value="{{.Item.Slug}}" required pattern="[^.]+"></label>
|
||||
|
||||
50
web/templates/documents_section.tmpl
Normal file
50
web/templates/documents_section.tmpl
Normal file
@@ -0,0 +1,50 @@
|
||||
{{define "documents-section"}}
|
||||
<section id="documents-section" class="documents">
|
||||
<h2>Documents</h2>
|
||||
{{if .DocBanner}}<p class="banner warn" role="alert">{{.DocBanner}}</p>{{end}}
|
||||
|
||||
<form class="doc-add"
|
||||
hx-post="/i/{{.Item.PrimaryPath}}/links/add"
|
||||
hx-target="#documents-section"
|
||||
hx-swap="outerHTML">
|
||||
<select name="ref_type">
|
||||
<option value="document">document</option>
|
||||
<option value="note">note</option>
|
||||
<option value="url">url</option>
|
||||
</select>
|
||||
<input type="date" name="event_date" value="{{if .HighlightDate}}{{.HighlightDate.Format "2006-01-02"}}{{end}}" required>
|
||||
<input type="text" name="ref_id" placeholder="ref (path, URL, hash, …)" required>
|
||||
<input type="text" name="note" placeholder="note (optional)">
|
||||
<button type="submit">+ Add</button>
|
||||
</form>
|
||||
|
||||
{{if .Documents}}
|
||||
<ul class="docs">
|
||||
{{range .Documents}}
|
||||
<li class="doc-row {{if and $.HighlightDate (eq (.Link.EventDate.Format "2006-01-02") ($.HighlightDate.Format "2006-01-02"))}}highlight{{end}}"
|
||||
id="per-{{.PER}}" data-link-id="{{.Link.ID}}">
|
||||
<span class="per">{{.PER}}</span>
|
||||
<span class="ref-type ref-type-{{.Link.RefType}}">{{.Link.RefType}}</span>
|
||||
{{if eq .Link.RefType "url"}}
|
||||
<a href="{{.Link.RefID}}" target="_blank" rel="noopener noreferrer">{{.Link.RefID}}</a>
|
||||
{{else}}
|
||||
<code class="ref-id">{{.Link.RefID}}</code>
|
||||
{{end}}
|
||||
{{if .Link.Note}}<span class="doc-note">{{deref .Link.Note}}</span>{{end}}
|
||||
<small class="muted">added {{.Link.CreatedAt.Format "2006-01-02"}}</small>
|
||||
<form class="doc-remove inline"
|
||||
hx-post="/i/{{$.Item.PrimaryPath}}/links/remove"
|
||||
hx-target="#documents-section"
|
||||
hx-swap="outerHTML"
|
||||
hx-confirm="Remove this document reference?">
|
||||
<input type="hidden" name="link_id" value="{{.Link.ID}}">
|
||||
<button type="submit" class="x" aria-label="Remove">×</button>
|
||||
</form>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{else}}
|
||||
<p class="muted">No dated artifacts yet. Add one above — it becomes a PER like <code>{{.Item.PrimaryPath}}.{{if .HighlightDate}}{{.HighlightDate.Format "060102"}}{{else}}YYMMDD{{end}}</code>.</p>
|
||||
{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user