Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
f3991bd
feat: add standalone About pages for spaces, resources, and users (#478)
tkuhn Jun 2, 2026
825da0b
feat: user About page views (introductions, profile) + alpha table st…
tkuhn Jun 2, 2026
0947361
feat: add Assigned roles view to space About page; cleaner role/view-…
tkuhn Jun 2, 2026
c194b9b
fix: update view-displays-view nanopub reference (#478)
tkuhn Jun 2, 2026
bbcb400
fix: define nanopub graph background as alpha so it stays visible on …
tkuhn Jun 3, 2026
4ffb663
feat: render view result actions on resource-less listings (e.g. /spa…
tkuhn Jun 3, 2026
1a9cec6
docs: add presets design document (#302)
tkuhn Jun 3, 2026
0d9f8c7
feat: render preset-supplied views and list them on About pages (#302)
tkuhn Jun 3, 2026
bcc02f2
feat: gate render-path view displays by admin/maintainer/affected use…
tkuhn Jun 3, 2026
ee73c5b
feat: consistent italic "(nothing found)" empty state across query views
tkuhn Jun 3, 2026
ada006d
feat: "add..." actions on the About-page preset/view-display listings…
tkuhn Jun 3, 2026
471bf0a
fix: place preset-supplied views by their own targeting (#302)
tkuhn Jun 3, 2026
9d81e2a
feat: resolve latest view versions in get-view-displays query (#302)
tkuhn Jun 4, 2026
b439e7e
feat: harden latest-view resolution in view-display queries (#302)
tkuhn Jun 5, 2026
b2b6b6d
perf: run-once view resolution in get-view-displays query (#302)
tkuhn Jun 5, 2026
89808eb
feat: consolidate resource pages into Content/About/Explore/Raw tabs …
tkuhn Jun 5, 2026
737989b
feat: hide result filter textfield when all rows fit on the first page
tkuhn Jun 5, 2026
77bb465
feat: breadcrumb label splitting, dropdown styling, np-column tweaks
tkuhn Jun 5, 2026
19720f2
feat: account controls on own About tab; outline dropdown chevron
tkuhn Jun 5, 2026
a35fc31
feat: show signing key in Account section of own About tab
tkuhn Jun 5, 2026
32d9bfe
feat: view-styled intro companion on own About tab; drop retract view…
tkuhn Jun 5, 2026
e5a82d3
docs: design note for session-bound ("magic") query parameters
tkuhn Jun 8, 2026
c96c6c1
docs: consolidate doc/ into docs/; add status headers and index
tkuhn Jun 8, 2026
2ab36e8
docs: add role-specific-views design note
tkuhn Jun 8, 2026
f10bc9f
feat: role-tier model + gen:isVisibleTo view-display visibility filter
tkuhn Jun 8, 2026
1d0c376
chore: point GET_SPACE_ROLES at the roleType-returning query republish
tkuhn Jun 8, 2026
ecda3df
feat: gate view action buttons by role via gen:isVisibleTo on action …
tkuhn Jun 8, 2026
d91479b
feat: add gen:EveryoneRole visibility sentinel; record view template
tkuhn Jun 8, 2026
503a073
docs: drop vestigial server-side enforcement; mark role-specific-view…
tkuhn Jun 8, 2026
a980214
fix: gate preset & view-display actions on About panels
tkuhn Jun 8, 2026
4809936
docs: record the preset/view-display action-leak fix
tkuhn Jun 8, 2026
820f44e
fix: treat "void" action-field sentinel as not-set in View parsing
tkuhn Jun 8, 2026
c00a307
docs: record view-displays-view fork merge + current view heads
tkuhn Jun 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Nanodash design docs

Design notes and proposals for Nanodash features. Each doc carries a
`**Status:**` line near the top; this index is the quick overview.

Status legend: ✅ Implemented · 🚧 In progress · 📋 Proposed

| Doc | Status | Summary |
| --- | --- | --- |
| [userlist-views](userlist-views.md) | ✅ Implemented | Human / Software / Non-Approved user lists as published views on `UserListPage` |
| [presets](presets.md) | 🚧 In progress | Publishable bundles of default views + roles, assignable to resources ([#302](https://github.com/knowledgepixels/nanodash/issues/302)) |
| [magic-query-params](magic-query-params.md) | 📋 Proposed | Session-bound view-query placeholders (`LOCALPUBKEY`, `SITEURL`); path to replace the custom introductions table with a proper view |
| [role-specific-views](role-specific-views.md) | ✅ Implemented | View **action buttons** gated to a role tier (Maintainer, …) or specific role, via `gen:isVisibleTo` on the action node |
| [custom-domains](custom-domains.md) | 📋 Proposed | Serve a user's profile from their own domain |
| [draft-with-ai](draft-with-ai.md) | 📋 Proposed | Server-side "Draft with AI" nanopub authoring |

When a doc's status changes, update both its `**Status:**` line and the row here.
2 changes: 2 additions & 0 deletions doc/custom-domains.md → docs/custom-domains.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# Custom Domain Support for Nanodash

**Status:** 📋 Proposed

Allow users to connect their own domain (e.g. tkuhn.org) so that it serves
their Nanodash profile page while keeping the custom domain visible in the
browser URL bar.
Expand Down
2 changes: 2 additions & 0 deletions doc/draft-with-ai.md → docs/draft-with-ai.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# Server-side "Draft with AI" for Nanopub Authoring

**Status:** 📋 Proposed

Add a server-side "Draft with AI" feature to Nanodash that calls a model
provider (Claude / OpenAI / Gemini / OpenAI-compatible) directly from the
backend and returns an unsigned TriG draft the user can then sign and publish
Expand Down
307 changes: 307 additions & 0 deletions docs/magic-query-params.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,307 @@
# Session-bound ("magic") query parameters

**Status:** 📋 Proposed

A **magic query parameter** is a view-query placeholder that Nanodash fills
automatically from the current browser session, rather than from a value the
caller passes or the user types into a form. It exists so that a *data-driven
view* can branch on session/site-local state — the local signing key, the site
URL — that a SPARQL query keyed only on a resource IRI cannot otherwise see.

The motivating case is the **Introductions** listing on a user's About page
(`AboutUserPanel` / `ProfileIntroItem`). Today the owner gets a hand-built,
editable companion (`ProfileIntroItem`) *instead of* the read-only
introductions-view, because the editable workflow branches on the session's
local key (which introductions declare it, whether it is approved, which keys
are missing). Magic params let the query compute those flags, so most of that
custom panel can be replaced by a proper view. See the gap analysis at the end
for what becomes declarative and what stays custom.

This complements, and is independent of, the role-dependent action work: magic
params supply the *data*; role gating and per-row action visibility decide which
*buttons* render.

## How placeholders work today (background)

View queries are grlc/BASIL queries published as nanopublications, parsed by
`QueryTemplate` (nanopub-java) and wrapped by `GrlcQuery`. Every placeholder is
a SPARQL variable written with a **leading underscore** — the BASIL `?_param`
convention — plus optional type/cardinality markers. The conventions, verified
against `org.nanopub:nanopub:1.90.0`:

| Marker | Method | Meaning | Example |
| --- | --- | --- | --- |
| leading `_` | (prefix on every placeholder) | marks the variable as a placeholder | `?_user` |
| leading `__` | `isOptionalPlaceholder` (`startsWith("__")`) | placeholder is optional | `?__user` |
| `_iri` suffix | `isIriPlaceholder` (`endsWith`) | bind as an IRI, not a literal | `?_user_iri` |
| `_multi` / `_multi_iri` / `_multi_val` suffix | `isMultiPlaceholder` (`endsWith`) | multi-valued `VALUES` block | `?_keys_multi_val` |

`QueryTemplate.getParamName(raw)` strips the leading underscore(s) **and** the
type suffix to produce the wire/QueryRef parameter name:

```
getParamName("_user_iri") = "user"
getParamName("_LOCALPUBKEY_multi_val") = "LOCALPUBKEY"
getParamName("__optionalfoo") = "optionalfoo"
```

This is why callers pass plain names — `new QueryRef(queryId, "user", iri)` —
even though the SPARQL variable is `?_user_iri`.

Two behaviours we rely on:

- **Cache key.** `ApiCache` keys every response on `queryRef.getAsUrlString()`,
i.e. the full set of parameters. Anything folded into the `QueryRef` therefore
partitions the cache automatically — two viewers with different local keys get
distinct cache entries, no collisions.
- **Graceful absence.** `GrlcQuery.expandQuery(params, false)` is non-strict:
for a **multi-valued** placeholder with no value, the empty `VALUES` block is
dropped and the query still runs. So an unbound magic param leaves the query
valid (e.g. `?declares_local_key` comes back false everywhere), degrading to
the plain read-only view for logged-out or non-owner viewers.

## What makes a parameter "magic"

A placeholder is magic **iff its parameter name is a registered magic name**.
Detection is pure registry membership — nothing else:

```java
isMagic(rawPlaceholder) = REGISTRY.containsKey(QueryTemplate.getParamName(rawPlaceholder));
```

### The registry

| Magic name | Declare in SPARQL as | Bound from | Notes |
| --- | --- | --- | --- |
| `LOCALPUBKEY` | `?_LOCALPUBKEY_multi_val` | `NanodashSession.get().getPubkeyString()` | the load-bearing one — drives every per-row flag and the create/derive key parameter |
| `SITEURL` | `?_SITEURL_multi_val` | `NanodashPreferences.get().getWebsiteUrl()` | `key-location` prefill; genuine non-derivable deployment state |

Declared as `_multi_*` so absence drops the `VALUES` block (see "graceful
absence"). When the session has no value (logged out, no key pair), the binding
is simply omitted.

Deliberately **not** in the registry:

- `LOCALPUBKEYSHORT` — not independent session state; it is a pure function of
`LOCALPUBKEY` (`getShortPubkeyName(SHA-256(pubkey))`). Compute it in the query
or in action-link expansion if ever needed; do not give a derived value its
own slot.
- `LOCALINTRO` — only the deferred *include-keys* action needs the specific
local-introduction IRI (for `supersede`). Every other flow derives from
`LOCALPUBKEY` alone. Add it back only when include-keys goes declarative.
- `CURRENTUSER` — the introductions view is already keyed on the page user. Add
it only when a view needs "who is viewing" distinct from "whose page," for the
general role-action work.

### Naming: uppercase is a best practice, not a rule

Magic names are written in `SCREAMING_CASE` so they stand out to query authors
as "filled by the platform, no form field here." This is **style only** — the
binding layer never inspects case, just registry membership. A user-defined
placeholder may also be uppercase (e.g. `?_FOO_iri`); since `FOO` is not in the
registry it is treated as an ordinary placeholder (UI field rendered, value
supplied by the caller).

**Tradeoff:** the registered names are *reserved*. A query that genuinely
declared a non-magic parameter named `LOCALPUBKEY` or `SITEURL` would have it
auto-bound. This is the standard reserved-word cost and is acceptable for a
small, well-known set.

## Binding mechanism

### Where

A single choke point: **`QueryResultTableBuilder.build()`** (and the sibling
list / paragraph builders), immediately before `ApiCache.retrieveResponseAsync`.

This location is required, not incidental:

- It runs on the **request thread**, where `NanodashSession.get()` is valid. The
actual fetch runs in `NanodashThreadPool` background threads where the Wicket
session is **not** available — so binding must be eager here, before the
`QueryRef` is handed off.
- It already holds `viewDisplay.getView().getQuery()`, so it can scan
`getPlaceholdersList()` for magic names.
- Folding bindings into the `QueryRef` keeps the cache key correct for free.

### How

```java
// Augment a QueryRef with session-bound magic parameters, on the request thread.
Multimap<String, String> params = LinkedHashMultimap.create(queryRef.getParams());
for (String raw : view.getQuery().getPlaceholdersList()) {
String name = QueryTemplate.getParamName(raw); // e.g. "LOCALPUBKEY"
MagicParam mp = REGISTRY.get(name);
if (mp == null) continue;
for (String value : mp.resolve()) { // empty if session has no value
params.put(name, value); // key = wire name (stem)
}
}
queryRef = new QueryRef(queryRef.getQueryId(), params); // ctor (String, Multimap)
```

Add bindings **deterministically** (e.g. sorted) so `getAsUrlString()` is stable
across requests and the cache key does not churn.

### UI-field suppression

`GrlcQuery.createParamFields` iterates placeholders to build editable
`QueryParamField`s for the publish/query forms. Skip magic placeholders there so
they never appear as user-editable inputs.

## Turning magic data into buttons (separate, reusable features)

Magic params provide row data; the pieces below (shared with the
role-dependent action work, not specific to introductions) turn that data into
conditional buttons. None of them add a new view-definition predicate.

### Per-row visibility: empty mapped value into a required target hides the button

An entry action's per-row button is **not rendered when its mapped value is
empty *and* the target is required**. There is no `visible-if` predicate;
visibility rides on the template's existing optionality plus conditional binding
in the query.

The mapped target decides what "required" means:

- **`param_X` → a non-optional template placeholder:** empty value hides the
button. An *optional* placeholder tolerates an empty value, so the button
stays (its mapped value was just a nice-to-have prefill).
- **fill-mode / structural key (`derive-a`, `supersede`):** inherently required
— a derive/supersede with no nanopub is meaningless — so an empty value hides
the button.

"Empty" means null or blank (`value == null || value.isBlank()`); an unbound
SPARQL variable comes back blank.

The query author expresses per-row visibility by **binding the action's target
column conditionally**, e.g.

```sparql
BIND( IF(?retractable, ?np_iri, "") AS ?retract_target ) # then map retract_target:nanopubToBeRetracted
```

so the compound conditions (`declares_local_key && count > 1`) live in the query
where they belong. Template optionality is read from `Template.isOptionalStatement`
/ the `OPTIONAL_STATEMENT` type (a placeholder is required iff it appears in at
least one non-optional statement); confirm the cheapest way to ask this — derive
it, or reuse the publish form's required-field logic.

Free wins: *include-keys* hides itself when there is nothing to include (empty
missing-keys column), and every key-dependent action vanishes when logged out
(`LOCALPUBKEY` unbound → empty).

### Multiple mappings per action + non-`param_` targets

Today an action carries a single `queryVar:templateParam` mapping that only sets
`param_X`. Two extensions, folded together because `derive` needs both:

- allow a **list** of mappings per action;
- allow a mapping to target a **non-`param_` URL key** (`derive-a`, `supersede`).

`derive` then declares two: `derive_target → derive-a` (conditional, drives
visibility) and `local_pubkey → public-key__.1`.

### Multi-value column → indexed params

Extend a mapping so a `_multi*` source column expands to
`param_<templateParam>__.1..N`. Only *include-keys* needs this.

### Echo-as-column (no code)

A query may `SELECT` a magic variable back out
(`?_LOCALPUBKEY_multi_val AS ?local_pubkey`) and feed it to an action via an
ordinary mapping (e.g. derive's key parameter).

## Worked example: the introductions view

Query keyed on `?_user_iri` plus magic `?_LOCALPUBKEY_multi_val`:

```sparql
SELECT ?np_iri ?date ?location ?keys_multi_val
?declares_local_key ?retractable ?derivable
?local_pubkey # echoed for derive's key bundle
WHERE {
# ... introductions of ?_user_iri ...
BIND( EXISTS { ?np npx:hasKeyDeclaration/npx:hasPublicKey ?_LOCALPUBKEY_multi_val }
AS ?declares_local_key )
BIND( (?declares_local_key && ?localCount > 1) AS ?retractable )
BIND( (!?declares_local_key && ?localCount = 0) AS ?derivable )
}
```

A conditionally-bound target column drives each row action's visibility:

```sparql
BIND( IF(?retractable, ?np_iri, "") AS ?retract_target )
BIND( IF(?derivable, ?np_iri, "") AS ?derive_target )
```

Action declarations on the view nanopub:

| Action | Type | Mappings (empty target → button hidden) |
| --- | --- | --- |
| Create Introduction | result action (owner-gated) | echoed `local_pubkey` → `public-key`, `SITEURL` → `key-location` |
| retract | entry action | `retract_target → nanopubToBeRetracted` (required) |
| derive | entry action | `derive_target → derive-a` (required, fill-mode key) + `local_pubkey → public-key__.1` |

`retract_target` / `derive_target` are empty for rows where the action does not
apply, so the empty-into-required rule hides the button there. The table and
these three row actions all become declarative.

## Gap analysis: what stays custom

- **Recommended-Actions prose.** The natural-language guidance on the owner's
About tab (keyed on approval / local-intro state) is not a view, table, or
action. It stays a small owner-only companion.
- **include-keys.** The strained one. Beyond multi-value → indexed expansion it
needs three correlated indexed parameters per missing key (`public-key`,
`key-declaration`, `key-declaration-ref`); the two `key-declaration*` values
are `getShortPubkeyName(SHA-256(pubkey))`, whose Nanodash-specific formatting
does not map cleanly to SPARQL string functions. Ship create/derive/retract
declaratively first; keep include-keys custom (or as a derive-only flow) until
the short-name derivation is worth pushing into the query — at which point
`LOCALINTRO` returns to the registry.

## Touch points

| Change | File |
| --- | --- |
| Magic registry + `isMagic` / binding helper | new `SessionQueryBindings.java` (or similar) |
| Call binding before fetch | `QueryResultTableBuilder.build()` (+ list / paragraph builders) |
| Suppress UI fields for magic params | `GrlcQuery.createParamFields` / `QueryParamField` |
| Empty-into-required hides entry action | `QueryResultTable` (entry-action loop), reading `Template` optionality |
| Multiple mappings per action + non-`param_` targets | `View` / action model, `QueryResultTable`, `QueryResultTableBuilder` |
| Multi → indexed expansion | `QueryResultTable` action mapping |

No new view-definition predicate is introduced: visibility rides on the
template's existing optionality. The placeholder conventions
(`QueryTemplate.getParamName` / `isMultiPlaceholder` / `isOptionalPlaceholder`)
are external (nanopub-java) and need no change.

## Phasing

1. **Magic-param binding + UI-field suppression.** Standalone and testable with
a throwaway query that just echoes `?_LOCALPUBKEY` into a result column.
Lowest risk, immediately reusable by any view. Do the wire smoke-test here.
2. **Empty-into-required hides entry-action buttons.** Skip an entry action for a
row when its mapped value is empty and the target is required (non-optional
placeholder, or a fill-mode key). No new predicate; feeds the role-dependent
action work too.
- **2b. Multiple mappings per action + non-`param_` targets.** Folds in with
phase 2; needed by `derive` (two mappings, one targeting `derive-a`).
3. **Republish the introductions view** with the magic query and the
create/derive/retract actions; drop the bespoke table from `ProfileIntroItem`,
keeping only the Recommended-Actions companion.
4. **Multi-expand + include-keys**, only if worthwhile (also returns `LOCALINTRO`
to the registry).

## To verify before building

The convention semantics above are confirmed in-process against nanopub-java
1.90.0. The one unverified link is the full wire round-trip: that grlc /
nanopub-query binds SPARQL variable `?_LOCALPUBKEY_multi_val` from URL parameter
`LOCALPUBKEY` exactly as it already does for `user` → `?_user_iri`. It almost
certainly does (same `getParamName` canonicalization), but it is worth one
integration smoke-test — a throwaway query echoing the parameter — before
committing to the convention.
Loading