diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 000000000..2adfa087b --- /dev/null +++ b/docs/README.md @@ -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. diff --git a/doc/custom-domains.md b/docs/custom-domains.md similarity index 99% rename from doc/custom-domains.md rename to docs/custom-domains.md index d58107cf4..a8df0cf59 100644 --- a/doc/custom-domains.md +++ b/docs/custom-domains.md @@ -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. diff --git a/doc/draft-with-ai.md b/docs/draft-with-ai.md similarity index 99% rename from doc/draft-with-ai.md rename to docs/draft-with-ai.md index e1cead809..708b63226 100644 --- a/doc/draft-with-ai.md +++ b/docs/draft-with-ai.md @@ -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 diff --git a/docs/magic-query-params.md b/docs/magic-query-params.md new file mode 100644 index 000000000..7be9d28c4 --- /dev/null +++ b/docs/magic-query-params.md @@ -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 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___.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. diff --git a/docs/presets.md b/docs/presets.md new file mode 100644 index 000000000..ff7940a80 --- /dev/null +++ b/docs/presets.md @@ -0,0 +1,227 @@ +# Presets for Nanodash + +**Status:** 🚧 In progress β€” see [issue #302](https://github.com/knowledgepixels/nanodash/issues/302) + +A **preset** is a named, publishable bundle of default views and roles that can +be applied to a resource page (a user, a space, or a maintained resource). +Instead of attaching views and roles to a resource one nanopublication at a +time, a maintainer publishes a preset once and then *assigns* it to as many +resources as needed. + +This is the design for [nanodash issue #302](https://github.com/knowledgepixels/nanodash/issues/302). + +## Overview + +There are two separate concerns, each backed by its own nanopublication: + +1. **Defining a preset** β€” declaring *what* the bundle contains (which views to + show, which roles to set up) and which type of resource it is meant for. +2. **Assigning a preset** β€” stating that a *specific* resource should use a + given preset. + +Keeping these apart means one preset definition can be reused across many +resources, and an assignment can be added (or revoked) by a different user than +the one who defined the preset. + +The whole design deliberately mirrors the existing **view display** mechanism +(`gen:ViewDisplay`, "Displaying a view for a resource"), so the same query and +aggregation logic applies, and the same activation/deactivation semantics carry +over. + +All terms use the `gen:` namespace `https://w3id.org/kpxl/gen/terms/`. + +## 1. Defining a preset + +A preset is published as a nanopublication whose assertion describes a +`gen:Preset`. Like a resource view, it carries a stable *kind* (via +`dct:isVersionOf`) so that the identity survives across superseding versions. + +```turtle +sub:preset a gen:Preset ; + dct:isVersionOf sub:presetKind ; # stable identity across versions + rdfs:label "Nano Session" ; # the preset name + dct:description "..." ; # optional + + # which resource type(s) this preset is meant for (repeatable): + gen:appliesToInstancesOf gen:Space ; # or gen:IndividualAgent / gen:MaintainedResource + gen:appliesToNamespace <...> ; # optional, advanced + + # the bundled content (each repeatable and optional): + gen:hasTopLevelView ; # shown at the top level + gen:hasView ; # shown by default + gen:hasRole . # role definition to set up +``` + +The preset node is both an embedded (`nt:EmbeddedResource`) and introduced +local resource; the introduced `presetKind` is what other nanopubs and lookups +reference so that the link is version-independent β€” exactly as done for +resource views. + +### Properties + +| Property | Cardinality | Range / value | +|----------------------------|----------------------|------------------------------------------------------------------------| +| `rdf:type` | required | `gen:Preset` | +| `dct:isVersionOf` | required | the stable preset *kind* | +| `rdfs:label` | required | the preset name (used as the nanopub label) | +| `dct:description` | optional | free text | +| `gen:appliesToInstancesOf` | repeatable | `gen:IndividualAgent`, `gen:Space`, or `gen:MaintainedResource` | +| `gen:appliesToNamespace` | optional, repeatable | a URI prefix (advanced) | +| `gen:hasTopLevelView` | optional, repeatable | a `gen:ResourceView` | +| `gen:hasView` | optional, repeatable | a `gen:ResourceView` | +| `gen:hasRole` | optional, repeatable | a `gen:SpaceMemberRole` | + +`gen:hasView` and `gen:hasRole` are reused from the existing view-display and +space-role vocabulary rather than introducing preset-specific properties, so a +preset's views and roles are queryable with the same machinery already in +place. `gen:hasTopLevelView` distinguishes views that should be shown at the top +level of the page from the default `gen:hasView` placement. + +Template: [Publishing a preset](https://w3id.org/np/RAjdBPJa3HQ1Oa5knoSQEs1ui6bf69iO8vGuEhoogRmcQ). + +## 2. Assigning a preset to a resource + +An assignment is a separate nanopublication that links a preset to a concrete +resource: + +```turtle +sub:assignment a gen:PresetAssignment ; + a gen:ActivatedPresetAssignment ; # or gen:DeactivatedPresetAssignment + gen:isAssignmentOfPreset ; + gen:isAssignmentFor . # a space or maintained resource +``` + +The crucial design point β€” copied directly from view displays β€” is that **the +assignment is identified by the `(preset, resource)` pair, not by the +nanopublication's URI.** The `sub:assignment` node is a fresh local resource +minted in each nanopub; what ties two nanopubs together is that they describe an +assignment of the *same* preset for the *same* resource. + +### Activation and cross-user deactivation + +Activation state is expressed as an additional `rdf:type`: + +- `gen:ActivatedPresetAssignment` (the default) +- `gen:DeactivatedPresetAssignment` + +Because identity is by properties rather than by URI, **a different user β€” with +a different key β€” can deactivate an assignment they did not create**, simply by +publishing a new nanopublication that describes a `gen:PresetAssignment` for the +same `(preset, resource)` pair and types it as +`gen:DeactivatedPresetAssignment`. They do not (and cannot) supersede the +original nanopub, since `npx:supersedes` requires the original signing key. + +Nanodash therefore resolves the effective state by **aggregating all +`gen:PresetAssignment` nodes for a given `(preset, resource)` pair**, considering +only assignments from agents who are authorized over the target (see +[Authority and aggregation](#authority-and-aggregation)) and letting the most +recent one win (latest-wins by publication time). + +Template: [Assigning a preset to a resource](https://w3id.org/np/RA5shNOPHqtqUWkHnAWmff94G3wreqWUYYQFlHmrMTYzo). + +## Authority and aggregation + +Two rules govern which preset/view statements actually take effect on a page: + +**1. Only authorized agents count.** When aggregating preset assignments β€” and +their resolved views and roles β€” only statements made by agents with authority +over the target resource are considered: + +- for a space or maintained resource: its **admins and maintainers**; +- for a user page: **only the user themselves**. + +Statements by anyone else are ignored for the purpose of what renders on the +page. (They remain valid nanopublications; they just don't drive the page's +default configuration.) + +**2. Time ordering defines overriding.** The effective set of views on a page is +the union of preset-supplied views and directly-attached view displays, resolved +by publication time β€” latest-wins. Crucially, presets and individual view +displays live in **one shared pool** and override each other in **both +directions**: + +- an individual `gen:ViewDisplay` (activated or deactivated) published *after* a + preset assignment can deactivate or override a view the preset would otherwise + contribute; +- conversely, a later preset assignment can override or re-activate a view that + an earlier individual view display had set or removed. + +So a preset is not a sealed bundle: each view it carries behaves as if it were an +individual view display contributed at the preset assignment's publication time, +and any later matching statement (preset-borne or standalone) for the same +`(view, resource)` pair supersedes it. + +## Vocabulary summary + +New terms proposed under `https://w3id.org/kpxl/gen/terms/`: + +| Term | Kind | Meaning | +|-------------------------------------|----------|-------------------------------------------------------------| +| `gen:Preset` | class | a named bundle of default views and roles | +| `gen:PresetAssignment` | class | the assignment of a preset to a resource | +| `gen:ActivatedPresetAssignment` | class | marks an assignment as active (default) | +| `gen:DeactivatedPresetAssignment` | class | marks an assignment as deactivated | +| `gen:hasTopLevelView` | property | preset β†’ a view to show at the top level | +| `gen:isAssignmentOfPreset` | property | assignment β†’ the preset | +| `gen:isAssignmentFor` | property | assignment β†’ the target resource | + +Reused existing terms: `gen:hasView`, `gen:hasRole`, `gen:appliesToInstancesOf`, +`gen:appliesToNamespace`, `gen:IndividualAgent`, `gen:Space`, +`gen:MaintainedResource`, `gen:ResourceView`, `gen:SpaceMemberRole`, +`dct:isVersionOf`. + +## Relation to view displays + +The preset model is intentionally parallel to the view-display model, so the +implementation can largely follow the existing code paths: + +| View displays | Presets | +|----------------------------------------|------------------------------------------| +| `gen:ViewDisplay` | `gen:PresetAssignment` | +| `gen:ActivatedViewDisplay` | `gen:ActivatedPresetAssignment` | +| `gen:DeactivatedViewDisplay` | `gen:DeactivatedPresetAssignment` | +| `gen:isDisplayOfView` | `gen:isAssignmentOfPreset` | +| `gen:isDisplayFor` | `gen:isAssignmentFor` | +| identity by `(view, resource)` | identity by `(preset, resource)` | +| "Displaying a view for a resource" | "Assigning a preset to a resource" | +| "Deactivating a view display ..." | (covered by the deactivated type toggle) | + +Reference view-display templates: +[Displaying a view for a resource](https://w3id.org/np/RAJnYnoOgXRJx31ad_Zm3__6jyvV6vuWCAKGFQCm4Xilo), +[Deactivating a view display for a user](https://w3id.org/np/RAZ47_4JquvEXk30HYnVeSgFRcQqHtpdibcfBOeqHI2j4). + +## Decided + +- **Conflict resolution / authority:** only assignments and view displays from + agents authorized over the target are considered β€” admins and maintainers for a + space or maintained resource, the user themselves for a user page. Among those, + latest-wins by publication time. See + [Authority and aggregation](#authority-and-aggregation). +- **Precedence:** preset-supplied views and directly-attached view displays share + one pool and override each other in both directions, by publication time. A + standalone view display can deactivate/override a preset's view and vice versa. + +## Open questions + +- **Top-level vs. default views:** confirm the intended rendering difference + between `gen:hasTopLevelView` and `gen:hasView` on the resource page. +- **A dedicated deactivation template:** the assignment template already exposes + the activated/deactivated toggle, but a separate "Deactivating a preset + assignment" template (mirroring the view-display one) may be friendlier in the + UI. +- **Assignment target granularity:** the example assignment references the + concrete preset-version node (`.../test-preset`) rather than the version- + independent `presetKind`, relying on supersedes-chain resolution (as views do). + Confirm this is the intended reference, or whether assignments should point at + the kind directly. + +## Example nanopubs + +Live instances now exist (so the assignment template's preset lookup returns +results): + +- Preset definition β€” "Test preset" (a `gen:Preset` for `gen:Space`, bundling two + roles and three views): + [`RAYZhvi5...`](https://w3id.org/np/RAYZhvi5MXiwSw349j9-Gpjl9VjegdVnIdrki5U3HPiqo) +- Preset assignment β€” assigns "Test preset" to the `preset-test` space: + [`RAofuHnw...`](https://w3id.org/np/RAofuHnwP_dJY3pwHDEoQeyLj56UMK8ANS7POG9g2fAFY) diff --git a/docs/role-specific-views.md b/docs/role-specific-views.md new file mode 100644 index 000000000..89540390f --- /dev/null +++ b/docs/role-specific-views.md @@ -0,0 +1,232 @@ +# Role-specific view actions + +**Status:** βœ… Implemented β€” per-action `gen:isVisibleTo` gating (tier model, matching, and filtering in the action renderers), the `get-space-roles` `roleType` republish (P0), and the authoring template field are all published. Only an optional performance tweak remains (see Phasing). + +A view's **action button** can declare that it is shown only to viewers holding a +given role, or a given role **tier** (class), relative to the resource's +governing space. Targeting a *tier* (e.g. Maintainer) matches anyone at that tier +or above; targeting a *specific role IRI* matches only holders of that exact role. + +This gates individual *actions*, not whole views β€” a view is shown to everyone, +but (say) its "retract" or "add member" button only appears for maintainers. It +builds on the role-tier model that **nanopub-query already defines and +materializes** (see +[`../nanopub-query/doc/design-space-repositories.md`](../../nanopub-query/doc/design-space-repositories.md), +Β§"Role types"). + +## Semantics & scope + +This is **relevance-gating, not a security boundary.** The template behind an +action and the underlying nanopubs are public; hiding a button only declutters β€” +publishing is still authorised server-side. That lets the gate run **client-side** +with role data the page already loads. + +The governing space is the resource the action is rendered for: the space itself +for a space page, the owning space for a maintained resource, and none for a user +page (`IndividualAgent`). A **user page is treated as a degenerate space whose +sole admin is the owner** β€” so the owner holds the admin tier (any tier-gated +action shows only to them) and no one else holds any role. Specific-role gates are +unholdable on a user page and therefore match nobody. (When agents can later +*observe* a user, `userTier` returns Observer for them and observer-gated actions +start matching automatically β€” no special-casing.) + +## The role model it rides on + +nanopub-query types every role with a **tier** β€” a subclass of +`gen:SpaceMemberRole` (`gen:` = `https://w3id.org/kpxl/gen/terms/`) β€” and +materializes it per space as `npa:hasRoleType ` alongside `npa:role +`. The tiers form a downward-grant chain: + +| Tier IRI | Meaning | +| --- | --- | +| `gen:AdminRole` | hardcoded singleton `…/adminRole` (the same IRI as Nanodash's `ADMIN_ROLE_IRI`), defines `gen:hasAdmin` | +| `gen:MaintainerRole` | granted by an admin | +| `gen:MemberRole` | granted by an admin or maintainer | +| `gen:ObserverRole` | granted by anyone above, or self-attested; **default** when a role declares no tier | + +The nanopub-query design doc explicitly leaves *per-tier privilege +enforcement β€” what each tier may do inside a space β€”* to Nanodash. Gating an +action by tier is exactly such a privilege, so the query layer hands us the tier +and this feature decides what to do with it. + +### The visibility ladder + +For matching, Nanodash ranks tiers, with a `gen:EveryoneRole` floor below Observer: + +``` +gen:EveryoneRole (0) < Observer (1) < Member (2) < Maintainer (3) < Admin (4) +``` + +`gen:EveryoneRole` means literally anyone, including logged-out viewers with no +role β€” distinct from Observer, the lowest *assigned* tier. It is a +**Nanodash-side visibility sentinel, not a nanopub-query grant tier** (those are +admin/maintainer/member/observer β€” you never "grant everybody"). It earns an +explicit IRI because the view-creation template **cannot leave the per-action +visibility statement optional inside a repeated action group**, so it needs a +concrete "no restriction" value to use as the default. A hand-authored action +that simply omits `gen:isVisibleTo` is also visible to everyone β€” omission and +`gen:EveryoneRole` are equivalent. + +## The predicate β€” on the action node + +`gen:isVisibleTo` attaches to the **action node inside the view nanopub** β€” the +IRI that is the object of `gen:hasViewAction` and carries `gen:hasActionTemplate` +plus the `gen:ViewAction` / `gen:ViewEntryAction` type. Not on the view itself, +and **not** on the shared action template (which is reusable across views). + +```turtle +sub:myview gen:hasViewAction sub:retractAction . +sub:retractAction a gen:ViewEntryAction ; + gen:hasActionTemplate <…/retract-template> ; + gen:isVisibleTo gen:MaintainerRole . # tier: this tier or above +# sub:retractAction gen:isVisibleTo <…/someRole> # or a specific role IRI +# sub:retractAction gen:isVisibleTo gen:EveryoneRole # everyone (the authored default) +# (no triple at all) # also everyone (hand-authored) +``` + +The object is either a tier IRI or a specific role IRI; disambiguation is the +fixed tier set (`gen:AdminRole` / `gen:MaintainerRole` / `gen:MemberRole` / +`gen:ObserverRole`), anything else is a specific role. Multiple triples are **OR**. + +## Matching + +``` +isViewerEntitled(reqs, viewer, governingSpace, viewerIsOwner): + if reqs empty -> entitled (Everyone) + if reqs contains gen:EveryoneRole -> entitled (everyone, incl. anonymous) + if governingSpace == null: // user page = owner is sole admin + tier = viewerIsOwner ? Admin : Everyone + return any tier-IRI X in reqs with tier >= rank(X) // specific roles unholdable here + if viewer == null -> not entitled + for X in reqs: + if X is a tier IRI and governingSpace.userTier(viewer) >= rank(X) -> entitled + if X is a role IRI and governingSpace.viewerHoldsRole(viewer, X) -> entitled + not entitled +``` + +- **`userTier`** = the highest-ranked tier among the roles the viewer holds in + that space (Everyone/rank-0 if none). +- **No admin override.** An admin who does not personally hold a specific role + does **not** see an action gated to that role. The escape hatch is + **self-assignment**: an admin can grant any non-admin-tier role (every custom + role is maintainer/member/observer tier), so an admin who needs the action + publishes a role-instantiation for themselves. No special-case code. + +Implemented as `SpaceMemberRole.isViewerEntitled(reqs, viewer, space, owner)` plus +a convenience overload `isViewerEntitled(reqs, resourceWithProfile)` that resolves +viewer/space/owner from the rendered resource. + +## Where it's enforced + +The action renderers drop an action whose `gen:isVisibleTo` the viewer does not +satisfy, **before** the button reaches `ButtonList`: + +- result actions: `QueryResultTableBuilder.addViewActions`, + `QueryResultListBuilder`, `QueryResultPlainParagraphBuilder` +- entry (per-row) actions: `QueryResultTable`, `QueryResultList` + +This is **additive**: an action without `gen:isVisibleTo` renders exactly as +before. It composes with β€” does not replace β€” the existing `ButtonList` routing +(see next). + +## Relationship to today's `ButtonList` routing + +Existing action visibility is coarse and wired by resource *type*, not declared: +`ButtonList` (`ButtonList.java:24-46`) has three buckets β€” regular (everyone), +member (space member), admin (space admin / user-page owner) β€” and `QueryResult` +/ `QueryResultTable` route a view's actions into a bucket based on whether the +resource is an `IndividualAgent`, `Space`, etc. That is why entry actions are +never gated and result actions are owner-gated only on user pages. + +`gen:isVisibleTo` is the precise, declarative gate layered on top. For now it only +*adds* a filter; a later step could let a declared action fully replace the +resource-type routing (and fix the leak below at the source). + +## Known gap this addresses β€” fixed + +Incidental routing used to leak: the **presets** and **view-displays** tables in +`AboutUserPanel` / `AboutSpacePanel` / `AboutResourcePanel` were built without +`resourceWithProfile`, so their "add preset…" / "add view display…" actions fell +into the unconditional regular bucket (`QueryResult.java:61`, +`ButtonList.java:24-26`) and showed on *other* users' (and visitors') pages. + +Fixed in two parts: + +- the panels now pass `resourceWithProfile` (owner-gates the user-page case via + the `IndividualAgent` admin bucket, and lets the per-action filter resolve the + governing space on space/resource pages); +- the two views were republished with `gen:isVisibleTo gen:MaintainerRole` on + their action (plus complete `"void"` action fields so the action group + round-trips on edit), gating space/resource pages too. Current latest heads: + `preset-assignments-view` β†’ `RA4fgqTAYcaKiHNA8NZ1JgMlJI4JAgfh7B9YOJsfRQIFE`, + `view-displays-view` β†’ `RABs0d67G0oOPlZZ28Y-x-U6_W6geJxpFO8K8fwCaPB0k` + (picked up automatically via `View.get`'s latest-version resolution β€” no + constant change needed). + +So those actions now show only to admins/maintainers (or the owner on a user +page) on every page type β€” the declarative fix for this class of bug, and the +feature's first real use. + +**Watch out for forked version chains.** The view-displays-view chain had two +latest heads β€” an earlier republish was built on a stale base (`RAVVUjFM…`) +while a separate June-5 head (`RAqh-ZN9…`, with a newer `list-view-displays` +query) had already branched from it. With two heads, `getLatestVersionId` +resolves ambiguously, so the gate appeared not to take effect on some pages. +Fixed by publishing one version (`RABs0d67…`) that `npx:supersedes` **both** +heads β€” merging the newer query with the gating/void β€” collapsing the fork to a +single head. When republishing a view, resolve the actual current head(s) first +(query `get-latest-version-of-np`) rather than assuming the constant's IRI is +latest. + +## Touch points + +| Change | File | Status | +| --- | --- | --- | +| Tier IRIs + `gen:isVisibleTo` term | `vocabulary/KPXL_TERMS.java` | done | +| `roleType` β†’ tier field, rank/`isTier`, `isViewerEntitled` | `SpaceMemberRole.java` | done | +| `roleType` column | `get-space-roles` query republish (P0) | published | +| `userTier` / `viewerHoldsRole` | `domain/Space.java` | done | +| Parse `gen:isVisibleTo` on action nodes | `View.java` (`getActionVisibleTo`) | done | +| Filter actions in the renderers | `QueryResultTable(Builder)`, `QueryResultList(Builder)`, `QueryResultPlainParagraphBuilder` | done | +| `gen:isVisibleTo` field in the view-creation template | nanopub `RA8_hijwsfGCryMYtjtEpec21ZSNY68-qmL0bHRWR0sWM` | published | + +## Phasing + +1. **Tier model** β€” IRIs, `SpaceMemberRole.tier`/rank/`isViewerEntitled`, + `Space.userTier`/`viewerHoldsRole`, the `get-space-roles` `roleType` column. βœ… +2. **Per-action parse + filter** β€” `View.getActionVisibleTo`, gates in the five + action renderers (additive). βœ… +3. **Authoring** βœ… β€” published as + `RA8_hijwsfGCryMYtjtEpec21ZSNY68-qmL0bHRWR0sWM` (supersedes the + `…declaring-a-resource-view` chain). Each action in the repeatable `st50` group + now carries a `gen:isVisibleTo` `nt:GuidedChoicePlaceholder`: + - fixed `nt:possibleValue`s for the tiers (`EveryoneRole`, `ObserverRole`, + `MemberRole`, `MaintainerRole`, `AdminRole`); + - `nt:possibleValuesFromApi …find-things?type=…SpaceMemberRole` to offer + published specific roles (the same source the role-assignment template uses); + - free text still allowed for any other role IRI. + + The statement **cannot be optional** (a known limitation: optional statements + inside a repeated group aren't supported yet), so it carries + `nt:hasDefaultValue gen:EveryoneRole` β€” the explicit "no restriction" value. + Republishing requires the template's original **Nanodash-web signing key** (it + was created there, not with the local CLI key), so it must be superseded via + Nanodash-web or by signing with that key. No Nanodash code change (templates are + discovered dynamically). +4. *(Optional, later β€” performance only)* have a query return the viewer's tier + in a space directly, so Nanodash needn't load the full role set to compute + `userTier`. This is **not** a security boundary β€” action gating is client-side + relevance-gating over public data, so there is nothing to "enforce" + server-side; it would only save a fetch on busy pages. + +## Relationship to nanopub-query + +This feature is purely a **consumer** of the spaces-repo role state. Tiers, grant +rules, and the per-space validated memberβ†’role materialization all live in +nanopub-query +([design-space-repositories.md](../../nanopub-query/doc/design-space-repositories.md)). +Nanodash adds only the *privilege* interpretation β€” "a viewer at tier β‰₯ T (or +holding role R) may use this action" β€” which that design explicitly leaves to +Nanodash. No new server-side role type is introduced: `gen:EveryoneRole` is a +Nanodash-side visibility sentinel (the rank-0 "no restriction" default), never a +grantable nanopub-query tier. diff --git a/docs/userlist-views.md b/docs/userlist-views.md index 629da8b90..62b6f6c00 100644 --- a/docs/userlist-views.md +++ b/docs/userlist-views.md @@ -1,5 +1,7 @@ # /userlist views β€” draft queries to publish +**Status:** βœ… Implemented β€” `UserListPage` renders the Human / Software / Non-Approved lists as published views. + Goal: turn the three remaining hard-coded lists on `UserListPage` (πŸ‘€ Human Users, πŸ€– Software Agents, ❓ Non-Approved Users) into proper views, the same way `topcreators` and `latestusers` already are. diff --git a/src/main/java/com/knowledgepixels/nanodash/NanodashSession.java b/src/main/java/com/knowledgepixels/nanodash/NanodashSession.java index b2f5b3da3..854311b75 100644 --- a/src/main/java/com/knowledgepixels/nanodash/NanodashSession.java +++ b/src/main/java/com/knowledgepixels/nanodash/NanodashSession.java @@ -54,6 +54,17 @@ public static NanodashSession get() { return (NanodashSession) Session.get(); } + /** + * Returns the current user's agent IRI, or null when no session is bound to + * the current thread (e.g. a background or non-request context) or no user is + * logged in. Safe to call outside a request cycle, unlike {@link #get()}. + * + * @return the current user IRI, or null + */ + public static IRI getCurrentUserIriOrNull() { + return Session.exists() ? get().getUserIri() : null; + } + /** * Constructs a new NanodashSession for the given request. * Initializes the HTTP session and loads profile information. diff --git a/src/main/java/com/knowledgepixels/nanodash/Preset.java b/src/main/java/com/knowledgepixels/nanodash/Preset.java new file mode 100644 index 000000000..1eef061bf --- /dev/null +++ b/src/main/java/com/knowledgepixels/nanodash/Preset.java @@ -0,0 +1,212 @@ +package com.knowledgepixels.nanodash; + +import com.knowledgepixels.nanodash.vocabulary.KPXL_TERMS; +import org.eclipse.rdf4j.model.IRI; +import org.eclipse.rdf4j.model.Statement; +import org.eclipse.rdf4j.model.vocabulary.DCTERMS; +import org.eclipse.rdf4j.model.vocabulary.RDF; +import org.eclipse.rdf4j.model.vocabulary.RDFS; +import org.nanopub.Nanopub; +import org.nanopub.NanopubUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.common.cache.Cache; +import com.google.common.cache.CacheBuilder; + +import java.io.Serializable; +import java.util.*; +import java.util.concurrent.TimeUnit; + +/** + * A class representing a Preset: a named bundle of default views and roles that + * can be assigned to a resource (a user, a space, or a maintained resource). + * + *

