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).
128 lines
3.6 KiB
Go
128 lines
3.6 KiB
Go
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")
|
|
}
|