Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ Status legend: ✅ Implemented · 🚧 In progress · 📋 Proposed
| [magic-query-params](magic-query-params.md) | ✅ Implemented | Session-bound view-query placeholders (`LOCALPUBKEY`, `SITEURL`, `CURRENTUSER`); replaced the custom introductions table with proper views (`ProfileIntroItem` removed) |
| [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 |
| [structural-position](structural-position.md) | ✅ Implemented | `gen:hasStructuralPosition` `<section>.<sub>.<label>` strings order & group views on a page; strict format, primary digits 3–7 (intro…outro) in use ([#279](https://github.com/knowledgepixels/nanodash/issues/279)) |
| [space-namespace-prefixes](space-namespace-prefixes.md) | ✅ Implemented | `nt:hasPrefix "~~SPACE~~/"` / `"~~NAMESPACE~~"` mint resources under the space or maintained resource the form was opened from, with a picker when the `context` param determines none ([#571](https://github.com/knowledgepixels/nanodash/issues/571)) |
| [fill-modes](fill-modes.md) | ✅ Implemented | Publish-form fill modes (use / supersede / derive / override): which IDs & root definition are kept vs. re-minted; derive now resets root, new `override` mode added ([#527](https://github.com/knowledgepixels/nanodash/issues/527)) |
| [space-ref-identity](space-ref-identity.md) | 🚧 In progress | A space's identity is IRI + root-definition NPID (rival roots = distinct spaces); ref-keyed `SpaceRepository` + ref-scoped per-space authority queries shipped; one-`Space`-per-ref identity + disambiguation UI still to come |
| [shacl-alignment](shacl-alignment.md) | 📋 Proposed | Build on the SHACL standard: adopt `sh:`/`dash:` constraint terms inside templates (A) and export SHACL shapes for external validation (B) |
Expand Down
113 changes: 113 additions & 0 deletions docs/space-namespace-prefixes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
# Space-/namespace-dependent prefixes in templates

**Status:** ✅ Implemented — [#571](https://github.com/knowledgepixels/nanodash/issues/571)

## Goal

Let a template mint resources under **whichever space or maintained resource the form was
opened from**, instead of a namespace the template author has to hard-code. Without this, a
template that creates, say, a dataset record under a space needs one copy per space.

A `nt:hasPrefix` value may now contain a placeholder standing for the currently applicable
space or maintained-resource namespace:

```
sub:resource nt:hasPrefix "~~SPACE~~/" . # https://w3id.org/space/foo/bar/
sub:resource nt:hasPrefix "~~SPACE~~/r/" . # https://w3id.org/space/foo/bar/r/
sub:resource nt:hasPrefix "~~NAMESPACE~~" . # the current maintained resource's namespace
```

- **`~~SPACE~~`** stands for the IRI of the applicable space, **without** a trailing
separator — the template writes the separator it wants (`"~~SPACE~~/"`,
`"~~SPACE~~/r/"`, …).
- **`~~NAMESPACE~~`** stands for the namespace of the current maintained resource
**including** its trailing separator: its declared namespace, or its own IRI plus `/`
when it declares none.

Only one placeholder per prefix is meaningful; `~~SPACE~~` wins if both appear.

## Where the value comes from

The base is taken from the **navigation context** — the `context` URL parameter that
Nanodash already carries across pages (`NavigationContext`), naming the space, maintained
resource, or user a page was reached under:

| Context resource | `~~SPACE~~` | `~~NAMESPACE~~` |
| --- | --- | --- |
| a space | the space's IRI | *unresolved* |
| a maintained resource | the IRI of the space maintaining it | the resource's namespace |
| a user, or no context | *unresolved* | *unresolved* |

When the context determines no base, the form renders a **picker** in front of the
placeholder's text field, offering the known spaces (for `~~SPACE~~`) or maintained
resources (for `~~NAMESPACE~~`) by label. The context always wins when it does determine
one: the picker only appears where it is actually needed, so the common case (publishing
from a space page) stays a single-field form.

## Implementation

- **`DynamicPrefix`** (`com.knowledgepixels.nanodash`) owns the placeholder constants,
detects which one a raw prefix uses, resolves it against a context id, and lists the
selectable bases (from `SpaceRepository.findAll()` /
`MaintainedResourceRepository.findAll()`). `Template` is untouched: it keeps storing the
`nt:hasPrefix` literal verbatim, and resolution is a fill-time concern.
- **`TemplateContext.getPrefix(IRI)`** is the resolved counterpart of
`Template.getPrefix(IRI)` and is what every consumer now calls (`IriTextfieldItem`,
`GuidedChoiceItem`, `ReadonlyItem`, the nanopub-label pattern in `PublishForm`, and
`processValue`). It returns null both when the template declares no prefix and when it
declares an unresolved dynamic one; `hasUnresolvedPrefix(IRI)` tells the two apart.
The navigation context is handed to the context via `setNavigationContextId`, which
`PublishForm` fills from its page parameters.
- **The picked base** lives in a component model keyed by the **token**, not by the
placeholder (`TemplateContext.getPrefixModelKey(token)` → `local:prefix-base/SPACE`,
`local:prefix-base/NAMESPACE`). Every field whose prefix depends on the same thing —
including every repetition, and including fields with different suffixes such as
`~~SPACE~~/` and `~~SPACE~~/r/` — therefore shares **one** model instance. That is what
makes picking a space in one dropdown apply to all of them: the pickers share a model, so
the same shared-model AJAX refresh the form already uses for placeholders that appear in
several statements (`c.getDefaultModel() == choice.getModel()`) reaches every other
picker, and each field keeps its own suffix on top of the shared base. `~~SPACE~~` and
`~~NAMESPACE~~` keep separate models — they are different things picked from different
lists. The base is prefillable via the URL parameter `param_<postfix>__prefix` on any of
the sharing placeholders (first one wins).
- **A field under a dynamic prefix ignores its own `param_<postfix>`.** The field holds
only the name below the prefix, and the namespace comes from the context or the picker;
a parameter for it — in practice a full IRI — would land in the field and bypass the
prefix altogether. Dropping it makes a bare `~~SPACE~~` behave exactly like
`~~SPACE~~/r/`, whatever the prefix's trailing path is. Static prefixes are unaffected:
`param_<postfix>` prefills them as before.
- **`IriTextfieldItem`** renders the picker (a select2 dropdown, `prefixchoice`) when the
prefix is dynamic and the navigation context resolves nothing. The dropdown is required
exactly when the paired text field holds something that isn't already a full URI, so an
untouched optional field and a fully-typed-out URI both stay valid. The prefix used for
display, validation, and unification is re-resolved on every access (`PrefixModel`), so
picking a base updates the field without rebuilding the form.
- **Publishing** with an unresolved prefix yields **no value** for the placeholder rather
than an IRI in the wrong namespace (`TemplateContext.processValue`); form validation
blocks the case anyway, this is the belt-and-braces half.

## Deliberately out of scope

Both are the "maybe for later" items of [#571](https://github.com/knowledgepixels/nanodash/issues/571):

- **Always showing the picker**, with the context merely pre-selecting it. The context is
authoritative today; a picker that can silently contradict the page you are on is a
bigger UX decision than this issue needed.
- **Restricting the options to the user's own spaces.** The picker lists every known
space / maintained resource; publishing into one the user has no rights on is caught
downstream, not in the form.

Also not covered: a dynamic prefix on a **restricted-choice** or **guided-choice**
placeholder renders no picker (only `IriTextfieldItem` has one). Such a placeholder still
resolves its prefix from the navigation context, and publishes nothing when it cannot —
the natural placeholder type for "mint a resource under this space" is a plain URI
placeholder.

## Backwards compatibility

- **Old templates, new code:** no deployed template uses the placeholders, so every prefix
is detected as static and handled exactly as before.
- **New templates, old code:** an old Nanodash treats `"~~SPACE~~/"` as a literal prefix
and would publish IRIs starting with `~~SPACE~~/`. There is no graceful degradation
here, so don't put the placeholders in templates shared with instances that predate this
feature.
141 changes: 141 additions & 0 deletions src/main/java/com/knowledgepixels/nanodash/DynamicPrefix.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
package com.knowledgepixels.nanodash;

import com.knowledgepixels.nanodash.domain.AbstractResourceWithProfile;
import com.knowledgepixels.nanodash.domain.MaintainedResource;
import com.knowledgepixels.nanodash.domain.Space;
import com.knowledgepixels.nanodash.repository.MaintainedResourceRepository;
import com.knowledgepixels.nanodash.repository.SpaceRepository;

import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

/**
* The space-/namespace-dependent placeholders a template can use in an
* {@code nt:hasPrefix} value, so a template can mint resources under whichever space or
* maintained resource the form was opened from instead of a namespace hard-coded by the
* template author (see issue #571 and docs/space-namespace-prefixes.md):
*
* <pre>
* nt:hasPrefix "~~SPACE~~/" → https://w3id.org/space/foo/bar/
* nt:hasPrefix "~~SPACE~~/r/" → https://w3id.org/space/foo/bar/r/
* nt:hasPrefix "~~NAMESPACE~~" → the current maintained resource's namespace
* </pre>
* <p>
* The base is taken from the navigation context (the {@code context} URL parameter); when
* that yields nothing, the form offers the user a choice among the known spaces /
* maintained resources instead.
*/
public class DynamicPrefix {

private DynamicPrefix() {
}

/**
* Placeholder standing for the IRI of the currently applicable space (no trailing
* separator; templates write {@code "~~SPACE~~/"}).
*/
public static final String SPACE_TOKEN = "~~SPACE~~";

/**
* Placeholder standing for the namespace of the current maintained resource
* (including its trailing separator).
*/
public static final String NAMESPACE_TOKEN = "~~NAMESPACE~~";

/**
* Returns the dynamic placeholder used in the given raw {@code nt:hasPrefix} value.
*
* @param rawPrefix the prefix as declared by the template, may be null
* @return {@link #SPACE_TOKEN}, {@link #NAMESPACE_TOKEN}, or null if the prefix is static
*/
public static String getToken(String rawPrefix) {
if (rawPrefix == null) return null;
if (rawPrefix.contains(SPACE_TOKEN)) return SPACE_TOKEN;
if (rawPrefix.contains(NAMESPACE_TOKEN)) return NAMESPACE_TOKEN;
return null;
}

/**
* Resolves the base a token stands for from the navigation context.
*
* @param token the token, as returned by {@link #getToken(String)}
* @param contextId the navigation context resource id (the {@code context} URL parameter)
* @return the base to substitute for the token, or null if the context doesn't determine one
*/
public static String resolveFromContext(String token, String contextId) {
AbstractResourceWithProfile resource = NavigationContext.resolve(contextId);
if (resource == null) return null;
if (SPACE_TOKEN.equals(token)) {
// A maintained resource resolves to the space maintaining it; a space to itself.
Space space = resource.getSpace();
return space == null ? null : space.getId();
}
if (NAMESPACE_TOKEN.equals(token)) {
if (resource instanceof MaintainedResource maintainedResource) {
return getNamespace(maintainedResource);
}
return null;
}
return null;
}

/**
* The namespace to mint sub-resources of the given maintained resource under: its
* declared namespace, or its own IRI plus a separator if it declares none.
*
* @param resource the maintained resource
* @return the namespace, ending in a separator
*/
public static String getNamespace(MaintainedResource resource) {
String namespace = resource.getNamespace();
if (namespace != null && !namespace.isBlank()) return namespace;
return resource.getId() + "/";
}

/**
* The bases the user can pick from when the navigation context doesn't determine one,
* mapped to their display labels and ordered by label.
*
* @param token the token, as returned by {@link #getToken(String)}
* @return the selectable bases, mapped to their labels; empty if the token is unknown
*/
public static Map<String, String> getOptions(String token) {
Map<String, String> options = new LinkedHashMap<>();
if (SPACE_TOKEN.equals(token)) {
List<Space> spaces = new ArrayList<>(SpaceRepository.get().findAll());
spaces.sort(Comparator.comparing(DynamicPrefix::getLabel, String.CASE_INSENSITIVE_ORDER));
for (Space space : spaces) {
options.putIfAbsent(space.getId(), getLabel(space));
}
} else if (NAMESPACE_TOKEN.equals(token)) {
List<MaintainedResource> resources = new ArrayList<>(MaintainedResourceRepository.get().findAll());
resources.sort(Comparator.comparing(DynamicPrefix::getLabel, String.CASE_INSENSITIVE_ORDER));
for (MaintainedResource resource : resources) {
options.putIfAbsent(getNamespace(resource), getLabel(resource));
}
}
return options;
}

/**
* The name of what the user is picking, used as the selector's placeholder text and
* as its label in validation messages.
*
* @param token the token, as returned by {@link #getToken(String)}
* @return the human-readable name of the selection
*/
public static String getSelectionLabel(String token) {
if (NAMESPACE_TOKEN.equals(token)) return "resource";
return "space";
}

private static String getLabel(AbstractResourceWithProfile resource) {
String label = resource.getLabel();
if (label == null || label.isBlank()) return resource.getId();
return label;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ public GuidedChoiceItem(String id, String parentId, final IRI iriP, boolean opti
possibleValues.add(v.toString());
}

prefix = template.getPrefix(iri);
prefix = context.getPrefix(iri);
if (prefix == null) prefix = "";
String prefixLabel = template.getPrefixLabel(iri);
Label prefixLabelComp;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
<body>

<wicket:panel>
<span class="tooltip"><a class="tooltiptext" wicket:id="prefixtooltiptext"></a><span wicket:id="prefix" class="valueitem"></span></span><input type="text" class="nanopub-textfield" wicket:id="textfield" />
<span class="tooltip"><a class="tooltiptext" wicket:id="prefixtooltiptext"></a><span wicket:id="prefix" class="valueitem"></span></span><select wicket:id="prefixchoice" class="prefix-select"></select><input type="text" class="nanopub-textfield" wicket:id="textfield" />
</wicket:panel>

</body>
Expand Down
Loading