This mirrors {@link View}: a preset carries a stable kind (via + * {@code dct:isVersionOf}) so its identity survives across superseding versions, + * and {@link #get(String)} automatically resolves to the latest version.

+ * + *

See {@code doc/presets.md} and nanodash issue #302.

+ */ +public class Preset implements Serializable { + + private static final Logger logger = LoggerFactory.getLogger(Preset.class); + + private static final Cache presets = CacheBuilder.newBuilder() + .maximumSize(5_000) + .expireAfterAccess(24, TimeUnit.HOURS) + .build(); + + /** + * Get a Preset by its ID, resolving to the latest version of its kind. + * + * @param id the ID of the Preset + * @return the Preset object, or null if it could not be loaded + */ + public static Preset get(String id) { + String npId = id.replaceFirst("^(.*[^A-Za-z0-9-_]RA[A-Za-z0-9-_]{43})[^A-Za-z0-9-_].*$", "$1"); + // Automatically select the latest version of the preset definition (same pattern as View.get()): + try { + String latestNpId = QueryApiAccess.getLatestVersionId(npId); + if (!latestNpId.equals(npId)) { + Nanopub np = Utils.getAsNanopub(latestNpId); + if (np != null) { + Set embeddedIris = NanopubUtils.getEmbeddedIriIds(np); + if (embeddedIris.size() == 1) { + String latestId = embeddedIris.iterator().next(); + Preset cached = presets.getIfPresent(latestId); + if (cached == null) { + cached = new Preset(latestId, np); + presets.put(latestId, cached); + } + return cached; + } + } + } + } catch (Exception ex) { + logger.error("Error resolving latest version for preset: {}", id, ex); + } + // Fall back to loading the nanopub as given: + Nanopub np = Utils.getAsNanopub(npId); + Preset cached = presets.getIfPresent(id); + if (cached == null) { + try { + cached = new Preset(id, np); + presets.put(id, cached); + } catch (Exception ex) { + logger.error("Couldn't load nanopub for preset: {}", id, ex); + } + } + return cached; + } + + private String id; + private Nanopub nanopub; + private IRI presetKind; + private String label; + private String description; + private final List topLevelViews = new ArrayList<>(); + private final List views = new ArrayList<>(); + private final List roles = new ArrayList<>(); + private final Set appliesToClasses = new HashSet<>(); + private final Set appliesToNamespaces = new HashSet<>(); + + private Preset(String id, Nanopub nanopub) { + this.id = id; + this.nanopub = nanopub; + boolean presetTypeFound = false; + for (Statement st : nanopub.getAssertion()) { + if (!st.getSubject().stringValue().equals(id)) continue; + if (st.getPredicate().equals(RDF.TYPE)) { + if (st.getObject().equals(KPXL_TERMS.PRESET)) { + presetTypeFound = true; + } + } else if (st.getPredicate().equals(DCTERMS.IS_VERSION_OF) && st.getObject() instanceof IRI objIri) { + presetKind = objIri; + } else if (st.getPredicate().equals(RDFS.LABEL)) { + label = st.getObject().stringValue(); + } else if (st.getPredicate().equals(DCTERMS.DESCRIPTION)) { + description = st.getObject().stringValue(); + } else if (st.getPredicate().equals(KPXL_TERMS.HAS_TOP_LEVEL_VIEW) && st.getObject() instanceof IRI objIri) { + topLevelViews.add(objIri); + } else if (st.getPredicate().equals(KPXL_TERMS.HAS_VIEW) && st.getObject() instanceof IRI objIri) { + views.add(objIri); + } else if (st.getPredicate().equals(KPXL_TERMS.HAS_ROLE) && st.getObject() instanceof IRI objIri) { + roles.add(objIri); + } else if (st.getPredicate().equals(KPXL_TERMS.APPLIES_TO_INSTANCES_OF) && st.getObject() instanceof IRI objIri) { + appliesToClasses.add(objIri); + } else if (st.getPredicate().equals(KPXL_TERMS.APPLIES_TO_NAMESPACE) && st.getObject() instanceof IRI objIri) { + appliesToNamespaces.add(objIri); + } + } + if (!presetTypeFound) throw new IllegalArgumentException("Not a proper preset nanopub: " + id); + } + + public String getId() { + return id; + } + + public Nanopub getNanopub() { + return nanopub; + } + + public IRI getNanopubId() { + return nanopub == null ? null : nanopub.getUri(); + } + + /** + * Gets the stable preset kind (the {@code dct:isVersionOf} target), which is + * version-independent and what assignments and lookups should reference. + * + * @return the preset kind IRI, or null if not set + */ + public IRI getPresetKindIri() { + return presetKind; + } + + public String getLabel() { + return label; + } + + public String getDescription() { + return description; + } + + /** + * Gets the views to be shown at the top level of the resource page + * ({@code gen:hasTopLevelView}). + * + * @return the list of top-level view IRIs + */ + public List getTopLevelViews() { + return topLevelViews; + } + + /** + * Gets the views to be shown by default ({@code gen:hasView}). + * + * @return the list of default view IRIs + */ + public List getViews() { + return views; + } + + /** + * Gets the role definitions bundled by this preset ({@code gen:hasRole}). + * + * @return the list of role IRIs + */ + public List getRoles() { + return roles; + } + + /** + * Checks whether this preset applies to the given resource, by namespace or + * by class. Mirrors {@link View#appliesTo(String, Set)}. + * + * @param resourceId the resource ID + * @param classes the classes the resource is an instance of + * @return true if the preset applies + */ + public boolean appliesTo(String resourceId, Set classes) { + for (IRI namespace : appliesToNamespaces) { + if (resourceId.startsWith(namespace.stringValue())) return true; + } + if (classes != null) { + for (IRI c : classes) { + if (appliesToClasses.contains(c)) return true; + } + } + return false; + } + + public boolean appliesToClass(IRI targetClass) { + return appliesToClasses.contains(targetClass); + } + + @Override + public String toString() { + return id; + } + +} diff --git a/src/main/java/com/knowledgepixels/nanodash/PresetAssignment.java b/src/main/java/com/knowledgepixels/nanodash/PresetAssignment.java new file mode 100644 index 000000000..97d280490 --- /dev/null +++ b/src/main/java/com/knowledgepixels/nanodash/PresetAssignment.java @@ -0,0 +1,160 @@ +package com.knowledgepixels.nanodash; + +import com.knowledgepixels.nanodash.vocabulary.KPXL_TERMS; +import org.eclipse.rdf4j.model.IRI; +import org.eclipse.rdf4j.model.Statement; +import org.eclipse.rdf4j.model.vocabulary.RDF; +import org.nanopub.Nanopub; +import org.nanopub.NanopubUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.Serializable; +import java.util.HashSet; +import java.util.Set; + +/** + * A class representing the assignment of a {@link Preset} to a resource. + * + *

Mirrors {@link ViewDisplay}: an assignment is identified by the + * {@code (preset, resource)} pair rather than by the nanopub URI, and its + * effective activation state is resolved by aggregating all assignments for that + * pair (latest-wins among authorized agents). A {@code gen:DeactivatedPresetAssignment} + * lets a different authorized agent deactivate an assignment they did not create.

+ * + *

See {@code doc/presets.md} and nanodash issue #302.

+ */ +public class PresetAssignment implements Serializable { + + private static final Logger logger = LoggerFactory.getLogger(PresetAssignment.class); + + private String id; + private Nanopub nanopub; + private IRI presetIri; + private IRI resource; + private final Set types = new HashSet<>(); + + /** + * Get a PresetAssignment by its ID, resolving to the latest version. + * + * @param id the ID of the PresetAssignment + * @return the PresetAssignment object + * @throws IllegalArgumentException if the nanopub is not a proper preset assignment + */ + public static PresetAssignment get(String id) throws IllegalArgumentException { + // Try to resolve to the latest version (same pattern as ViewDisplay.get()): + try { + String npId = id.replaceFirst("^(.*[^A-Za-z0-9-_]RA[A-Za-z0-9-_]{43})[^A-Za-z0-9-_].*$", "$1"); + String latestNpId = QueryApiAccess.getLatestVersionId(npId); + if (!latestNpId.equals(npId)) { + Nanopub np = Utils.getAsNanopub(latestNpId); + if (np != null) { + Set embeddedIris = NanopubUtils.getEmbeddedIriIds(np); + if (embeddedIris.size() == 1) { + return new PresetAssignment(embeddedIris.iterator().next(), np); + } + } + } + } catch (Exception ex) { + logger.error("Error resolving latest version for preset assignment: {}", id, ex); + } + // Fall back to loading the nanopub as given: + try { + Nanopub np = Utils.getAsNanopub(id.replaceFirst("^(.*[^A-Za-z0-9-_])?(RA[A-Za-z0-9-_]{43})[^A-Za-z0-9-_].*$", "$2")); + return new PresetAssignment(id, np); + } catch (Exception ex) { + logger.error("Couldn't load nanopub for preset assignment: {}", id, ex); + throw new IllegalArgumentException("invalid preset assignment value " + id); + } + } + + private PresetAssignment(String id, Nanopub nanopub) { + this.id = id; + this.nanopub = nanopub; + boolean assignmentTypeFound = false; + for (Statement st : nanopub.getAssertion()) { + if (!st.getSubject().stringValue().equals(id)) continue; + if (st.getPredicate().equals(RDF.TYPE)) { + if (st.getObject().equals(KPXL_TERMS.PRESET_ASSIGNMENT)) { + assignmentTypeFound = true; + } + if (st.getObject() instanceof IRI objIri && !st.getObject().equals(KPXL_TERMS.PRESET_ASSIGNMENT)) { + types.add(objIri); + } + } else if (st.getPredicate().equals(KPXL_TERMS.IS_ASSIGNMENT_OF_PRESET) && st.getObject() instanceof IRI objIri) { + if (presetIri != null) { + throw new IllegalArgumentException("Preset already set: " + objIri); + } + presetIri = objIri; + } else if (st.getPredicate().equals(KPXL_TERMS.IS_ASSIGNMENT_FOR) && st.getObject() instanceof IRI objIri) { + if (resource != null) { + throw new IllegalArgumentException("Resource already set: " + objIri); + } + resource = objIri; + } + } + if (!assignmentTypeFound) throw new IllegalArgumentException("Not a proper preset assignment nanopub: " + id); + if (presetIri == null) throw new IllegalArgumentException("Preset not found: " + id); + if (resource == null) throw new IllegalArgumentException("Resource not found: " + id); + } + + public String getId() { + return id; + } + + public Nanopub getNanopub() { + return nanopub; + } + + public IRI getNanopubId() { + return nanopub == null ? null : nanopub.getUri(); + } + + public boolean hasType(IRI type) { + return types.contains(type); + } + + /** + * Whether this assignment is active. An assignment is active unless it is + * explicitly typed as {@code gen:DeactivatedPresetAssignment}. + * + * @return true if the assignment is active + */ + public boolean isActive() { + return !types.contains(KPXL_TERMS.DEACTIVATED_PRESET_ASSIGNMENT); + } + + /** + * Gets the IRI of the assigned preset ({@code gen:isAssignmentOfPreset}). + * + * @return the preset IRI + */ + public IRI getPresetIri() { + return presetIri; + } + + /** + * Resolves and returns the assigned {@link Preset}, following the supersedes + * chain to its latest version. + * + * @return the resolved Preset, or null if it could not be loaded + */ + public Preset getPreset() { + return Preset.get(presetIri.stringValue()); + } + + /** + * Gets the target resource of this assignment ({@code gen:isAssignmentFor}). + * + * @return the resource IRI + */ + public IRI getResource() { + return resource; + } + + @Override + public String toString() { + return id; + } + +} diff --git a/src/main/java/com/knowledgepixels/nanodash/QueryApiAccess.java b/src/main/java/com/knowledgepixels/nanodash/QueryApiAccess.java index 2509fbf9c..c1db0f803 100644 --- a/src/main/java/com/knowledgepixels/nanodash/QueryApiAccess.java +++ b/src/main/java/com/knowledgepixels/nanodash/QueryApiAccess.java @@ -66,7 +66,16 @@ private QueryApiAccess() { public static final String GET_LATEST_RIO_CANDIDATES = "RAehKOCOnZ3uDBmI0kkCNTh5k9Nl6YYNj7tyc20tVymxY/get-latest-rio-candidates"; public static final String GET_REACTIONS = "RAe7k3L0oElPOrFoUMkUhqU9dGUqfBaUSw3cVplOUn3Fk/get-reactions"; public static final String GET_TERM_DEFINITIONS = "RAZUsK7jU85oUYEVKvMPFlqbwn19oR55IQuFkXuiS_Tkg/get-term-definitions"; - public static final String GET_VIEW_DISPLAYS = "RAIMs6C9gfjqX-TYGd8mrq7MJ7K-DoofW6v4dzFQJTs7Y/get-view-displays"; + // v10 (issue #302): standalone + preset-supplied views (unbound ?display), gated to + // admins/maintainers of the owning space or the affected user themselves. Each + // referenced view is resolved to its latest version server-side: the version tree's + // most recent current head (a nanopub itself neither superseded nor validly retracted + // via npx:invalidates), robust to backdated supersedes and retracted versions, so + // ?view is already the latest and needs no separate per-view lookup. v10 wraps that + // resolution in a run-once sub-SELECT so the cross-repo lookup federates once for the + // whole view set instead of once per referenced view -- cut a 44-display page from + // ~4.5s to ~1.7s (the per-view federation round-trips were the dominant cost). + public static final String GET_VIEW_DISPLAYS = "RAy49uUd2fPLHJAZ_7QKDtIDVgqaQ589OgQhMwNamKy-4/get-view-displays"; // Spaces-repo queries (endpoint: nanopub-query .../repo/spaces) public static final String GET_SPACES = "RAxGboS_juHuMyJQghGV3elEgZmQTew5oyw_aC9O9FFQI/get-spaces"; @@ -74,7 +83,7 @@ private QueryApiAccess() { public static final String GET_MAINTAINED_RESOURCES = "RAOOq81R84exTUKUBQT3BbgCaSJyC2lqPDXIP2XaDTosM/get-maintained-resources"; public static final String GET_SPACE_ADMINS = "RAaHOXMQ7Kq37T9syR9at0RqushclHenlPOFRwFDn0Cfs/get-space-admins"; public static final String GET_SPACE_ADMIN_PUBKEY_HASHES = "RAJvvNY6KXqveJivZKh-chTCntrsY_KJSGLVNRQdi0pUc/get-space-admin-pubkey-hashes"; - public static final String GET_SPACE_ROLES = "RAERgisNLMcIq9eZgXA6ASW3XaewAJIvMSGs4v1yn-FdM/get-space-roles"; + public static final String GET_SPACE_ROLES = "RAKJFw-xIQ2r_aSKT4-6Pm3JkeqlWC_wmypfpA1JWPJl8/get-space-roles"; public static final String GET_SPACE_MEMBERS = "RAo0c4UNoD-uTP3xATU_-TB6vO-nMO4Ya-mvdaGjX5qVE/get-space-members"; private static final Logger logger = LoggerFactory.getLogger(QueryApiAccess.class); diff --git a/src/main/java/com/knowledgepixels/nanodash/QueryResult.java b/src/main/java/com/knowledgepixels/nanodash/QueryResult.java index c24c424f7..e03cf2433 100644 --- a/src/main/java/com/knowledgepixels/nanodash/QueryResult.java +++ b/src/main/java/com/knowledgepixels/nanodash/QueryResult.java @@ -121,6 +121,18 @@ public void addButton(String label, Class pageClass, Pag buttons.add(button); } + /** + * Whether all result rows fit on the first page, so no pagination is needed + * and the filter textfield can be hidden. Also true when the page size is + * unlimited ({@code < 1}). + * + * @return true if all entries fit on the first page + */ + protected boolean fitsOnFirstPage() { + int pageSize = viewDisplay.getPageSize(); + return pageSize < 1 || response.getData().size() <= pageSize; + } + /** * Populate the component with the query results. */ diff --git a/src/main/java/com/knowledgepixels/nanodash/SpaceMemberRole.java b/src/main/java/com/knowledgepixels/nanodash/SpaceMemberRole.java index 914552d86..a7a8e4e86 100644 --- a/src/main/java/com/knowledgepixels/nanodash/SpaceMemberRole.java +++ b/src/main/java/com/knowledgepixels/nanodash/SpaceMemberRole.java @@ -1,6 +1,8 @@ package com.knowledgepixels.nanodash; import com.google.common.collect.Multimap; +import com.knowledgepixels.nanodash.domain.AbstractResourceWithProfile; +import com.knowledgepixels.nanodash.domain.IndividualAgent; import com.knowledgepixels.nanodash.domain.Space; import com.knowledgepixels.nanodash.template.Template; import com.knowledgepixels.nanodash.template.TemplateData; @@ -9,6 +11,7 @@ import org.nanopub.extra.services.ApiResponseEntry; import java.io.Serializable; +import java.util.Set; import java.util.stream.Stream; /** @@ -20,6 +23,16 @@ public class SpaceMemberRole implements Serializable { private String label, name, title; private Template roleAssignmentTemplate = null; private IRI[] regularProperties, inverseProperties; + private IRI tier; + + /** + * Rank of the "everyone" floor (no role held). Below {@link #OBSERVER_RANK}. + */ + public static final int EVERYONE_RANK = 0; + private static final int OBSERVER_RANK = 1; + private static final int MEMBER_RANK = 2; + private static final int MAINTAINER_RANK = 3; + private static final int ADMIN_RANK = 4; /** * Construct a SpaceMemberRole from an API response entry. @@ -36,9 +49,10 @@ public SpaceMemberRole(ApiResponseEntry e) { } regularProperties = stringToIriArray(e.get("regularProperties")); inverseProperties = stringToIriArray(e.get("inverseProperties")); + this.tier = parseTier(e.get("roleType")); } - private SpaceMemberRole(IRI id, String label, String name, String title, Template roleAssignmentTemplate, IRI[] regularProperties, IRI[] inverseProperties) { + private SpaceMemberRole(IRI id, String label, String name, String title, Template roleAssignmentTemplate, IRI[] regularProperties, IRI[] inverseProperties, IRI tier) { this.id = id; this.label = label; this.name = name; @@ -46,6 +60,21 @@ private SpaceMemberRole(IRI id, String label, String name, String title, Templat this.roleAssignmentTemplate = roleAssignmentTemplate; this.regularProperties = regularProperties; this.inverseProperties = inverseProperties; + this.tier = tier; + } + + /** + * Parse the role tier from the {@code roleType} query column (the + * server-materialized {@code npa:hasRoleType} value). Defaults to + * {@link KPXL_TERMS#OBSERVER_ROLE} when absent, matching the server-side + * default for roles that declare no tier subclass. + * + * @param roleType the role-type IRI string, or null/blank + * @return the tier IRI (never null) + */ + private static IRI parseTier(String roleType) { + if (roleType == null || roleType.isBlank()) return KPXL_TERMS.OBSERVER_ROLE; + return Utils.vf.createIRI(roleType); } /** @@ -120,6 +149,125 @@ public IRI[] getInverseProperties() { return inverseProperties; } + /** + * Get the tier (role class) of this role β€” one of the role-tier IRIs in + * {@link KPXL_TERMS} ({@code ADMIN_ROLE_TYPE} / {@code MAINTAINER_ROLE} / + * {@code MEMBER_ROLE} / {@code OBSERVER_ROLE}). + * + * @return The tier IRI (never null; defaults to observer). + */ + public IRI getTier() { + return tier; + } + + /** + * Get the numeric rank of this role's tier, for threshold comparisons + * (admin {@literal >} maintainer {@literal >} member {@literal >} observer). + * + * @return The tier rank (1..4). + */ + public int getTierRank() { + return tierRank(tier); + } + + /** + * Numeric rank of a role-tier IRI, for threshold comparisons. Unknown or + * null tiers (the "everyone" floor) rank below observer. + * + * @param tier a role-tier IRI, or null + * @return the rank: admin=4, maintainer=3, member=2, observer=1, else 0 + */ + public static int tierRank(IRI tier) { + if (KPXL_TERMS.ADMIN_ROLE_TYPE.equals(tier)) return ADMIN_RANK; + if (KPXL_TERMS.MAINTAINER_ROLE.equals(tier)) return MAINTAINER_RANK; + if (KPXL_TERMS.MEMBER_ROLE.equals(tier)) return MEMBER_RANK; + if (KPXL_TERMS.OBSERVER_ROLE.equals(tier)) return OBSERVER_RANK; + return EVERYONE_RANK; + } + + /** + * Whether the given IRI is one of the known role-tier IRIs (as opposed to a + * specific role IRI). Used to interpret {@code gen:isVisibleTo} objects. + * + * @param iri an IRI, or null + * @return true if the IRI is a role tier + */ + public static boolean isTier(IRI iri) { + return KPXL_TERMS.EVERYONE_ROLE.equals(iri) + || KPXL_TERMS.ADMIN_ROLE_TYPE.equals(iri) + || KPXL_TERMS.MAINTAINER_ROLE.equals(iri) + || KPXL_TERMS.MEMBER_ROLE.equals(iri) + || KPXL_TERMS.OBSERVER_ROLE.equals(iri); + } + + /** + * Evaluates a {@code gen:isVisibleTo} restriction (a set of role-tier and/or + * specific-role IRIs) against a viewer. Used to gate per-action visibility on + * views (see docs/role-specific-views.md). + * + *

An empty restriction is visible to everyone. A role-tier IRI matches when + * the viewer's highest tier in the governing space meets or exceeds it + * (admin {@literal >} maintainer {@literal >} member {@literal >} observer); a + * specific role IRI matches when the viewer holds exactly that role. Multiple + * entries are OR-ed; there is no admin override for specific roles. When there + * is no governing space (e.g. a user page), a non-empty restriction is + * satisfied only for the resource owner.

+ * + * @param requiredVisibility the set of {@code gen:isVisibleTo} IRIs (may be empty) + * @param viewer the viewer's agent IRI, or null if logged out + * @param governingSpace the space whose roles govern visibility, or null + * @param viewerIsOwner whether the viewer owns the resource (used only + * when there is no governing space) + * @return true if the viewer is entitled + */ + public static boolean isViewerEntitled(Set requiredVisibility, IRI viewer, Space governingSpace, boolean viewerIsOwner) { + if (requiredVisibility == null || requiredVisibility.isEmpty()) return true; + // gen:EveryoneRole is the explicit "no restriction" value (the default the + // view-creation template emits since it cannot leave the statement optional); + // it is visible to everyone, including anonymous viewers, so short-circuit + // before the null-viewer / null-space guards below. + if (requiredVisibility.contains(KPXL_TERMS.EVERYONE_ROLE)) return true; + if (governingSpace == null) { + // A user page is a degenerate space: the owner is its sole admin and no + // other members or role assignments exist (observers may be added + // later). So the owner holds the admin tier and everyone else the + // everyone floor; only tier requirements can match here, and specific + // role IRIs β€” unholdable without a space β€” never do. + int tier = viewerIsOwner ? ADMIN_RANK : EVERYONE_RANK; + for (IRI req : requiredVisibility) { + if (isTier(req) && tier >= tierRank(req)) return true; + } + return false; + } + if (viewer == null) return false; + for (IRI req : requiredVisibility) { + if (isTier(req)) { + if (governingSpace.userTier(viewer) >= tierRank(req)) return true; + } else if (governingSpace.viewerHoldsRole(viewer, req)) { + return true; + } + } + return false; + } + + /** + * Convenience overload that resolves the current viewer, the governing space, + * and ownership from a resource-with-profile, then evaluates the + * {@code gen:isVisibleTo} restriction. The governing space is the resource + * itself if it is a space, otherwise its owning space (null for a user page). + * + * @param requiredVisibility the set of {@code gen:isVisibleTo} IRIs (may be empty) + * @param resource the resource the action is being rendered for, or null + * @return true if the current viewer is entitled + */ + public static boolean isViewerEntitled(Set requiredVisibility, AbstractResourceWithProfile resource) { + if (requiredVisibility == null || requiredVisibility.isEmpty()) return true; + Space governingSpace = (resource instanceof Space s) ? s : (resource != null ? resource.getSpace() : null); + IRI viewer = NanodashSession.getCurrentUserIriOrNull(); + boolean viewerIsOwner = viewer != null && resource instanceof IndividualAgent ia && ia.isCurrentUser(); + return isViewerEntitled(requiredVisibility, viewer, governingSpace, viewerIsOwner); + } + /** * Add the role parameters to the given multimap. * @@ -137,7 +285,7 @@ public void addRoleParams(Multimap params) { /** * The predefined admin role. */ - public static final SpaceMemberRole ADMIN_ROLE = new SpaceMemberRole(ADMIN_ROLE_IRI, "Admin role", "admin", "Admins", TemplateData.get().getTemplate(ADMIN_ROLE_ASSIGNMENT_TEMPLATE_ID), new IRI[]{}, new IRI[]{KPXL_TERMS.HAS_ADMIN_PREDICATE}); + public static final SpaceMemberRole ADMIN_ROLE = new SpaceMemberRole(ADMIN_ROLE_IRI, "Admin role", "admin", "Admins", TemplateData.get().getTemplate(ADMIN_ROLE_ASSIGNMENT_TEMPLATE_ID), new IRI[]{}, new IRI[]{KPXL_TERMS.HAS_ADMIN_PREDICATE}, KPXL_TERMS.ADMIN_ROLE_TYPE); /** * Convert a space-separated string of IRIs to an array of IRI objects. diff --git a/src/main/java/com/knowledgepixels/nanodash/View.java b/src/main/java/com/knowledgepixels/nanodash/View.java index a2ba5e7e9..a07e60b08 100644 --- a/src/main/java/com/knowledgepixels/nanodash/View.java +++ b/src/main/java/com/knowledgepixels/nanodash/View.java @@ -58,34 +58,53 @@ public class View implements Serializable { .build(); /** - * Get a View by its ID. + * Get a View by its ID, resolving to the latest version (following the + * supersedes chain). * * @param id the ID of the View * @return the View object */ public static View get(String id) { + return get(id, true); + } + + /** + * Get a View by its ID. + * + * @param id the ID of the View + * @param resolveLatest if true, follow the supersedes chain to load the latest + * version of the view; if false, load exactly the given + * version without a latest-version lookup. Pass false when + * the caller already holds a latest-resolved IRI (e.g. from + * the get-view-displays query, which now resolves it + * server-side) to avoid a redundant network round-trip. + * @return the View object + */ + public static View get(String id, boolean resolveLatest) { String npId = id.replaceFirst("^(.*[^A-Za-z0-9-_]RA[A-Za-z0-9-_]{43})[^A-Za-z0-9-_].*$", "$1"); - // Automatically selecting latest version of view definition: - // TODO This should be made configurable at some point, so one can make it a fixed version. - try { - String latestNpId = QueryApiAccess.getLatestVersionId(npId); - if (!latestNpId.equals(npId)) { - Nanopub np = Utils.getAsNanopub(latestNpId); - if (np != null) { - Set embeddedIris = NanopubUtils.getEmbeddedIriIds(np); - if (embeddedIris.size() == 1) { - String latestId = embeddedIris.iterator().next(); - View cached = views.getIfPresent(latestId); - if (cached == null) { - cached = new View(latestId, np); - views.put(latestId, cached); + if (resolveLatest) { + // Automatically selecting latest version of view definition: + // TODO This should be made configurable at some point, so one can make it a fixed version. + try { + String latestNpId = QueryApiAccess.getLatestVersionId(npId); + if (!latestNpId.equals(npId)) { + Nanopub np = Utils.getAsNanopub(latestNpId); + if (np != null) { + Set embeddedIris = NanopubUtils.getEmbeddedIriIds(np); + if (embeddedIris.size() == 1) { + String latestId = embeddedIris.iterator().next(); + View cached = views.getIfPresent(latestId); + if (cached == null) { + cached = new View(latestId, np); + views.put(latestId, cached); + } + return cached; } - return cached; } } + } catch (Exception ex) { + logger.error("Error resolving latest version for view: {}", id, ex); } - } catch (Exception ex) { - logger.error("Error resolving latest version for view: {}", id, ex); } // Fall back to loading the nanopub as given: Nanopub np = Utils.getAsNanopub(npId); @@ -122,6 +141,7 @@ public static View get(String id) { private Map actionTemplateQueryMappingMap = new HashMap<>(); private Map labelMap = new HashMap<>(); private IRI viewType; + private Map> actionVisibleToMap = new HashMap<>(); private View(String id, Nanopub nanopub) { this.id = id; @@ -171,11 +191,16 @@ private View(String id, Nanopub nanopub) { Template template = TemplateData.get().getTemplate(st.getObject().stringValue()); actionTemplateMap.put((IRI) st.getSubject(), template); } else if (st.getPredicate().equals(KPXL_TERMS.HAS_ACTION_TEMPLATE_TARGET_FIELD)) { - actionTemplateTargetFieldMap.put((IRI) st.getSubject(), st.getObject().stringValue()); + putUnlessVoid(actionTemplateTargetFieldMap, (IRI) st.getSubject(), st.getObject().stringValue()); } else if (st.getPredicate().equals(KPXL_TERMS.HAS_ACTION_TEMPLATE_PART_FIELD)) { - actionTemplatePartFieldMap.put((IRI) st.getSubject(), st.getObject().stringValue()); + putUnlessVoid(actionTemplatePartFieldMap, (IRI) st.getSubject(), st.getObject().stringValue()); } else if (st.getPredicate().equals(KPXL_TERMS.HAS_ACTION_TEMPLATE_QUERY_MAPPING)) { - actionTemplateQueryMappingMap.put((IRI) st.getSubject(), st.getObject().stringValue()); + putUnlessVoid(actionTemplateQueryMappingMap, (IRI) st.getSubject(), st.getObject().stringValue()); + } else if (st.getPredicate().equals(KPXL_TERMS.IS_VISIBLE_TO) && st.getObject() instanceof IRI objIri) { + // Per-action visibility: gen:isVisibleTo on an action node restricts + // that action button to viewers holding the given role tier or + // specific role. See docs/role-specific-views.md. + actionVisibleToMap.computeIfAbsent((IRI) st.getSubject(), k -> new HashSet<>()).add(objIri); } else if (st.getPredicate().equals(RDFS.LABEL)) { labelMap.put((IRI) st.getSubject(), st.getObject().stringValue()); } else if (st.getPredicate().equals(RDF.TYPE)) { @@ -195,6 +220,20 @@ private View(String id, Nanopub nanopub) { if (query == null) throw new IllegalArgumentException("Query not found: " + id); } + /** + * Stores an action-field value unless it is the {@code "void"} sentinel. + * View-creation templates can't leave a statement optional inside a repeated + * action group, so views carry every action field, with {@code "void"} for the + * not-applicable ones (its presence is what lets Nanodash repopulate the action + * group when superseding a view). It is treated here as absent β€” so e.g. a + * "void" part field never becomes a bogus {@code param_void}. + */ + private static void putUnlessVoid(Map map, IRI key, String value) { + if (value != null && !value.equals("void")) { + map.put(key, value); + } + } + /** * Gets the ID of the View. * @@ -270,6 +309,19 @@ public String getStructuralPosition() { return structuralPosition; } + /** + * Gets the visibility restriction declared on a given action node via + * {@code gen:isVisibleTo}: the set of role-tier or specific-role IRIs a viewer + * must hold for that action button to be shown. An empty set means the action + * is visible to everyone (subject to the existing button-list routing). + * + * @param actionIri the action IRI (a result or entry action of this view) + * @return the set of {@code gen:isVisibleTo} IRIs for that action (never null) + */ + public Set getActionVisibleTo(IRI actionIri) { + return actionVisibleToMap.getOrDefault(actionIri, Collections.emptySet()); + } + /** * Gets the list of action IRIs associated with the View. * diff --git a/src/main/java/com/knowledgepixels/nanodash/ViewDisplay.java b/src/main/java/com/knowledgepixels/nanodash/ViewDisplay.java index 4b74dff92..3a8d1253e 100644 --- a/src/main/java/com/knowledgepixels/nanodash/ViewDisplay.java +++ b/src/main/java/com/knowledgepixels/nanodash/ViewDisplay.java @@ -7,7 +7,6 @@ import org.eclipse.rdf4j.model.vocabulary.DCTERMS; import org.eclipse.rdf4j.model.vocabulary.RDF; import org.nanopub.Nanopub; -import org.nanopub.NanopubUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -54,32 +53,84 @@ public ViewDisplay(View view) { * @return the View object */ public static ViewDisplay get(String id) throws IllegalArgumentException { - // Try to resolve to the latest version (same pattern as View.get()): - try { - String npId = id.replaceFirst("^(.*[^A-Za-z0-9-_]RA[A-Za-z0-9-_]{43})[^A-Za-z0-9-_].*$", "$1"); - String latestNpId = QueryApiAccess.getLatestVersionId(npId); - if (!latestNpId.equals(npId)) { - Nanopub np = Utils.getAsNanopub(latestNpId); - if (np != null) { - Set embeddedIris = NanopubUtils.getEmbeddedIriIds(np); - if (embeddedIris.size() == 1) { - return new ViewDisplay(embeddedIris.iterator().next(), np); - } - } - } - } catch (Exception ex) { - logger.error("Error resolving latest version for view display: {}", id, ex); - } - // Fall back to loading the nanopub as given: + return get(id, null); + } + + /** + * Get a ViewDisplay by its ID, using a pre-resolved latest view IRI. + * + *

The display nanopub is loaded directly, without a per-display latest-version + * lookup: the get-view-displays query already returns only current display + * nanopubs (its {@code npx:invalidates} filter excludes superseded ones, since + * superseding a display also invalidates it under the same signing key). The + * displayed view is likewise loaded directly from {@code latestViewIri}, which the + * query resolved to its latest version server-side. The result: no one-by-one + * latest-version network round-trips when building the view displays for a page.

+ * + * @param id the ID of the ViewDisplay (a current display nanopub) + * @param latestViewIri the already latest-resolved IRI of the displayed view (from + * the get-view-displays query), or null to resolve the view's + * latest version separately + * @return the ViewDisplay object + */ + public static ViewDisplay get(String id, String latestViewIri) throws IllegalArgumentException { try { Nanopub np = Utils.getAsNanopub(id.replaceFirst("^(.*[^A-Za-z0-9-_])?(RA[A-Za-z0-9-_]{43})[^A-Za-z0-9-_].*$", "$2")); - return new ViewDisplay(id, np); + return new ViewDisplay(id, np, latestViewIri); } catch (Exception ex) { logger.error("Couldn't load nanopub for resource: {}", id, ex); throw new IllegalArgumentException("invalid view value " + id); } } + /** + * Builds a view display derived from a preset assignment (issue #302). + * + *

Used for the preset-derived rows emitted by the {@code get-view-displays} + * query: instead of a standalone view-display nanopub, the row carries the + * resolved view IRI plus the assignment's activation state. The resulting + * object behaves like a top-level view display for {@code resourceId}, so it + * flows through the same latest-wins / deactivation aggregation in + * {@link com.knowledgepixels.nanodash.domain.AbstractResourceWithProfile} as + * standalone displays.

+ * + * @param resourceId the resource the preset is assigned to + * @param viewIri the resolved view IRI (a {@code gen:hasView} / + * {@code gen:hasTopLevelView} target of the preset) + * @param topLevel whether this came from {@code gen:hasTopLevelView} (shown + * at the top level of the resource page) vs. {@code gen:hasView} + * (shown at the part level, for parts matching the view's own + * class/namespace targeting) + * @param deactivated whether the underlying preset assignment is deactivated + * @return the ViewDisplay, or null if the view could not be resolved + */ + public static ViewDisplay forPresetView(String resourceId, String viewIri, boolean topLevel, boolean deactivated) { + // viewIri is already latest-resolved by the get-view-displays query, so load + // it directly without a separate latest-version lookup. + View view = View.get(viewIri, false); + if (view == null) { + logger.error("Couldn't resolve preset view: {}", viewIri); + return null; + } + return new ViewDisplay(resourceId, view, topLevel, deactivated); + } + + private ViewDisplay(String resourceId, View view, boolean topLevel, boolean deactivated) { + this.id = null; + this.nanopub = view.getNanopub(); + this.view = view; + if (topLevel) { + // gen:hasTopLevelView: pin to the resource's own page (top level). + this.appliesTo.add(resourceId); + } + // else gen:hasView: leave appliesTo empty so applicability falls back to the + // view's own class/namespace targeting -> shown at the part level for matching + // parts, not at the resource's top level. + if (deactivated) { + this.types.add(KPXL_TERMS.DEACTIVATED_VIEW_DISPLAY); + } + } + /** * Constructor for ViewDisplay. * @@ -87,6 +138,19 @@ public static ViewDisplay get(String id) throws IllegalArgumentException { * @param nanopub the Nanopub containing the data for this ViewDisplay */ private ViewDisplay(String id, Nanopub nanopub) { + this(id, nanopub, null); + } + + /** + * Constructor for ViewDisplay. + * + * @param id the ID of the ViewDisplay + * @param nanopub the Nanopub containing the data for this ViewDisplay + * @param latestViewIri the already latest-resolved IRI of the displayed view, or + * null to resolve the view's latest version separately. When + * given, the view is loaded directly (no extra round-trip). + */ + private ViewDisplay(String id, Nanopub nanopub, String latestViewIri) { this.id = id; this.nanopub = nanopub; @@ -107,7 +171,9 @@ private ViewDisplay(String id, Nanopub nanopub) { throw new IllegalArgumentException("View already set: " + objIri); } viewIri = objIri; - view = View.get(objIri.stringValue()); + view = (latestViewIri != null && !latestViewIri.isEmpty()) + ? View.get(latestViewIri, false) + : View.get(objIri.stringValue()); } else if (st.getPredicate().equals(KPXL_TERMS.IS_DISPLAY_FOR) && st.getObject() instanceof IRI objIri) { if (resource != null) { throw new IllegalArgumentException("Resource already set: " + objIri); diff --git a/src/main/java/com/knowledgepixels/nanodash/component/AboutResourcePanel.html b/src/main/java/com/knowledgepixels/nanodash/component/AboutResourcePanel.html new file mode 100644 index 000000000..809716dfa --- /dev/null +++ b/src/main/java/com/knowledgepixels/nanodash/component/AboutResourcePanel.html @@ -0,0 +1,8 @@ + +
+
+
+
+
+
+
diff --git a/src/main/java/com/knowledgepixels/nanodash/component/AboutResourcePanel.java b/src/main/java/com/knowledgepixels/nanodash/component/AboutResourcePanel.java new file mode 100644 index 000000000..0690d3c44 --- /dev/null +++ b/src/main/java/com/knowledgepixels/nanodash/component/AboutResourcePanel.java @@ -0,0 +1,30 @@ +package com.knowledgepixels.nanodash.component; + +import com.knowledgepixels.nanodash.View; +import com.knowledgepixels.nanodash.ViewDisplay; +import com.knowledgepixels.nanodash.domain.MaintainedResource; +import org.apache.wicket.markup.html.panel.Panel; +import org.nanopub.extra.services.QueryRef; + +/** + * The "About" tab body for a maintained resource: its assigned presets and the + * listing of its configured view displays (issue #302). Resource-level + * members/roles are intentionally not shown (roles live on the parent space). + */ +public class AboutResourcePanel extends Panel { + + /** + * @param id the Wicket markup id + * @param resource the maintained resource whose About listings to render + */ + public AboutResourcePanel(String id, MaintainedResource resource) { + super(id); + + View presetsView = View.get(AboutSpacePanel.PRESET_ASSIGNMENTS_VIEW); + add(QueryResultTableBuilder.create("presets", new QueryRef(presetsView.getQuery().getQueryId(), "resource", resource.getId()), new ViewDisplay(presetsView)).resourceWithProfile(resource).id(resource.getId()).contextId(resource.getId()).build()); + + View vdView = View.get(AboutSpacePanel.VIEW_DISPLAYS_VIEW); + add(QueryResultTableBuilder.create("viewdisplays", new QueryRef(vdView.getQuery().getQueryId(), "resource", resource.getId()), new ViewDisplay(vdView)).resourceWithProfile(resource).id(resource.getId()).contextId(resource.getId()).build()); + } + +} diff --git a/src/main/java/com/knowledgepixels/nanodash/component/AboutSpacePanel.html b/src/main/java/com/knowledgepixels/nanodash/component/AboutSpacePanel.html new file mode 100644 index 000000000..a2619d777 --- /dev/null +++ b/src/main/java/com/knowledgepixels/nanodash/component/AboutSpacePanel.html @@ -0,0 +1,11 @@ + +
+
+
+
+
+
+
+
+
+
diff --git a/src/main/java/com/knowledgepixels/nanodash/component/AboutSpacePanel.java b/src/main/java/com/knowledgepixels/nanodash/component/AboutSpacePanel.java new file mode 100644 index 000000000..72e05d16e --- /dev/null +++ b/src/main/java/com/knowledgepixels/nanodash/component/AboutSpacePanel.java @@ -0,0 +1,51 @@ +package com.knowledgepixels.nanodash.component; + +import com.knowledgepixels.nanodash.View; +import com.knowledgepixels.nanodash.ViewDisplay; +import com.knowledgepixels.nanodash.domain.Space; +import org.apache.wicket.markup.html.panel.Panel; +import org.nanopub.extra.services.QueryRef; + +/** + * The "About" tab body for a space: its assigned roles, assigned presets, and + * the listing of its configured view displays (issue #302). Rendered as views + * (query result tables) rather than the live view content. + */ +public class AboutSpacePanel extends Panel { + + /** + * View that lists all assigned view displays of a resource (built on the + * get-view-displays query Nanodash uses internally). Shown on About tabs + * instead of rendering the assigned views themselves. + */ + public static final String VIEW_DISPLAYS_VIEW = "https://w3id.org/np/RAVVUjFMWIylf0Bz0n5-NdFG4_T0d6TWQvRYC23IZscYo/view-displays-view"; + + /** + * View listing the presets assigned to a resource (issue #302). + */ + public static final String PRESET_ASSIGNMENTS_VIEW = "https://w3id.org/np/RAFRpDY_Tw7PYCGQ0t8UYLvM-EPIj-n4BpwwUDUjdj2I4/preset-assignments-view"; + + /** + * View listing a space's assigned roles, built on the existing + * get-space-roles query. + */ + public static final String SPACE_ROLES_VIEW = "https://w3id.org/np/RAsH9ItKDb5sRdMul-dTT-Dqb7u4u80RmeYUndZKyjGZ8/space-roles-view"; + + /** + * @param id the Wicket markup id + * @param space the space whose About listings to render + */ + public AboutSpacePanel(String id, Space space) { + super(id); + + View rolesView = View.get(SPACE_ROLES_VIEW); + add(QueryResultTableBuilder.create("roles", new QueryRef(rolesView.getQuery().getQueryId(), "space", space.getId()), new ViewDisplay(rolesView)).build()); + + View presetsView = View.get(PRESET_ASSIGNMENTS_VIEW); + add(QueryResultTableBuilder.create("presets", new QueryRef(presetsView.getQuery().getQueryId(), "resource", space.getId()), new ViewDisplay(presetsView)).resourceWithProfile(space).id(space.getId()).contextId(space.getId()).build()); + + View vdView = View.get(VIEW_DISPLAYS_VIEW); + add(QueryResultTableBuilder.create("viewdisplays", new QueryRef(vdView.getQuery().getQueryId(), "resource", space.getId()), new ViewDisplay(vdView)).resourceWithProfile(space).id(space.getId()).contextId(space.getId()).build()); + } + +} diff --git a/src/main/java/com/knowledgepixels/nanodash/component/AboutUserPanel.html b/src/main/java/com/knowledgepixels/nanodash/component/AboutUserPanel.html new file mode 100644 index 000000000..d94e21589 --- /dev/null +++ b/src/main/java/com/knowledgepixels/nanodash/component/AboutUserPanel.html @@ -0,0 +1,15 @@ + +
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/main/java/com/knowledgepixels/nanodash/component/AboutUserPanel.java b/src/main/java/com/knowledgepixels/nanodash/component/AboutUserPanel.java new file mode 100644 index 000000000..bda0afef7 --- /dev/null +++ b/src/main/java/com/knowledgepixels/nanodash/component/AboutUserPanel.java @@ -0,0 +1,81 @@ +package com.knowledgepixels.nanodash.component; + +import com.knowledgepixels.nanodash.NanodashSession; +import com.knowledgepixels.nanodash.View; +import com.knowledgepixels.nanodash.ViewDisplay; +import com.knowledgepixels.nanodash.domain.IndividualAgent; +import org.apache.wicket.behavior.AttributeAppender; +import org.apache.wicket.markup.html.panel.EmptyPanel; +import org.apache.wicket.markup.html.panel.Panel; +import org.nanopub.extra.services.QueryRef; + +/** + * The "About" tab body for a user: their introduction nanopublications, a public + * read-only view of their profile (default license, profile picture), assigned + * presets, and the listing of their configured view displays (issue #302). + */ +public class AboutUserPanel extends Panel { + + /** + * Read-only view that lists a user's introduction nanopublications (shown to + * other users; the current user gets the editable {@link ProfileIntroItem} + * companion instead). + */ + public static final String INTRODUCTIONS_VIEW = "https://w3id.org/np/RAElH_0Za_T9H_GeyixS35lGwOAL_OD3r4XYs__BF6tl4/introductions-view"; + + /** + * View showing a user's basic profile properties (default license and + * profile picture), one per row. + */ + public static final String PROFILE_VIEW = "https://w3id.org/np/RAtTP_qhEqsz2V8YoR6MfZ_j7gwcJ9SE2WvzjXLiagb9Q/profile-view"; + + /** + * @param id the Wicket markup id + * @param userIriString the user IRI + */ + public AboutUserPanel(String id, String userIriString) { + super(id); + + // Account/identity controls (logout, local-mode ORCID form) only on the + // current user's own About page. + NanodashSession session = NanodashSession.get(); + boolean ownPage = session.getUserIri() != null && session.getUserIri().stringValue().equals(userIriString); + if (ownPage) { + add(new ProfileAccountPanel("account", userIriString)); + } else { + add(new EmptyPanel("account").setVisible(false)); + } + + // Introductions: the current user (with a local key) gets the editable + // companion with the full intro workflow; everyone else gets the + // read-only view. The companion is styled to match the view tables. + if (ownPage && session.getKeyPair() != null) { + ProfileIntroItem introItem = new ProfileIntroItem("introductions"); + introItem.add(new AttributeAppender("class", " col-12")); + add(introItem); + } else { + View introView = View.get(INTRODUCTIONS_VIEW); + add(QueryResultTableBuilder.create("introductions", new QueryRef(introView.getQuery().getQueryId(), "user", userIriString), new ViewDisplay(introView)) + .resourceWithProfile(IndividualAgent.get(userIriString)) + .id(userIriString) + .contextId(userIriString) + .build()); + } + + // Profile view with result actions to update the profile image/license; + // needs the resourceWithProfile/id/contextId for the action links. + View profileView = View.get(PROFILE_VIEW); + add(QueryResultTableBuilder.create("profile", new QueryRef(profileView.getQuery().getQueryId(), "user", userIriString), new ViewDisplay(profileView)) + .resourceWithProfile(IndividualAgent.get(userIriString)) + .id(userIriString) + .contextId(userIriString) + .build()); + + View presetsView = View.get(AboutSpacePanel.PRESET_ASSIGNMENTS_VIEW); + add(QueryResultTableBuilder.create("presets", new QueryRef(presetsView.getQuery().getQueryId(), "resource", userIriString), new ViewDisplay(presetsView)).resourceWithProfile(IndividualAgent.get(userIriString)).id(userIriString).contextId(userIriString).build()); + + View vdView = View.get(AboutSpacePanel.VIEW_DISPLAYS_VIEW); + add(QueryResultTableBuilder.create("viewdisplays", new QueryRef(vdView.getQuery().getQueryId(), "resource", userIriString), new ViewDisplay(vdView)).resourceWithProfile(IndividualAgent.get(userIriString)).id(userIriString).contextId(userIriString).build()); + } + +} diff --git a/src/main/java/com/knowledgepixels/nanodash/component/DownloadRdfLinks.html b/src/main/java/com/knowledgepixels/nanodash/component/DownloadRdfLinks.html index 6896b5708..36b2908ec 100644 --- a/src/main/java/com/knowledgepixels/nanodash/component/DownloadRdfLinks.html +++ b/src/main/java/com/knowledgepixels/nanodash/component/DownloadRdfLinks.html @@ -1,17 +1,20 @@ -
- Raw page content -

Full nanopublications: - TriG(txt), - JSON-LD(txt), - N-Quads(txt), - TriX(txt) -

-

Assertions only: - Turtle(txt), - JSON-LD(txt), - N-Triples(txt), - RDF/XML(txt) -

-
+
+
+

Full nanopublications

+ +

Assertions only

+ +
+
diff --git a/src/main/java/com/knowledgepixels/nanodash/component/DownloadRdfLinks.java b/src/main/java/com/knowledgepixels/nanodash/component/DownloadRdfLinks.java index 2de74a549..aaf40260e 100644 --- a/src/main/java/com/knowledgepixels/nanodash/component/DownloadRdfLinks.java +++ b/src/main/java/com/knowledgepixels/nanodash/component/DownloadRdfLinks.java @@ -6,9 +6,11 @@ import org.apache.wicket.request.mapper.parameter.PageParameters; /** - * A reusable panel that renders "Raw page content" download links for RDF formats, - * used on user/space/resource pages to download all nanopubs on the page. - * Each format has a native-type link and a text/plain link (txt) that always displays in the browser. + * A reusable panel that renders the raw page content as RDF download links, + * grouped into "Full nanopublications" and "Assertions only". Used on the Raw + * tab page (and a few list pages) to download all nanopubs on the page. Each + * format has a native-type link and a text/plain link (txt) that always displays + * in the browser. */ public class DownloadRdfLinks extends Panel { diff --git a/src/main/java/com/knowledgepixels/nanodash/component/ExplorePanel.html b/src/main/java/com/knowledgepixels/nanodash/component/ExplorePanel.html new file mode 100644 index 000000000..3584c334d --- /dev/null +++ b/src/main/java/com/knowledgepixels/nanodash/component/ExplorePanel.html @@ -0,0 +1,11 @@ + +
+
+
+
+
+
+
+
+
+
diff --git a/src/main/java/com/knowledgepixels/nanodash/component/ExplorePanel.java b/src/main/java/com/knowledgepixels/nanodash/component/ExplorePanel.java new file mode 100644 index 000000000..e291d9835 --- /dev/null +++ b/src/main/java/com/knowledgepixels/nanodash/component/ExplorePanel.java @@ -0,0 +1,46 @@ +package com.knowledgepixels.nanodash.component; + +import com.knowledgepixels.nanodash.View; +import com.knowledgepixels.nanodash.ViewDisplay; +import com.knowledgepixels.nanodash.page.ReferencesPage; +import org.apache.wicket.markup.html.panel.Panel; +import org.nanopub.extra.services.QueryRef; + +/** + * The "Explore" tab body for a resource: the generic exploration panels (RDF + * types/classes, where the thing is described, instances, templates) plus the + * references to the thing. This is the inline equivalent of what {@link + * com.knowledgepixels.nanodash.page.ExplorePage} shows for an arbitrary term; + * the standalone page forwards here for known spaces/users/resources/parts. + */ +public class ExplorePanel extends Panel { + + private static final String DESCRIBED_IN_VIEW = "https://w3id.org/np/RAMH_7qMY-jmgXr2jqqk5F_XW7t2k2n3NCB6LtoKEXDzY/described-in-view"; + private static final String CLASSES_VIEW = "https://w3id.org/np/RAHPtR1VriEW09tcvZhrM8Dr3vE1JnMWWi9-ajKJWNOJs/classes-view"; + private static final String INSTANCES_VIEW = "https://w3id.org/np/RABXfsNoT_RYlk8LpDmKfJ2poSlvIGk3jgq4DkR4YLAps/instances-view"; + private static final String TEMPLATES_VIEW = "https://w3id.org/np/RAP0-S9PUUVF1rQiqo8vq8z6XWsXkeGBUo60DJf8JsXsc/templates-view"; + + /** + * @param id the Wicket markup id + * @param ref the IRI of the thing to explore + */ + public ExplorePanel(String id, String ref) { + super(id); + + View classesView = View.get(CLASSES_VIEW); + add(QueryResultListBuilder.create("classes-panel", new QueryRef(classesView.getQuery().getQueryId(), "thing", ref), new ViewDisplay(classesView)).build()); + + View describedInView = View.get(DESCRIBED_IN_VIEW); + add(QueryResultNanopubSetBuilder.create("definitions-panel", new QueryRef(describedInView.getQuery().getQueryId(), "term", ref), new ViewDisplay(describedInView)).build()); + + View instancesView = View.get(INSTANCES_VIEW); + add(QueryResultListBuilder.create("instances-panel", new QueryRef(instancesView.getQuery().getQueryId(), "class", ref), new ViewDisplay(instancesView)).build()); + + View templatesView = View.get(TEMPLATES_VIEW); + add(QueryResultListBuilder.create("templates-panel", new QueryRef(templatesView.getQuery().getQueryId(), "thing", ref), new ViewDisplay(templatesView)).build()); + + View refView = View.get(ReferencesPage.REFERENCES_VIEW); + add(QueryResultTableBuilder.create("references", new QueryRef(refView.getQuery().getQueryId(), "ref", ref), new ViewDisplay(refView)).build()); + } + +} diff --git a/src/main/java/com/knowledgepixels/nanodash/component/ExternalLinkWithActionsPanel.java b/src/main/java/com/knowledgepixels/nanodash/component/ExternalLinkWithActionsPanel.java index 58c769e4b..40e809425 100644 --- a/src/main/java/com/knowledgepixels/nanodash/component/ExternalLinkWithActionsPanel.java +++ b/src/main/java/com/knowledgepixels/nanodash/component/ExternalLinkWithActionsPanel.java @@ -1,8 +1,6 @@ package com.knowledgepixels.nanodash.component; import com.knowledgepixels.nanodash.component.menu.BaseDisplayMenu; -import com.knowledgepixels.nanodash.component.menu.ExploreDisplayMenu; -import com.knowledgepixels.nanodash.page.ExplorePage; import org.apache.wicket.ajax.AjaxRequestTarget; import org.apache.wicket.ajax.markup.html.AjaxLink; import org.apache.wicket.markup.html.basic.Label; @@ -10,7 +8,6 @@ import org.apache.wicket.markup.html.link.ExternalLink; import org.apache.wicket.markup.html.panel.Panel; import org.apache.wicket.model.IModel; -import org.apache.wicket.request.mapper.parameter.PageParameters; import org.apache.wicket.request.resource.ContextRelativeResourceReference; import org.eclipse.rdf4j.model.IRI; @@ -66,25 +63,14 @@ public void onClick(AjaxRequestTarget target) { copyLinkButton.add(new Image("copyIcon", new ContextRelativeResourceReference("images/copy-icon.svg", false))); add(copyLinkButton); + // The "explore" action is now reachable via the resource's Explore tab, + // so this panel no longer shows an explore button or an explore menu. + // A custom menu (e.g. the space admin actions) is still shown when given. + add(new Label("exploreButton", "").setVisible(false)); if (customMenu != null) { - add(new Label("exploreButton", "").setVisible(false)); add(customMenu); - } else if (sourceNanopub != null) { - add(new Label("exploreButton", "").setVisible(false)); - add(new ExploreDisplayMenu("np", urlModel.getObject(), labelModel.getObject(), sourceNanopub)); } else { add(new Label("np", "").setVisible(false)); - if (labelModel != null) { - AjaxLink exploreButton = new AjaxLink<>("exploreButton") { - @Override - public void onClick(AjaxRequestTarget target) { - setResponsePage(ExplorePage.class, new PageParameters().set("id", urlModel.getObject()).set("label", labelModel.getObject())); - } - }; - add(exploreButton); - } else { - add(new Label("exploreButton", "").setVisible(false)); - } } } diff --git a/src/main/java/com/knowledgepixels/nanodash/component/ProfileAccountPanel.html b/src/main/java/com/knowledgepixels/nanodash/component/ProfileAccountPanel.html new file mode 100644 index 000000000..2fd055fca --- /dev/null +++ b/src/main/java/com/knowledgepixels/nanodash/component/ProfileAccountPanel.html @@ -0,0 +1,14 @@ + +
+
+

Account

+

logout

+
+ https://orcid.org/ + +
+
+
+
+
+
diff --git a/src/main/java/com/knowledgepixels/nanodash/component/ProfileAccountPanel.java b/src/main/java/com/knowledgepixels/nanodash/component/ProfileAccountPanel.java new file mode 100644 index 000000000..1b3c02941 --- /dev/null +++ b/src/main/java/com/knowledgepixels/nanodash/component/ProfileAccountPanel.java @@ -0,0 +1,77 @@ +package com.knowledgepixels.nanodash.component; + +import com.knowledgepixels.nanodash.NanodashPreferences; +import com.knowledgepixels.nanodash.NanodashSession; +import com.knowledgepixels.nanodash.page.ProfilePage; +import com.knowledgepixels.nanodash.page.UserPage; +import org.apache.wicket.RestartResponseException; +import org.apache.wicket.markup.html.form.Form; +import org.apache.wicket.markup.html.form.TextField; +import org.apache.wicket.markup.html.link.Link; +import org.apache.wicket.markup.html.panel.FeedbackPanel; +import org.apache.wicket.markup.html.panel.Panel; +import org.apache.wicket.model.Model; +import org.apache.wicket.request.mapper.parameter.PageParameters; +import org.apache.wicket.validation.validator.PatternValidator; + +/** + * Account/identity controls for the current user's own About tab: a logout + * button, and β€” only in local mode (running without ORCID authentication) β€” a + * form to set or change the ORCID identifier. There is deliberately no login + * button here (that belongs to the logged-out state); in ORCID-login mode the + * identifier comes from authentication and the form is hidden. + */ +public class ProfileAccountPanel extends Panel { + + /** + * @param id the Wicket markup id + * @param userIriString the IRI of the user whose (own) About page this is + */ + public ProfileAccountPanel(String id, String userIriString) { + super(id); + final NanodashSession session = NanodashSession.get(); + session.loadProfileInfo(); + final boolean loginMode = NanodashPreferences.get().isOrcidLoginMode(); + + // Logout only makes sense with an ORCID-login session; in local mode + // there is no login to end. + Link logout = new Link("logout") { + @Override + public void onClick() { + session.logout(); + throw new RestartResponseException(UserPage.class, new PageParameters().set("id", userIriString)); + } + }; + logout.setVisible(loginMode); + add(logout); + + Model model = Model.of(""); + if (session.getUserIri() != null) { + model.setObject(session.getUserIri().stringValue().replaceFirst("^https://orcid.org/", "")); + } + final TextField orcidField = new TextField<>("orcidfield", model); + orcidField.add(new PatternValidator(ProfilePage.ORCID_PATTERN)); + Form form = new Form("form") { + @Override + protected void onSubmit() { + if (loginMode) return; + session.setOrcid(orcidField.getModelObject()); + String newUserIri = "https://orcid.org/" + orcidField.getModelObject(); + session.invalidateNow(); + throw new RestartResponseException(UserPage.class, + new PageParameters().set("id", newUserIri).set("tab", "about")); + } + }; + form.add(orcidField); + // Setting/changing the ORCID is only available in local mode (no ORCID + // authentication); in ORCID-login mode the identifier comes from auth. + form.setVisible(!loginMode); + add(form); + + add(new FeedbackPanel("feedback")); + + // Signing key: public key + (local mode) the local key-file path. + add(new ProfileSigItem("sigpart")); + } + +} diff --git a/src/main/java/com/knowledgepixels/nanodash/component/ProfileIntroItem.html b/src/main/java/com/knowledgepixels/nanodash/component/ProfileIntroItem.html index db4a55f0e..5a91fc5ab 100644 --- a/src/main/java/com/knowledgepixels/nanodash/component/ProfileIntroItem.html +++ b/src/main/java/com/knowledgepixels/nanodash/component/ProfileIntroItem.html @@ -8,41 +8,43 @@ -
-

Recommended Actions

-
+

Recommended Actions

+ +

  • -
  • Use 'derive new introduction' below to declare the given keys alongside the local key from this site.
  • -
  • Use 'include keys' below to declare the missing keys also in the introduction from this site.
  • -
  • Retract redundant introductions using 'retract' below.
  • +
  • Use 'derive new introduction' in the table below to declare the given keys alongside the local key from this site.
  • +
  • Use 'include keys' in the table below to declare the missing keys also in the introduction from this site.
  • +
  • You have multiple introductions from this site. Retract redundant introductions using 'retract' in the table below.
  • Go to the site where you created your approved introduction and include the local key from this site there.
  • Ask an approved user to approve your introduction:
  • Follow these guidelines to link your introduction from your ORCID account.
-
-

Introductions

-
- -

- -
    -
  • - -created on -at -declares keys: -  - - - -
  • -
+

πŸ‘‹ Introductions

+ + + + + + + + + + + + + + +
datelocationkeysnp
+ + + +
diff --git a/src/main/java/com/knowledgepixels/nanodash/component/ProfileIntroItem.java b/src/main/java/com/knowledgepixels/nanodash/component/ProfileIntroItem.java index bf4a88a0d..e0968ef22 100644 --- a/src/main/java/com/knowledgepixels/nanodash/component/ProfileIntroItem.java +++ b/src/main/java/com/knowledgepixels/nanodash/component/ProfileIntroItem.java @@ -129,7 +129,9 @@ public ProfileIntroItem(String id) { } else if (session.getLocalIntroCount() == 1) { add(new Label("intro-note", "")); } else { - add(new Label("intro-note", "You have multiple introductions from this site.").setEscapeModelStrings(false)); + // The "multiple introductions from this site" message is shown in the + // retract recommended-action bullet instead. + add(new Label("intro-note", "")); } if (recommendedActionsCount == 0) { add(new Label("action-note", "There are no recommended actions.").setEscapeModelStrings(false)); diff --git a/src/main/java/com/knowledgepixels/nanodash/component/QueryResultItemList.html b/src/main/java/com/knowledgepixels/nanodash/component/QueryResultItemList.html index 69312e748..13302530b 100644 --- a/src/main/java/com/knowledgepixels/nanodash/component/QueryResultItemList.html +++ b/src/main/java/com/knowledgepixels/nanodash/component/QueryResultItemList.html @@ -19,6 +19,7 @@

Query

+

(nothing found)

[content]

+

(nothing found)

diff --git a/src/main/java/com/knowledgepixels/nanodash/component/QueryResultPlainParagraph.java b/src/main/java/com/knowledgepixels/nanodash/component/QueryResultPlainParagraph.java index 78ebe9c1f..9cadc31e4 100644 --- a/src/main/java/com/knowledgepixels/nanodash/component/QueryResultPlainParagraph.java +++ b/src/main/java/com/knowledgepixels/nanodash/component/QueryResultPlainParagraph.java @@ -57,6 +57,7 @@ protected void onUpdate(AjaxRequestTarget target) { } } }); + filterField.setVisible(!fitsOnFirstPage()); add(filterField); populateComponent(); @@ -69,6 +70,13 @@ protected void populateComponent() { paragraphsContainer = new WebMarkupContainer("paragraphs-container"); paragraphsContainer.setOutputMarkupId(true); paragraphsContainer.add(buildParagraphsView()); + paragraphsContainer.add(new Label("no-records", "(nothing found)") { + @Override + protected void onConfigure() { + super.onConfigure(); + setVisible(filteredDataProvider.getFilteredData().isEmpty()); + } + }); add(paragraphsContainer); } diff --git a/src/main/java/com/knowledgepixels/nanodash/component/QueryResultPlainParagraphBuilder.java b/src/main/java/com/knowledgepixels/nanodash/component/QueryResultPlainParagraphBuilder.java index 19350c448..4244e102a 100644 --- a/src/main/java/com/knowledgepixels/nanodash/component/QueryResultPlainParagraphBuilder.java +++ b/src/main/java/com/knowledgepixels/nanodash/component/QueryResultPlainParagraphBuilder.java @@ -1,6 +1,7 @@ package com.knowledgepixels.nanodash.component; import com.knowledgepixels.nanodash.ApiCache; +import com.knowledgepixels.nanodash.SpaceMemberRole; import com.knowledgepixels.nanodash.View; import com.knowledgepixels.nanodash.ViewDisplay; import com.knowledgepixels.nanodash.domain.AbstractResourceWithProfile; @@ -37,6 +38,9 @@ private void addResultButtons(QueryResultPlainParagraph resultPlainParagraph) { View view = viewDisplay.getView(); if (view == null) return; for (IRI actionIri : view.getViewResultActionList()) { + // Per-action role gating (docs/role-specific-views.md): skip an action + // whose gen:isVisibleTo the viewer does not satisfy. + if (!SpaceMemberRole.isViewerEntitled(view.getActionVisibleTo(actionIri), pageResource)) continue; Template t = view.getTemplateForAction(actionIri); if (t == null) continue; String targetField = view.getTemplateTargetFieldForAction(actionIri); diff --git a/src/main/java/com/knowledgepixels/nanodash/component/QueryResultRdf.html b/src/main/java/com/knowledgepixels/nanodash/component/QueryResultRdf.html index 88dd5a8b0..0169f4b06 100644 --- a/src/main/java/com/knowledgepixels/nanodash/component/QueryResultRdf.html +++ b/src/main/java/com/knowledgepixels/nanodash/component/QueryResultRdf.html @@ -9,6 +9,7 @@
+

(nothing found)

diff --git a/src/main/java/com/knowledgepixels/nanodash/component/QueryResultRdf.java b/src/main/java/com/knowledgepixels/nanodash/component/QueryResultRdf.java index e82588915..d2c56d693 100644 --- a/src/main/java/com/knowledgepixels/nanodash/component/QueryResultRdf.java +++ b/src/main/java/com/knowledgepixels/nanodash/component/QueryResultRdf.java @@ -5,7 +5,6 @@ import org.apache.wicket.extensions.markup.html.repeater.data.table.AbstractColumn; import org.apache.wicket.extensions.markup.html.repeater.data.table.DataTable; import org.apache.wicket.extensions.markup.html.repeater.data.table.HeadersToolbar; -import org.apache.wicket.extensions.markup.html.repeater.data.table.NoRecordsToolbar; import org.apache.wicket.extensions.markup.html.repeater.data.sort.ISortState; import org.apache.wicket.extensions.markup.html.repeater.data.table.ISortableDataProvider; import org.apache.wicket.extensions.markup.html.repeater.util.SingleSortState; @@ -44,9 +43,11 @@ public QueryResultRdf(String id, org.eclipse.rdf4j.model.Model rdfModel) { DataTable table = new DataTable<>("table", columns, new TripleDataProvider(rows), 20); table.addBottomToolbar(new AjaxNavigationToolbar(table)); - table.addBottomToolbar(new NoRecordsToolbar(table)); table.addTopToolbar(new HeadersToolbar<>(table, null)); + // Hide the whole table (header included) when empty; show "(nothing found)" instead. + table.setVisible(!rows.isEmpty()); add(table); + add(new Label("no-records", "(nothing found)").setVisible(rows.isEmpty())); } private static class TripleDataProvider implements ISortableDataProvider { diff --git a/src/main/java/com/knowledgepixels/nanodash/component/QueryResultTable.html b/src/main/java/com/knowledgepixels/nanodash/component/QueryResultTable.html index 2d42beaf7..966762884 100644 --- a/src/main/java/com/knowledgepixels/nanodash/component/QueryResultTable.html +++ b/src/main/java/com/knowledgepixels/nanodash/component/QueryResultTable.html @@ -15,6 +15,7 @@

Query

+

(nothing found)

error messages

diff --git a/src/main/java/com/knowledgepixels/nanodash/component/QueryResultTable.java b/src/main/java/com/knowledgepixels/nanodash/component/QueryResultTable.java index 328ae7d41..dde31a03f 100644 --- a/src/main/java/com/knowledgepixels/nanodash/component/QueryResultTable.java +++ b/src/main/java/com/knowledgepixels/nanodash/component/QueryResultTable.java @@ -41,6 +41,7 @@ public class QueryResultTable extends QueryResult { private Model errorMessages = Model.of(""); private DataTable table; + private Label noRecordsLabel; private Label errorLabel; private FilteredQueryResultDataProvider filteredDataProvider; private Model filterModel = Model.of(""); @@ -72,9 +73,11 @@ protected void onUpdate(AjaxRequestTarget target) { if (filteredDataProvider != null && table != null) { filteredDataProvider.setFilterText(filterModel.getObject()); target.add(table); + if (noRecordsLabel != null) target.add(noRecordsLabel); } } }); + filterField.setVisible(!fitsOnFirstPage()); add(filterField); populateComponent(); @@ -97,6 +100,13 @@ protected void populateComponent() { List> columns = new ArrayList<>(); QueryResultDataProvider dataProvider; try { + // The last data column (ignoring _label helper columns); if it is the + // source-nanopub column ("np"/"nps") its header is left blank. + String lastColumnKey = null; + for (String h : response.getHeader()) { + if (h.endsWith("_label") || h.endsWith("_label_multi")) continue; + lastColumnKey = h; + } for (String h : response.getHeader()) { if (h.endsWith("_label") || h.endsWith("_label_multi")) { continue; @@ -111,34 +121,69 @@ protected void populateComponent() { } else if (displayLabel.endsWith("_iri")) { displayLabel = displayLabel.substring(0, displayLabel.length() - "_iri".length()); } - columns.add(new Column(displayLabel.replaceAll("_", " "), h)); + String columnHeader = displayLabel.replaceAll("_", " "); + if (h.equals(lastColumnKey) && (h.equals("np") || h.equals("nps"))) { + columnHeader = ""; + columns.add(new Column(columnHeader, h, "cell-right")); + } else { + columns.add(new Column(columnHeader, h)); + } } if (viewDisplay.getView() != null && !viewDisplay.getView().getViewEntryActionList().isEmpty()) { columns.add(new Column("", Column.ACTIONS)); } dataProvider = new QueryResultDataProvider(response.getData()); filteredDataProvider = new FilteredQueryResultDataProvider(dataProvider, response); - table = new DataTable<>("table", columns, filteredDataProvider, viewDisplay.getPageSize() < 1 ? Integer.MAX_VALUE : viewDisplay.getPageSize()); - table.setOutputMarkupId(true); + // The whole table (header included) is hidden when there is nothing to show; + // a "(nothing found)" note is shown instead. No NoRecordsToolbar, since that + // would leave the header row visible. + table = new DataTable<>("table", columns, filteredDataProvider, viewDisplay.getPageSize() < 1 ? Integer.MAX_VALUE : viewDisplay.getPageSize()) { + @Override + protected void onConfigure() { + super.onConfigure(); + setVisible(errorMessages.getObject().isEmpty() && filteredDataProvider.size() > 0); + } + }; + table.setOutputMarkupPlaceholderTag(true); table.addBottomToolbar(new AjaxNavigationToolbar(table)); - table.addBottomToolbar(new NoRecordsToolbar(table)); table.addTopToolbar(new AjaxFallbackHeadersToolbar(table, dataProvider)); add(table); + noRecordsLabel = new Label("no-records", "(nothing found)") { + @Override + protected void onConfigure() { + super.onConfigure(); + setVisible(errorMessages.getObject().isEmpty() && filteredDataProvider.size() == 0); + } + }; + noRecordsLabel.setOutputMarkupPlaceholderTag(true); + add(noRecordsLabel); } catch (Exception ex) { logger.error("Error creating table for query {}", grlcQuery.getQueryId(), ex); add(new Label("table", "").setVisible(false)); + add(new Label("no-records", "").setVisible(false)); addErrorMessage(ex.getMessage()); } } - private class Column extends AbstractColumn { + private class Column extends AbstractColumn implements IStyledColumn { private String key; + private String cssClass; public static final String ACTIONS = "*actions*"; public Column(String title, String key) { + this(title, key, null); + } + + public Column(String title, String key, String cssClass) { super(new Model(title), key); this.key = key; + this.cssClass = cssClass; + } + + @Override + public String getCssClass() { + return cssClass; } @Override @@ -148,6 +193,10 @@ public void populateItem(Item> cellItem, String if (key.equals(ACTIONS) && view != null) { List links = new ArrayList<>(); for (IRI actionIri : view.getViewEntryActionList()) { + // Per-action role gating (docs/role-specific-views.md): skip an + // action whose gen:isVisibleTo the viewer does not satisfy. + // Additive β€” actions without gen:isVisibleTo are unaffected. + if (!SpaceMemberRole.isViewerEntitled(view.getActionVisibleTo(actionIri), resourceWithProfile)) continue; // TODO Copied code and adjusted from QueryResultTableBuilder: Template t = view.getTemplateForAction(actionIri); if (t == null) continue; diff --git a/src/main/java/com/knowledgepixels/nanodash/component/QueryResultTableBuilder.java b/src/main/java/com/knowledgepixels/nanodash/component/QueryResultTableBuilder.java index 77beeb5f6..34b7cb412 100644 --- a/src/main/java/com/knowledgepixels/nanodash/component/QueryResultTableBuilder.java +++ b/src/main/java/com/knowledgepixels/nanodash/component/QueryResultTableBuilder.java @@ -1,6 +1,7 @@ package com.knowledgepixels.nanodash.component; import com.knowledgepixels.nanodash.ApiCache; +import com.knowledgepixels.nanodash.SpaceMemberRole; import com.knowledgepixels.nanodash.View; import com.knowledgepixels.nanodash.ViewDisplay; import com.knowledgepixels.nanodash.domain.AbstractResourceWithProfile; @@ -109,40 +110,7 @@ public Component build() { } table.setResourceWithProfile(resourceWithProfile); table.setPageResource(resourceWithProfile); - View view = viewDisplay.getView(); - if (view != null) { - for (IRI actionIri : view.getViewResultActionList()) { - Template t = view.getTemplateForAction(actionIri); - if (t == null) continue; - String targetField = view.getTemplateTargetFieldForAction(actionIri); - if (targetField == null) targetField = "resource"; - String label = view.getLabelForAction(actionIri); - if (label == null) label = "action..."; - if (!label.endsWith("...")) label += "..."; - PageParameters params = new PageParameters().set("template", t.getId()) - .set("param_" + targetField, id) - .set("context", contextId) - .set("template-version", "latest"); - if (id != null && contextId != null && !id.equals(contextId)) { - params.set("part", id); - } - String partField = view.getTemplatePartFieldForAction(actionIri); - if (partField != null) { - // TODO Find a better way to pass the MaintainedResource object to this method: - MaintainedResource r = MaintainedResourceRepository.get().findById(contextId); - if (r != null && r.getNamespace() != null) { - params.set("param_" + partField, r.getNamespace() + ""); - } - } - String queryMapping = view.getTemplateQueryMapping(actionIri); - if (queryMapping != null && queryMapping.contains(":")) { - params.set("values-from-query", queryRef.getAsUrlString()); - params.set("values-from-query-mapping", queryMapping); - } - params.set("refresh-upon-publish", queryRef.getAsUrlString()); - table.addButton(label, PublishPage.class, params); - } - } + addViewActions(table, viewDisplay, queryRef, id, contextId, resourceWithProfile); table.add(new AttributeAppender("class", colClass)); return table; } else { @@ -156,39 +124,7 @@ public Component getApiResultComponent(String markupId, ApiResponse response) { } table.setResourceWithProfile(resourceWithProfile); table.setPageResource(resourceWithProfile); - View view = viewDisplay.getView(); - if (view != null) { - for (IRI actionIri : view.getViewResultActionList()) { - Template t = view.getTemplateForAction(actionIri); - if (t == null) continue; - String targetField = view.getTemplateTargetFieldForAction(actionIri); - if (targetField == null) targetField = "resource"; - String label = view.getLabelForAction(actionIri); - if (label == null) label = "action..."; - PageParameters params = new PageParameters().set("template", t.getId()) - .set("param_" + targetField, id) - .set("context", contextId) - .set("template-version", "latest"); - if (id != null && contextId != null && !id.equals(contextId)) { - params.set("part", id); - } - String partField = view.getTemplatePartFieldForAction(actionIri); - if (partField != null) { - // TODO Find a better way to pass the MaintainedResource object to this method: - MaintainedResource r = MaintainedResourceRepository.get().findById(contextId); - if (r != null && r.getNamespace() != null) { - params.set("param_" + partField, r.getNamespace() + ""); - } - } - String queryMapping = view.getTemplateQueryMapping(actionIri); - if (queryMapping != null && queryMapping.contains(":")) { - params.set("values-from-query", queryRef.getAsUrlString()); - params.set("values-from-query-mapping", queryMapping); - } - params.set("refresh-upon-publish", queryRef.getAsUrlString()); - table.addButton(label, PublishPage.class, params); - } - } + addViewActions(table, viewDisplay, queryRef, id, contextId, resourceWithProfile); return table; } }; @@ -199,6 +135,7 @@ public Component getApiResultComponent(String markupId, ApiResponse response) { if (response != null) { QueryResultTable table = new QueryResultTable(markupId, queryRef, response, viewDisplay, plain); table.setContextId(contextId); + addViewActions(table, viewDisplay, queryRef, id, contextId, resourceWithProfile); table.add(new AttributeAppender("class", colClass)); return table; } else { @@ -207,6 +144,7 @@ public Component getApiResultComponent(String markupId, ApiResponse response) { public Component getApiResultComponent(String markupId, ApiResponse response) { QueryResultTable table = new QueryResultTable(markupId, queryRef, response, viewDisplay, plain); table.setContextId(contextId); + addViewActions(table, viewDisplay, queryRef, id, contextId, resourceWithProfile); return table; } }; @@ -216,4 +154,56 @@ public Component getApiResultComponent(String markupId, ApiResponse response) { } } + /** + * Adds a button to the table for each result action declared by the view, linking to the + * action's template on the publish page. Resource-context parameters (the target field, the + * context, the part, and the part field) are only set when the corresponding id/contextId is + * available, so this also works on resource-less listings such as the general Spaces page. + * + * @param table the table to add the action buttons to + * @param viewDisplay the view display whose view declares the actions + * @param queryRef the query reference backing the table (used for refresh and query mapping) + * @param id the resource id, or null if there is no specific resource in context + * @param contextId the context id, or null if there is no context + */ + private static void addViewActions(QueryResultTable table, ViewDisplay viewDisplay, QueryRef queryRef, String id, String contextId, AbstractResourceWithProfile resourceWithProfile) { + View view = viewDisplay.getView(); + if (view == null) return; + for (IRI actionIri : view.getViewResultActionList()) { + // Per-action role gating (docs/role-specific-views.md): skip an action + // whose gen:isVisibleTo the current viewer does not satisfy. Additive β€” + // actions without gen:isVisibleTo are unaffected. + if (!SpaceMemberRole.isViewerEntitled(view.getActionVisibleTo(actionIri), resourceWithProfile)) continue; + Template t = view.getTemplateForAction(actionIri); + if (t == null) continue; + String targetField = view.getTemplateTargetFieldForAction(actionIri); + if (targetField == null) targetField = "resource"; + String label = view.getLabelForAction(actionIri); + if (label == null) label = "action..."; + if (!label.endsWith("...")) label += "..."; + PageParameters params = new PageParameters().set("template", t.getId()) + .set("template-version", "latest"); + if (id != null) params.set("param_" + targetField, id); + if (contextId != null) params.set("context", contextId); + if (id != null && contextId != null && !id.equals(contextId)) { + params.set("part", id); + } + String partField = view.getTemplatePartFieldForAction(actionIri); + if (partField != null && contextId != null) { + // TODO Find a better way to pass the MaintainedResource object to this method: + MaintainedResource r = MaintainedResourceRepository.get().findById(contextId); + if (r != null && r.getNamespace() != null) { + params.set("param_" + partField, r.getNamespace() + ""); + } + } + String queryMapping = view.getTemplateQueryMapping(actionIri); + if (queryMapping != null && queryMapping.contains(":")) { + params.set("values-from-query", queryRef.getAsUrlString()); + params.set("values-from-query-mapping", queryMapping); + } + params.set("refresh-upon-publish", queryRef.getAsUrlString()); + table.addButton(label, PublishPage.class, params); + } + } + } \ No newline at end of file diff --git a/src/main/java/com/knowledgepixels/nanodash/component/ResourceTabs.html b/src/main/java/com/knowledgepixels/nanodash/component/ResourceTabs.html new file mode 100644 index 000000000..347ee6e0a --- /dev/null +++ b/src/main/java/com/knowledgepixels/nanodash/component/ResourceTabs.html @@ -0,0 +1,8 @@ + +
+ Content + About + Explore + Raw +
+
diff --git a/src/main/java/com/knowledgepixels/nanodash/component/ResourceTabs.java b/src/main/java/com/knowledgepixels/nanodash/component/ResourceTabs.java new file mode 100644 index 000000000..b0201455d --- /dev/null +++ b/src/main/java/com/knowledgepixels/nanodash/component/ResourceTabs.java @@ -0,0 +1,145 @@ +package com.knowledgepixels.nanodash.component; + +import com.knowledgepixels.nanodash.page.MaintainedResourcePage; +import com.knowledgepixels.nanodash.page.ResourcePartPage; +import com.knowledgepixels.nanodash.page.SpacePage; +import com.knowledgepixels.nanodash.page.UserPage; +import org.apache.wicket.behavior.AttributeAppender; +import org.apache.wicket.markup.html.WebMarkupContainer; +import org.apache.wicket.markup.html.WebPage; +import org.apache.wicket.markup.html.link.BookmarkablePageLink; +import org.apache.wicket.markup.html.panel.Panel; +import org.apache.wicket.request.mapper.parameter.PageParameters; + +/** + * Tab strip shown at the top of a resource's page, switching between the + * Content tab (the rendered view displays), the About tab (the + * listing of roles/presets/view displays), the Explore tab (the generic + * exploration panels and references), and the Raw tab (the downloadable + * RDF of all nanopubs on the page). + * + *

All tabs are the same page, selected via the {@code tab} query parameter + * ({@code content} is the default and carries no parameter). Parts + * ({@code type == "part"}) have no About tab.

+ */ +public class ResourceTabs extends Panel { + + /** + * Which tab is currently shown (rendered as the selected tab). + */ + public enum Tab {CONTENT, ABOUT, EXPLORE, RAW} + + /** + * Maps the {@code tab} query parameter to a {@link Tab} (defaulting to + * {@link Tab#CONTENT}). Used by the resource pages to pick which tab body to + * render and which tab to mark active. + * + * @param parameters the page parameters + * @return the selected tab + */ + public static Tab activeFromParam(PageParameters parameters) { + switch (parameters.get("tab").toString("content")) { + case "about": + return Tab.ABOUT; + case "explore": + return Tab.EXPLORE; + case "raw": + return Tab.RAW; + default: + return Tab.CONTENT; + } + } + + /** + * Constructs the tab strip for a top-level resource (space, user, resource). + * + * @param id the Wicket markup id + * @param type the resource kind: {@code "space"}, {@code "user"}, or {@code "resource"} + * @param resourceId the resource IRI + * @param active the tab to mark as selected + */ + public ResourceTabs(String id, String type, String resourceId, Tab active) { + this(id, type, resourceId, null, active); + } + + /** + * Constructs the tab strip, optionally for a part (which carries a context). + * + * @param id the Wicket markup id + * @param type the resource kind: {@code "space"}, {@code "user"}, {@code "resource"}, or {@code "part"} + * @param resourceId the resource (or part) IRI + * @param contextId the context resource IRI (for parts), or {@code null} + * @param active the tab to mark as selected + */ + public ResourceTabs(String id, String type, String resourceId, String contextId, Tab active) { + super(id); + + Class pageClass; + boolean hasAbout; + switch (type) { + case "space": + pageClass = SpacePage.class; + hasAbout = true; + break; + case "user": + pageClass = UserPage.class; + hasAbout = true; + break; + case "resource": + pageClass = MaintainedResourcePage.class; + hasAbout = true; + break; + case "part": + pageClass = ResourcePartPage.class; + hasAbout = false; + break; + default: + throw new IllegalArgumentException("Unknown resource type: " + type); + } + + add(tabLink("content-tab", pageClass, params(resourceId, contextId, null), active == Tab.CONTENT)); + if (hasAbout) { + add(tabLink("about-tab", pageClass, params(resourceId, contextId, "about"), active == Tab.ABOUT)); + } else { + add(new WebMarkupContainer("about-tab").setVisible(false)); + } + add(tabLink("explore-tab", pageClass, params(resourceId, contextId, "explore"), active == Tab.EXPLORE)); + add(tabLink("raw-tab", pageClass, params(resourceId, contextId, "raw"), active == Tab.RAW)); + } + + /** + * The gray-italic title suffix shown after the resource name on non-content + * tabs (e.g. " – About"); empty for the content tab. + * + * @param tab the active tab + * @return the suffix string (possibly empty) + */ + public static String titleSuffix(Tab tab) { + switch (tab) { + case ABOUT: + return " – About"; + case EXPLORE: + return " – Explore"; + case RAW: + return " – Raw"; + default: + return ""; + } + } + + private PageParameters params(String resourceId, String contextId, String tab) { + PageParameters p = new PageParameters().set("id", resourceId); + if (contextId != null) p.set("context", contextId); + if (tab != null) p.set("tab", tab); + return p; + } + + private BookmarkablePageLink tabLink(String id, Class pageClass, PageParameters params, boolean selected) { + BookmarkablePageLink link = new BookmarkablePageLink<>(id, pageClass, params); + if (selected) { + link.add(new AttributeAppender("class", " selected")); + } + return link; + } + +} diff --git a/src/main/java/com/knowledgepixels/nanodash/component/TitleBar.html b/src/main/java/com/knowledgepixels/nanodash/component/TitleBar.html index aaefccb45..8510f3f2c 100644 --- a/src/main/java/com/knowledgepixels/nanodash/component/TitleBar.html +++ b/src/main/java/com/knowledgepixels/nanodash/component/TitleBar.html @@ -29,12 +29,11 @@ -
...
diff --git a/src/main/java/com/knowledgepixels/nanodash/page/ListPage.java b/src/main/java/com/knowledgepixels/nanodash/page/ListPage.java index be0b021f0..214a1a240 100644 --- a/src/main/java/com/knowledgepixels/nanodash/page/ListPage.java +++ b/src/main/java/com/knowledgepixels/nanodash/page/ListPage.java @@ -261,8 +261,6 @@ public void onClick(AjaxRequestTarget ajaxRequestTarget) { if (endDate != null) { downloadParams.set("endtime", endDate.toInstant().toString()); } - add(new DownloadRdfLinks("download-rdf", downloadParams)); - refresh(); } diff --git a/src/main/java/com/knowledgepixels/nanodash/page/MaintainedResourcePage.html b/src/main/java/com/knowledgepixels/nanodash/page/MaintainedResourcePage.html index a1320988f..5eab51d48 100644 --- a/src/main/java/com/knowledgepixels/nanodash/page/MaintainedResourcePage.html +++ b/src/main/java/com/knowledgepixels/nanodash/page/MaintainedResourcePage.html @@ -14,15 +14,16 @@
-

Resource ABC

+

Resource ABC

Namespace:

-

+ view display

-
-
+ +
+
+
diff --git a/src/main/java/com/knowledgepixels/nanodash/page/MaintainedResourcePage.java b/src/main/java/com/knowledgepixels/nanodash/page/MaintainedResourcePage.java index fbb0f233d..6791a7745 100644 --- a/src/main/java/com/knowledgepixels/nanodash/page/MaintainedResourcePage.java +++ b/src/main/java/com/knowledgepixels/nanodash/page/MaintainedResourcePage.java @@ -9,7 +9,9 @@ import com.knowledgepixels.nanodash.repository.MaintainedResourceRepository; import org.apache.wicket.Component; import org.apache.wicket.extensions.ajax.markup.html.AjaxLazyLoadPanel; +import org.apache.wicket.markup.html.WebMarkupContainer; import org.apache.wicket.markup.html.basic.Label; +import org.apache.wicket.markup.html.panel.EmptyPanel; import org.apache.wicket.markup.html.link.BookmarkablePageLink; import org.apache.wicket.model.IModel; import org.apache.wicket.model.LoadableDetachableModel; @@ -61,58 +63,63 @@ protected MaintainedResource load() { return MaintainedResourceRepository.get().findById(resourceId); } }; - Space space = resource.getSpace(); resource.triggerDataUpdate(); + ResourceTabs.Tab activeTab = ResourceTabs.activeFromParam(parameters); + List superSpaces = resource.getAllSuperSpacesUntilRoot(); superSpaces.add(resource.getSpace()); superSpaces.add(resource); add(new TitleBar("titlebar", this, null, superSpaces.stream().map(ss -> new NanodashPageRef(SpacePage.class, new PageParameters().add("id", ss.getId()), ss.getLabel())).toArray(NanodashPageRef[]::new) - )); + ).setTabs(new ResourceTabs("tabs", "resource", resource.getId(), activeTab))); add(new JustPublishedMessagePanel("justPublishedMessage", parameters)); add(new Label("pagetitle", resource.getLabel() + " (resource) | nanodash")); add(new Label("resourcename", resource.getLabel())); + add(new Label("titlesuffix", ResourceTabs.titleSuffix(activeTab))); add(new ExternalLinkWithActionsPanel("id", Model.of(resource.getId()), Model.of(resource.getLabel()), Values.iri(resource.getNanopubId()))); String namespaceUri = resource.getNamespace() == null ? "" : resource.getNamespace(); add(new BookmarkablePageLink("namespace", ExplorePage.class, new PageParameters().set("id", namespaceUri)).setBody(Model.of(namespaceUri))); - boolean isAdmin = SpaceMemberRole.isCurrentUserAdmin(space); - add(new AddViewDisplayButton("addviewdisplay", - "https://w3id.org/np/RAe0zantvnJlVWIC2LueG1IAMktXGFIqCdWliok1rOrmU", - "latest", - resource.getId(), - resource.getId(), - new PageParameters() - .set("param_appliesToResource", resource.getId()) - .set("refresh-upon-publish", resource.getId()) - ).setVisible(isAdmin)); - add(new DownloadRdfLinks("download-rdf", "resource", resource.getId())); - - if (resource.isDataInitialized()) { - add(new ViewList("views", resource)); - } else { - add(new AjaxLazyLoadPanel("views") { - @Override - public Component getLazyLoadComponent(String markupId) { - return new ViewList(markupId, resourceModel.getObject()); - } + WebMarkupContainer contentContainer = new WebMarkupContainer("contentContainer"); + add(contentContainer); + if (activeTab == ResourceTabs.Tab.CONTENT) { + add(new EmptyPanel("otherTab").setVisible(false)); + if (resource.isDataInitialized()) { + contentContainer.add(new ViewList("views", resource)); + } else { + contentContainer.add(new AjaxLazyLoadPanel("views") { + + @Override + public Component getLazyLoadComponent(String markupId) { + return new ViewList(markupId, resourceModel.getObject()); + } - @Override - protected boolean isContentReady() { - return resourceModel.getObject().isDataInitialized(); - } + @Override + protected boolean isContentReady() { + return resourceModel.getObject().isDataInitialized(); + } - @Override - public Component getLoadingComponent(String id) { - return new Label(id, "
" + ResultComponent.getWaitIconHtml() + "
").setEscapeModelStrings(false); - } + @Override + public Component getLoadingComponent(String id) { + return new Label(id, "
" + ResultComponent.getWaitIconHtml() + "
").setEscapeModelStrings(false); + } - }); + }); + } + } else { + contentContainer.setVisible(false); + if (activeTab == ResourceTabs.Tab.ABOUT) { + add(new AboutResourcePanel("otherTab", resource)); + } else if (activeTab == ResourceTabs.Tab.EXPLORE) { + add(new ExplorePanel("otherTab", resource.getId())); + } else { + add(new DownloadRdfLinks("otherTab", "resource", resource.getId())); + } } } diff --git a/src/main/java/com/knowledgepixels/nanodash/page/ReferencesPage.java b/src/main/java/com/knowledgepixels/nanodash/page/ReferencesPage.java index 4c64ca122..1ad8d436f 100644 --- a/src/main/java/com/knowledgepixels/nanodash/page/ReferencesPage.java +++ b/src/main/java/com/knowledgepixels/nanodash/page/ReferencesPage.java @@ -16,7 +16,10 @@ public class ReferencesPage extends NanodashPage { public static final String MOUNT_PATH = "/references"; - private static final String REFERENCES_VIEW = "https://w3id.org/np/RAZ0EGsBlca8unLqQzGl5kVapGgllKvDbGFlTA_FFD7oM/references-view"; + /** + * The view used to render references to a given URI. Shared with the About pages. + */ + public static final String REFERENCES_VIEW = "https://w3id.org/np/RAZ0EGsBlca8unLqQzGl5kVapGgllKvDbGFlTA_FFD7oM/references-view"; @Override public String getMountPath() { diff --git a/src/main/java/com/knowledgepixels/nanodash/page/ResourcePartPage.html b/src/main/java/com/knowledgepixels/nanodash/page/ResourcePartPage.html index 3195f01a6..77b19c4c9 100644 --- a/src/main/java/com/knowledgepixels/nanodash/page/ResourcePartPage.html +++ b/src/main/java/com/knowledgepixels/nanodash/page/ResourcePartPage.html @@ -14,15 +14,17 @@
-

ABC

+

ABC

Description...

+ view display

-
-
+ +
+
+
diff --git a/src/main/java/com/knowledgepixels/nanodash/page/ResourcePartPage.java b/src/main/java/com/knowledgepixels/nanodash/page/ResourcePartPage.java index b90caa284..56bddf17c 100644 --- a/src/main/java/com/knowledgepixels/nanodash/page/ResourcePartPage.java +++ b/src/main/java/com/knowledgepixels/nanodash/page/ResourcePartPage.java @@ -15,7 +15,9 @@ import com.knowledgepixels.nanodash.repository.SpaceRepository; import org.apache.wicket.Component; import org.apache.wicket.extensions.ajax.markup.html.AjaxLazyLoadPanel; +import org.apache.wicket.markup.html.WebMarkupContainer; import org.apache.wicket.markup.html.basic.Label; +import org.apache.wicket.markup.html.panel.EmptyPanel; import org.apache.wicket.model.Model; import org.apache.wicket.request.mapper.parameter.PageParameters; import org.eclipse.rdf4j.model.IRI; @@ -137,14 +139,16 @@ public ResourcePartPage(final PageParameters parameters) { } breadCrumb.add(new NanodashPageRef(ResourcePartPage.class, new PageParameters().add("id", id).add("context", contextId).add("label", label), label)); NanodashPageRef[] breadCrumbArray = breadCrumb.toArray(new NanodashPageRef[0]); + ResourceTabs.Tab activeTab = ResourceTabs.activeFromParam(parameters); add(new TitleBar("titlebar", this, null, breadCrumbArray - )); + ).setTabs(new ResourceTabs("tabs", "part", id, contextId, activeTab))); add(new JustPublishedMessagePanel("justPublishedMessage", parameters)); add(new Label("pagetitle", label + " (resource part) | nanodash")); add(new Label("name", label)); + add(new Label("titlesuffix", ResourceTabs.titleSuffix(activeTab))); add(new ExternalLinkWithActionsPanel("id", Model.of(id), Model.of(label), nanopubId == null ? Values.iri(id) : Values.iri(nanopubId))); boolean showButton = false; @@ -165,30 +169,39 @@ public ResourcePartPage(final PageParameters parameters) { .set("refresh-upon-publish", resourceWithProfile.getId()) ).setVisible(showButton)); - add(new DownloadRdfLinks("download-rdf", "part", id, resourceWithProfile.getId())); - final String nanopubRef = nanopubId == null ? "x:" : nanopubId; - if (resourceWithProfile.isDataInitialized()) { - add(new ViewList("views", resourceWithProfile, id, nanopubRef, classes)); + WebMarkupContainer contentContainer = new WebMarkupContainer("contentContainer"); + add(contentContainer); + if (activeTab == ResourceTabs.Tab.EXPLORE) { + contentContainer.setVisible(false); + add(new ExplorePanel("otherTab", id)); + } else if (activeTab == ResourceTabs.Tab.RAW) { + contentContainer.setVisible(false); + add(new DownloadRdfLinks("otherTab", "part", id, resourceWithProfile.getId())); } else { - add(new AjaxLazyLoadPanel("views") { + add(new EmptyPanel("otherTab").setVisible(false)); + if (resourceWithProfile.isDataInitialized()) { + contentContainer.add(new ViewList("views", resourceWithProfile, id, nanopubRef, classes)); + } else { + contentContainer.add(new AjaxLazyLoadPanel("views") { - @Override - public Component getLazyLoadComponent(String markupId) { - return new ViewList(markupId, resourceWithProfile, id, nanopubRef, classes); - } + @Override + public Component getLazyLoadComponent(String markupId) { + return new ViewList(markupId, resourceWithProfile, id, nanopubRef, classes); + } - @Override - protected boolean isContentReady() { - return resourceWithProfile.isDataInitialized(); - } + @Override + protected boolean isContentReady() { + return resourceWithProfile.isDataInitialized(); + } - @Override - public Component getLoadingComponent(String id) { - return new Label(id, "
" + ResultComponent.getWaitIconHtml() + "
").setEscapeModelStrings(false); - } + @Override + public Component getLoadingComponent(String id) { + return new Label(id, "
" + ResultComponent.getWaitIconHtml() + "
").setEscapeModelStrings(false); + } - }); + }); + } } } diff --git a/src/main/java/com/knowledgepixels/nanodash/page/SpacePage.html b/src/main/java/com/knowledgepixels/nanodash/page/SpacePage.html index 530bd17e1..b5bd626b1 100644 --- a/src/main/java/com/knowledgepixels/nanodash/page/SpacePage.html +++ b/src/main/java/com/knowledgepixels/nanodash/page/SpacePage.html @@ -14,15 +14,14 @@
-

Space ABC

+

Space ABC

Space type

...
-

+ view display

-
+

πŸ“… Date

@@ -55,6 +54,8 @@

β„Ή About

+ +
diff --git a/src/main/java/com/knowledgepixels/nanodash/page/SpacePage.java b/src/main/java/com/knowledgepixels/nanodash/page/SpacePage.java index a30e09fb8..13dd46766 100644 --- a/src/main/java/com/knowledgepixels/nanodash/page/SpacePage.java +++ b/src/main/java/com/knowledgepixels/nanodash/page/SpacePage.java @@ -16,8 +16,10 @@ import org.apache.wicket.Component; import org.apache.wicket.RestartResponseException; import org.apache.wicket.extensions.ajax.markup.html.AjaxLazyLoadPanel; +import org.apache.wicket.markup.html.WebMarkupContainer; import org.apache.wicket.markup.html.basic.Label; import org.apache.wicket.markup.html.link.BookmarkablePageLink; +import org.apache.wicket.markup.html.panel.EmptyPanel; import org.apache.wicket.model.IModel; import org.apache.wicket.model.LoadableDetachableModel; import org.apache.wicket.model.Model; @@ -83,38 +85,29 @@ protected Space load() { Nanopub np = space.getNanopub(); + ResourceTabs.Tab activeTab = ResourceTabs.activeFromParam(parameters); + List superSpaces = space.getAllSuperSpacesUntilRoot(); if (superSpaces.isEmpty()) { add(new TitleBar("titlebar", this, null, new NanodashPageRef(SpacePage.class, new PageParameters().add("id", space.getId()), space.getLabel()) - )); + ).setTabs(new ResourceTabs("tabs", "space", space.getId(), activeTab))); } else { superSpaces.add(space); add(new TitleBar("titlebar", this, null, superSpaces.stream().map(ss -> new NanodashPageRef(SpacePage.class, new PageParameters().add("id", ss.getId()), ss.getLabel())).toArray(NanodashPageRef[]::new) - )); + ).setTabs(new ResourceTabs("tabs", "space", space.getId(), activeTab))); } add(new JustPublishedMessagePanel("justPublishedMessage", parameters)); add(new Label("pagetitle", space.getLabel() + " (space) | nanodash")); add(new Label("spacename", space.getLabel())); + add(new Label("titlesuffix", ResourceTabs.titleSuffix(activeTab))); add(new Label("spacetype", space.getTypeLabel())); add(new ExternalLinkWithActionsPanel("id", Model.of(space.getId()), Model.of(space.getLabel()), new SpaceExploreMenu("np", space.getId(), space.getLabel(), np.getUri(), space))); - boolean isAdmin = SpaceMemberRole.isCurrentUserAdmin(space); - add(new AddViewDisplayButton("addviewdisplay", - "https://w3id.org/np/RAwPPxDxkXwgWwYhmvzi6SUs8djPZS4IgWJdp2G0blqoQ", - "latest", - space.getId(), - space.getId(), - new PageParameters() - .set("param_appliesToResource", space.getId()) - .set("refresh-upon-publish", space.getId()) - ).setVisible(isAdmin)); - add(new DownloadRdfLinks("download-rdf", "space", space.getId())); - add(new ItemListPanel( "altids", "Alternative IDs:", @@ -122,6 +115,21 @@ protected Space load() { i -> new ExternalLinkWithActionsPanel("item", Model.of(i), Model.of(i)) )); + WebMarkupContainer contentContainer = new WebMarkupContainer("contentContainer"); + add(contentContainer); + if (activeTab != ResourceTabs.Tab.CONTENT) { + contentContainer.setVisible(false); + if (activeTab == ResourceTabs.Tab.ABOUT) { + add(new AboutSpacePanel("otherTab", space)); + } else if (activeTab == ResourceTabs.Tab.EXPLORE) { + add(new ExplorePanel("otherTab", space.getId())); + } else { + add(new DownloadRdfLinks("otherTab", "space", space.getId())); + } + return; + } + add(new EmptyPanel("otherTab").setVisible(false)); + if (space.getStartDate() != null) { ZoneId startZone = space.getStartDate().getTimeZone().toZoneId(); ZonedDateTime startDt = ZonedDateTime.ofInstant(space.getStartDate().toInstant(), startZone); @@ -137,17 +145,17 @@ protected Space load() { dateString += " - " + endDateStr; } } - add(new Label("date", dateString)); + contentContainer.add(new Label("date", dateString)); } else { - add(new Label("date").setVisible(false)); + contentContainer.add(new Label("date").setVisible(false)); } - add(new Label("description", "" + Utils.sanitizeHtml(space.getDescription()) + "").setEscapeModelStrings(false)); + contentContainer.add(new Label("description", "" + Utils.sanitizeHtml(space.getDescription()) + "").setEscapeModelStrings(false)); if (space.isDataInitialized()) { - add(new ViewList("views", space)); + contentContainer.add(new ViewList("views", space)); } else { - add(new AjaxLazyLoadPanel("views") { + contentContainer.add(new AjaxLazyLoadPanel("views") { @Override public Component getLazyLoadComponent(String markupId) { @@ -167,7 +175,7 @@ public Component getLoadingComponent(String id) { }); } - add(new ItemListPanel<>( + contentContainer.add(new ItemListPanel<>( "roles", "Roles:", () -> spaceModel.getObject().isDataInitialized(), @@ -185,9 +193,9 @@ public Component getLoadingComponent(String id) { ); if (space.isDataInitialized()) { - add(new SpaceUserList("user-lists", space)); + contentContainer.add(new SpaceUserList("user-lists", space)); } else { - add(new AjaxLazyLoadPanel("user-lists") { + contentContainer.add(new AjaxLazyLoadPanel("user-lists") { @Override public Component getLazyLoadComponent(String markupId) { @@ -202,22 +210,22 @@ protected boolean isContentReady() { }); } - addSubspacePanel("Alliance"); - addSubspacePanel("Consortium"); - addSubspacePanel("Organization"); - addSubspacePanel("Taskforce"); - addSubspacePanel("Division"); - addSubspacePanel("Taskunit"); - addSubspacePanel("Group"); - addSubspacePanel("Project"); - addSubspacePanel("Program"); - addSubspacePanel("Initiative"); - addSubspacePanel("Outlet"); - addSubspacePanel("Campaign"); - addSubspacePanel("Community"); - addSubspacePanel("Event"); - - add(new ItemListPanel( + addSubspacePanel(contentContainer, "Alliance"); + addSubspacePanel(contentContainer, "Consortium"); + addSubspacePanel(contentContainer, "Organization"); + addSubspacePanel(contentContainer, "Taskforce"); + addSubspacePanel(contentContainer, "Division"); + addSubspacePanel(contentContainer, "Taskunit"); + addSubspacePanel(contentContainer, "Group"); + addSubspacePanel(contentContainer, "Project"); + addSubspacePanel(contentContainer, "Program"); + addSubspacePanel(contentContainer, "Initiative"); + addSubspacePanel(contentContainer, "Outlet"); + addSubspacePanel(contentContainer, "Campaign"); + addSubspacePanel(contentContainer, "Community"); + addSubspacePanel(contentContainer, "Event"); + + contentContainer.add(new ItemListPanel( "resources", "πŸ“¦ Maintained Resources", () -> true, @@ -228,17 +236,17 @@ protected boolean isContentReady() { String shortId = space.getId().replace("https://w3id.org/spaces/", ""); ConnectorConfig cc = ConnectorConfig.get(shortId); if (cc != null) { - add(new BookmarkablePageLink("content-button", GenOverviewPage.class, new PageParameters().set("journal", shortId)).setBody(Model.of("Nanopublication Submissions"))); + contentContainer.add(new BookmarkablePageLink("content-button", GenOverviewPage.class, new PageParameters().set("journal", shortId)).setBody(Model.of("Nanopublication Submissions"))); } else { - add(new Label("content-button").setVisible(false)); + contentContainer.add(new Label("content-button").setVisible(false)); } } - private void addSubspacePanel(String type) { + private void addSubspacePanel(WebMarkupContainer container, String type) { String typePl = type + "s"; typePl = typePl.replaceFirst("ys$", "ies"); - add(new ItemListPanel<>( + container.add(new ItemListPanel<>( typePl.toLowerCase(), Space.getTypeEmoji(type) + " " + typePl, SpaceRepository.get().findSubspaces(spaceModel.getObject(), KPXL_TERMS.NAMESPACE + type), diff --git a/src/main/java/com/knowledgepixels/nanodash/page/UserPage.html b/src/main/java/com/knowledgepixels/nanodash/page/UserPage.html index 5a043e364..620dea79f 100644 --- a/src/main/java/com/knowledgepixels/nanodash/page/UserPage.html +++ b/src/main/java/com/knowledgepixels/nanodash/page/UserPage.html @@ -15,26 +15,24 @@

- User Name

+ User Name -

- See Your Profile Details - + view display -

-
-
+ +
-
-
-

This user hasn't configured their profile yet, but below you can find their latest nanopublications. -

+
+
+

This user hasn't configured their profile yet, but below you can find their latest nanopublications. +

+
-
-
+
+ +
diff --git a/src/main/java/com/knowledgepixels/nanodash/page/UserPage.java b/src/main/java/com/knowledgepixels/nanodash/page/UserPage.java index c9b7800ee..06a39245a 100644 --- a/src/main/java/com/knowledgepixels/nanodash/page/UserPage.java +++ b/src/main/java/com/knowledgepixels/nanodash/page/UserPage.java @@ -5,7 +5,6 @@ import com.knowledgepixels.nanodash.View; import com.knowledgepixels.nanodash.ViewDisplay; import com.knowledgepixels.nanodash.component.*; -import com.knowledgepixels.nanodash.component.menu.UserPageMenu; import com.knowledgepixels.nanodash.domain.IndividualAgent; import com.knowledgepixels.nanodash.domain.User; import org.apache.wicket.Component; @@ -69,8 +68,10 @@ public UserPage(final PageParameters parameters) { } if (!pubkeyHashes.isEmpty()) pubkeyHashes = pubkeyHashes.substring(1); + ResourceTabs.Tab activeTab = ResourceTabs.activeFromParam(parameters); String pageType = "users"; - add(new TitleBar("titlebar", this, pageType)); + add(new TitleBar("titlebar", this, pageType) + .setTabs(new ResourceTabs("tabs", "user", userIriString, activeTab))); add(new JustPublishedMessagePanel("justPublishedMessage", parameters)); @@ -93,21 +94,9 @@ public UserPage(final PageParameters parameters) { final String displayName = User.getShortDisplayName(userIri); add(new Label("pagetitle", displayName + " (user) | nanodash")); add(new Label("username", displayName)); + add(new Label("titlesuffix", ResourceTabs.titleSuffix(activeTab))); - add(new ExternalLinkWithActionsPanel("fullid", Model.of(userIriString), Model.of(displayName), - new UserPageMenu("np", userIriString, displayName))); - boolean isOwnPage = userIri.equals(NanodashSession.get().getUserIri()); - add(new BookmarkablePageLink("showprofile", ProfilePage.class).setVisible(isOwnPage)); - add(new AddViewDisplayButton("addviewdisplay", - "https://w3id.org/np/RAQhTCHtfzGCj1YiE1LualWcZjg3thlRiquFWUE14UF-g", - "latest", - userIriString, - userIriString, - new PageParameters() - .set("refresh-upon-publish", userIriString) - .set("param_appliesToResource", userIriString) - ).setVisible(isOwnPage)); - add(new DownloadRdfLinks("download-rdf", "user", userIriString)); + add(new ExternalLinkWithActionsPanel("fullid", Model.of(userIriString), Model.of(displayName))); // final Map statsParams = new HashMap<>(); // final String statsQueryName; @@ -151,66 +140,80 @@ public UserPage(final PageParameters parameters) { // }); // } - IndividualAgent individualAgent = IndividualAgent.get(userIriString); - if (individualAgent.isDataInitialized()) { - boolean empty = individualAgent.getTopLevelViewDisplays().isEmpty(); - if (empty) { - add(new WebMarkupContainer("views").setVisible(false)); + WebMarkupContainer contentContainer = new WebMarkupContainer("contentContainer"); + add(contentContainer); + if (activeTab != ResourceTabs.Tab.CONTENT) { + contentContainer.setVisible(false); + if (activeTab == ResourceTabs.Tab.ABOUT) { + add(new AboutUserPanel("otherTab", userIriString)); + } else if (activeTab == ResourceTabs.Tab.EXPLORE) { + add(new ExplorePanel("otherTab", userIriString)); } else { - add(new ViewList("views", individualAgent)); - } - add(new WebMarkupContainer("unconfigured-notice").setVisible(empty)); - if (empty) { - ViewDisplay defaultViewDisplay = new ViewDisplay(View.get("https://w3id.org/np/RAwktOZ3vwTZJcGRbueLpxIFSiOj7XmMG2-8rzPuDEpPc/latest-nanopubs-by-user")); - add(new ViewList("latestnanopubsview", individualAgent, List.of(defaultViewDisplay))); - } else { - add(new EmptyPanel("latestnanopubsview").setVisible(false)); + add(new DownloadRdfLinks("otherTab", "user", userIriString)); } } else { - final WebMarkupContainer unconfiguredNotice = new WebMarkupContainer("unconfigured-notice"); - unconfiguredNotice.setVisible(false); - unconfiguredNotice.setOutputMarkupPlaceholderTag(true); - add(unconfiguredNotice); - - ViewDisplay defaultViewDisplay = new ViewDisplay(View.get("https://w3id.org/np/RAwktOZ3vwTZJcGRbueLpxIFSiOj7XmMG2-8rzPuDEpPc/latest-nanopubs-by-user")); - final ViewList latestNanopubsView = new ViewList("latestnanopubsview", individualAgent, List.of(defaultViewDisplay)); - latestNanopubsView.setVisible(false); - latestNanopubsView.setOutputMarkupPlaceholderTag(true); - add(latestNanopubsView); - - add(new AjaxLazyLoadPanel("views") { - - @Override - public Component getLazyLoadComponent(String markupId) { - return new ViewList(markupId, individualAgent); + add(new EmptyPanel("otherTab").setVisible(false)); + IndividualAgent individualAgent = IndividualAgent.get(userIriString); + if (individualAgent.isDataInitialized()) { + boolean empty = individualAgent.getTopLevelViewDisplays().isEmpty(); + if (empty) { + contentContainer.add(new WebMarkupContainer("views").setVisible(false)); + } else { + contentContainer.add(new ViewList("views", individualAgent)); } - - @Override - protected boolean isContentReady() { - return individualAgent.isDataInitialized(); - } - - @Override - public Component getLoadingComponent(String id) { - return new Label(id, "
" + ResultComponent.getWaitIconHtml() + "
").setEscapeModelStrings(false); - } - - @Override - protected void onContentLoaded(Component content, Optional target) { - super.onContentLoaded(content, target); - target.ifPresent(t -> { - boolean isEmpty = individualAgent.getTopLevelViewDisplays().isEmpty(); - if (isEmpty) { - t.appendJavaScript("document.getElementById('" + getMarkupId() + "').remove();"); - } - unconfiguredNotice.setVisible(isEmpty); - t.add(unconfiguredNotice); - latestNanopubsView.setVisible(isEmpty); - t.add(latestNanopubsView); - }); + contentContainer.add(new WebMarkupContainer("unconfigured-notice").setVisible(empty)); + if (empty) { + ViewDisplay defaultViewDisplay = new ViewDisplay(View.get("https://w3id.org/np/RAwktOZ3vwTZJcGRbueLpxIFSiOj7XmMG2-8rzPuDEpPc/latest-nanopubs-by-user")); + contentContainer.add(new ViewList("latestnanopubsview", individualAgent, List.of(defaultViewDisplay))); + } else { + contentContainer.add(new EmptyPanel("latestnanopubsview").setVisible(false)); } + } else { + final WebMarkupContainer unconfiguredNotice = new WebMarkupContainer("unconfigured-notice"); + unconfiguredNotice.setVisible(false); + unconfiguredNotice.setOutputMarkupPlaceholderTag(true); + contentContainer.add(unconfiguredNotice); - }); + ViewDisplay defaultViewDisplay = new ViewDisplay(View.get("https://w3id.org/np/RAwktOZ3vwTZJcGRbueLpxIFSiOj7XmMG2-8rzPuDEpPc/latest-nanopubs-by-user")); + final ViewList latestNanopubsView = new ViewList("latestnanopubsview", individualAgent, List.of(defaultViewDisplay)); + latestNanopubsView.setVisible(false); + latestNanopubsView.setOutputMarkupPlaceholderTag(true); + contentContainer.add(latestNanopubsView); + + contentContainer.add(new AjaxLazyLoadPanel("views") { + + @Override + public Component getLazyLoadComponent(String markupId) { + return new ViewList(markupId, individualAgent); + } + + @Override + protected boolean isContentReady() { + return individualAgent.isDataInitialized(); + } + + @Override + public Component getLoadingComponent(String id) { + return new Label(id, "
" + ResultComponent.getWaitIconHtml() + "
").setEscapeModelStrings(false); + } + + @Override + protected void onContentLoaded(Component content, Optional target) { + super.onContentLoaded(content, target); + target.ifPresent(t -> { + boolean isEmpty = individualAgent.getTopLevelViewDisplays().isEmpty(); + if (isEmpty) { + t.appendJavaScript("document.getElementById('" + getMarkupId() + "').remove();"); + } + unconfiguredNotice.setVisible(isEmpty); + t.add(unconfiguredNotice); + latestNanopubsView.setVisible(isEmpty); + t.add(latestNanopubsView); + }); + } + + }); + } } } diff --git a/src/main/java/com/knowledgepixels/nanodash/vocabulary/KPXL_TERMS.java b/src/main/java/com/knowledgepixels/nanodash/vocabulary/KPXL_TERMS.java index fd756e9c8..2a3f0a3ac 100644 --- a/src/main/java/com/knowledgepixels/nanodash/vocabulary/KPXL_TERMS.java +++ b/src/main/java/com/knowledgepixels/nanodash/vocabulary/KPXL_TERMS.java @@ -39,6 +39,18 @@ public class KPXL_TERMS { public static final IRI VIEW_RESULT_ACTION = VocabUtils.createIRI(NAMESPACE, "ViewResultAction"); public static final IRI VIEW_ENTRY_ACTION = VocabUtils.createIRI(NAMESPACE, "ViewEntryAction"); + // Presets (issue #302): a named bundle of default views and roles, and the + // assignment of such a bundle to a resource. Mirrors the view-display model. + // Resource types (the values used in gen:appliesToInstancesOf). + public static final IRI SPACE = VocabUtils.createIRI(NAMESPACE, "Space"); + public static final IRI MAINTAINED_RESOURCE = VocabUtils.createIRI(NAMESPACE, "MaintainedResource"); + public static final IRI INDIVIDUAL_AGENT = VocabUtils.createIRI(NAMESPACE, "IndividualAgent"); + + public static final IRI PRESET = VocabUtils.createIRI(NAMESPACE, "Preset"); + public static final IRI PRESET_ASSIGNMENT = VocabUtils.createIRI(NAMESPACE, "PresetAssignment"); + public static final IRI ACTIVATED_PRESET_ASSIGNMENT = VocabUtils.createIRI(NAMESPACE, "ActivatedPresetAssignment"); + public static final IRI DEACTIVATED_PRESET_ASSIGNMENT = VocabUtils.createIRI(NAMESPACE, "DeactivatedPresetAssignment"); + public static final IRI HAS_DISPLAY_WIDTH = VocabUtils.createIRI(NAMESPACE, "hasDisplayWidth"); public static final IRI HAS_VIEW_QUERY = VocabUtils.createIRI(NAMESPACE, "hasViewQuery"); public static final IRI HAS_VIEW_QUERY_TARGET_FIELD = VocabUtils.createIRI(NAMESPACE, "hasViewQueryTargetField"); @@ -55,6 +67,13 @@ public class KPXL_TERMS { public static final IRI IS_DISPLAY_OF_VIEW = VocabUtils.createIRI(NAMESPACE, "isDisplayOfView"); public static final IRI IS_DISPLAY_FOR = VocabUtils.createIRI(NAMESPACE, "isDisplayFor"); + // Preset properties (issue #302): + public static final IRI HAS_TOP_LEVEL_VIEW = VocabUtils.createIRI(NAMESPACE, "hasTopLevelView"); + public static final IRI HAS_VIEW = VocabUtils.createIRI(NAMESPACE, "hasView"); + public static final IRI HAS_ROLE = VocabUtils.createIRI(NAMESPACE, "hasRole"); + public static final IRI IS_ASSIGNMENT_OF_PRESET = VocabUtils.createIRI(NAMESPACE, "isAssignmentOfPreset"); + public static final IRI IS_ASSIGNMENT_FOR = VocabUtils.createIRI(NAMESPACE, "isAssignmentFor"); + // TODO Remove these deprecated terms. // Deprecated: public static final IRI TOP_LEVEL_VIEW_DISPLAY = VocabUtils.createIRI(NAMESPACE, "TopLevelViewDisplay"); @@ -87,4 +106,31 @@ public class KPXL_TERMS { public static final IRI HAS_DEFAULT_LICENSE = VocabUtils.createIRI(NAMESPACE, "hasDefaultLicense"); + // Role tiers (subclasses of gen:SpaceMemberRole; materialized server-side by + // nanopub-query as the npa:hasRoleType value). Ordered admin > maintainer > + // member > observer; observer is the default when a role declares no tier. + // Used to gate view-display visibility by role tier; see + // docs/role-specific-views.md. + public static final IRI ADMIN_ROLE_TYPE = VocabUtils.createIRI(NAMESPACE, "AdminRole"); + public static final IRI MAINTAINER_ROLE = VocabUtils.createIRI(NAMESPACE, "MaintainerRole"); + public static final IRI MEMBER_ROLE = VocabUtils.createIRI(NAMESPACE, "MemberRole"); + public static final IRI OBSERVER_ROLE = VocabUtils.createIRI(NAMESPACE, "ObserverRole"); + + /** + * Visibility sentinel tier meaning "everyone, including anonymous viewers" + * (the rank-0 floor). Unlike the tiers above it is not a + * nanopub-query grant tier β€” it is never granted, only used as a + * {@code gen:isVisibleTo} value/default to express "no restriction" + * explicitly (needed because a view-creation template cannot leave the + * per-action visibility statement optional). See docs/role-specific-views.md. + */ + public static final IRI EVERYONE_ROLE = VocabUtils.createIRI(NAMESPACE, "EveryoneRole"); + + /** + * Restricts a view display (or view) to viewers holding the given role tier + * (one of the role-tier IRIs above) or a specific role IRI. Absent means + * visible to everyone. See docs/role-specific-views.md. + */ + public static final IRI IS_VISIBLE_TO = VocabUtils.createIRI(NAMESPACE, "isVisibleTo"); + } diff --git a/src/main/webapp/style.css b/src/main/webapp/style.css index 363f1f117..8b5fbb25f 100644 --- a/src/main/webapp/style.css +++ b/src/main/webapp/style.css @@ -408,7 +408,10 @@ th { } tbody tr:nth-child(even) { - background-color: #efefef; + /* Alpha-based so the zebra stripe darkens whatever is behind it (β‰ˆ #efefef + on white), instead of looking brighter than darker backgrounds (e.g. on + the About pages). */ + background-color: rgba(0, 0, 0, 0.06); } table.activitypanel td { @@ -705,6 +708,72 @@ span.prelink { color: #fff; } +/* Breadcrumb (left) + Content | About | Raw tabs (right), sharing the grey + breadcrumb strip; they sit on one line when they fit and wrap otherwise. */ +.breadcrumb-tab-row { + display: flex; + align-items: flex-end; + flex-wrap: wrap; + gap: 2px 16px; +} +.breadcrumb-tab-row .breadcrumb-links { + min-width: 0; + padding-bottom: 7px; +} +.breadcrumb-tab-row .tabs-container { + margin-left: auto; +} +.resource-tabs { + display: flex; + gap: 4px; + /* Pull the strip down so the tabs end flush with the bottom edge of the + grey breadcrumb stripe (which has 10px bottom padding on its column). */ + margin-bottom: -10px; +} +.resource-tabs a { + padding: 6px 16px 10px; + font-family: Inter, Verdana, Helvetica, sans-serif; + font-size: 10pt; + font-weight: 700; + color: #0B73DA; + text-decoration: none; + /* Rounded only at the top -> tab shape rising out of the stripe. */ + border-radius: 8px 8px 0 0; + line-height: 1.3; +} +.resource-tabs a:hover:not(.selected) { + background-color: #0B73DA1A; +} +.resource-tabs a.selected { + background-color: #ffffff; + color: #0B73DA; +} + +/* Right-aligned table column (used for the trailing np/nps source-nanopub column) */ +.cell-right { + text-align: right; +} + +/* Gray italic "– About" / "– Raw" suffix appended to a page title */ +.title-suffix { + color: #888888; + font-style: italic; + font-weight: normal; +} + +/* Raw page content: subtitles + format link lists */ +.raw-subtitle { + font-weight: 700; + margin: 0.7em 0 0.2em; +} +.raw-formats { + margin: 0 0 0.5em; + padding-left: 1.4em; +} +.raw-formats li { + line-height: 1.7; +} + a.actionlink { font-family: "Noto Emoji", Inter, Verdana, Helvetica, sans-serif !important; font-style: italic !important; @@ -871,20 +940,36 @@ input[type=radio] + label strong { .actionmenu-button { - font-size: 7pt; + font-size: 11pt; font-weight: 700; - width: 22px; - height: 22px; - padding: 0; + min-width: 22px; + padding: 0 4px; margin: 1px; - background-color: #0B73DA; - color: #fff; - border-width: 0; + background-color: transparent; + color: #0B73DA; + border: 1px solid #0B73DA; border-radius: 4px; display: inline-block; cursor: pointer; line-height: 20px !important; text-align: center; + /* Match the box model + alignment of the (vertical-align:middle) action + buttons so the dropdown lines up with them and the filter/title. */ + vertical-align: middle; +} + +/* Down chevron ("v" / outline arrow, no top line), drawn with two borders. */ +.actionmenu-button::before { + content: ""; + display: inline-block; + width: 6px; + height: 6px; + border-right: 2px solid currentColor; + border-bottom: 2px solid currentColor; + transform: rotate(45deg); + vertical-align: middle; + position: relative; + top: -2px; } .actionmenu { @@ -892,6 +977,7 @@ input[type=radio] + label strong { padding: 0; position: relative; display: inline-block; + vertical-align: middle; } .actionmenu-content { @@ -927,7 +1013,8 @@ input[type=radio] + label strong { } .actionmenu:hover .actionmenu-button { - background-color: #0B73DAC0; + background-color: #0B73DA; + color: #fff; } @@ -972,9 +1059,6 @@ select { font-weight: 700; font-size: 9pt; background-color: #F3F6F9; - border-color: #F3F6F9; - border-style: solid; - border-width: 0 0 4px 0; } .breadcrumbpath [class*="col-"] { @@ -1201,7 +1285,9 @@ div.nanopub-head { } div.nanopub-graph { - background: #F3F6F9; + /* Alpha-defined grey: renders as #F3F6F9 over white, but stays visible + (darker) over the #F3F6F9 row-section stripe background. */ + background: rgba(135, 165, 195, 0.1); padding: 12px 15px 10px 15px; margin-top: 3px; border-radius: 4px; diff --git a/src/test/java/com/knowledgepixels/nanodash/SpaceMemberRoleTest.java b/src/test/java/com/knowledgepixels/nanodash/SpaceMemberRoleTest.java index 28c182e67..a26d26920 100644 --- a/src/test/java/com/knowledgepixels/nanodash/SpaceMemberRoleTest.java +++ b/src/test/java/com/knowledgepixels/nanodash/SpaceMemberRoleTest.java @@ -2,6 +2,8 @@ import com.google.common.collect.ArrayListMultimap; import com.google.common.collect.Multimap; +import com.knowledgepixels.nanodash.domain.Space; +import com.knowledgepixels.nanodash.vocabulary.KPXL_TERMS; import org.eclipse.rdf4j.model.IRI; import org.eclipse.rdf4j.model.util.Values; import org.junit.jupiter.api.BeforeEach; @@ -9,6 +11,7 @@ import org.nanopub.extra.services.ApiResponseEntry; import java.util.Arrays; +import java.util.Set; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.mock; @@ -91,4 +94,133 @@ void getInverseProperties() { }, role.getInverseProperties()); } + private static SpaceMemberRole roleWithType(String roleType) { + ApiResponseEntry entry = mock(ApiResponseEntry.class); + when(entry.get("role")).thenReturn("https://example.org/role"); + when(entry.get("roleType")).thenReturn(roleType); + return new SpaceMemberRole(entry); + } + + @Test + void defaultsToObserverTierWhenRoleTypeAbsent() { + // The shared setUp() role stubs no roleType. + assertEquals(KPXL_TERMS.OBSERVER_ROLE, role.getTier()); + assertEquals(1, role.getTierRank()); + } + + @Test + void parsesTierFromRoleType() { + assertEquals(KPXL_TERMS.MAINTAINER_ROLE, roleWithType(KPXL_TERMS.MAINTAINER_ROLE.stringValue()).getTier()); + assertEquals(3, roleWithType(KPXL_TERMS.MAINTAINER_ROLE.stringValue()).getTierRank()); + assertEquals(KPXL_TERMS.MEMBER_ROLE, roleWithType(KPXL_TERMS.MEMBER_ROLE.stringValue()).getTier()); + assertEquals(2, roleWithType(KPXL_TERMS.MEMBER_ROLE.stringValue()).getTierRank()); + } + + @Test + void tierRankIsStrictlyOrdered() { + int admin = SpaceMemberRole.tierRank(KPXL_TERMS.ADMIN_ROLE_TYPE); + int maintainer = SpaceMemberRole.tierRank(KPXL_TERMS.MAINTAINER_ROLE); + int member = SpaceMemberRole.tierRank(KPXL_TERMS.MEMBER_ROLE); + int observer = SpaceMemberRole.tierRank(KPXL_TERMS.OBSERVER_ROLE); + assertTrue(admin > maintainer); + assertTrue(maintainer > member); + assertTrue(member > observer); + assertTrue(observer > SpaceMemberRole.EVERYONE_RANK); + } + + @Test + void unknownAndNullTierRankToEveryoneFloor() { + assertEquals(SpaceMemberRole.EVERYONE_RANK, SpaceMemberRole.tierRank(Values.iri("https://example.org/role"))); + assertEquals(SpaceMemberRole.EVERYONE_RANK, SpaceMemberRole.tierRank(null)); + assertEquals(0, SpaceMemberRole.EVERYONE_RANK); + } + + @Test + void isTierRecognizesOnlyTierIris() { + assertTrue(SpaceMemberRole.isTier(KPXL_TERMS.ADMIN_ROLE_TYPE)); + assertTrue(SpaceMemberRole.isTier(KPXL_TERMS.MAINTAINER_ROLE)); + assertTrue(SpaceMemberRole.isTier(KPXL_TERMS.MEMBER_ROLE)); + assertTrue(SpaceMemberRole.isTier(KPXL_TERMS.OBSERVER_ROLE)); + assertFalse(SpaceMemberRole.isTier(Values.iri("https://example.org/role"))); + assertFalse(SpaceMemberRole.isTier(null)); + } + + @Test + void adminRoleHasAdminTier() { + assertEquals(KPXL_TERMS.ADMIN_ROLE_TYPE, SpaceMemberRole.ADMIN_ROLE.getTier()); + assertEquals(4, SpaceMemberRole.ADMIN_ROLE.getTierRank()); + assertTrue(SpaceMemberRole.ADMIN_ROLE.isAdminRole()); + } + + private static final IRI VIEWER = Values.iri("https://orcid.org/0000-0000-0000-0001"); + + @Test + void emptyRestrictionIsVisibleToEveryone() { + assertTrue(SpaceMemberRole.isViewerEntitled(Set.of(), null, null, false)); + assertTrue(SpaceMemberRole.isViewerEntitled(Set.of(), VIEWER, mock(Space.class), false)); + assertTrue(SpaceMemberRole.isViewerEntitled(null, null, null, false)); + } + + @Test + void userPageOwnerHoldsAdminTier() { + // No governing space (user page): the owner is the sole admin, so any tier + // requirement matches the owner and nobody else. + for (IRI tier : new IRI[]{KPXL_TERMS.ADMIN_ROLE_TYPE, KPXL_TERMS.MAINTAINER_ROLE, + KPXL_TERMS.MEMBER_ROLE, KPXL_TERMS.OBSERVER_ROLE}) { + assertTrue(SpaceMemberRole.isViewerEntitled(Set.of(tier), VIEWER, null, true), tier.toString()); + assertFalse(SpaceMemberRole.isViewerEntitled(Set.of(tier), VIEWER, null, false), tier.toString()); + } + } + + @Test + void everyoneRoleIsVisibleToAll() { + Set reqs = Set.of(KPXL_TERMS.EVERYONE_ROLE); + // anonymous viewer on a space page + assertTrue(SpaceMemberRole.isViewerEntitled(reqs, null, mock(Space.class), false)); + // member of a space (any tier) + Space space = mock(Space.class); + when(space.userTier(VIEWER)).thenReturn(SpaceMemberRole.EVERYONE_RANK); + assertTrue(SpaceMemberRole.isViewerEntitled(reqs, VIEWER, space, false)); + // non-owner on a user page + assertTrue(SpaceMemberRole.isViewerEntitled(reqs, VIEWER, null, false)); + // EveryoneRole ranks at the floor, below Observer + assertEquals(SpaceMemberRole.EVERYONE_RANK, SpaceMemberRole.tierRank(KPXL_TERMS.EVERYONE_ROLE)); + assertTrue(SpaceMemberRole.isTier(KPXL_TERMS.EVERYONE_ROLE)); + } + + @Test + void userPageSpecificRoleMatchesNobody() { + // Specific roles are unholdable without a space, so even the owner (admin + // tier, not that role) does not match. + IRI customRole = Values.iri("https://example.org/newsletterEditor"); + assertFalse(SpaceMemberRole.isViewerEntitled(Set.of(customRole), VIEWER, null, true)); + assertFalse(SpaceMemberRole.isViewerEntitled(Set.of(customRole), VIEWER, null, false)); + } + + @Test + void loggedOutViewerFailsRestriction() { + assertFalse(SpaceMemberRole.isViewerEntitled(Set.of(KPXL_TERMS.MEMBER_ROLE), null, mock(Space.class), false)); + } + + @Test + void tierThresholdMatchesAtOrAboveRequired() { + Space space = mock(Space.class); + when(space.userTier(VIEWER)).thenReturn(3); // maintainer + assertTrue(SpaceMemberRole.isViewerEntitled(Set.of(KPXL_TERMS.MAINTAINER_ROLE), VIEWER, space, false)); + assertTrue(SpaceMemberRole.isViewerEntitled(Set.of(KPXL_TERMS.MEMBER_ROLE), VIEWER, space, false)); + assertFalse(SpaceMemberRole.isViewerEntitled(Set.of(KPXL_TERMS.ADMIN_ROLE_TYPE), VIEWER, space, false)); + } + + @Test + void specificRoleMatchesOnlyWhenHeld() { + IRI customRole = Values.iri("https://example.org/newsletterEditor"); + Space space = mock(Space.class); + when(space.userTier(VIEWER)).thenReturn(4); // even an admin... + when(space.viewerHoldsRole(VIEWER, customRole)).thenReturn(false); + // ...does not see a specific-role-gated action they don't hold (no admin override) + assertFalse(SpaceMemberRole.isViewerEntitled(Set.of(customRole), VIEWER, space, false)); + when(space.viewerHoldsRole(VIEWER, customRole)).thenReturn(true); + assertTrue(SpaceMemberRole.isViewerEntitled(Set.of(customRole), VIEWER, space, false)); + } + } \ No newline at end of file