mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-24 19:32:10 +00:00
ca3428fa07061a8eb966f25b5432d2df19dfd77f
5 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
905876af04 |
feat(backlinks): cross-workspace wiki-links + request-independent ACL (Phase 2b) (#622)
* feat(backlinks): cross-workspace wiki-links + request-independent ACL (Phase 2b) Phase 2b of PLAN-1593 (TASK-1597). Completes the wiki-link reverse index by indexing and surfacing `[[workspace::REF]]` cross-workspace references. Builds on Phase 2a's title work (PR #621). Phase 3 (TASK-1596) owns the UI/MCP/CLI rendering changes. What changed - internal/store/backlinks_visibility.go (new): request-independent ACL helper `Store.ResolveBacklinksVisibility(userID, workspaceID, includeDeletedItems)`. Mirrors the role-determination + collection- merge logic from server.guestResourceFilterCore but doesn't depend on a request context, so cross-ws traversal can compute per-source- workspace ACLs without a `workspaceRole(r)` lookup. The Codex planning-round review caught the prior plan reusing the request- scoped helper as a hidden architectural cost; this is the resolution. - internal/server/server.go: guestResourceFilterCore refactored to delegate to the new store helper. Keeps the request-scoped wrapper signature stable for all existing handler call sites; only the internals move. - internal/links/extract.go: lift the Phase-2a workspace_ref emit gate. WikiLinkKindWorkspaceRef now flows through ExtractWikiLinks alongside ref and title kinds. parseBody recognition was already in place from earlier rounds. - internal/store/wiki_links.go: WikiLinkKindWorkspaceRef branch in replaceWikiLinks stores (target_workspace_id, target_ref) verbatim, resolving the slug→ID via new resolveWorkspaceSlugTx (with per-call cache so repeated `[[ws::X]]` in one body don't re-query). Unknown slugs persist with target_workspace_id=NULL — broken-link semantics, identical to existing ref/title patterns. - internal/store/wiki_links.go: new `Store.GetCrossWorkspaceBacklinks` enumerates accessible workspaces via Store.GetUserWorkspaces (which includes guest-only access — broader than membership query), then per-workspace computes visibility via ResolveBacklinksVisibility and runs the SQL backlinks query with the per-ws (FullCollectionIDs, GrantedItemIDs) predicate inline. Results sorted by updated_at DESC in Go, paginated globally. Per-workspace safety cap (offset+limit) prevents one workspace from dominating the global slice. - internal/store/wiki_links.go: new `Store.CountBacklinks` for same-ws pagination boundary detection. Needed so the handler knows where the cross-ws tier begins for pages 2+. - internal/models/backlink.go: new `SourceWorkspaceSlug string` (omitempty) field. Populated only by cross-ws rows; same-ws rows leave it empty so the existing wire shape is preserved. - internal/server/handlers_backlinks.go: union pagination across same-ws and cross-ws tiers. Same-ws first (matches the renderer's UI mental model — your own workspace's links at the top of the panel). Count-based slice math handles pages 2+ correctly when same-ws is exhausted. Tests - internal/links/extract_test.go: workspace_ref forms emit correctly (bare, display alias, mixed case, invalid-slug fallback to title). - internal/store/wiki_links_xws_test.go (new): six cross-ws scenarios plus a role-matrix test: - end-to-end cross-ws index + query - non-member sees nothing - guest with collection grant sees only that collection - guest with item grant sees only the granted item - unknown workspace slug → broken row, no query results - same-ws rows leave SourceWorkspaceSlug empty - ResolveBacklinksVisibility role matrix (admin/full member/guest with grants/non-member non-grant) Out of scope (Phase 3 / TASK-1596) UI rendering of cross-ws backlinks (workspace badge + workspace- prefixed ref), MCP `pad_item.action: backlinks` cross-ws fields, CLI display tweaks. PLAN-1593 / TASK-1597. * fix(backlinks): admin enumeration + cross-prefix ref fallback + unbounded perWsCap (Codex round 1) Three P2 findings from Codex round 1 against PR #622: Finding 1 — admin users miss cross-ws backlinks. `GetUserWorkspaces` returns only memberships + grant-only guest workspaces, but RequireWorkspaceAccess (middleware_auth.go:481) gives admins implicit access to every workspace. An admin querying for backlinks would silently miss links from workspaces they're not explicitly a member of. Fix: in GetCrossWorkspaceBacklinks, branch on user.Role: - admin → s.ListWorkspaces() (every non-deleted workspace) - non-admin → s.GetUserWorkspaces (memberships + grants) Stale user IDs return empty result rather than erroring. Finding 2 — cross-ws ref matching doesn't handle cross-prefix moves. Same-ws is immune because target_item_id is resolved at parse time and survives renames/moves; cross-ws resolves at query time, so a `[[other-ws::OLD-42]]` row written before the target moved from OLD→NEW collection wouldn't match a query under the NEW ref. Fix: in queryCrossWorkspaceBacklinksForWorkspace, dual ref-match clause: exact `LOWER(wl.target_ref) = LOWER(?)` OR `LOWER(wl.target_ref) LIKE LOWER('%-N')` where N is the item_number from the target ref. Pad prefixes are alphanumeric with no internal `-`, so trailing `-N` uniquely identifies the number suffix — no false positives like "TASK-142" matching "%-42" (LIKE anchors to the trailing literal). Finding 3 — per-workspace cap of 1000 silently broke pagination beyond offset>=1000. The 1000 ceiling was defensive paranoia; the correct math is offset+limit per workspace (worst case all rows come from one workspace and the global slice still needs that many). Fix: drop the 1000 ceiling. perWsCap = offset+limit unconditionally. For runaway offsets the per-workspace transfer cost is proportional; documented as a known characteristic (callers shouldn't be paging past offset=10000 anyway). Regression tests: - TestWikiLinks_CrossWorkspaceAdminSeesAllWorkspaces: admin sees cross-ws backlink without being a workspace member. - TestWikiLinks_CrossWorkspaceRefNumberFallback: move target to new collection, query under new ref, old-ref-stored row still surfaces. PLAN-1593 / TASK-1597. * fix(backlinks): honor OAuth/MCP token workspace allow-list (Codex round 2) Codex round 2 P1: cross-workspace backlinks bypassed the OAuth/MCP token's workspace allow-list (TASK-952). A token consented for workspace A but with the underlying user having access to B would still surface source rows from B via the cross-ws query — leaking data outside the token's consent scope. Fix: thread `allowedWorkspaceSlugs []string` through GetCrossWorkspaceBacklinks. Handler populates it from TokenAllowedWorkspacesFromContext(r.Context()): - nil → no token gate (PAT or pre-TASK-952 token, allow all) - "*" wildcard → allow all - explicit list → strict slug membership Workspace enumeration skips any source workspace whose slug isn't in the allowlist. The same-ws path is unchanged because RequireWorkspaceAccess already gated the target workspace against the allow-list (so we only reach this handler when the target IS in the list). Regression test in wiki_links_xws_test.go covers four shapes: nil, wildcard, target-only (blocks cross-ws), explicit source-workspace (allows cross-ws). PLAN-1593 / TASK-1597. * fix(backlinks): normalize limit at handler boundary (Codex round 3) Codex round 3 P2: the backlinks handler parsed ?limit=N but didn't normalize it before computing the same-ws/cross-ws pagination split. GetBacklinks and GetCrossWorkspaceBacklinks each clamp >300 internally, but the handler's 'remaining := limit - len(sameWs)' used the original (potentially huge) value. With ?limit=301 and more than 50 same-ws backlinks, the first page would mix cross-ws in before same-ws was exhausted, violating the documented tier order. Fix: clamp 'limit' to <=300 at the handler boundary, before any pagination math runs. PLAN-1593 / TASK-1597. * fix(backlinks): normalize same-workspace [[ws::REF]] to ref-kind (Codex round 4) Codex round 4 P2: `[[<current-ws>::TASK-1]]` was being indexed as a workspace_ref row with target_workspace_id = current workspace. But the same-ws GetBacklinks query requires target_item_id (workspace_ref rows leave it NULL), AND GetCrossWorkspaceBacklinks explicitly skips the target workspace — so the link rendered and navigated correctly in the UI but no backlink ever surfaced. The renderer's L307 short-circuits same-workspace fully-qualified form to behave identically to `[[REF]]`; the index must follow. Fix: in replaceWikiLinks, normalize a workspace_ref link to ref-kind when its slug resolves to the current workspace. The promotion canonicalizes the ref (via new links.CanonicalizeRef exported alias) so `[[ws::task-5]]` stores the same canonical shape as `[[TASK-5]]`. Tests: - TestWikiLinks_CrossWorkspaceSameWorkspaceQualifiedNormalized: same-ws fully-qualified `[[ws::REF]]` surfaces in same-ws backlinks and is absent from cross-ws backlinks. PLAN-1593 / TASK-1597. * fix(backlinks): same-ws qualified ref miss doesn't title-fallback (Codex round 5) Codex round 5 P2: my round-4 normalization was too aggressive. It promoted `[[<current-ws>::REF]]` to ref-kind and let the regular ref branch handle it — including the title-fallback path that runs on ref miss. But the renderer's same-ws qualified branch (markdown.ts:472-481) does NOT title-fallback: a ref miss in that path returns the wiki-link verbatim (broken). Only the bare `[[REF]]` path (markdown.ts:513) falls through to title lookup. So my normalization could create ghost backlinks for source bodies like `[[ws::ISO-9001]]` when an item titled "ISO-9001" exists but no ISO collection — the renderer renders broken text, but the index would point at the title-matching item. Fix: handle same-ws qualified refs inline at the top of the loop, BEFORE the switch dispatches. Insert as ref-kind row (resolved or NULL) and `continue` past the switch. Bypasses the title-fallback path entirely, mirroring the renderer's behavior. Regression test in wiki_links_xws_test.go pairs same-ws qualified miss (must NOT title-fallback) with bare ref miss (SHOULD title-fallback) to lock the asymmetry in. PLAN-1593 / TASK-1597. |
||
|
|
c67c167c43 |
feat(backlinks): title-form wiki-links + rename cascade (Phase 2a) (#621)
* feat(backlinks): title-form wiki-links + rename cascade (Phase 2a) Phase 2a of PLAN-1593 (TASK-1595). Extends the server-side wiki-link reverse index from Phase 1's `[[REF-N]]` coverage to also handle `[[Title]]` and `[[collection/Title]]`. Cross-workspace `[[ws::REF]]` forms stay gated until TASK-1597 (Phase 2b) ships the request- independent ACL helper. What changed - internal/links/extract.go: lift the Phase-1 emit gate for WikiLinkKindTitle; keep WikiLinkKindWorkspaceRef gated. - internal/store/wiki_links.go: title branch in replaceWikiLinks (verbatim target_title storage + case-insensitive resolution), resolveTitleTx with full-key match first / `/`-split fallback (mirrors renderer order — Codex review caught the inverse on the planning round), cascadeTitleRename + resolveBrokenTitleLinks. - internal/store/items.go: rename cascade fires in-tx on title change; create path flips pre-existing broken title rows. - internal/store/migrations/062 + pgmigrations/041: one-shot `DELETE FROM item_wiki_links` so the startup backfill repopulates with the Phase 2a vocabulary. - Tests cover title round-trip, case-insensitive resolution, broken- link persistence + later resolution, full rename cascade with content rewrite, collection-qualified form, full-key-beats-split precedence (regresses Codex finding #3), broken-row flip on rename, no-op rename, and self-reference filtering. PLAN-1593 / TASK-1595. * fix(backlinks): rename cascade preserves display aliases (Codex round 1) Codex round 1 against PR #621 caught: the title-rename cascade selects sources via target_item_id (correctly hitting all rows that resolve to the renamed item — including aliased and mixed-case shapes), but the literal `strings.ReplaceAll` rewrite step only matched `[[Old Title]]` / `[[<slug>/Old Title]]`. Rows from `[[Old Title|alias]]`, `[[old title]]` (mixed case), or `[[<slug>/Old Title|alias]]` slipped past the rewrite; the trailing replaceWikiLinks re-parse then saw the same body, failed to resolve under the new title, and converted the row to broken — exactly the regression the cascade exists to prevent. Fix: new internal/links/RewriteWikiTitle helper handles all four title-form shapes with case-insensitive title matching and verbatim display-alias preservation. Cascade swaps from ReplaceAll to this helper. Unit tests in links_test.go cover the matrix; integration test in wiki_links_test.go regresses the original failure mode by mixing all four shapes in one source and asserting all four stay resolved after rename. Known limitation documented in the helper: titles containing wiki- link escape characters (`]`, `|`, `\`) — stored escaped in source content — don't match the regex's literal old-title segment. Same limitation exists in the legacy ReplaceTitle helper; promotable if a real user hits it. PLAN-1593 / TASK-1595. * fix(backlinks): retarget qualified-fallback rows on literal-title arrival (Codex round 2) Codex round 2 against PR #621 (P2): resolveBrokenTitleLinks only flipped target_item_id IS NULL rows, missing the arrival-order case where a literal `[[tasks/Setup]]` should win stage 1 over a row previously resolved via stage 2 (qualified fallback to item "Setup" in collection "tasks"). The index would stay stale until the source's content was rewritten — a latent inconsistency between the persisted backlink and what the renderer would actually show. Fix: drop the IS NULL constraint on the stage-1 UPDATE. Stage 1 ALWAYS wins per the renderer's order at markdown.ts:541, so any row with a matching literal title flips to the new item — including rows currently pointing at a qualified-fallback target. Added a target_item_id != ? guard so we don't churn rows that already point at us. Stage 2 keeps the NULL constraint so already-resolved qualified rows don't churn when another fallback candidate appears. Regression test in wiki_links_test.go reproduces the exact scenario Codex described: fallback resolves first, literal arrival steals the row from the fallback target. PLAN-1593 / TASK-1595. * fix(backlinks): renderer parity for [[A|B]] + scope arrival retarget (Codex round 3) Two findings from Codex round 3 against PR #621: P1 — parser/renderer parity for [[A|B]] matching literal title "A|B". The renderer (web/src/lib/utils/markdown.ts:516-525) tries the FULL body as a title FIRST when a pipe is present, only falling through to the split interpretation on miss. Our parseBody always split on the first unescaped pipe, so an item literally titled "A|B" was indexed as title="A" with display="B" — the index would miss backlinks the UI shows, or point them at a different "A" item. Fix: in replaceWikiLinks for title kind, when HasDisplay, try the full body (Title+"|"+Display) as a title FIRST via resolveTitleTx; on hit, store target_title=fullBody and drop the display override (the display segment was actually part of the title). On miss, fall back to the existing split-key interpretation. The cascade's RewriteWikiTitle regex correctly handles both storage shapes via QuoteMeta on the title. P2 — stage-1 UPDATE was too aggressive after the round-2 fix. The broad UPDATE (no IS NULL constraint) silently stole backlinks from legitimately-resolved rows when a SECOND item was created/renamed to the same title. Titles aren't unique, the renderer's Array.find() is order-dependent, and silent churn is worse than no-op stability. Fix: split resolveBrokenTitleLinks into three updates: (1) Plain literal flip — NULL only. (2) Qualified literal flip — NULL only. (3) Literal-arrival retarget — gated on title containing `/`. Only fires for `[[<slug>/Title]]` rows, which are the only ones that COULD have been stage-2 qualified-fallback resolved. Rows with target_title='Foo' (no slash) can only have been stage-1 literal — we don't steal those. Regression tests: - TestWikiLinks_LiteralPipeInTitleResolves — `[[A|B]]` resolves to item literally titled "A|B". - TestWikiLinks_LiteralPipeInTitleFallsThroughToSplit — `[[A|B]]` falls back to item "A" when no "A|B" item exists. - TestWikiLinks_SecondItemSameTitleDoesNotStealBacklinks — adding a second "Foo" item doesn't redirect the existing backlink. PLAN-1593 / TASK-1595. * fix(backlinks): broken pipe-in-body rows keyed on full body (Codex round 4) Codex round 4 against PR #621: when a source body `[[A|B]]` is written before any matching item exists, the broken row was stored with target_title="A" (the split key). If an item literally titled "A|B" was later created, resolveBrokenTitleLinks looking for target_title="A|B" couldn't find the row — index went stale while the renderer's preferred full-body interpretation would correctly resolve the link. Fix: when nothing resolves AND a pipe was present (HasDisplay), key the broken row on the FULL body (Title+"|"+Display) instead of the split key. resolveBrokenTitleLinks then naturally finds it via the literal-arrival path. The remaining asymmetry — a broken row keyed on full body won't pick up a future split-fallback resolution to a new item titled "A" — is documented in the code as a v3-promotable limitation. The full-body path is the renderer's PREFERRED interpretation (markdown.ts:516), so prioritizing it is the right tradeoff in the rare case both interpretations could apply. Regression test in wiki_links_test.go reproduces the scenario: source written first with `[[A|B]]`, item titled "A|B" created later, backlink should resolve. PLAN-1593 / TASK-1595. * fix(backlinks): scope stage-3 retarget + cascade self-refs (Codex round 5) Two findings from Codex round 5 against PR #621: Finding 1 — stage-3 literal-arrival retarget could still steal backlinks from a legitimate stage-1 literal-match row when a SECOND item with the same slash-containing title is created. The previous fix (round 3) gated stage-3 on title containing `/`, which let through the qualified-fallback retarget case correctly but didn't distinguish stage-1-resolved rows pointing at a literal twin from stage-2-resolved rows pointing at the fallback target. Fix: add an EXISTS check that scopes the flip to rows whose CURRENT target has a title NOT matching ours. Stage-1 (literal) resolutions point at items literally titled the same as the row's target_title; stage-2 (qualified-fallback) resolutions point at items titled just the trailing segment. The EXISTS clause picks out only the latter. Finding 2 — self-references on the renamed item went stale on title-only renames. The cascade's `s.id != renamedItemID` filter excluded self, but items.go only re-indexes content when input.Content != nil. So a title-only rename of an item whose body mentions itself by its old title kept the body's now-broken `[[Old Title]]` literal in place while the index still recorded a "working" backlink — drift between renderer state and index state. Fix: drop the self-exclusion from the cascade SELECT. RewriteWikiTitle rewrites the renamed item's own content along with everyone else's; GetBacklinks still hides self-links at query time, so the backlinks panel behavior is unchanged. Tests: - TestWikiLinks_DuplicateSlashTitleNoTheft regresses Finding 1. - TestWikiLinks_TitleRenameRewritesSelfReferences asserts Finding 2's new correct behavior (replaces the prior test that asserted the old buggy behavior). PLAN-1593 / TASK-1595. * fix(backlinks): ref→title fallback + reorder cascade self-ref (Codex round 6) Two findings from Codex round 6 against PR #621: Finding 1 — ref-shaped title fallback missing. parseBody returns WikiLinkKindRef for `[[ISO-9001]]` (matches refPattern), but if no ISO-9001 ref-item exists, the renderer falls through to legacy title lookup (markdown.ts:513) and resolves to an item literally titled "ISO-9001". The store inserted only a broken ref-kind row with target_title=NULL, so GetBacklinks never surfaced the backlink even when the renderer rendered it. Fix: in replaceWikiLinks' WikiLinkKindRef branch, when resolveRefTx misses, try resolveTitleTx on the same body. If title resolves, INSERT as title-kind row with target_title=ref-shaped-body. The rename cascade catches these correctly via target_kind='title' + target_item_id. The asymmetry — a future ref-item creation can't auto-retarget these title-stored rows — is documented as a v3 limitation. Finding 2 — combined title+content update broke self-ref cascade. The original order (main UPDATE → replaceWikiLinks(self) → cascade) wiped self's `target_item_id=renamedItemID` row before cascade ran: when input.Content contains `[[Old Title]]`, re-indexing self resolved it as broken (target_item_id=NULL), so cascade's SELECT missed self for the title+content path. Fix: reorder so cascade runs BEFORE the self re-index — the pre-existing wl rows are still intact at cascade time. The final re-index re-reads items.content from the DB (since cascade may have rewritten it in-band) rather than using *input.Content directly; otherwise the re-index would undo the cascade's self-ref rewrite. Tests: - TestWikiLinks_RefShapedFallsThroughToTitle — `[[ISO-9001]]` resolves to an item titled "ISO-9001". - TestWikiLinks_TitleAndContentRenameCascadesSelfRef — combined title+content rename with self-ref in new content gets the self-ref rewritten by cascade. PLAN-1593 / TASK-1595. * fix(backlinks): ref+pipe→title parity, position-based cascade, scoped self-rewrite (Codex round 7) Three intertwined fixes addressing Codex round 7 findings against PR #621: Finding 1 — ref→title fallback missed pipe-bodies. For `[[ISO-9001|Spec]]`, renderer tries full-body title "ISO-9001|Spec" BEFORE falling to bare "ISO-9001" (markdown.ts:516). Our ref-fallback only tried bare. Extended ref-branch's fallback to try full body first when HasDisplay, then bare — same order as the title-branch's stage (a)/(b) pattern. Finding 2 — cascade corrupted UNRELATED literal-pipe titles. Items A "Old Title" and B "Old Title|alias" both referenced from one source; renaming A previously triggered RewriteWikiTitle's regex `(?i:Old Title)((?:\|...)?)` which matched BOTH A's `[[Old Title]]` AND B's `[[Old Title|alias]]` — corrupting the B link. Refactored cascadeTitleRename to be POSITION-BASED: SELECT each individual wl row with its position + target_title (no longer DISTINCT sources). Per-row, rewrite the bracket AT THAT EXACT POSITION via new links.RewriteBracketAt helper. Process rows in descending position order per source so earlier offsets don't shift. Brackets whose wl row doesn't resolve to the renamed item are never visited. Scoped self-rewrite — title-only renames cascade self (input.Content == nil); combined title+content renames EXCLUDE self (input.Content != nil). User-supplied content is authoritative; auto-rewriting their just-submitted brackets would surprise them. Mirrors documents.go::updateLinksInTx, which also leaves the renamed entity's own content alone. Codex round 6 finding 2 is fully addressed: title-only path still rewrites self-refs, combined path respects user submission and the index correctly records the bracket as broken (matching what the renderer would render). New links.RewriteBracketAt helper with full unit-test coverage: plain/aliased/qualified/qualified+aliased shapes, case-insensitive matching, slug-prefix preservation, full-body vs split-key target disambiguation, out-of-bounds defensive guards. Integration test TestWikiLinks_CascadeDoesNotCorruptLiteralPipeNeighbor reproduces Codex round 7 finding 2's scenario. PLAN-1593 / TASK-1595. * fix(backlinks): retarget rows pointing at soft-deleted targets (Codex round 8) Codex round 8 P2: resolveBrokenTitleLinks only considered target_item_id IS NULL rows as eligible for flip. A row that resolved to item A and then had A soft-deleted stayed pointing at deleted A; creating a new item B titled the same as A wouldn't flip the row, so GetBacklinks(B) missed the backlink the renderer would actually show (renderer hides deleted-target links). Fix: introduce a "broken-in-practice" predicate (target_item_id IS NULL OR NOT EXISTS ( SELECT 1 FROM items t WHERE t.id = item_wiki_links.target_item_id AND t.deleted_at IS NULL )) applied to stages 1 (plain literal flip) and 2 (qualified literal flip). Stage 3 (slash-title literal-arrival retarget) already considered "current target deleted" implicitly via its title- mismatch EXISTS check. Regression test in wiki_links_test.go covers the exact scenario: A "Foo" resolves a backlink → A soft-deleted → B "Foo" created → backlink flips to B. Known limitation deferred to v3: the symmetric case (delete A while B with same title already exists) doesn't fire any hook that re-resolves the row. A dedicated DeleteItem hook would close that gap; not blocking Phase 2a. PLAN-1593 / TASK-1595. * fix(backlinks): preserve title whitespace to match renderer (Codex round 9) Codex round 9 P2: parseBody trimmed whitespace from the body BEFORE deciding it was a title kind. The renderer doesn't trim before title matching (markdown.ts:541-543) — `[[ Foo ]]` is matched against items.title with the surrounding spaces intact, so an item titled "Foo" wouldn't match. Trimming server-side created index entries the UI couldn't click — backlinks showed in the panel for links that the renderer rendered as broken. Fix: keep an UNTRIMMED unescaped body for title-kind fallthrough, and a trimmed copy only for ref / workspace_ref SHAPE detection (refs are whitespace-free by construction, the renderer's key.trim() at L503 is just typing forgiveness for the ref form). Tests in extract_test.go: - `[[ Foo ]]` emits title-kind with Title=" Foo " (whitespace preserved). - `[[ TASK-5 ]]` still parses as ref (shape detection trims). - `[[Project Goals]]` (two spaces inside) preserves internal whitespace. PLAN-1593 / TASK-1595. * fix(backlinks): ref→title fallback uses raw untrimmed key (Codex round 10) Codex round 10 P2: after round 9's title-kind whitespace fix, the ref→title FALLBACK path still used canonical-trimmed link.Ref for its title lookup. The renderer's fallback at markdown.ts:541-543 uses the UNTRIMMED key (no .trim() on the title-lookup path), so `[[ TASK-5 ]]` falling through to title would search " TASK-5 " (with whitespace) — an item literally titled " TASK-5 " resolves in the UI but not in our index. Fix: add a RawKey field to WikiLinkRef capturing the untrimmed unescaped key for ref kinds. parseBody populates it alongside the canonical Ref. replaceWikiLinks' ref→title fallback path uses RawKey (with defensive fallback to Ref for old rows) when constructing title candidates. Mirrors the renderer's untrimmed title lookup across both bare and pipe forms. Regression test in wiki_links_test.go covers the exact scenario: item titled " TASK-5 " (with whitespace), source body `[[ TASK-5 ]]`, backlink resolves via the untrimmed ref→title fallback. PLAN-1593 / TASK-1595. |
||
|
|
8e7d4040fd |
feat(backlinks): server-side reverse index for [[...]] (Phase 1) (#620)
* feat(backlinks): server-side reverse index for [[...]] wiki-links (Phase 1)
First phase of PLAN-1593. Today [[REF]] is parsed only at render time
on the client and there's no way to ask "who links to TASK-5?" without
a full-text scan. This change adds a materialized reverse index
(item_wiki_links) that's written every time an item's content changes
and exposes it via REST + CLI.
Phase 1 covers ref-form links only (`[[TASK-5]]` / `[[TASK-5|Display]]`).
Phase 2 (TASK-1595) will extend to titles + cross-workspace; Phase 3
(TASK-1596) adds the web UI panel + MCP action.
What lands here:
* Migrations 061 (SQLite) and 040 (Postgres) create item_wiki_links
with partial indexes on target_item_id, (target_workspace_id, target_ref),
and target_title — the schema accommodates all 5 wiki-link forms
up-front so Phase 2 doesn't ALTER.
* internal/links/extract.go is the canonical parser. It strips fenced
and inline code regions before extracting [[...]] occurrences, so
example refs in docs / code blocks don't pollute the index. Phase 1
emits only WikiLinkKindRef rows; title and workspace_ref kinds parse
successfully but are gated out until Phase 2.
* internal/store/wiki_links.go (replaceWikiLinks + GetBacklinks +
helpers) handles write-time bookkeeping and the read query. Resolution
to target_item_id happens at parse time inside the same transaction
as the items INSERT/UPDATE, so partial state never lands. Broken refs
(target_item_id IS NULL) intentionally persist — they feed a future
broken-links report.
* internal/store/wiki_links_backfill.go + cmd/pad/main.go hook the
idempotent backfill into server startup. Existing items get indexed
on first boot after the migration; subsequent boots are near-no-ops
via an EXISTS short-circuit.
* internal/store/items.go is amended in two places: tryCreateItem
always calls replaceWikiLinks (empty content → no-op DELETE), and
UpdateItemWithPreCheck re-parses whenever input.Content was supplied.
* internal/server/handlers_backlinks.go serves
`GET /api/v1/workspaces/{ws}/items/{itemSlug}/backlinks` with
visibility + guest-grant filtering on the source items.
* internal/cli/client.go adds GetBacklinks; cmd/pad/main.go adds the
`pad item backlinks <ref>` command (registered in groups.go).
Behavior decisions (per PLAN-1593):
- code blocks excluded (fenced + inline)
- self-links filtered at query time (kept in storage)
- repeated mentions stored as separate rows by position
- ordering: source updated_at DESC, position ASC
Tests:
- internal/links/extract_test.go: 26 sub-cases covering ref/title/
workspace-ref discrimination, code-block exclusion (fenced + inline +
unclosed fence), position-is-byte-offset (UTF-8 safety), and edge
inputs.
- internal/store/wiki_links_test.go: 8 integration tests covering the
create/update/delete/self-link/broken-ref/repeated/code-block
scenarios plus backfill idempotence.
All pass. `make check` clean (lint + go test + web build).
Refs: TASK-1594, PLAN-1593, IDEA-1577
* fix(backlinks): visibility-aware pagination + case-insensitive refs per Codex review (round 1)
Two fixes from Codex code review:
P1 — GetBacklinks now takes a visibleCollectionIDs []string argument
that's applied INSIDE the SQL WHERE clause. Previously the handler
fetched LIMIT raw rows and filtered visible ones in Go, so a
restricted user asking for limit=50 could receive an empty page even
when later visible backlinks existed. Pushing visibility into SQL
makes LIMIT/OFFSET count visible rows.
nil → no restriction (owners, editors, root tokens)
[] → see nothing (returns early, no SQL)
[..] → AND s.collection_id IN (?, ?, ...)
Item-level guest grants still apply post-fetch — they're rare enough
that the residual page shrink is acceptable and pushing them into SQL
would balloon the query.
P2 — refPattern now accepts mixed/lowercase refs and parseBody
canonicalizes the prefix to uppercase at the single chokepoint.
Previously the renderer accepted `[[task-5]]` as a real link (its
REF_PATTERN is case-insensitive) but the indexer's ^[A-Z]... pattern
silently dropped it — divergent parsing on the same input. Storage
shape is canonical uppercase so the (workspace, prefix, number)
lookup against collections.prefix (also uppercase) has one shape.
New helper: canonicalizeRef("task-5") → "TASK-5".
Regressions:
internal/links/extract_test.go
+ TestCanonicalizeRef — helper unit tests
+ TestExtractWikiLinks_RefVsTitleFallback updated to assert
mixed/lowercase parses-as-ref-and-uppercases
+ edge-case test renamed from "lowercase ref" to "number-led
not a ref" (lowercase IS a ref now per Codex P2)
internal/store/wiki_links_test.go
+ TestWikiLinks_MixedCaseRefIndexed — `[[task-5]]` produces a
backlink row whose target_ref is "TASK-5"
+ TestWikiLinks_VisibilityAwarePagination — three sub-cases:
nil → all 3, visible-only limit=2 → 2 visible rows (not 1 with
hidden one consuming a slot), empty → 0
All call sites updated (8 in tests + 1 in handler).
`make check` clean (lint + tests + web build).
Refs: TASK-1594, PLAN-1593
* fix(backlinks): SQL-level item-grant filter per Codex review (round 2)
Round 1 fixed pagination for collection-level visibility but Codex
round 2 correctly flagged the same class of bug at the item-grant
layer: `visibleCollectionIDs` returns the UNION (full grants ∪
collections containing granted items), and the handler then
filtered each row's item-level visibility in Go AFTER fetching —
letting hidden rows in a granted-item's collection consume LIMIT
slots.
The refactor moves the precise predicate into SQL. New shape:
type BacklinksVisibility struct {
Unrestricted bool // admin / full-access member
FullCollectionIDs []string // direct collection grants
GrantedItemIDs []string // item-level grants
}
// SQL predicate when Unrestricted=false:
// AND (s.collection_id IN (?...) OR s.id IN (?...))
This matches `guestResourceFilter` (which returns the precise
primitives), so the handler now passes them straight through and
drops the post-fetch filter loop entirely. Pagination is correct
for guests, restricted members, and unrestricted users alike.
New test:
TestWikiLinks_ItemGrantPagination — guest with item-grant on ONE
item in an otherwise-hidden collection sees exactly that one item;
hidden siblings in the same collection do NOT leak in, and limit=2
returns 1 row (not silently shrunken).
Other call sites updated:
- TestWikiLinks_VisibilityAwarePagination → uses
BacklinksVisibility{FullCollectionIDs: ...} and
BacklinksVisibility{} for the no-access case.
- 8 existing tests → BacklinksVisibility{Unrestricted: true}.
- handlers_backlinks.go → no longer calls visibleCollectionIDs;
uses guestResourceFilter exclusively and skips the Go-side filter.
Verification:
- make check clean
- All TestWikiLinks_* pass
Refs: TASK-1594, PLAN-1593
* fix(backlinks): scan EXISTS into bool not int for Postgres parity (Codex round 3)
`SELECT EXISTS(...)` returns boolean on Postgres but integer 0/1 on
SQLite. Scanning into `int` happened to work on SQLite (the modernc.org
driver coerces) but would fail on Postgres — silently disabling the
backfill short-circuit there and meaning upgraded Postgres installs
wouldn't populate backlinks for pre-existing content until each item
got edited.
Fix: scan into bool. Both database/sql drivers in use (modernc.org/
sqlite and lib/pq) coerce their native representation into Go's bool,
so this single shape works on both engines.
make check clean.
Refs: TASK-1594, PLAN-1593
* fix(backlinks): allow CommonMark 0-3 space indented fences per Codex (round 5)
Round 5 flagged two edge cases in the code-stripping pass:
1. Multi-backtick inline code (``see [[X]]``) — traced through the
parser; my permissive close-on-next-backtick logic already covers
it correctly (range = [opener-start, after-closer-run]). Added
a regression test to lock this in:
TestExtractWikiLinks_CodeBlocksExcluded /
"multi-backtick inline code excludes ref"
2. Indented fenced blocks — CommonMark allows 0-3 leading spaces of
indentation before a fence opener (4+ spaces makes it an indented
code block, a different construct). My fencedCodeRanges only
matched fences at column 0, so ` ```\n[[X]]\n```` ` would
render as code in the UI but leak a false backlink. Fixed both
fencedCodeRanges (opener) and findFenceCloser (closer) to skip
up to 3 leading spaces, with a hard cap at 4 (which would be
indented-code, not a fence). Regression test:
TestExtractWikiLinks_CodeBlocksExcluded /
"indented fenced block (CommonMark 0-3 spaces)"
Not addressed:
- Round-4 escape-body parity finding. extract.go mirrors
renderMarkdown's regex (web/src/lib/utils/markdown.ts:300), which
is the actual render-time link parser; wikiLinksToMarkdown's more
permissive escape grammar is editor-serializer-side and the
renderer can't even consume its escaped output. Indexing what the
user actually sees as a link is the correct invariant.
make check clean.
Refs: TASK-1594, PLAN-1593
* fix(backlinks): tilde fences + strict closer lines per Codex (round 6)
Two CommonMark conformance gaps in the code-block stripping pass:
1. Tilde-fenced code blocks (~~~) were ignored. marked() treats them
the same as backtick fences, so a [[REF]] inside a tilde block
would render as code in the UI but leak as a false backlink.
Fixed by parameterizing fenceChar across fencedCodeRanges and
findFenceCloser, with separate handling for the backtick-specific
"no backtick in info string" rule (CommonMark §4.5).
2. Closer-line strictness — CommonMark requires the closing fence
line to contain only the fence + optional trailing spaces. The
previous accept-any-fence-prefixed-line check would terminate
a still-open fence prematurely on a line like ```not-closed,
leaking later refs in the still-rendered code block.
Refs reside in 4 new sub-tests under TestExtractWikiLinks_CodeBlocksExcluded:
- tilde fence excludes refs inside
- tilde fence with language tag
- mixed fence types don't pair
- closer-line strictness — backticks plus other text is not a closer
- closer-line strictness — trailing spaces OK
make check clean.
Refs: TASK-1594, PLAN-1593
* fix(backlinks): inline code closer must match opener length per Codex (round 7)
CommonMark §6.1 requires an inline-code span opened with N backticks
to close on a run of EXACTLY N backticks. The previous "close on next
backtick run of any length" logic would prematurely end the excluded
range on a stray single backtick inside a ``...`` span, leaking any
[[REF]] in the latter half of the code text as a false backlink.
Concrete failure case:
``has ` inside [[X-1]] and more``
→ old: range [0, 7], [[X-1]] indexed (bug)
→ new: range [0, end-of-closer], [[X-1]] excluded (correct)
Fix: track the opener-run length and scan only for matching-length
closer runs. Wrong-length runs in between are code text.
Two new regression sub-cases under TestExtractWikiLinks_CodeBlocksExcluded:
- inline code closer matches opener length — the main case
- single-backtick span unaffected by adjacent multi-backtick run —
asserts the opposite direction (opener=1 doesn't close on ``)
Not addressed:
- Re-flagged round-4/round-7 escape-body parity finding. extract.go
intentionally mirrors renderMarkdown's regex (markdown.ts:300), not
wikiLinksToMarkdown's more permissive escape grammar (markdown.ts:461).
renderMarkdown is the actual link parser at display time; its regex
rejects escaped-`]` bodies, so any link with an escaped `]` in its
body is NOT shown as a clickable link in the UI. Indexing it would
produce phantom backlinks the user can't see. The wikiLinksToMarkdown
permissive grammar is paranoid serialization that the renderer can't
consume — that's a pre-existing inconsistency in the editor pipeline,
not a backlinks bug.
make check clean (lint + tests + web build).
Refs: TASK-1594, PLAN-1593
* fix(backlinks): rune-align snippet end-edge to keep UTF-8 valid (Codex round 8)
The previous snippetAround() trimmed `start` to a rune boundary (so
the leading edge of the snippet was always at a valid codepoint) but
left `end` as a raw +40-byte clamp. When that landed in the middle of
a multi-byte rune — common around emoji or accented text — the
resulting slice was invalid UTF-8 and the JSON encoder would emit
replacement characters in backlink snippets.
Fix: same forward-advance pattern at the end as at the start.
Continuation bytes (10xxxxxx) get skipped until we land on a leading
byte. Going forward keeps the snippet anchored slightly past the
match rather than slightly before it, which is a small UX win
(emoji or accented text right after the link survives intact).
Regression test:
TestWikiLinks_SnippetIsValidUTF8 — pads body with enough 4-byte
emoji on each side that the ±40-byte window cuts through one;
asserts utf8.ValidString on the resulting snippet.
make check clean (lint + tests + web build).
Refs: TASK-1594, PLAN-1593
* fix(backlinks): inline code spans cross newlines, break on blank lines (Codex round 9)
CommonMark §6.1: an inline-code span can cross single newlines but
terminates at a blank line (a line containing no chars or only
whitespace, which ends the enclosing paragraph). My previous scanner
broke at every newline, so multi-line spans like
`pre
[[INSIDE-1]]
post`
would treat the opener as unclosed and leak [[INSIDE-1]] as a false
backlink. Fixed by:
1. The newline branch in the closer scan now peeks ahead via the
new isBlankLineAt() helper. Same-paragraph newlines are
traversed; blank-line breaks terminate the span unmatched.
2. isBlankLineAt() treats any line with only space/tab as blank
(mirroring CommonMark's blank-line definition).
Three new regression sub-cases under TestExtractWikiLinks_CodeBlocksExcluded:
- inline code spans single newline (CommonMark §6.1)
- inline code breaks at blank line (paragraph boundary)
- inline code breaks at whitespace-only blank line
Trade-off: a truly-unclosed inline backtick now consumes from the
opener up to the next blank line instead of just the rest of the
line. False-positive on wiki-links in that span, but the surface
area is small (unclosed backticks are rare in published prose) and
matches the renderer's behavior.
make check clean.
Refs: TASK-1594, PLAN-1593
* fix(backlinks): accept escaped wiki-link bodies per editor grammar (Codex round 10)
After 3 rounds of disagreement, capitulating on the escape-body parity
finding. My position was technically correct for the CURRENT
renderMarkdown behavior (which uses [^\]]+ and can't parse escaped-
bracket bodies), but the editor's wikiLinksToMarkdown grammar at
markdown.ts:461 explicitly produces such bodies — making the
renderer's regex the inconsistent half of the pipeline, not mine.
Mirroring the editor's grammar in the extractor makes the index
forward-compatible: when the renderer eventually gets fixed, no
change here is needed. The cost is a few "phantom" rows in the
interim (indexed links the renderer doesn't currently display as
clickable), but those are harmless and aligned with author intent.
Changes:
- wikiLinkPattern now uses `\[\[((?:\\.|[^\]\\])+)\]\]` — mirrors
markdown.ts:461 verbatim.
- New splitOnUnescapedPipe() helper — scans for the first `|`
that isn't preceded by `\`. Mirrors splitWikiBody at
markdown.ts:664.
- New unescapeWikiBody() helper — undoes `\]`, `\|`, `\\` escapes
in display text and key. Mirrors unescapeWikiBody at markdown.ts:657.
- parseBody() now uses both helpers — split on unescaped `|`,
unescape both sides.
Regression coverage:
- TestExtractWikiLinks_EscapedBodyChars (5 sub-cases): escaped `]`,
escaped `|`, escaped `\`, non-escape backslash passes through,
Position still points at opening `[[` despite escapes.
- TestSplitOnUnescapedPipe + TestUnescapeWikiBody: direct unit
tests for the helpers (round-trip safety vs the editor's
escape/unescape pair).
make check clean.
Refs: TASK-1594, PLAN-1593
* fix(backlinks): preserve display text verbatim per Codex round 11 P3
The previous parseBody trimmed the display side of [[X|Display]] but
the WikiLinkRef.Display contract promises verbatim storage and the
renderer at markdown.ts doesn't trim either. Trimming would silently
diverge on padded display text like [[TASK-1| spaces ]] (renderer
keeps the spaces, extractor stripped them).
Fix: drop TrimSpace from the suffix half of the split. Keep trimming
the key/ref side because refPattern is anchored — a leading or
trailing space in the key would force the body to fall through to
the title kind even though the renderer resolves it as a ref.
Regression test:
TestExtractWikiLinks_EscapedBodyChars / "display text preserved
verbatim (no TrimSpace)"
make check clean.
Refs: TASK-1594, PLAN-1593
* fix(backlinks): distinguish empty display override from no-pipe per Codex round 12
[[REF|]] (explicit empty display) and [[REF]] (no display) are distinct
shapes in the editor: splitWikiBody returns displayOverride="" for the
former, null for the latter, and the renderer uses `displayOverride ??
title` (nullish coalescing, NOT empty-string fallback) so "" is
preserved. The previous extractor collapsed both into display_text=NULL,
violating verbatim-display preservation for the empty-string edge case.
Fix:
- WikiLinkRef gains a HasDisplay bool. parseBody sets HasDisplay=true
iff splitOnUnescapedPipe found a pipe; downstream uses HasDisplay
(not Display!="") to decide whether to persist the override.
- replaceWikiLinks in store: NullString.Valid is keyed off HasDisplay.
display_text='' for explicit empty, NULL for no override.
Regression coverage:
- internal/links/extract_test.go:
"explicit empty display override is distinguished from no pipe"
- internal/store/wiki_links_test.go:
TestWikiLinks_EmptyDisplayDistinct (two-source assert: NOT NULL
for [[REF|]], NULL for [[REF]])
make check clean.
Refs: TASK-1594, PLAN-1593
* fix(backlinks): pointer-typed DisplayText to preserve empty distinction over JSON (Codex round 13)
Round 12 added HasDisplay on the parser side and made the store
preserve display_text='' vs NULL on the DB row, but the wire model
collapsed the distinction at JSON-serialization time:
DisplayText string `json:"display_text,omitempty"`
`omitempty` drops empty strings, so [[REF|]] (empty override) and
[[REF]] (no override) serialized identically on the API and CLI JSON
output. The end-to-end goal of round 12 wasn't reached.
Fix: change DisplayText to *string. nil → no override (field omitted
from JSON via omitempty), pointer to "" → explicit empty override
(field present with empty value). The store's NullString.Valid drives
the assignment, so the SQL round-trip matches the JSON shape.
Knock-on: the CLI's `pad item backlinks` now dereferences the pointer
and prints both populated and empty overrides ("displayed as: ").
Regression coverage:
- TestWikiLinks_EmptyDisplayDistinct extended to assert
withBL.DisplayText is non-nil-pointing-at-"" and noBL.DisplayText
is nil after a GetBacklinks round-trip.
make check clean.
Refs: TASK-1594, PLAN-1593
|
||
|
|
dd381e1066 |
chore: delete 5 unwired document handlers (TASK-769) (#252)
* chore: delete 5 unwired document handlers (TASK-769)
internal/server/handlers_documents.go had 5 dead HTTP handlers that
were drafted as Documents-v1 extensions but never wired into the
router (server.go:509 already labels Documents itself as "v1, will be
replaced by items in Phase 2"):
- handleQuickSave (POST /documents/quick-save) — title-based upsert
- handleBulkRead (POST /documents/bulk-read) — multi-doc fetch by IDs
- handleGetBacklinks (GET /documents/{id}/backlinks)
- handleGetLinks (GET /documents/{id}/links)
- handleGetContext (GET /documents/context?type=)
Investigation confirmed zero consumers:
- Not registered in setupRouter (`grep -n "QuickSave\|BulkRead\|Backlinks\|GetLinks\|GetContext" server.go` → empty).
- Not used by the SvelteKit frontend (`web/src/`).
- Not used by the CLI (`internal/cli/`).
- Pre-launch repo, no fork or downstream that could be relying on them.
Delete scope is intentionally limited to the HTTP handlers. The
underlying `Store.QuickSave / BulkRead / GetBacklinks / GetLinks /
GetContext` methods stay — they're tested at the store level
(internal/store/store_test.go) and preserve optionality if Phase 2
work needs to revive any of these features. `models.QuickSave` stays
for the same reason.
After this lands, IDEA-732's lint catalog is fully cleared on main
(staticcheck SA* + U1000 returns zero). TASK-771 (flip CI
only-new-issues=false) becomes safe.
Verified:
- `go build ./...` clean
- `go vet ./...` clean
- `go test ./...` all pass
- `staticcheck -checks "SA*,U1000" ./...` clean
- All `import "strings"` etc. still used elsewhere in file
Parent: PLAN-644.
* chore: also delete now-test-only document store helpers (TASK-769)
Codex round 1 on PR #252 flagged that the document-store helpers
retained for "Phase 2 optionality" are now exclusively kept alive by
their own store tests — Store.QuickSave, BulkRead, GetBacklinks,
GetLinks, GetContext are not called by any production code path after
the handler deletions in the previous commit. Same for the
models.QuickSave struct.
Pre-launch with no external consumers, optionality preservation has a
real cost (dead code on main, ongoing test maintenance). When Phase 2
needs any of these capabilities it is cheaper to re-derive them
against the Items model than to drag dead Documents-v1 plumbing
forward. So delete them now.
Removed:
- internal/store/documents.go: QuickSave (38 lines), BulkRead (28),
GetBacklinks (15), GetLinks (28), GetContext (41).
- internal/models/document.go: QuickSave struct.
- internal/store/store_test.go: TestQuickSave (38 lines), TestBulkRead
(16), TestDocumentLinking (29), TestContext (23).
Kept:
- TestDocumentLinkRename — exercises UpdateDocument's internal
link-rewriting path, not any of the deleted helpers.
- GetDocumentByTitle — still used by TestDocumentLinkRename.
- The full CRUD/restore handlers and their store methods — these are
still wired into setupRouter and have their own coverage.
Verified:
- `go build ./...` clean
- `go vet ./...` clean
- `go test ./...` all pass (TestDocumentLinkRename and the wider doc
CRUD/version/activity tests still cover the surviving paths).
- `staticcheck -checks "SA*,U1000" ./...` clean
- No new unused imports introduced (links package is still used by
documents.go for ReplaceTitle in UpdateDocument).
Parent: PLAN-644.
* chore: drop GetDocumentByTitle and refactor TestDocumentLinkRename (TASK-769)
Codex round 2 caught the chain — after deleting QuickSave/BulkRead/
GetBacklinks/GetLinks/GetContext, Store.GetDocumentByTitle was kept
alive by exactly one test (TestDocumentLinkRename), which was
re-fetching by title only because the test ignored the *Document
already returned by createTestDoc.
Use the createTestDoc return value instead, then drop GetDocumentByTitle
from the store. Same idea, cleaner test, one fewer test-only API on
the store. The rename behaviour (the actual thing under test) is
unchanged.
Verified:
- `go build ./...` clean
- `go test ./internal/store` and `./internal/server` pass
- `staticcheck -checks "SA*,U1000" ./...` still clean
Parent: PLAN-644.
* chore: drop now-orphaned links.Extract (TASK-769)
Codex round 3 caught the next link in the chain: after Store.GetLinks
was deleted, links.Extract had no remaining callers — links.ReplaceTitle
is the only Extract-package function still used (by UpdateDocument's
rename rewrite). The linkPattern regex was only used by Extract.
Drop linkPattern, the regexp import, and Extract itself. Leaves
ReplaceTitle and its private string helpers (replaceAll, indexOf)
intact.
The cleanup chain ends here: ReplaceTitle is still wired into a live
production path (Documents-v1 rename), and the supporting helpers
have no other roles to inherit.
Verified:
- `go build ./...` clean
- `go test ./internal/store` and `./internal/server` pass
- `staticcheck -checks "SA*,U1000" ./...` clean
Parent: PLAN-644.
|
||
|
|
81579847c6 |
Initial release
Pad — project management for developers and AI agents. Single Go binary with embedded SvelteKit web UI, SQLite storage, CLI, and Claude Code /pad skill integration. https://getpad.dev |