From f3991bdb77ae4bdbd18a189049e29d4b2d588780 Mon Sep 17 00:00:00 2001 From: Tobias Kuhn Date: Tue, 2 Jun 2026 13:50:31 +0200 Subject: [PATCH 01/33] feat: add standalone About pages for spaces, resources, and users (#478) Add three standalone, mounted, parameterized About pages, reachable by direct URL only (not yet linked from the existing pages, and the existing pages are unchanged): - AboutSpacePage -> /spaceabout?id= - AboutResourcePage -> /resourceabout?id= - AboutUserPage -> /userabout?id= Each page shows a listing of the resource's assigned view displays (built on the get-view-displays query Nanodash uses internally, rendered via a newly published ResourceView/TabularView nanopub) plus a references table -- rather than rendering the assigned data views themselves. The user About page additionally shows a new read-only PublicProfilePanel (introductions, public keys with per-user approval, default license) that works for any user, unlike the session-bound ProfilePage items. ReferencesPage.REFERENCES_VIEW is promoted to public so the About pages can reuse it. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../nanodash/WicketApplication.java | 3 + .../component/PublicProfilePanel.html | 42 ++++++ .../component/PublicProfilePanel.java | 109 +++++++++++++++ .../nanodash/page/AboutResourcePage.html | 35 +++++ .../nanodash/page/AboutResourcePage.java | 126 ++++++++++++++++++ .../nanodash/page/AboutSpacePage.html | 34 +++++ .../nanodash/page/AboutSpacePage.java | 92 +++++++++++++ .../nanodash/page/AboutUserPage.html | 41 ++++++ .../nanodash/page/AboutUserPage.java | 100 ++++++++++++++ .../nanodash/page/ReferencesPage.java | 5 +- 10 files changed, 586 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/knowledgepixels/nanodash/component/PublicProfilePanel.html create mode 100644 src/main/java/com/knowledgepixels/nanodash/component/PublicProfilePanel.java create mode 100644 src/main/java/com/knowledgepixels/nanodash/page/AboutResourcePage.html create mode 100644 src/main/java/com/knowledgepixels/nanodash/page/AboutResourcePage.java create mode 100644 src/main/java/com/knowledgepixels/nanodash/page/AboutSpacePage.html create mode 100644 src/main/java/com/knowledgepixels/nanodash/page/AboutSpacePage.java create mode 100644 src/main/java/com/knowledgepixels/nanodash/page/AboutUserPage.html create mode 100644 src/main/java/com/knowledgepixels/nanodash/page/AboutUserPage.java diff --git a/src/main/java/com/knowledgepixels/nanodash/WicketApplication.java b/src/main/java/com/knowledgepixels/nanodash/WicketApplication.java index 2004b14f..d712858d 100644 --- a/src/main/java/com/knowledgepixels/nanodash/WicketApplication.java +++ b/src/main/java/com/knowledgepixels/nanodash/WicketApplication.java @@ -188,6 +188,9 @@ protected void init() { mountPage(MaintainedResourcePage.MOUNT_PATH, MaintainedResourcePage.class); mountPage(ResourcePartPage.MOUNT_PATH, ResourcePartPage.class); mountPage(DownloadRdfPage.MOUNT_PATH, DownloadRdfPage.class); + mountPage(AboutSpacePage.MOUNT_PATH, AboutSpacePage.class); + mountPage(AboutResourcePage.MOUNT_PATH, AboutResourcePage.class); + mountPage(AboutUserPage.MOUNT_PATH, AboutUserPage.class); getCspSettings().blocking().disabled(); getStoreSettings().setMaxSizePerSession(Bytes.megabytes(100)); diff --git a/src/main/java/com/knowledgepixels/nanodash/component/PublicProfilePanel.html b/src/main/java/com/knowledgepixels/nanodash/component/PublicProfilePanel.html new file mode 100644 index 00000000..0e7618e0 --- /dev/null +++ b/src/main/java/com/knowledgepixels/nanodash/component/PublicProfilePanel.html @@ -0,0 +1,42 @@ + + + + + + + + + + +
+

Introductions

+
+ +

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

Public Keys

+
+ +

+ +
    +
  • +
+ +

License:

+ +
+ + + + diff --git a/src/main/java/com/knowledgepixels/nanodash/component/PublicProfilePanel.java b/src/main/java/com/knowledgepixels/nanodash/component/PublicProfilePanel.java new file mode 100644 index 00000000..1a8d2f4e --- /dev/null +++ b/src/main/java/com/knowledgepixels/nanodash/component/PublicProfilePanel.java @@ -0,0 +1,109 @@ +package com.knowledgepixels.nanodash.component; + +import com.knowledgepixels.nanodash.Utils; +import com.knowledgepixels.nanodash.domain.User; +import com.knowledgepixels.nanodash.page.ExplorePage; +import net.trustyuri.TrustyUriUtils; +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.link.ExternalLink; +import org.apache.wicket.markup.html.panel.Panel; +import org.apache.wicket.markup.repeater.Item; +import org.apache.wicket.markup.repeater.data.DataView; +import org.apache.wicket.markup.repeater.data.ListDataProvider; +import org.apache.wicket.model.Model; +import org.apache.wicket.request.mapper.parameter.PageParameters; +import org.eclipse.rdf4j.model.IRI; +import org.nanopub.SimpleTimestampPattern; +import org.nanopub.extra.security.KeyDeclaration; +import org.nanopub.extra.setting.IntroNanopub; + +import java.util.Calendar; +import java.util.List; + +/** + * A read-only, public-facing view of a user's profile information: their + * introduction nanopublications, declared public keys, and default license. + *

+ * Unlike the session-bound profile items used on the editable + * {@code ProfilePage} (e.g. {@code ProfileIntroItem}, {@code ProfileSigItem}), + * this panel takes an arbitrary user IRI and shows no publish/edit actions, so + * it can be rendered for any user. + */ +public class PublicProfilePanel extends Panel { + + /** + * Constructs a PublicProfilePanel for the given user. + * + * @param id the Wicket component id + * @param userIri the IRI of the user whose profile is shown + */ + public PublicProfilePanel(String id, IRI userIri) { + super(id); + + // Introductions + List intros = User.getIntroNanopubs(userIri); + add(new Label("intro-note", intros.isEmpty() + ? "There are no introductions yet." : "").setEscapeModelStrings(false)); + add(new DataView("intro-nps", new ListDataProvider<>(intros)) { + @Override + protected void populateItem(Item item) { + final IntroNanopub inp = item.getModelObject(); + String uri = inp.getNanopub().getUri().stringValue(); + BookmarkablePageLink link = new BookmarkablePageLink<>("intro-uri", ExplorePage.class, new PageParameters().set("id", uri)); + link.add(new Label("intro-uri-label", TrustyUriUtils.getArtifactCode(uri).substring(0, 10))); + item.add(link); + if (User.isApproved(inp)) { + item.add(new Label("intro-note", " (approved)").setEscapeModelStrings(false)); + } else { + item.add(new Label("intro-note", "")); + } + IRI location = Utils.getLocation(inp); + if (location == null) { + item.add(new Label("location", "unknown site")); + } else { + item.add(new Label("location", "" + location + "").setEscapeModelStrings(false)); + } + Calendar creationDate = SimpleTimestampPattern.getCreationTime(inp.getNanopub()); + item.add(new Label("date", creationDate == null ? "unknown date" : NanopubItem.simpleDateFormat.format(creationDate.getTime()))); + + item.add(new DataView("intro-keys", new ListDataProvider<>(inp.getKeyDeclarations())) { + @Override + protected void populateItem(Item kdi) { + kdi.add(new Label("intro-key", Utils.getShortPubkeyName(Utils.createSha256HexHash(kdi.getModelObject().getPublicKeyString())))); + } + }); + } + }); + + // Public keys (with per-user approval status) + List pubkeyhashes = User.getPubkeyhashes(userIri, null); + add(new Label("keys-note", pubkeyhashes.isEmpty() + ? "There are no public keys yet." : "").setEscapeModelStrings(false)); + add(new DataView("keys", new ListDataProvider<>(pubkeyhashes)) { + @Override + protected void populateItem(Item item) { + String hash = item.getModelObject(); + item.add(new Label("key-label", Utils.getShortPubkeyName(hash))); + if (User.isApprovedPubkeyhashForUser(hash, userIri)) { + item.add(new Label("key-note", " (approved)").setEscapeModelStrings(false)); + } else { + item.add(new Label("key-note", " (not approved)").setEscapeModelStrings(false)); + } + } + }); + + // Default license + IRI license = User.getDefaultLicense(userIri); + WebMarkupContainer licenseContainer = new WebMarkupContainer("license-container"); + if (license == null) { + licenseContainer.setVisible(false); + licenseContainer.add(new ExternalLink("license", ".")); + } else { + licenseContainer.add(new ExternalLink("license", license.stringValue(), license.stringValue())); + } + add(licenseContainer); + } + +} diff --git a/src/main/java/com/knowledgepixels/nanodash/page/AboutResourcePage.html b/src/main/java/com/knowledgepixels/nanodash/page/AboutResourcePage.html new file mode 100644 index 00000000..abbf7c89 --- /dev/null +++ b/src/main/java/com/knowledgepixels/nanodash/page/AboutResourcePage.html @@ -0,0 +1,35 @@ + + + + + + + + ... (about) | nanodash + + + + + +

+
+
+

Resource ABC

+

+

Namespace:

+

Maintained by:

+ +
+
+ +
+
+
+ +
+
+
+ + + + diff --git a/src/main/java/com/knowledgepixels/nanodash/page/AboutResourcePage.java b/src/main/java/com/knowledgepixels/nanodash/page/AboutResourcePage.java new file mode 100644 index 00000000..79e1dfec --- /dev/null +++ b/src/main/java/com/knowledgepixels/nanodash/page/AboutResourcePage.java @@ -0,0 +1,126 @@ +package com.knowledgepixels.nanodash.page; + +import com.knowledgepixels.nanodash.NanodashPageRef; +import com.knowledgepixels.nanodash.View; +import com.knowledgepixels.nanodash.ViewDisplay; +import com.knowledgepixels.nanodash.component.*; +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 org.apache.wicket.markup.html.basic.Label; +import org.apache.wicket.markup.html.link.BookmarkablePageLink; +import org.apache.wicket.model.IModel; +import org.apache.wicket.model.LoadableDetachableModel; +import org.apache.wicket.model.Model; +import org.apache.wicket.request.mapper.parameter.PageParameters; +import org.eclipse.rdf4j.model.util.Values; +import org.nanopub.extra.services.QueryRef; + +import java.util.List; + +/** + * Standalone "About" page for a maintained resource, showing its views and + * references. Reachable by direct URL only; not yet linked from the main + * {@link MaintainedResourcePage}. + *

+ * Resource-level members/roles are intentionally not shown: the domain model + * has no per-resource membership (roles/members live on the parent + * {@link Space}). See issue #478 for the planned follow-up. + */ +public class AboutResourcePage extends NanodashPage { + + /** + * The mount path for this page. + */ + public static final String MOUNT_PATH = "/resourceabout"; + + /** + * {@inheritDoc} + */ + @Override + public String getMountPath() { + return MOUNT_PATH; + } + + private final String resourceId; + private final IModel resourceModel; + + /** + * Constructor for the AboutResourcePage. + * + * @param parameters the page parameters, must include "id" with the resource IRI + */ + public AboutResourcePage(final PageParameters parameters) { + super(parameters); + + MaintainedResource resource = MaintainedResourceRepository.get().findById(parameters.get("id").toString()); + if (resource == null) { + throw new IllegalArgumentException("No maintained resource found for id: " + parameters.get("id")); + } + resourceId = resource.getId(); + resourceModel = new LoadableDetachableModel() { + @Override + protected MaintainedResource load() { + return MaintainedResourceRepository.get().findById(resourceId); + } + }; + Space space = resource.getSpace(); + resource.triggerDataUpdate(); + + 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) + )); + + add(new JustPublishedMessagePanel("justPublishedMessage", parameters)); + + add(new Label("pagetitle", resource.getLabel() + " (about) | nanodash")); + add(new Label("resourcename", resource.getLabel())); + 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))); + + // Pointer to the maintaining space (membership lives there, not on the resource). + if (space != null) { + add(new BookmarkablePageLink("space", SpacePage.class, new PageParameters().set("id", space.getId())).setBody(Model.of(space.getLabel()))); + } else { + add(new BookmarkablePageLink("space", SpacePage.class).setVisible(false)); + } + + add(new DownloadRdfLinks("download-rdf", "resource", resource.getId())); + + // Assigned view displays (a listing of the configured view displays, + // not the rendered views themselves). + View vdView = View.get(AboutSpacePage.VIEW_DISPLAYS_VIEW); + QueryRef vdQueryRef = new QueryRef(vdView.getQuery().getQueryId(), "resource", resource.getId()); + add(QueryResultTableBuilder.create("viewdisplays", vdQueryRef, new ViewDisplay(vdView)).build()); + + // References + View refView = View.get(ReferencesPage.REFERENCES_VIEW); + QueryRef refQueryRef = new QueryRef(refView.getQuery().getQueryId(), "ref", resource.getId()); + add(QueryResultTableBuilder.create("references", refQueryRef, new ViewDisplay(refView)).build()); + } + + /** + * Checks if auto-refresh is enabled for this page. + * + * @return true if auto-refresh is enabled, false otherwise + */ + protected boolean hasAutoRefreshEnabled() { + return true; + } + + /** + * {@inheritDoc} + */ + @Override + protected void onDetach() { + resourceModel.detach(); + super.onDetach(); + } + +} diff --git a/src/main/java/com/knowledgepixels/nanodash/page/AboutSpacePage.html b/src/main/java/com/knowledgepixels/nanodash/page/AboutSpacePage.html new file mode 100644 index 00000000..25b65615 --- /dev/null +++ b/src/main/java/com/knowledgepixels/nanodash/page/AboutSpacePage.html @@ -0,0 +1,34 @@ + + + + + + + + ... (about) | nanodash + + + + + +

+
+
+

Space ABC

+

Space type

+

+ +
+
+ +
+
+
+ +
+
+
+ + + + diff --git a/src/main/java/com/knowledgepixels/nanodash/page/AboutSpacePage.java b/src/main/java/com/knowledgepixels/nanodash/page/AboutSpacePage.java new file mode 100644 index 00000000..a9738357 --- /dev/null +++ b/src/main/java/com/knowledgepixels/nanodash/page/AboutSpacePage.java @@ -0,0 +1,92 @@ +package com.knowledgepixels.nanodash.page; + +import com.knowledgepixels.nanodash.NanodashPageRef; +import com.knowledgepixels.nanodash.View; +import com.knowledgepixels.nanodash.ViewDisplay; +import com.knowledgepixels.nanodash.component.*; +import com.knowledgepixels.nanodash.domain.AbstractResourceWithProfile; +import com.knowledgepixels.nanodash.domain.Space; +import com.knowledgepixels.nanodash.repository.SpaceRepository; +import org.apache.wicket.markup.html.basic.Label; +import org.apache.wicket.model.Model; +import org.apache.wicket.request.mapper.parameter.PageParameters; +import org.nanopub.extra.services.QueryRef; + +import java.util.List; + +/** + * Standalone "About" page for a space, showing a listing of its assigned view + * displays and references. Reachable by direct URL only; not yet linked from + * the main {@link SpacePage}. + */ +public class AboutSpacePage extends NanodashPage { + + /** + * The mount path for this page. + */ + public static final String MOUNT_PATH = "/spaceabout"; + + /** + * {@inheritDoc} + */ + @Override + public String getMountPath() { + return MOUNT_PATH; + } + + /** + * View that lists all assigned view displays of a resource (built on the + * get-view-displays query Nanodash uses internally). Shown on About pages + * instead of rendering the assigned views themselves. + */ + public static final String VIEW_DISPLAYS_VIEW = "https://w3id.org/np/RAQO0kK2FFtdfdGShuLkpFTbrq90btzt38z4w1hGVQfJE/view-displays-view"; + + /** + * Constructor for the AboutSpacePage. + * + * @param parameters the page parameters, must include "id" with the space IRI + */ + public AboutSpacePage(final PageParameters parameters) { + super(parameters); + + Space space = SpaceRepository.get().findById(parameters.get("id").toString()); + if (space == null) { + throw new IllegalArgumentException("No space found for id: " + parameters.get("id")); + } + + List superSpaces = space.getAllSuperSpacesUntilRoot(); + 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) + )); + + add(new JustPublishedMessagePanel("justPublishedMessage", parameters)); + + add(new Label("pagetitle", space.getLabel() + " (about) | nanodash")); + add(new Label("spacename", space.getLabel())); + add(new Label("spacetype", space.getTypeLabel())); + add(new ExternalLinkWithActionsPanel("id", Model.of(space.getId()), Model.of(space.getLabel()))); + add(new DownloadRdfLinks("download-rdf", "space", space.getId())); + + // Assigned view displays (a listing of the configured view displays, + // not the rendered views themselves). + View vdView = View.get(VIEW_DISPLAYS_VIEW); + QueryRef vdQueryRef = new QueryRef(vdView.getQuery().getQueryId(), "resource", space.getId()); + add(QueryResultTableBuilder.create("viewdisplays", vdQueryRef, new ViewDisplay(vdView)).build()); + + // References + View refView = View.get(ReferencesPage.REFERENCES_VIEW); + QueryRef refQueryRef = new QueryRef(refView.getQuery().getQueryId(), "ref", space.getId()); + add(QueryResultTableBuilder.create("references", refQueryRef, new ViewDisplay(refView)).build()); + } + + /** + * Checks if auto-refresh is enabled for this page. + * + * @return true if auto-refresh is enabled, false otherwise + */ + protected boolean hasAutoRefreshEnabled() { + return true; + } + +} diff --git a/src/main/java/com/knowledgepixels/nanodash/page/AboutUserPage.html b/src/main/java/com/knowledgepixels/nanodash/page/AboutUserPage.html new file mode 100644 index 00000000..7c1bfc43 --- /dev/null +++ b/src/main/java/com/knowledgepixels/nanodash/page/AboutUserPage.html @@ -0,0 +1,41 @@ + + + + + + + + ... (about) | nanodash + + + + +
+
+
+

+ + User Name

+ + +
+
+ +
+
+
+
+
+ +
+
+
+ +
+
+
+ +
+ + + diff --git a/src/main/java/com/knowledgepixels/nanodash/page/AboutUserPage.java b/src/main/java/com/knowledgepixels/nanodash/page/AboutUserPage.java new file mode 100644 index 00000000..7debc0dd --- /dev/null +++ b/src/main/java/com/knowledgepixels/nanodash/page/AboutUserPage.java @@ -0,0 +1,100 @@ +package com.knowledgepixels.nanodash.page; + +import com.knowledgepixels.nanodash.Utils; +import com.knowledgepixels.nanodash.View; +import com.knowledgepixels.nanodash.ViewDisplay; +import com.knowledgepixels.nanodash.component.*; +import com.knowledgepixels.nanodash.domain.IndividualAgent; +import com.knowledgepixels.nanodash.domain.User; +import org.apache.wicket.markup.html.basic.Label; +import org.apache.wicket.markup.html.image.ExternalImage; +import org.apache.wicket.markup.html.image.Image; +import org.apache.wicket.model.Model; +import org.apache.wicket.request.mapper.parameter.PageParameters; +import org.apache.wicket.request.resource.ContextRelativeResourceReference; +import org.eclipse.rdf4j.model.IRI; +import org.nanopub.extra.services.QueryRef; + +/** + * Standalone "About" page for a user, showing a public read-only view of their + * profile (introductions, keys, license), their configured views, and + * references. Reachable by direct URL only; not yet linked from the main + * {@link UserPage}. + *

+ * Space memberships ("assigned roles") are not listed yet: there is no ready + * API to enumerate the spaces a user belongs to. See issue #478 for the + * planned follow-up. + */ +public class AboutUserPage extends NanodashPage { + + /** + * The mount path for this page. + */ + public static final String MOUNT_PATH = "/userabout"; + + /** + * {@inheritDoc} + */ + @Override + public String getMountPath() { + return MOUNT_PATH; + } + + /** + * Constructor for the AboutUserPage. + * + * @param parameters the page parameters, must include "id" with the user IRI + */ + public AboutUserPage(final PageParameters parameters) { + super(parameters); + + if (parameters.get("id").isEmpty()) { + throw new IllegalArgumentException("No user id given"); + } + final String userIriString = parameters.get("id").toString(); + final IRI userIri = Utils.vf.createIRI(userIriString); + + add(new TitleBar("titlebar", this, "users")); + + add(new JustPublishedMessagePanel("justPublishedMessage", parameters)); + + IRI profilePictureIri = User.getProfilePicture(userIri); + if (profilePictureIri != null) { + add(new ExternalImage("userIcon", profilePictureIri)); + } else if (IndividualAgent.isSoftware(userIri)) { + add(new Image("userIcon", new ContextRelativeResourceReference("images/bot-icon.svg", false))); + } else { + add(new Image("userIcon", new ContextRelativeResourceReference("images/user-icon.svg", false))); + } + + final String displayName = User.getShortDisplayName(userIri); + add(new Label("pagetitle", displayName + " (about) | nanodash")); + add(new Label("username", displayName)); + add(new ExternalLinkWithActionsPanel("fullid", Model.of(userIriString), Model.of(displayName))); + add(new DownloadRdfLinks("download-rdf", "user", userIriString)); + + // Public read-only profile + add(new PublicProfilePanel("profile", userIri)); + + // Assigned view displays (a listing of the configured view displays, + // not the rendered views themselves). + View vdView = View.get(AboutSpacePage.VIEW_DISPLAYS_VIEW); + QueryRef vdQueryRef = new QueryRef(vdView.getQuery().getQueryId(), "resource", userIriString); + add(QueryResultTableBuilder.create("viewdisplays", vdQueryRef, new ViewDisplay(vdView)).build()); + + // References + View refView = View.get(ReferencesPage.REFERENCES_VIEW); + QueryRef refQueryRef = new QueryRef(refView.getQuery().getQueryId(), "ref", userIriString); + add(QueryResultTableBuilder.create("references", refQueryRef, new ViewDisplay(refView)).build()); + } + + /** + * Checks if auto-refresh is enabled for this page. + * + * @return true if auto-refresh is enabled, false otherwise + */ + protected boolean hasAutoRefreshEnabled() { + return true; + } + +} diff --git a/src/main/java/com/knowledgepixels/nanodash/page/ReferencesPage.java b/src/main/java/com/knowledgepixels/nanodash/page/ReferencesPage.java index 4c64ca12..1ad8d436 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() { From 825da0baa8d389b7383b4455e09fc4d46cd4550c Mon Sep 17 00:00:00 2001 From: Tobias Kuhn Date: Tue, 2 Jun 2026 14:48:27 +0200 Subject: [PATCH 02/33] feat: user About page views (introductions, profile) + alpha table stripes (#478) - Render the user's introductions as a proper view on AboutUserPage (live ResourceView/TabularView backed by a per-user get-user-introductions query), replacing the custom intro DataView. - Replace PublicProfilePanel with a "Profile" view (default license + profile picture, one row each, with source-nanopub column and "update profile image/license..." result actions); remove the panel and the public-keys section. - style.css: make the table zebra stripe alpha-based (rgba(0,0,0,0.06)) so it darkens whatever is behind it instead of looking brighter on darker backgrounds. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../component/PublicProfilePanel.html | 42 ------- .../component/PublicProfilePanel.java | 109 ------------------ .../nanodash/page/AboutUserPage.html | 8 +- .../nanodash/page/AboutUserPage.java | 38 +++++- src/main/webapp/style.css | 5 +- 5 files changed, 41 insertions(+), 161 deletions(-) delete mode 100644 src/main/java/com/knowledgepixels/nanodash/component/PublicProfilePanel.html delete mode 100644 src/main/java/com/knowledgepixels/nanodash/component/PublicProfilePanel.java diff --git a/src/main/java/com/knowledgepixels/nanodash/component/PublicProfilePanel.html b/src/main/java/com/knowledgepixels/nanodash/component/PublicProfilePanel.html deleted file mode 100644 index 0e7618e0..00000000 --- a/src/main/java/com/knowledgepixels/nanodash/component/PublicProfilePanel.html +++ /dev/null @@ -1,42 +0,0 @@ - - - - - - - - - - -

-

Introductions

-
- -

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

Public Keys

-
- -

- -
    -
  • -
- -

License:

- - - - - - diff --git a/src/main/java/com/knowledgepixels/nanodash/component/PublicProfilePanel.java b/src/main/java/com/knowledgepixels/nanodash/component/PublicProfilePanel.java deleted file mode 100644 index 1a8d2f4e..00000000 --- a/src/main/java/com/knowledgepixels/nanodash/component/PublicProfilePanel.java +++ /dev/null @@ -1,109 +0,0 @@ -package com.knowledgepixels.nanodash.component; - -import com.knowledgepixels.nanodash.Utils; -import com.knowledgepixels.nanodash.domain.User; -import com.knowledgepixels.nanodash.page.ExplorePage; -import net.trustyuri.TrustyUriUtils; -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.link.ExternalLink; -import org.apache.wicket.markup.html.panel.Panel; -import org.apache.wicket.markup.repeater.Item; -import org.apache.wicket.markup.repeater.data.DataView; -import org.apache.wicket.markup.repeater.data.ListDataProvider; -import org.apache.wicket.model.Model; -import org.apache.wicket.request.mapper.parameter.PageParameters; -import org.eclipse.rdf4j.model.IRI; -import org.nanopub.SimpleTimestampPattern; -import org.nanopub.extra.security.KeyDeclaration; -import org.nanopub.extra.setting.IntroNanopub; - -import java.util.Calendar; -import java.util.List; - -/** - * A read-only, public-facing view of a user's profile information: their - * introduction nanopublications, declared public keys, and default license. - *

- * Unlike the session-bound profile items used on the editable - * {@code ProfilePage} (e.g. {@code ProfileIntroItem}, {@code ProfileSigItem}), - * this panel takes an arbitrary user IRI and shows no publish/edit actions, so - * it can be rendered for any user. - */ -public class PublicProfilePanel extends Panel { - - /** - * Constructs a PublicProfilePanel for the given user. - * - * @param id the Wicket component id - * @param userIri the IRI of the user whose profile is shown - */ - public PublicProfilePanel(String id, IRI userIri) { - super(id); - - // Introductions - List intros = User.getIntroNanopubs(userIri); - add(new Label("intro-note", intros.isEmpty() - ? "There are no introductions yet." : "").setEscapeModelStrings(false)); - add(new DataView("intro-nps", new ListDataProvider<>(intros)) { - @Override - protected void populateItem(Item item) { - final IntroNanopub inp = item.getModelObject(); - String uri = inp.getNanopub().getUri().stringValue(); - BookmarkablePageLink link = new BookmarkablePageLink<>("intro-uri", ExplorePage.class, new PageParameters().set("id", uri)); - link.add(new Label("intro-uri-label", TrustyUriUtils.getArtifactCode(uri).substring(0, 10))); - item.add(link); - if (User.isApproved(inp)) { - item.add(new Label("intro-note", " (approved)").setEscapeModelStrings(false)); - } else { - item.add(new Label("intro-note", "")); - } - IRI location = Utils.getLocation(inp); - if (location == null) { - item.add(new Label("location", "unknown site")); - } else { - item.add(new Label("location", "" + location + "").setEscapeModelStrings(false)); - } - Calendar creationDate = SimpleTimestampPattern.getCreationTime(inp.getNanopub()); - item.add(new Label("date", creationDate == null ? "unknown date" : NanopubItem.simpleDateFormat.format(creationDate.getTime()))); - - item.add(new DataView("intro-keys", new ListDataProvider<>(inp.getKeyDeclarations())) { - @Override - protected void populateItem(Item kdi) { - kdi.add(new Label("intro-key", Utils.getShortPubkeyName(Utils.createSha256HexHash(kdi.getModelObject().getPublicKeyString())))); - } - }); - } - }); - - // Public keys (with per-user approval status) - List pubkeyhashes = User.getPubkeyhashes(userIri, null); - add(new Label("keys-note", pubkeyhashes.isEmpty() - ? "There are no public keys yet." : "").setEscapeModelStrings(false)); - add(new DataView("keys", new ListDataProvider<>(pubkeyhashes)) { - @Override - protected void populateItem(Item item) { - String hash = item.getModelObject(); - item.add(new Label("key-label", Utils.getShortPubkeyName(hash))); - if (User.isApprovedPubkeyhashForUser(hash, userIri)) { - item.add(new Label("key-note", " (approved)").setEscapeModelStrings(false)); - } else { - item.add(new Label("key-note", " (not approved)").setEscapeModelStrings(false)); - } - } - }); - - // Default license - IRI license = User.getDefaultLicense(userIri); - WebMarkupContainer licenseContainer = new WebMarkupContainer("license-container"); - if (license == null) { - licenseContainer.setVisible(false); - licenseContainer.add(new ExternalLink("license", ".")); - } else { - licenseContainer.add(new ExternalLink("license", license.stringValue(), license.stringValue())); - } - add(licenseContainer); - } - -} diff --git a/src/main/java/com/knowledgepixels/nanodash/page/AboutUserPage.html b/src/main/java/com/knowledgepixels/nanodash/page/AboutUserPage.html index 7c1bfc43..11d9dab0 100644 --- a/src/main/java/com/knowledgepixels/nanodash/page/AboutUserPage.html +++ b/src/main/java/com/knowledgepixels/nanodash/page/AboutUserPage.html @@ -22,9 +22,11 @@

-
-
-
+
+
+ +
+
diff --git a/src/main/java/com/knowledgepixels/nanodash/page/AboutUserPage.java b/src/main/java/com/knowledgepixels/nanodash/page/AboutUserPage.java index 7debc0dd..3d6f33bb 100644 --- a/src/main/java/com/knowledgepixels/nanodash/page/AboutUserPage.java +++ b/src/main/java/com/knowledgepixels/nanodash/page/AboutUserPage.java @@ -16,10 +16,10 @@ import org.nanopub.extra.services.QueryRef; /** - * Standalone "About" page for a user, showing a public read-only view of their - * profile (introductions, keys, license), their configured views, and - * references. Reachable by direct URL only; not yet linked from the main - * {@link UserPage}. + * Standalone "About" page for a user, showing their introductions, a public + * read-only view of their profile (keys, license), their assigned view + * displays, and references. Reachable by direct URL only; not yet linked from + * the main {@link UserPage}. *

* Space memberships ("assigned roles") are not listed yet: there is no ready * API to enumerate the spaces a user belongs to. See issue #478 for the @@ -32,6 +32,18 @@ public class AboutUserPage extends NanodashPage { */ public static final String MOUNT_PATH = "/userabout"; + /** + * View that lists a user's introduction nanopublications (built on the + * get-user-introductions query). + */ + public static final String INTRODUCTIONS_VIEW = "https://w3id.org/np/RABbGzAaESdtU2ZG4Ar5fnwcUiV4kpFMp_k5p-6wOnc_s/introductions-view"; + + /** + * View showing a user's basic profile properties (default license and + * profile picture), one per row (built on the get-user-profile-info query). + */ + public static final String PROFILE_VIEW = "https://w3id.org/np/RAtTP_qhEqsz2V8YoR6MfZ_j7gwcJ9SE2WvzjXLiagb9Q/profile-view"; + /** * {@inheritDoc} */ @@ -73,8 +85,22 @@ public AboutUserPage(final PageParameters parameters) { add(new ExternalLinkWithActionsPanel("fullid", Model.of(userIriString), Model.of(displayName))); add(new DownloadRdfLinks("download-rdf", "user", userIriString)); - // Public read-only profile - add(new PublicProfilePanel("profile", userIri)); + // Introductions (rendered as a proper view) + View introView = View.get(INTRODUCTIONS_VIEW); + QueryRef introQueryRef = new QueryRef(introView.getQuery().getQueryId(), "user", userIriString); + add(QueryResultTableBuilder.create("introductions", introQueryRef, new ViewDisplay(introView)).build()); + + // Profile: default license and profile picture, one per row (a view), + // with result actions to update the profile image/license. The + // resourceWithProfile/id/contextId are needed for the action links to + // render (and to bind their "user" target field to this user). + View profileView = View.get(PROFILE_VIEW); + QueryRef profileQueryRef = new QueryRef(profileView.getQuery().getQueryId(), "user", userIriString); + add(QueryResultTableBuilder.create("profile", profileQueryRef, new ViewDisplay(profileView)) + .resourceWithProfile(IndividualAgent.get(userIriString)) + .id(userIriString) + .contextId(userIriString) + .build()); // Assigned view displays (a listing of the configured view displays, // not the rendered views themselves). diff --git a/src/main/webapp/style.css b/src/main/webapp/style.css index 363f1f11..ca4f3b10 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 { From 0947361d5bc6855ef7fecd952f4013768cf3ec9a Mon Sep 17 00:00:00 2001 From: Tobias Kuhn Date: Tue, 2 Jun 2026 15:18:31 +0200 Subject: [PATCH 03/33] feat: add Assigned roles view to space About page; cleaner role/view-display queries (#478) - AboutSpacePage: show an "Assigned roles" view (role/user/date + source np), rendered above the view-displays listing. - Point the Assigned roles and Assigned view displays views at dedicated, less-technical queries (linked view/role/user columns, status, date, source np) instead of the app-internal GET_SPACE_ROLES/GET_VIEW_DISPLAYS queries. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../nanodash/page/AboutSpacePage.html | 4 ++++ .../nanodash/page/AboutSpacePage.java | 13 ++++++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/knowledgepixels/nanodash/page/AboutSpacePage.html b/src/main/java/com/knowledgepixels/nanodash/page/AboutSpacePage.html index 25b65615..54567a01 100644 --- a/src/main/java/com/knowledgepixels/nanodash/page/AboutSpacePage.html +++ b/src/main/java/com/knowledgepixels/nanodash/page/AboutSpacePage.html @@ -21,6 +21,10 @@

Space ABC

+
+
+
+
diff --git a/src/main/java/com/knowledgepixels/nanodash/page/AboutSpacePage.java b/src/main/java/com/knowledgepixels/nanodash/page/AboutSpacePage.java index a9738357..95691734 100644 --- a/src/main/java/com/knowledgepixels/nanodash/page/AboutSpacePage.java +++ b/src/main/java/com/knowledgepixels/nanodash/page/AboutSpacePage.java @@ -39,7 +39,13 @@ public String getMountPath() { * get-view-displays query Nanodash uses internally). Shown on About pages * instead of rendering the assigned views themselves. */ - public static final String VIEW_DISPLAYS_VIEW = "https://w3id.org/np/RAQO0kK2FFtdfdGShuLkpFTbrq90btzt38z4w1hGVQfJE/view-displays-view"; + public static final String VIEW_DISPLAYS_VIEW = "https://w3id.org/np/RAL94vd5RPlmLIQ1mA5zIV08Z1AtOUWOzL1YJsK5ex3Ik/view-displays-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"; /** * Constructor for the AboutSpacePage. @@ -68,6 +74,11 @@ public AboutSpacePage(final PageParameters parameters) { add(new ExternalLinkWithActionsPanel("id", Model.of(space.getId()), Model.of(space.getLabel()))); add(new DownloadRdfLinks("download-rdf", "space", space.getId())); + // Assigned roles (rendered as a view, using the existing get-space-roles query). + View rolesView = View.get(SPACE_ROLES_VIEW); + QueryRef rolesQueryRef = new QueryRef(rolesView.getQuery().getQueryId(), "space", space.getId()); + add(QueryResultTableBuilder.create("roles", rolesQueryRef, new ViewDisplay(rolesView)).build()); + // Assigned view displays (a listing of the configured view displays, // not the rendered views themselves). View vdView = View.get(VIEW_DISPLAYS_VIEW); From c194b9bf9427c3d38aa2bbdc92d3d7e92b460255 Mon Sep 17 00:00:00 2001 From: Tobias Kuhn Date: Tue, 2 Jun 2026 16:39:50 +0200 Subject: [PATCH 04/33] fix: update view-displays-view nanopub reference (#478) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../java/com/knowledgepixels/nanodash/page/AboutSpacePage.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/knowledgepixels/nanodash/page/AboutSpacePage.java b/src/main/java/com/knowledgepixels/nanodash/page/AboutSpacePage.java index 95691734..c4ae8c07 100644 --- a/src/main/java/com/knowledgepixels/nanodash/page/AboutSpacePage.java +++ b/src/main/java/com/knowledgepixels/nanodash/page/AboutSpacePage.java @@ -39,7 +39,7 @@ public String getMountPath() { * get-view-displays query Nanodash uses internally). Shown on About pages * instead of rendering the assigned views themselves. */ - public static final String VIEW_DISPLAYS_VIEW = "https://w3id.org/np/RAL94vd5RPlmLIQ1mA5zIV08Z1AtOUWOzL1YJsK5ex3Ik/view-displays-view"; + public static final String VIEW_DISPLAYS_VIEW = "https://w3id.org/np/RAVlEpw_JEaFxAoOj7sJvE7cut_yb1XmFdJmb9job0O-w/view-displays-view"; /** * View listing a space's assigned roles, built on the existing From bbcb400b88b5d9244caa11bf199b26d9920b072d Mon Sep 17 00:00:00 2001 From: Tobias Kuhn Date: Wed, 3 Jun 2026 08:45:54 +0200 Subject: [PATCH 05/33] fix: define nanopub graph background as alpha so it stays visible on stripe (#478) Renders identically (#F3F6F9) over white sections, but darkens over the matching #F3F6F9 row-section stripe instead of blending in. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/main/webapp/style.css | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/webapp/style.css b/src/main/webapp/style.css index ca4f3b10..7a11cbdc 100644 --- a/src/main/webapp/style.css +++ b/src/main/webapp/style.css @@ -1204,7 +1204,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; From 4ffb663cfa54ebbd0c89831d9a3ad82f20688c08 Mon Sep 17 00:00:00 2001 From: Tobias Kuhn Date: Wed, 3 Jun 2026 10:06:38 +0200 Subject: [PATCH 06/33] feat: render view result actions on resource-less listings (e.g. /spaces) (#478) Extract the duplicated action-rendering loop into a shared addViewActions helper and call it from the resourceWithProfile == null branch too, so view "linked actions" (template buttons) appear on the general Spaces page. The helper only sets the target/context/part params when an id/contextId is present, so resource-less listings work without bogus params. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../component/QueryResultTableBuilder.java | 119 ++++++++---------- 1 file changed, 52 insertions(+), 67 deletions(-) diff --git a/src/main/java/com/knowledgepixels/nanodash/component/QueryResultTableBuilder.java b/src/main/java/com/knowledgepixels/nanodash/component/QueryResultTableBuilder.java index 77beeb5f..65448f5a 100644 --- a/src/main/java/com/knowledgepixels/nanodash/component/QueryResultTableBuilder.java +++ b/src/main/java/com/knowledgepixels/nanodash/component/QueryResultTableBuilder.java @@ -109,40 +109,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); table.add(new AttributeAppender("class", colClass)); return table; } else { @@ -156,39 +123,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); return table; } }; @@ -199,6 +134,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); table.add(new AttributeAppender("class", colClass)); return table; } else { @@ -207,6 +143,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); return table; } }; @@ -216,4 +153,52 @@ 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) { + View view = viewDisplay.getView(); + if (view == null) return; + 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("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 From 1a9cec67ca8799c64efaaece8b3a05423d54b27a Mon Sep 17 00:00:00 2001 From: Tobias Kuhn Date: Wed, 3 Jun 2026 10:24:49 +0200 Subject: [PATCH 07/33] docs: add presets design document (#302) Bundles of default views and roles, defined once and assigned to resources. Mirrors the view-display mechanism: authorized agents only (admins+maintainers, or the user on a user page), latest-wins, with presets and standalone view displays sharing one override pool. Co-Authored-By: Claude Opus 4.8 (1M context) --- doc/presets.md | 225 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 225 insertions(+) create mode 100644 doc/presets.md diff --git a/doc/presets.md b/doc/presets.md new file mode 100644 index 00000000..bc3483d2 --- /dev/null +++ b/doc/presets.md @@ -0,0 +1,225 @@ +# Presets for Nanodash + +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) From 0d9f8c752793bd686905610d5a0b7b60b48fdb52 Mon Sep 17 00:00:00 2001 From: Tobias Kuhn Date: Wed, 3 Jun 2026 13:38:13 +0200 Subject: [PATCH 08/33] feat: render preset-supplied views and list them on About pages (#302) Presets (issue #302) bundle default views and roles and are assigned to a resource. The get-view-displays query now also returns preset-supplied views (unbound ?display), which AbstractResourceWithProfile builds via ViewDisplay.forPresetView and merges into the existing latest-wins/dedup pool. Adds Preset and PresetAssignment domain models, the gen: preset vocabulary, and two About-page listings (authority-filtered to admins/maintainers/affected user): the existing "View displays" view now includes preset-supplied views marked with their preset, and a new "Assigned presets" view is shown before it on the Space, Resource and User About pages. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../com/knowledgepixels/nanodash/Preset.java | 212 ++++++++++++++++++ .../nanodash/PresetAssignment.java | 160 +++++++++++++ .../nanodash/QueryApiAccess.java | 3 +- .../knowledgepixels/nanodash/ViewDisplay.java | 42 ++++ .../domain/AbstractResourceWithProfile.java | 18 +- .../nanodash/page/AboutResourcePage.html | 4 + .../nanodash/page/AboutResourcePage.java | 7 +- .../nanodash/page/AboutSpacePage.html | 4 + .../nanodash/page/AboutSpacePage.java | 15 +- .../nanodash/page/AboutUserPage.html | 4 + .../nanodash/page/AboutUserPage.java | 7 +- .../nanodash/vocabulary/KPXL_TERMS.java | 14 ++ 12 files changed, 484 insertions(+), 6 deletions(-) create mode 100644 src/main/java/com/knowledgepixels/nanodash/Preset.java create mode 100644 src/main/java/com/knowledgepixels/nanodash/PresetAssignment.java 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 00000000..1eef061b --- /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 00000000..97d28049 --- /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 2509fbf9..6cbf27f7 100644 --- a/src/main/java/com/knowledgepixels/nanodash/QueryApiAccess.java +++ b/src/main/java/com/knowledgepixels/nanodash/QueryApiAccess.java @@ -66,7 +66,8 @@ 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"; + // v5 (issue #302) returns both standalone view displays and preset-supplied views (unbound ?display). + public static final String GET_VIEW_DISPLAYS = "RAb9H0-YcjhyLSLJw8opEO0jDRcKaCwA4ELHoHX0zQh84/get-view-displays"; // Spaces-repo queries (endpoint: nanopub-query .../repo/spaces) public static final String GET_SPACES = "RAxGboS_juHuMyJQghGV3elEgZmQTew5oyw_aC9O9FFQI/get-spaces"; diff --git a/src/main/java/com/knowledgepixels/nanodash/ViewDisplay.java b/src/main/java/com/knowledgepixels/nanodash/ViewDisplay.java index 4b74dff9..ecbdf431 100644 --- a/src/main/java/com/knowledgepixels/nanodash/ViewDisplay.java +++ b/src/main/java/com/knowledgepixels/nanodash/ViewDisplay.java @@ -80,6 +80,48 @@ public static ViewDisplay get(String id) throws IllegalArgumentException { } } + /** + * 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} + * (reserved; the top-level vs. default placement is the + * open rendering question in {@code doc/presets.md}) + * @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) { + View view = View.get(viewIri); + 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; + // TODO Differentiate top-level (gen:hasTopLevelView) from default (gen:hasView) + // placement once the rendering semantics are decided (doc/presets.md open + // question). For now both render at the resource's top level. + this.appliesTo.add(resourceId); + if (deactivated) { + this.types.add(KPXL_TERMS.DEACTIVATED_VIEW_DISPLAY); + } + } + /** * Constructor for ViewDisplay. * diff --git a/src/main/java/com/knowledgepixels/nanodash/domain/AbstractResourceWithProfile.java b/src/main/java/com/knowledgepixels/nanodash/domain/AbstractResourceWithProfile.java index 4f6e8069..c4e01751 100644 --- a/src/main/java/com/knowledgepixels/nanodash/domain/AbstractResourceWithProfile.java +++ b/src/main/java/com/knowledgepixels/nanodash/domain/AbstractResourceWithProfile.java @@ -131,9 +131,25 @@ public synchronized Future triggerDataUpdate() { ResourceWithProfile newData = new ResourceWithProfile(); + // The query returns both standalone view displays (bound ?display) and + // preset-supplied views (issue #302: unbound ?display, the assignment's + // preset expanded into its views server-side), already ordered by date + // (latest first) so the per-view-kind latest-wins / deactivation + // aggregation in getViewDisplays() resolves overrides between presets and + // standalone displays correctly, in either direction. for (ApiResponseEntry r : ApiCache.retrieveResponseSync(new QueryRef(QueryApiAccess.GET_VIEW_DISPLAYS, "resource", id), true).getData()) { try { - newData.viewDisplays.add(ViewDisplay.get(r.get("display"))); + String display = r.get("display"); + if (display != null && !display.isEmpty()) { + newData.viewDisplays.add(ViewDisplay.get(display)); + } else { + String view = r.get("view"); + if (view == null || view.isEmpty()) continue; + boolean topLevel = KPXL_TERMS.TOP_LEVEL_VIEW_DISPLAY.stringValue().equals(r.get("displayType")); + boolean deactivated = KPXL_TERMS.DEACTIVATED_PRESET_ASSIGNMENT.stringValue().equals(r.get("displayMode")); + ViewDisplay vd = ViewDisplay.forPresetView(id, view, topLevel, deactivated); + if (vd != null) newData.viewDisplays.add(vd); + } } catch (IllegalArgumentException ex) { logger.error("Couldn't generate view display object", ex); } diff --git a/src/main/java/com/knowledgepixels/nanodash/page/AboutResourcePage.html b/src/main/java/com/knowledgepixels/nanodash/page/AboutResourcePage.html index abbf7c89..8d9098a2 100644 --- a/src/main/java/com/knowledgepixels/nanodash/page/AboutResourcePage.html +++ b/src/main/java/com/knowledgepixels/nanodash/page/AboutResourcePage.html @@ -22,6 +22,10 @@

Resource ABC

+
+
+
+
diff --git a/src/main/java/com/knowledgepixels/nanodash/page/AboutResourcePage.java b/src/main/java/com/knowledgepixels/nanodash/page/AboutResourcePage.java index 79e1dfec..efb684a2 100644 --- a/src/main/java/com/knowledgepixels/nanodash/page/AboutResourcePage.java +++ b/src/main/java/com/knowledgepixels/nanodash/page/AboutResourcePage.java @@ -93,8 +93,13 @@ protected MaintainedResource load() { add(new DownloadRdfLinks("download-rdf", "resource", resource.getId())); + // Assigned presets (issue #302). + View presetsView = View.get(AboutSpacePage.PRESET_ASSIGNMENTS_VIEW); + QueryRef presetsQueryRef = new QueryRef(presetsView.getQuery().getQueryId(), "resource", resource.getId()); + add(QueryResultTableBuilder.create("presets", presetsQueryRef, new ViewDisplay(presetsView)).build()); + // Assigned view displays (a listing of the configured view displays, - // not the rendered views themselves). + // not the rendered views themselves; includes preset-supplied views). View vdView = View.get(AboutSpacePage.VIEW_DISPLAYS_VIEW); QueryRef vdQueryRef = new QueryRef(vdView.getQuery().getQueryId(), "resource", resource.getId()); add(QueryResultTableBuilder.create("viewdisplays", vdQueryRef, new ViewDisplay(vdView)).build()); diff --git a/src/main/java/com/knowledgepixels/nanodash/page/AboutSpacePage.html b/src/main/java/com/knowledgepixels/nanodash/page/AboutSpacePage.html index 54567a01..6ea9ef7d 100644 --- a/src/main/java/com/knowledgepixels/nanodash/page/AboutSpacePage.html +++ b/src/main/java/com/knowledgepixels/nanodash/page/AboutSpacePage.html @@ -25,6 +25,10 @@

Space ABC

+
+
+
+
diff --git a/src/main/java/com/knowledgepixels/nanodash/page/AboutSpacePage.java b/src/main/java/com/knowledgepixels/nanodash/page/AboutSpacePage.java index c4ae8c07..0698141c 100644 --- a/src/main/java/com/knowledgepixels/nanodash/page/AboutSpacePage.java +++ b/src/main/java/com/knowledgepixels/nanodash/page/AboutSpacePage.java @@ -39,7 +39,13 @@ public String getMountPath() { * get-view-displays query Nanodash uses internally). Shown on About pages * instead of rendering the assigned views themselves. */ - public static final String VIEW_DISPLAYS_VIEW = "https://w3id.org/np/RAVlEpw_JEaFxAoOj7sJvE7cut_yb1XmFdJmb9job0O-w/view-displays-view"; + public static final String VIEW_DISPLAYS_VIEW = "https://w3id.org/np/RA-o92qp6rr50wsMSfwy-HUNEkHiCdBR6nTMjSQs3wvII/view-displays-view"; + + /** + * View listing the presets assigned to a resource (issue #302). Shown on + * About pages just before the view displays listing. + */ + public static final String PRESET_ASSIGNMENTS_VIEW = "https://w3id.org/np/RAlcZ2FdqNVFqbxtzs1o2NPWmOhL8hqnokHm7mbjo8KuQ/preset-assignments-view"; /** * View listing a space's assigned roles, built on the existing @@ -79,8 +85,13 @@ public AboutSpacePage(final PageParameters parameters) { QueryRef rolesQueryRef = new QueryRef(rolesView.getQuery().getQueryId(), "space", space.getId()); add(QueryResultTableBuilder.create("roles", rolesQueryRef, new ViewDisplay(rolesView)).build()); + // Assigned presets (issue #302). + View presetsView = View.get(PRESET_ASSIGNMENTS_VIEW); + QueryRef presetsQueryRef = new QueryRef(presetsView.getQuery().getQueryId(), "resource", space.getId()); + add(QueryResultTableBuilder.create("presets", presetsQueryRef, new ViewDisplay(presetsView)).build()); + // Assigned view displays (a listing of the configured view displays, - // not the rendered views themselves). + // not the rendered views themselves; includes preset-supplied views). View vdView = View.get(VIEW_DISPLAYS_VIEW); QueryRef vdQueryRef = new QueryRef(vdView.getQuery().getQueryId(), "resource", space.getId()); add(QueryResultTableBuilder.create("viewdisplays", vdQueryRef, new ViewDisplay(vdView)).build()); diff --git a/src/main/java/com/knowledgepixels/nanodash/page/AboutUserPage.html b/src/main/java/com/knowledgepixels/nanodash/page/AboutUserPage.html index 11d9dab0..2ede7d1d 100644 --- a/src/main/java/com/knowledgepixels/nanodash/page/AboutUserPage.html +++ b/src/main/java/com/knowledgepixels/nanodash/page/AboutUserPage.html @@ -29,6 +29,10 @@

+
+
+
+
diff --git a/src/main/java/com/knowledgepixels/nanodash/page/AboutUserPage.java b/src/main/java/com/knowledgepixels/nanodash/page/AboutUserPage.java index 3d6f33bb..a52f3e92 100644 --- a/src/main/java/com/knowledgepixels/nanodash/page/AboutUserPage.java +++ b/src/main/java/com/knowledgepixels/nanodash/page/AboutUserPage.java @@ -102,8 +102,13 @@ public AboutUserPage(final PageParameters parameters) { .contextId(userIriString) .build()); + // Assigned presets (issue #302). + View presetsView = View.get(AboutSpacePage.PRESET_ASSIGNMENTS_VIEW); + QueryRef presetsQueryRef = new QueryRef(presetsView.getQuery().getQueryId(), "resource", userIriString); + add(QueryResultTableBuilder.create("presets", presetsQueryRef, new ViewDisplay(presetsView)).build()); + // Assigned view displays (a listing of the configured view displays, - // not the rendered views themselves). + // not the rendered views themselves; includes preset-supplied views). View vdView = View.get(AboutSpacePage.VIEW_DISPLAYS_VIEW); QueryRef vdQueryRef = new QueryRef(vdView.getQuery().getQueryId(), "resource", userIriString); add(QueryResultTableBuilder.create("viewdisplays", vdQueryRef, new ViewDisplay(vdView)).build()); 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 fd756e9c..5f962169 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,13 @@ 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. + 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 +62,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"); From bcc02f2a3a5dd9e79c0a1de1812ae7c84fb312d4 Mon Sep 17 00:00:00 2001 From: Tobias Kuhn Date: Wed, 3 Jun 2026 13:42:31 +0200 Subject: [PATCH 09/33] feat: gate render-path view displays by admin/maintainer/affected user (#302) Repoint GET_VIEW_DISPLAYS to get-view-displays v6, which applies the same authority gate as the About-page listings: only view displays and preset assignments signed by an admin or maintainer of the owning space, or by the affected user themselves, are rendered. This also closes the user-page gap (space-less resources were previously unfiltered). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../java/com/knowledgepixels/nanodash/QueryApiAccess.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/knowledgepixels/nanodash/QueryApiAccess.java b/src/main/java/com/knowledgepixels/nanodash/QueryApiAccess.java index 6cbf27f7..d808d7d1 100644 --- a/src/main/java/com/knowledgepixels/nanodash/QueryApiAccess.java +++ b/src/main/java/com/knowledgepixels/nanodash/QueryApiAccess.java @@ -66,8 +66,9 @@ 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"; - // v5 (issue #302) returns both standalone view displays and preset-supplied views (unbound ?display). - public static final String GET_VIEW_DISPLAYS = "RAb9H0-YcjhyLSLJw8opEO0jDRcKaCwA4ELHoHX0zQh84/get-view-displays"; + // v6 (issue #302): standalone + preset-supplied views (unbound ?display), gated to + // admins/maintainers of the owning space or the affected user themselves. + public static final String GET_VIEW_DISPLAYS = "RAd-T_vYETxcKf_1lEgIMoJSnSCFJTdpFDWYqvGNM91Ew/get-view-displays"; // Spaces-repo queries (endpoint: nanopub-query .../repo/spaces) public static final String GET_SPACES = "RAxGboS_juHuMyJQghGV3elEgZmQTew5oyw_aC9O9FFQI/get-spaces"; From ee73c5b10f9c5a378c2607c4fa2a0beaed7a5aef Mon Sep 17 00:00:00 2001 From: Tobias Kuhn Date: Wed, 3 Jun 2026 16:01:21 +0200 Subject: [PATCH 10/33] feat: consistent italic "(nothing found)" empty state across query views All QueryResult* views (Table, Rdf, List, ItemList, NanopubSet, PlainParagraph) now show an italic "(nothing found)" note (class no-records-note) and hide the data element entirely when empty. Table and Rdf drop Wicket's NoRecordsToolbar so the header row is no longer shown for empty results; the table's visibility is driven by onConfigure (with an output-markup placeholder) so the AJAX filter collapses it when a filter matches nothing. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../component/QueryResultItemList.html | 1 + .../component/QueryResultItemList.java | 9 +++++++ .../nanodash/component/QueryResultList.html | 2 +- .../nanodash/component/QueryResultList.java | 2 +- .../component/QueryResultNanopubSet.html | 2 +- .../component/QueryResultNanopubSet.java | 4 +-- .../component/QueryResultPlainParagraph.html | 1 + .../component/QueryResultPlainParagraph.java | 7 +++++ .../nanodash/component/QueryResultRdf.html | 1 + .../nanodash/component/QueryResultRdf.java | 5 ++-- .../nanodash/component/QueryResultTable.html | 1 + .../nanodash/component/QueryResultTable.java | 26 ++++++++++++++++--- 12 files changed, 51 insertions(+), 10 deletions(-) diff --git a/src/main/java/com/knowledgepixels/nanodash/component/QueryResultItemList.html b/src/main/java/com/knowledgepixels/nanodash/component/QueryResultItemList.html index 69312e74..13302530 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 78ebe9c1..da0230c9 100644 --- a/src/main/java/com/knowledgepixels/nanodash/component/QueryResultPlainParagraph.java +++ b/src/main/java/com/knowledgepixels/nanodash/component/QueryResultPlainParagraph.java @@ -69,6 +69,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/QueryResultRdf.html b/src/main/java/com/knowledgepixels/nanodash/component/QueryResultRdf.html index 88dd5a8b..0169f4b0 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 e8258891..d2c56d69 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 2d42beaf..96676288 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 328ae7d4..f90586fe 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,6 +73,7 @@ protected void onUpdate(AjaxRequestTarget target) { if (filteredDataProvider != null && table != null) { filteredDataProvider.setFilterText(filterModel.getObject()); target.add(table); + if (noRecordsLabel != null) target.add(noRecordsLabel); } } }); @@ -118,15 +120,33 @@ protected void populateComponent() { } 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()); } } From ada006d7fd199155ea31edd3a771a50309d6a6ef Mon Sep 17 00:00:00 2001 From: Tobias Kuhn Date: Wed, 3 Jun 2026 16:01:29 +0200 Subject: [PATCH 11/33] feat: "add..." actions on the About-page preset/view-display listings (#302) Repoint the view-displays-view and preset-assignments-view constants to the versions that declare an "add view display..." / "add preset..." result action, and pass the page resource as id/contextId to the table builder so the action pre-fills param_resource (the target space/resource/user) on the publish form. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../knowledgepixels/nanodash/page/AboutResourcePage.java | 4 ++-- .../com/knowledgepixels/nanodash/page/AboutSpacePage.java | 8 ++++---- .../com/knowledgepixels/nanodash/page/AboutUserPage.java | 4 ++-- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/knowledgepixels/nanodash/page/AboutResourcePage.java b/src/main/java/com/knowledgepixels/nanodash/page/AboutResourcePage.java index efb684a2..40afd4d4 100644 --- a/src/main/java/com/knowledgepixels/nanodash/page/AboutResourcePage.java +++ b/src/main/java/com/knowledgepixels/nanodash/page/AboutResourcePage.java @@ -96,13 +96,13 @@ protected MaintainedResource load() { // Assigned presets (issue #302). View presetsView = View.get(AboutSpacePage.PRESET_ASSIGNMENTS_VIEW); QueryRef presetsQueryRef = new QueryRef(presetsView.getQuery().getQueryId(), "resource", resource.getId()); - add(QueryResultTableBuilder.create("presets", presetsQueryRef, new ViewDisplay(presetsView)).build()); + add(QueryResultTableBuilder.create("presets", presetsQueryRef, new ViewDisplay(presetsView)).id(resource.getId()).contextId(resource.getId()).build()); // Assigned view displays (a listing of the configured view displays, // not the rendered views themselves; includes preset-supplied views). View vdView = View.get(AboutSpacePage.VIEW_DISPLAYS_VIEW); QueryRef vdQueryRef = new QueryRef(vdView.getQuery().getQueryId(), "resource", resource.getId()); - add(QueryResultTableBuilder.create("viewdisplays", vdQueryRef, new ViewDisplay(vdView)).build()); + add(QueryResultTableBuilder.create("viewdisplays", vdQueryRef, new ViewDisplay(vdView)).id(resource.getId()).contextId(resource.getId()).build()); // References View refView = View.get(ReferencesPage.REFERENCES_VIEW); diff --git a/src/main/java/com/knowledgepixels/nanodash/page/AboutSpacePage.java b/src/main/java/com/knowledgepixels/nanodash/page/AboutSpacePage.java index 0698141c..7e186dca 100644 --- a/src/main/java/com/knowledgepixels/nanodash/page/AboutSpacePage.java +++ b/src/main/java/com/knowledgepixels/nanodash/page/AboutSpacePage.java @@ -39,13 +39,13 @@ public String getMountPath() { * get-view-displays query Nanodash uses internally). Shown on About pages * instead of rendering the assigned views themselves. */ - public static final String VIEW_DISPLAYS_VIEW = "https://w3id.org/np/RA-o92qp6rr50wsMSfwy-HUNEkHiCdBR6nTMjSQs3wvII/view-displays-view"; + 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). Shown on * About pages just before the view displays listing. */ - public static final String PRESET_ASSIGNMENTS_VIEW = "https://w3id.org/np/RAlcZ2FdqNVFqbxtzs1o2NPWmOhL8hqnokHm7mbjo8KuQ/preset-assignments-view"; + 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 @@ -88,13 +88,13 @@ public AboutSpacePage(final PageParameters parameters) { // Assigned presets (issue #302). View presetsView = View.get(PRESET_ASSIGNMENTS_VIEW); QueryRef presetsQueryRef = new QueryRef(presetsView.getQuery().getQueryId(), "resource", space.getId()); - add(QueryResultTableBuilder.create("presets", presetsQueryRef, new ViewDisplay(presetsView)).build()); + add(QueryResultTableBuilder.create("presets", presetsQueryRef, new ViewDisplay(presetsView)).id(space.getId()).contextId(space.getId()).build()); // Assigned view displays (a listing of the configured view displays, // not the rendered views themselves; includes preset-supplied views). View vdView = View.get(VIEW_DISPLAYS_VIEW); QueryRef vdQueryRef = new QueryRef(vdView.getQuery().getQueryId(), "resource", space.getId()); - add(QueryResultTableBuilder.create("viewdisplays", vdQueryRef, new ViewDisplay(vdView)).build()); + add(QueryResultTableBuilder.create("viewdisplays", vdQueryRef, new ViewDisplay(vdView)).id(space.getId()).contextId(space.getId()).build()); // References View refView = View.get(ReferencesPage.REFERENCES_VIEW); diff --git a/src/main/java/com/knowledgepixels/nanodash/page/AboutUserPage.java b/src/main/java/com/knowledgepixels/nanodash/page/AboutUserPage.java index a52f3e92..3c925fe4 100644 --- a/src/main/java/com/knowledgepixels/nanodash/page/AboutUserPage.java +++ b/src/main/java/com/knowledgepixels/nanodash/page/AboutUserPage.java @@ -105,13 +105,13 @@ public AboutUserPage(final PageParameters parameters) { // Assigned presets (issue #302). View presetsView = View.get(AboutSpacePage.PRESET_ASSIGNMENTS_VIEW); QueryRef presetsQueryRef = new QueryRef(presetsView.getQuery().getQueryId(), "resource", userIriString); - add(QueryResultTableBuilder.create("presets", presetsQueryRef, new ViewDisplay(presetsView)).build()); + add(QueryResultTableBuilder.create("presets", presetsQueryRef, new ViewDisplay(presetsView)).id(userIriString).contextId(userIriString).build()); // Assigned view displays (a listing of the configured view displays, // not the rendered views themselves; includes preset-supplied views). View vdView = View.get(AboutSpacePage.VIEW_DISPLAYS_VIEW); QueryRef vdQueryRef = new QueryRef(vdView.getQuery().getQueryId(), "resource", userIriString); - add(QueryResultTableBuilder.create("viewdisplays", vdQueryRef, new ViewDisplay(vdView)).build()); + add(QueryResultTableBuilder.create("viewdisplays", vdQueryRef, new ViewDisplay(vdView)).id(userIriString).contextId(userIriString).build()); // References View refView = View.get(ReferencesPage.REFERENCES_VIEW); From 471bf0af92caa85ec2e40c3b46952673391d951a Mon Sep 17 00:00:00 2001 From: Tobias Kuhn Date: Wed, 3 Jun 2026 16:43:48 +0200 Subject: [PATCH 12/33] fix: place preset-supplied views by their own targeting (#302) A preset's gen:hasTopLevelView pins to the resource's own page; gen:hasView defers to the view's own class/namespace targeting (gen:appliesToInstancesOf / appliesToNamespace) instead of being forced to the top level. Top-level rendering now passes the resource's own type (getOwnClasses: Space / MaintainedResource / IndividualAgent) so a view targeting the resource type shows there, while a view targeting a part type (e.g. owl:Class) shows on matching parts. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../knowledgepixels/nanodash/ViewDisplay.java | 18 +++++++++++------- .../domain/AbstractResourceWithProfile.java | 15 ++++++++++++++- .../nanodash/domain/IndividualAgent.java | 6 ++++++ .../nanodash/domain/MaintainedResource.java | 6 ++++++ .../knowledgepixels/nanodash/domain/Space.java | 5 +++++ .../nanodash/vocabulary/KPXL_TERMS.java | 5 +++++ 6 files changed, 47 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/knowledgepixels/nanodash/ViewDisplay.java b/src/main/java/com/knowledgepixels/nanodash/ViewDisplay.java index ecbdf431..7de8e64d 100644 --- a/src/main/java/com/knowledgepixels/nanodash/ViewDisplay.java +++ b/src/main/java/com/knowledgepixels/nanodash/ViewDisplay.java @@ -94,9 +94,10 @@ public static ViewDisplay get(String id) throws IllegalArgumentException { * @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} - * (reserved; the top-level vs. default placement is the - * open rendering question in {@code doc/presets.md}) + * @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 */ @@ -113,10 +114,13 @@ private ViewDisplay(String resourceId, View view, boolean topLevel, boolean deac this.id = null; this.nanopub = view.getNanopub(); this.view = view; - // TODO Differentiate top-level (gen:hasTopLevelView) from default (gen:hasView) - // placement once the rendering semantics are decided (doc/presets.md open - // question). For now both render at the resource's top level. - this.appliesTo.add(resourceId); + 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); } diff --git a/src/main/java/com/knowledgepixels/nanodash/domain/AbstractResourceWithProfile.java b/src/main/java/com/knowledgepixels/nanodash/domain/AbstractResourceWithProfile.java index c4e01751..7848313f 100644 --- a/src/main/java/com/knowledgepixels/nanodash/domain/AbstractResourceWithProfile.java +++ b/src/main/java/com/knowledgepixels/nanodash/domain/AbstractResourceWithProfile.java @@ -221,7 +221,20 @@ public List getViewDisplays() { @Override public List getTopLevelViewDisplays() { - return getViewDisplays(true, getId(), null); + // Pass the resource's own type(s) so that views targeting that type (e.g. a + // messages view for gen:MaintainedResource) are shown at the top level, while + // views targeting part types (e.g. gen:hasViewTargetClass owl:Class) are not. + return getViewDisplays(true, getId(), getOwnClasses()); + } + + /** + * The resource's own type IRI(s), used to match top-level views by + * {@code gen:appliesToInstancesOf}. Empty by default; overridden per resource type. + * + * @return the resource's own classes (never null) + */ + protected Set getOwnClasses() { + return Collections.emptySet(); } @Override diff --git a/src/main/java/com/knowledgepixels/nanodash/domain/IndividualAgent.java b/src/main/java/com/knowledgepixels/nanodash/domain/IndividualAgent.java index cf12bf07..613ba4cf 100644 --- a/src/main/java/com/knowledgepixels/nanodash/domain/IndividualAgent.java +++ b/src/main/java/com/knowledgepixels/nanodash/domain/IndividualAgent.java @@ -3,6 +3,7 @@ import com.knowledgepixels.nanodash.NanodashSession; import com.knowledgepixels.nanodash.Utils; import com.knowledgepixels.nanodash.ViewDisplay; +import com.knowledgepixels.nanodash.vocabulary.KPXL_TERMS; import org.eclipse.rdf4j.model.IRI; import org.nanopub.Nanopub; @@ -71,6 +72,11 @@ public String getNamespace() { return null; } + @Override + protected Set getOwnClasses() { + return Set.of(KPXL_TERMS.INDIVIDUAL_AGENT); + } + /** * Checks if this user is the currently logged-in user. * diff --git a/src/main/java/com/knowledgepixels/nanodash/domain/MaintainedResource.java b/src/main/java/com/knowledgepixels/nanodash/domain/MaintainedResource.java index 1a96740d..6884bbb2 100644 --- a/src/main/java/com/knowledgepixels/nanodash/domain/MaintainedResource.java +++ b/src/main/java/com/knowledgepixels/nanodash/domain/MaintainedResource.java @@ -2,6 +2,7 @@ import com.knowledgepixels.nanodash.Utils; import com.knowledgepixels.nanodash.ViewDisplay; +import com.knowledgepixels.nanodash.vocabulary.KPXL_TERMS; import org.eclipse.rdf4j.model.IRI; import org.nanopub.Nanopub; import org.nanopub.extra.services.ApiResponseEntry; @@ -80,4 +81,9 @@ public String getNamespace() { return namespace; } + @Override + protected Set getOwnClasses() { + return Set.of(KPXL_TERMS.MAINTAINED_RESOURCE); + } + } diff --git a/src/main/java/com/knowledgepixels/nanodash/domain/Space.java b/src/main/java/com/knowledgepixels/nanodash/domain/Space.java index 40596bc1..8e69e8f3 100644 --- a/src/main/java/com/knowledgepixels/nanodash/domain/Space.java +++ b/src/main/java/com/knowledgepixels/nanodash/domain/Space.java @@ -147,6 +147,11 @@ public Nanopub getNanopub() { return rootNanopub; } + @Override + protected Set getOwnClasses() { + return Set.of(KPXL_TERMS.SPACE); + } + @Override public String getNamespace() { // FIXME this will be removed in the future 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 5f962169..7d90f3a8 100644 --- a/src/main/java/com/knowledgepixels/nanodash/vocabulary/KPXL_TERMS.java +++ b/src/main/java/com/knowledgepixels/nanodash/vocabulary/KPXL_TERMS.java @@ -41,6 +41,11 @@ public class KPXL_TERMS { // 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"); From 9d81e2af6b40cd27f088ac65d3978c2e7360f9d5 Mon Sep 17 00:00:00 2001 From: Tobias Kuhn Date: Thu, 4 Jun 2026 21:31:47 +0200 Subject: [PATCH 13/33] feat: resolve latest view versions in get-view-displays query (#302) The get-view-displays query now follows each referenced view's npx:supersedes chain server-side (as list-view-displays does), so ?view is already the latest version. New artifact: RAWD7diDNUAR0DLMS5gUMCyk9O6wAu7wRkfTGtS_Hf9Uo/get-view-displays (supersedes RAd-T...). Nanodash no longer resolves latest versions one-by-one when building a page's view displays: - View.get(id, resolveLatest) overload skips getLatestVersionId; the query's already-latest ?view is loaded directly. - ViewDisplay.get(id, latestViewIri) + 3-arg ctor thread the view IRI through; forPresetView loads its view directly. - The per-display getLatestVersionId in ViewDisplay.get is removed: the query's invalidates filter already returns only current display nanopubs (all superseded displays are also invalidated under the same signing key), so the display np is loaded directly. Net result: zero one-by-one latest-version round-trips in the get-view-displays render path. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../nanodash/QueryApiAccess.java | 8 ++- .../com/knowledgepixels/nanodash/View.java | 55 ++++++++++------ .../knowledgepixels/nanodash/ViewDisplay.java | 62 ++++++++++++------- .../domain/AbstractResourceWithProfile.java | 4 +- .../nanodash/page/DownloadRdfPage.java | 2 +- 5 files changed, 87 insertions(+), 44 deletions(-) diff --git a/src/main/java/com/knowledgepixels/nanodash/QueryApiAccess.java b/src/main/java/com/knowledgepixels/nanodash/QueryApiAccess.java index d808d7d1..73cf4da8 100644 --- a/src/main/java/com/knowledgepixels/nanodash/QueryApiAccess.java +++ b/src/main/java/com/knowledgepixels/nanodash/QueryApiAccess.java @@ -66,9 +66,11 @@ 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"; - // v6 (issue #302): standalone + preset-supplied views (unbound ?display), gated to - // admins/maintainers of the owning space or the affected user themselves. - public static final String GET_VIEW_DISPLAYS = "RAd-T_vYETxcKf_1lEgIMoJSnSCFJTdpFDWYqvGNM91Ew/get-view-displays"; + // v7 (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 (supersedes chain), + // so ?view is already the latest and needs no separate per-view lookup. + public static final String GET_VIEW_DISPLAYS = "RAWD7diDNUAR0DLMS5gUMCyk9O6wAu7wRkfTGtS_Hf9Uo/get-view-displays"; // Spaces-repo queries (endpoint: nanopub-query .../repo/spaces) public static final String GET_SPACES = "RAxGboS_juHuMyJQghGV3elEgZmQTew5oyw_aC9O9FFQI/get-spaces"; diff --git a/src/main/java/com/knowledgepixels/nanodash/View.java b/src/main/java/com/knowledgepixels/nanodash/View.java index a2ba5e7e..62a90fcd 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); diff --git a/src/main/java/com/knowledgepixels/nanodash/ViewDisplay.java b/src/main/java/com/knowledgepixels/nanodash/ViewDisplay.java index 7de8e64d..3a8d1253 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,26 +53,30 @@ 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); @@ -102,7 +105,9 @@ public static ViewDisplay get(String id) throws IllegalArgumentException { * @return the ViewDisplay, or null if the view could not be resolved */ public static ViewDisplay forPresetView(String resourceId, String viewIri, boolean topLevel, boolean deactivated) { - View view = View.get(viewIri); + // 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; @@ -133,6 +138,19 @@ private ViewDisplay(String resourceId, View view, boolean topLevel, boolean deac * @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; @@ -153,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/domain/AbstractResourceWithProfile.java b/src/main/java/com/knowledgepixels/nanodash/domain/AbstractResourceWithProfile.java index 7848313f..1fd9e8a4 100644 --- a/src/main/java/com/knowledgepixels/nanodash/domain/AbstractResourceWithProfile.java +++ b/src/main/java/com/knowledgepixels/nanodash/domain/AbstractResourceWithProfile.java @@ -141,7 +141,9 @@ public synchronized Future triggerDataUpdate() { try { String display = r.get("display"); if (display != null && !display.isEmpty()) { - newData.viewDisplays.add(ViewDisplay.get(display)); + // The query resolves ?view to its latest version server-side, so + // pass it through to avoid a separate per-view latest-version lookup. + newData.viewDisplays.add(ViewDisplay.get(display, r.get("view"))); } else { String view = r.get("view"); if (view == null || view.isEmpty()) continue; diff --git a/src/main/java/com/knowledgepixels/nanodash/page/DownloadRdfPage.java b/src/main/java/com/knowledgepixels/nanodash/page/DownloadRdfPage.java index 6a01633f..f003c94c 100644 --- a/src/main/java/com/knowledgepixels/nanodash/page/DownloadRdfPage.java +++ b/src/main/java/com/knowledgepixels/nanodash/page/DownloadRdfPage.java @@ -568,7 +568,7 @@ private List fetchViewDisplays(AbstractResourceWithProfile resource List allDisplays = new ArrayList<>(); for (ApiResponseEntry r : response.getData()) { try { - allDisplays.add(ViewDisplay.get(r.get("display"))); + allDisplays.add(ViewDisplay.get(r.get("display"), r.get("view"))); } catch (IllegalArgumentException ex) { logger.error("Couldn't generate view display object", ex); } From b439e7e319addd0dba7890aa3860860adf66d10b Mon Sep 17 00:00:00 2001 From: Tobias Kuhn Date: Fri, 5 Jun 2026 08:57:46 +0200 Subject: [PATCH 14/33] feat: harden latest-view resolution in view-display queries (#302) Point GET_VIEW_DISPLAYS at the v8 get-view-displays query (RAJtBiC...). Both get-view-displays and list-view-displays now resolve each referenced view to the most recent *current head* of its version tree -- a nanopub itself neither superseded nor validly retracted (npx:invalidates) -- with date only as a tiebreak among surviving heads, instead of picking the max-timestamp node. This is robust to backdated supersedes and to retracted versions. Verified identical to the prior queries on all current live data. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../com/knowledgepixels/nanodash/QueryApiAccess.java | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/knowledgepixels/nanodash/QueryApiAccess.java b/src/main/java/com/knowledgepixels/nanodash/QueryApiAccess.java index 73cf4da8..be7b9fd0 100644 --- a/src/main/java/com/knowledgepixels/nanodash/QueryApiAccess.java +++ b/src/main/java/com/knowledgepixels/nanodash/QueryApiAccess.java @@ -66,11 +66,15 @@ 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"; - // v7 (issue #302): standalone + preset-supplied views (unbound ?display), gated to + // v8 (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 (supersedes chain), - // so ?view is already the latest and needs no separate per-view lookup. - public static final String GET_VIEW_DISPLAYS = "RAWD7diDNUAR0DLMS5gUMCyk9O6wAu7wRkfTGtS_Hf9Uo/get-view-displays"; + // so ?view is already the latest and needs no separate per-view lookup. v8 hardens + // that resolution: rather than picking the max-timestamp node, it picks the version + // tree's most recent current head (a nanopub that is itself neither superseded nor + // validly retracted via npx:invalidates), making it robust to backdated supersedes + // and to retracted versions. + public static final String GET_VIEW_DISPLAYS = "RAJtBiC42q8RCd8yaTI5KwFTm7nPiBgzWD-DHHQiCt57A/get-view-displays"; // Spaces-repo queries (endpoint: nanopub-query .../repo/spaces) public static final String GET_SPACES = "RAxGboS_juHuMyJQghGV3elEgZmQTew5oyw_aC9O9FFQI/get-spaces"; From b2b6b6da1a21f7abe9f8657bab31036fbeb740bf Mon Sep 17 00:00:00 2001 From: Tobias Kuhn Date: Fri, 5 Jun 2026 10:22:23 +0200 Subject: [PATCH 15/33] perf: run-once view resolution in get-view-displays query (#302) Point GET_VIEW_DISPLAYS at the v10 query (RAy49uUd...), which wraps the cross-repo latest-view resolution in a run-once sub-SELECT. The resolution was previously invoked once per referenced view, costing ~44 federation round-trips (~4.3s of a ~4.5s query); evaluating it once for the whole view set cuts a 44-display page to ~1.7s, with identical results (verified against the deployed closure across all 548 views). list-view-displays got the same treatment and view-displays-view was re-pointed at it. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../nanodash/QueryApiAccess.java | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/knowledgepixels/nanodash/QueryApiAccess.java b/src/main/java/com/knowledgepixels/nanodash/QueryApiAccess.java index be7b9fd0..897d4e4d 100644 --- a/src/main/java/com/knowledgepixels/nanodash/QueryApiAccess.java +++ b/src/main/java/com/knowledgepixels/nanodash/QueryApiAccess.java @@ -66,15 +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"; - // v8 (issue #302): standalone + preset-supplied views (unbound ?display), gated to + // 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 (supersedes chain), - // so ?view is already the latest and needs no separate per-view lookup. v8 hardens - // that resolution: rather than picking the max-timestamp node, it picks the version - // tree's most recent current head (a nanopub that is itself neither superseded nor - // validly retracted via npx:invalidates), making it robust to backdated supersedes - // and to retracted versions. - public static final String GET_VIEW_DISPLAYS = "RAJtBiC42q8RCd8yaTI5KwFTm7nPiBgzWD-DHHQiCt57A/get-view-displays"; + // 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"; From 89808eb71aedce675f103987b2222a319de408e8 Mon Sep 17 00:00:00 2001 From: Tobias Kuhn Date: Fri, 5 Jun 2026 12:39:42 +0200 Subject: [PATCH 16/33] feat: consolidate resource pages into Content/About/Explore/Raw tabs (#478) Replace the standalone About/Raw pages with a tab strip selected via a ?tab= query param on the main /space, /user, /resource and /part pages (default content). Tab bodies are reusable panels (AboutSpacePanel, AboutResourcePanel, AboutUserPanel, ExplorePanel) plus DownloadRdfLinks for Raw; bodies are built conditionally so non-content tabs don't fire the content queries. The tab strip lives in the breadcrumb stripe (rounded-top tabs, gray-italic "- About/Explore/Raw" title suffixes); its bottom border was removed. /explore stays for arbitrary terms but forwards known resources to their page's Explore tab. References moved from About to Explore. Also: deleted AboutSpacePage/AboutUserPage/AboutResourcePage/RawPage and their mounts; removed the "+ view display" buttons (kept on /part), the per-id Explore button and the ExploreDisplayMenu/UserPageMenu dropdowns, "See Your Profile Details", and the raw component on /list. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../nanodash/WicketApplication.java | 3 - .../component/AboutResourcePanel.html | 8 + .../component/AboutResourcePanel.java | 30 ++++ .../nanodash/component/AboutSpacePanel.html | 11 ++ .../nanodash/component/AboutSpacePanel.java | 51 ++++++ .../nanodash/component/AboutUserPanel.html | 14 ++ .../nanodash/component/AboutUserPanel.java | 53 +++++++ .../nanodash/component/DownloadRdfLinks.html | 33 ++-- .../nanodash/component/DownloadRdfLinks.java | 8 +- .../nanodash/component/ExplorePanel.html | 11 ++ .../nanodash/component/ExplorePanel.java | 46 ++++++ .../ExternalLinkWithActionsPanel.java | 22 +-- .../nanodash/component/ResourceTabs.html | 8 + .../nanodash/component/ResourceTabs.java | 145 ++++++++++++++++++ .../nanodash/component/TitleBar.html | 7 +- .../nanodash/component/TitleBar.java | 39 ++++- .../component/menu/ExploreDisplayMenu.html | 18 --- .../component/menu/ExploreDisplayMenu.java | 31 ---- .../component/menu/SpaceExploreMenu.html | 3 - .../component/menu/SpaceExploreMenu.java | 9 -- .../nanodash/component/menu/UserPageMenu.html | 15 -- .../nanodash/component/menu/UserPageMenu.java | 25 --- .../nanodash/page/AboutResourcePage.html | 39 ----- .../nanodash/page/AboutResourcePage.java | 131 ---------------- .../nanodash/page/AboutSpacePage.html | 42 ----- .../nanodash/page/AboutSpacePage.java | 114 -------------- .../nanodash/page/AboutUserPage.html | 47 ------ .../nanodash/page/AboutUserPage.java | 131 ---------------- .../nanodash/page/ExplorePage.html | 5 +- .../nanodash/page/ExplorePage.java | 21 ++- .../nanodash/page/ListPage.html | 1 - .../nanodash/page/ListPage.java | 2 - .../nanodash/page/MaintainedResourcePage.html | 9 +- .../nanodash/page/MaintainedResourcePage.java | 69 +++++---- .../nanodash/page/ResourcePartPage.html | 8 +- .../nanodash/page/ResourcePartPage.java | 51 +++--- .../nanodash/page/SpacePage.html | 7 +- .../nanodash/page/SpacePage.java | 92 ++++++----- .../nanodash/page/UserPage.html | 24 ++- .../nanodash/page/UserPage.java | 143 ++++++++--------- src/main/webapp/style.css | 64 +++++++- 41 files changed, 744 insertions(+), 846 deletions(-) create mode 100644 src/main/java/com/knowledgepixels/nanodash/component/AboutResourcePanel.html create mode 100644 src/main/java/com/knowledgepixels/nanodash/component/AboutResourcePanel.java create mode 100644 src/main/java/com/knowledgepixels/nanodash/component/AboutSpacePanel.html create mode 100644 src/main/java/com/knowledgepixels/nanodash/component/AboutSpacePanel.java create mode 100644 src/main/java/com/knowledgepixels/nanodash/component/AboutUserPanel.html create mode 100644 src/main/java/com/knowledgepixels/nanodash/component/AboutUserPanel.java create mode 100644 src/main/java/com/knowledgepixels/nanodash/component/ExplorePanel.html create mode 100644 src/main/java/com/knowledgepixels/nanodash/component/ExplorePanel.java create mode 100644 src/main/java/com/knowledgepixels/nanodash/component/ResourceTabs.html create mode 100644 src/main/java/com/knowledgepixels/nanodash/component/ResourceTabs.java delete mode 100644 src/main/java/com/knowledgepixels/nanodash/component/menu/ExploreDisplayMenu.html delete mode 100644 src/main/java/com/knowledgepixels/nanodash/component/menu/ExploreDisplayMenu.java delete mode 100644 src/main/java/com/knowledgepixels/nanodash/component/menu/UserPageMenu.html delete mode 100644 src/main/java/com/knowledgepixels/nanodash/component/menu/UserPageMenu.java delete mode 100644 src/main/java/com/knowledgepixels/nanodash/page/AboutResourcePage.html delete mode 100644 src/main/java/com/knowledgepixels/nanodash/page/AboutResourcePage.java delete mode 100644 src/main/java/com/knowledgepixels/nanodash/page/AboutSpacePage.html delete mode 100644 src/main/java/com/knowledgepixels/nanodash/page/AboutSpacePage.java delete mode 100644 src/main/java/com/knowledgepixels/nanodash/page/AboutUserPage.html delete mode 100644 src/main/java/com/knowledgepixels/nanodash/page/AboutUserPage.java diff --git a/src/main/java/com/knowledgepixels/nanodash/WicketApplication.java b/src/main/java/com/knowledgepixels/nanodash/WicketApplication.java index d712858d..2004b14f 100644 --- a/src/main/java/com/knowledgepixels/nanodash/WicketApplication.java +++ b/src/main/java/com/knowledgepixels/nanodash/WicketApplication.java @@ -188,9 +188,6 @@ protected void init() { mountPage(MaintainedResourcePage.MOUNT_PATH, MaintainedResourcePage.class); mountPage(ResourcePartPage.MOUNT_PATH, ResourcePartPage.class); mountPage(DownloadRdfPage.MOUNT_PATH, DownloadRdfPage.class); - mountPage(AboutSpacePage.MOUNT_PATH, AboutSpacePage.class); - mountPage(AboutResourcePage.MOUNT_PATH, AboutResourcePage.class); - mountPage(AboutUserPage.MOUNT_PATH, AboutUserPage.class); getCspSettings().blocking().disabled(); getStoreSettings().setMaxSizePerSession(Bytes.megabytes(100)); 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 00000000..809716df --- /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 00000000..9777c9b7 --- /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)).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)).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 00000000..a2619d77 --- /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 00000000..c94d99ab --- /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)).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)).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 00000000..d5fbb4ed --- /dev/null +++ b/src/main/java/com/knowledgepixels/nanodash/component/AboutUserPanel.html @@ -0,0 +1,14 @@ + +
+
+
+
+
+
+
+
+
+
+
+
+
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 00000000..8311ca77 --- /dev/null +++ b/src/main/java/com/knowledgepixels/nanodash/component/AboutUserPanel.java @@ -0,0 +1,53 @@ +package com.knowledgepixels.nanodash.component; + +import com.knowledgepixels.nanodash.View; +import com.knowledgepixels.nanodash.ViewDisplay; +import com.knowledgepixels.nanodash.domain.IndividualAgent; +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 { + + /** + * View that lists a user's introduction nanopublications. + */ + public static final String INTRODUCTIONS_VIEW = "https://w3id.org/np/RABbGzAaESdtU2ZG4Ar5fnwcUiV4kpFMp_k5p-6wOnc_s/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); + + View introView = View.get(INTRODUCTIONS_VIEW); + add(QueryResultTableBuilder.create("introductions", new QueryRef(introView.getQuery().getQueryId(), "user", userIriString), new ViewDisplay(introView)).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)).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)).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 6896b570..36b2908e 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 2de74a54..aaf40260 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 00000000..3584c334 --- /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 00000000..e291d983 --- /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 58c769e4..40e80942 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/ResourceTabs.html b/src/main/java/com/knowledgepixels/nanodash/component/ResourceTabs.html new file mode 100644 index 00000000..347ee6e0 --- /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 00000000..b0201455 --- /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 aaefccb4..8510f3f2 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 be0b021f..214a1a24 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 a1320988..ebcaa9a8 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 fbb0f233..6791a774 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/ResourcePartPage.html b/src/main/java/com/knowledgepixels/nanodash/page/ResourcePartPage.html index 3195f01a..29b5b0bc 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 b90caa28..56bddf17 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 530bd17e..9273c590 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 a30e09fb..13dd4676 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 5a043e36..d19103e4 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 c9b7800e..06a39245 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/webapp/style.css b/src/main/webapp/style.css index 7a11cbdc..21075e92 100644 --- a/src/main/webapp/style.css +++ b/src/main/webapp/style.css @@ -708,6 +708,67 @@ 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; +} + +/* 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; @@ -975,9 +1036,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-"] { From 737989b3ad761601f22e926c9ec281ba301aa811 Mon Sep 17 00:00:00 2001 From: Tobias Kuhn Date: Fri, 5 Jun 2026 12:49:39 +0200 Subject: [PATCH 17/33] feat: hide result filter textfield when all rows fit on the first page Add QueryResult.fitsOnFirstPage() (page size < 1, or total rows <= page size) and hide the filter input in QueryResultTable/List/ItemList/ NanopubSet/PlainParagraph when it holds, so the Filter box only shows when there are more rows than fit on the first page. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../com/knowledgepixels/nanodash/QueryResult.java | 12 ++++++++++++ .../nanodash/component/QueryResultItemList.java | 1 + .../nanodash/component/QueryResultList.java | 1 + .../nanodash/component/QueryResultNanopubSet.java | 1 + .../component/QueryResultPlainParagraph.java | 1 + .../nanodash/component/QueryResultTable.java | 1 + 6 files changed, 17 insertions(+) diff --git a/src/main/java/com/knowledgepixels/nanodash/QueryResult.java b/src/main/java/com/knowledgepixels/nanodash/QueryResult.java index c24c424f..e03cf243 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/component/QueryResultItemList.java b/src/main/java/com/knowledgepixels/nanodash/component/QueryResultItemList.java index e0350f42..e78d657a 100644 --- a/src/main/java/com/knowledgepixels/nanodash/component/QueryResultItemList.java +++ b/src/main/java/com/knowledgepixels/nanodash/component/QueryResultItemList.java @@ -54,6 +54,7 @@ protected void onUpdate(AjaxRequestTarget target) { } } }); + filterField.setVisible(!fitsOnFirstPage()); add(filterField); populateComponent(); diff --git a/src/main/java/com/knowledgepixels/nanodash/component/QueryResultList.java b/src/main/java/com/knowledgepixels/nanodash/component/QueryResultList.java index cfb8e834..2bf7a5b5 100644 --- a/src/main/java/com/knowledgepixels/nanodash/component/QueryResultList.java +++ b/src/main/java/com/knowledgepixels/nanodash/component/QueryResultList.java @@ -75,6 +75,7 @@ protected void onUpdate(AjaxRequestTarget target) { } } }); + filterField.setVisible(!fitsOnFirstPage()); add(filterField); populateComponent(); diff --git a/src/main/java/com/knowledgepixels/nanodash/component/QueryResultNanopubSet.java b/src/main/java/com/knowledgepixels/nanodash/component/QueryResultNanopubSet.java index 6f54493d..3072a3f2 100644 --- a/src/main/java/com/knowledgepixels/nanodash/component/QueryResultNanopubSet.java +++ b/src/main/java/com/knowledgepixels/nanodash/component/QueryResultNanopubSet.java @@ -85,6 +85,7 @@ protected void onUpdate(AjaxRequestTarget target) { } } }); + filterField.setVisible(!fitsOnFirstPage()); viewSelector.add(filterField); setOutputMarkupId(true); diff --git a/src/main/java/com/knowledgepixels/nanodash/component/QueryResultPlainParagraph.java b/src/main/java/com/knowledgepixels/nanodash/component/QueryResultPlainParagraph.java index da0230c9..9cadc31e 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(); diff --git a/src/main/java/com/knowledgepixels/nanodash/component/QueryResultTable.java b/src/main/java/com/knowledgepixels/nanodash/component/QueryResultTable.java index f90586fe..72a5d607 100644 --- a/src/main/java/com/knowledgepixels/nanodash/component/QueryResultTable.java +++ b/src/main/java/com/knowledgepixels/nanodash/component/QueryResultTable.java @@ -77,6 +77,7 @@ protected void onUpdate(AjaxRequestTarget target) { } } }); + filterField.setVisible(!fitsOnFirstPage()); add(filterField); populateComponent(); From 77bb465355d9ce2bfa0bafa7570430a64a1474fe Mon Sep 17 00:00:00 2001 From: Tobias Kuhn Date: Fri, 5 Jun 2026 14:35:07 +0200 Subject: [PATCH 18/33] feat: breadcrumb label splitting, dropdown styling, np-column tweaks - Breadcrumbs: split each label on list/title punctuation (, : ; | and spaced -) into separate crumb segments instead of truncating at ": ". - Action-menu dropdowns: white-background (light) style; nudge the down- arrow up so it lines up with the action buttons and filter field. - Result tables: hide the header of a trailing np/nps column and right-align its cells (the ^ source-nanopub links). - Hide the result filter textfield when all rows fit on the first page. - Make the per-tab contentContainer a transparent wicket:container so the content row-sections stay direct children of #content-pane, fixing the alternating background stripes (were appearing only after a refresh). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../nanodash/component/QueryResultTable.java | 28 +++++- .../nanodash/component/TitleBar.java | 88 ++++++++++++------- .../nanodash/page/MaintainedResourcePage.html | 4 +- .../nanodash/page/ResourcePartPage.html | 4 +- .../nanodash/page/SpacePage.html | 4 +- .../nanodash/page/UserPage.html | 4 +- src/main/webapp/style.css | 22 ++++- 7 files changed, 110 insertions(+), 44 deletions(-) diff --git a/src/main/java/com/knowledgepixels/nanodash/component/QueryResultTable.java b/src/main/java/com/knowledgepixels/nanodash/component/QueryResultTable.java index 72a5d607..d129c547 100644 --- a/src/main/java/com/knowledgepixels/nanodash/component/QueryResultTable.java +++ b/src/main/java/com/knowledgepixels/nanodash/component/QueryResultTable.java @@ -100,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; @@ -114,7 +121,13 @@ 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)); @@ -152,14 +165,25 @@ protected void onConfigure() { } } - 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 diff --git a/src/main/java/com/knowledgepixels/nanodash/component/TitleBar.java b/src/main/java/com/knowledgepixels/nanodash/component/TitleBar.java index f1bc6e92..f03ae469 100644 --- a/src/main/java/com/knowledgepixels/nanodash/component/TitleBar.java +++ b/src/main/java/com/knowledgepixels/nanodash/component/TitleBar.java @@ -1,5 +1,6 @@ package com.knowledgepixels.nanodash.component; +import java.io.Serializable; import java.util.ArrayList; import java.util.List; @@ -15,7 +16,6 @@ import com.knowledgepixels.nanodash.NanodashPageRef; import com.knowledgepixels.nanodash.NanodashPreferences; -import com.knowledgepixels.nanodash.Utils; import com.knowledgepixels.nanodash.page.NanodashPage; import com.knowledgepixels.nanodash.page.PublishPage; import com.knowledgepixels.nanodash.page.QueryListPage; @@ -59,17 +59,18 @@ public TitleBar(String id, NanodashPage page, String highlight, NanodashPageRef. breadcrumbPath = new WebMarkupContainer("breadcrumbpath"); WebMarkupContainer breadcrumbLinks = new WebMarkupContainer("breadcrumblinks"); - if (pathRefs.length > 0) { - final String[] displayLabels = simplifyBreadcrumbLabels(pathRefs); - breadcrumbLinks.add(pathRefs[0].createComponent("firstpathelement", displayLabels[0])); + List crumbParts = buildCrumbParts(pathRefs); + if (!crumbParts.isEmpty()) { + CrumbPart first = crumbParts.get(0); + breadcrumbLinks.add(first.ref().createComponent("firstpathelement", first.label())); // Getting serialization exception if not using 'new ArrayList<...>(...)' here: - List morePathElements = new ArrayList(Utils.subList(pathRefs, 1, pathRefs.length)); - breadcrumbLinks.add(new DataView("morepathelements", new ListDataProvider(morePathElements)) { + List moreParts = new ArrayList(crumbParts.subList(1, crumbParts.size())); + breadcrumbLinks.add(new DataView("morepathelements", new ListDataProvider(moreParts)) { @Override - protected void populateItem(Item item) { - int index = (int) item.getIndex() + 1; - item.add(item.getModelObject().createComponent("furtherpathelement", displayLabels[index])); + protected void populateItem(Item item) { + CrumbPart part = item.getModelObject(); + item.add(part.ref().createComponent("furtherpathelement", part.label())); } }); @@ -86,9 +87,15 @@ protected void populateItem(Item item) { } /** - * Computes shortened display labels for a breadcrumb path. - * - * Two simplifications are applied: + * One breadcrumb segment: the {@link NanodashPageRef} it links to and the + * (possibly split) label text to show for it. + */ + public record CrumbPart(NanodashPageRef ref, String label) implements Serializable { + } + + /** + * Flattens a breadcrumb path into the segments to render. Two transforms are + * applied to each ref's label: *
    *
  • For each non-root crumb, the part it shares with its parent label * is stripped (e.g. parent "Knowledge Pixels", child "Knowledge @@ -97,30 +104,51 @@ protected void populateItem(Item item) { * (almost) all of the parent and ends on a non-letter/digit boundary * in the child — this also catches singular/plural variations like * parent "Nano Sessions", child "Nano Session #30" → "#30".
  • - *
  • Any ": " in a label and everything after it is removed (e.g. - * "Incubator 1: Some title" becomes "Incubator 1"). Applied to all - * crumbs, including the first.
  • + *
  • The remaining label is split on list/title punctuation ({@code ,}, + * {@code :}, {@code ;}, {@code |}, and spaced {@code -}) into separate + * breadcrumb segments — e.g. "General Nanopub Ecosystem Ontology, + * version 0.4 (incomplete)" renders as two crumbs "General Nanopub + * Ecosystem Ontology" › "version 0.4 (incomplete)". All segments of a + * label link to the same page.
  • *
- * - * The parent-prefix comparison uses the original (un-simplified) labels, - * so that simplifications on the parent don't interfere with the child. + * The parent-prefix comparison uses the original (un-simplified) labels. */ - static String[] simplifyBreadcrumbLabels(NanodashPageRef[] pathRefs) { - String[] displayLabels = new String[pathRefs.length]; + static List buildCrumbParts(NanodashPageRef[] pathRefs) { + List parts = new ArrayList<>(); for (int i = 0; i < pathRefs.length; i++) { String label = pathRefs[i].getLabel(); - if (label != null) { - if (i > 0) { - label = stripParentPrefix(pathRefs[i - 1].getLabel(), label); - } - int colonIdx = label.indexOf(": "); - if (colonIdx > 0) { - label = label.substring(0, colonIdx); - } + if (label == null) { + parts.add(new CrumbPart(pathRefs[i], null)); + continue; } - displayLabels[i] = label; + if (i > 0) { + label = stripParentPrefix(pathRefs[i - 1].getLabel(), label); + } + for (String segment : splitLabel(label)) { + parts.add(new CrumbPart(pathRefs[i], segment)); + } + } + return parts; + } + + /** + * Splits a label on list/title separators — comma, colon, semicolon, pipe + * (with or without surrounding spaces), or a space-surrounded hyphen — into + * trimmed, non-empty segments. Returns the trimmed whole label if there is + * no separator (so the result always has at least one element). + */ + static List splitLabel(String label) { + List segments = new ArrayList<>(); + if (label == null) return segments; + for (String s : label.split("\\s*[,;:]\\s+|\\s*\\|\\s*|\\s+-\\s+")) { + String trimmed = s.trim(); + if (!trimmed.isEmpty()) segments.add(trimmed); + } + if (segments.isEmpty()) { + String trimmed = label.trim(); + if (!trimmed.isEmpty()) segments.add(trimmed); } - return displayLabels; + return segments; } /** diff --git a/src/main/java/com/knowledgepixels/nanodash/page/MaintainedResourcePage.html b/src/main/java/com/knowledgepixels/nanodash/page/MaintainedResourcePage.html index ebcaa9a8..5eab51d4 100644 --- a/src/main/java/com/knowledgepixels/nanodash/page/MaintainedResourcePage.html +++ b/src/main/java/com/knowledgepixels/nanodash/page/MaintainedResourcePage.html @@ -20,9 +20,9 @@

Resource ABC

-
+
-
+
diff --git a/src/main/java/com/knowledgepixels/nanodash/page/ResourcePartPage.html b/src/main/java/com/knowledgepixels/nanodash/page/ResourcePartPage.html index 29b5b0bc..77b19c4c 100644 --- a/src/main/java/com/knowledgepixels/nanodash/page/ResourcePartPage.html +++ b/src/main/java/com/knowledgepixels/nanodash/page/ResourcePartPage.html @@ -21,9 +21,9 @@

ABC +
- +
diff --git a/src/main/java/com/knowledgepixels/nanodash/page/SpacePage.html b/src/main/java/com/knowledgepixels/nanodash/page/SpacePage.html index 9273c590..b5bd626b 100644 --- a/src/main/java/com/knowledgepixels/nanodash/page/SpacePage.html +++ b/src/main/java/com/knowledgepixels/nanodash/page/SpacePage.html @@ -21,7 +21,7 @@

Space ABC -
+

📅 Date

@@ -54,7 +54,7 @@

ℹ About

-
+
diff --git a/src/main/java/com/knowledgepixels/nanodash/page/UserPage.html b/src/main/java/com/knowledgepixels/nanodash/page/UserPage.html index d19103e4..620dea79 100644 --- a/src/main/java/com/knowledgepixels/nanodash/page/UserPage.html +++ b/src/main/java/com/knowledgepixels/nanodash/page/UserPage.html @@ -20,7 +20,7 @@

-
+
@@ -31,7 +31,7 @@

-
+
diff --git a/src/main/webapp/style.css b/src/main/webapp/style.css index 21075e92..5412e7b8 100644 --- a/src/main/webapp/style.css +++ b/src/main/webapp/style.css @@ -749,6 +749,11 @@ span.prelink { 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; @@ -941,9 +946,9 @@ input[type=radio] + label strong { height: 22px; padding: 0; 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; @@ -991,7 +996,8 @@ input[type=radio] + label strong { } .actionmenu:hover .actionmenu-button { - background-color: #0B73DAC0; + background-color: #0B73DA; + color: #fff; } @@ -2106,6 +2112,14 @@ div.navigator .goto a[disabled] { margin-left: auto; } +/* The action-menu dropdown's down-arrow glyph baselines a couple of pixels + lower than the text action buttons; nudge it up so they line up (and with + the filter field / title, which the buttons already match). */ +.paneltitlerow span.buttons .actionmenu-button { + position: relative; + top: -2px; +} + .paneltitlerow > span.buttons:has(~ span.buttons) { order: 1; margin-left: 0; From 19720f2b26cc8e20b757361115b923b3634d77eb Mon Sep 17 00:00:00 2001 From: Tobias Kuhn Date: Fri, 5 Jun 2026 15:34:22 +0200 Subject: [PATCH 19/33] feat: account controls on own About tab; outline dropdown chevron - Add ProfileAccountPanel to the current user's /user?tab=about: logout (ORCID-login mode only) and an ORCID set/change form (local mode only). - Dropdown arrows: CSS-drawn outline "v" chevron (no top line), matching the action buttons' box and vertical-align so they line up. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../nanodash/component/AboutUserPanel.html | 1 + .../nanodash/component/AboutUserPanel.java | 12 +++ .../component/ProfileAccountPanel.html | 13 ++++ .../component/ProfileAccountPanel.java | 74 +++++++++++++++++++ .../nanodash/component/menu/ActionMenu.html | 2 +- .../component/menu/BaseDisplayMenu.html | 2 +- src/main/webapp/style.css | 33 ++++++--- 7 files changed, 123 insertions(+), 14 deletions(-) create mode 100644 src/main/java/com/knowledgepixels/nanodash/component/ProfileAccountPanel.html create mode 100644 src/main/java/com/knowledgepixels/nanodash/component/ProfileAccountPanel.java diff --git a/src/main/java/com/knowledgepixels/nanodash/component/AboutUserPanel.html b/src/main/java/com/knowledgepixels/nanodash/component/AboutUserPanel.html index d5fbb4ed..d94e2158 100644 --- a/src/main/java/com/knowledgepixels/nanodash/component/AboutUserPanel.html +++ b/src/main/java/com/knowledgepixels/nanodash/component/AboutUserPanel.html @@ -1,4 +1,5 @@ +
diff --git a/src/main/java/com/knowledgepixels/nanodash/component/AboutUserPanel.java b/src/main/java/com/knowledgepixels/nanodash/component/AboutUserPanel.java index 8311ca77..0b5d91ac 100644 --- a/src/main/java/com/knowledgepixels/nanodash/component/AboutUserPanel.java +++ b/src/main/java/com/knowledgepixels/nanodash/component/AboutUserPanel.java @@ -1,8 +1,10 @@ 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.markup.html.panel.EmptyPanel; import org.apache.wicket.markup.html.panel.Panel; import org.nanopub.extra.services.QueryRef; @@ -31,6 +33,16 @@ public class AboutUserPanel extends Panel { 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)); + } + View introView = View.get(INTRODUCTIONS_VIEW); add(QueryResultTableBuilder.create("introductions", new QueryRef(introView.getQuery().getQueryId(), "user", userIriString), new ViewDisplay(introView)).build()); 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 00000000..962cc16b --- /dev/null +++ b/src/main/java/com/knowledgepixels/nanodash/component/ProfileAccountPanel.html @@ -0,0 +1,13 @@ + +
+
+

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 00000000..d6d74547 --- /dev/null +++ b/src/main/java/com/knowledgepixels/nanodash/component/ProfileAccountPanel.java @@ -0,0 +1,74 @@ +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")); + } + +} diff --git a/src/main/java/com/knowledgepixels/nanodash/component/menu/ActionMenu.html b/src/main/java/com/knowledgepixels/nanodash/component/menu/ActionMenu.html index 27a85fc1..472a05f3 100644 --- a/src/main/java/com/knowledgepixels/nanodash/component/menu/ActionMenu.html +++ b/src/main/java/com/knowledgepixels/nanodash/component/menu/ActionMenu.html @@ -8,7 +8,7 @@ - + Item diff --git a/src/main/java/com/knowledgepixels/nanodash/component/menu/BaseDisplayMenu.html b/src/main/java/com/knowledgepixels/nanodash/component/menu/BaseDisplayMenu.html index d97c8726..d861f977 100644 --- a/src/main/java/com/knowledgepixels/nanodash/component/menu/BaseDisplayMenu.html +++ b/src/main/java/com/knowledgepixels/nanodash/component/menu/BaseDisplayMenu.html @@ -3,7 +3,7 @@ - + diff --git a/src/main/webapp/style.css b/src/main/webapp/style.css index 5412e7b8..8b5fbb25 100644 --- a/src/main/webapp/style.css +++ b/src/main/webapp/style.css @@ -940,11 +940,10 @@ 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: transparent; color: #0B73DA; @@ -954,6 +953,23 @@ input[type=radio] + label strong { 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 { @@ -961,6 +977,7 @@ input[type=radio] + label strong { padding: 0; position: relative; display: inline-block; + vertical-align: middle; } .actionmenu-content { @@ -2112,14 +2129,6 @@ div.navigator .goto a[disabled] { margin-left: auto; } -/* The action-menu dropdown's down-arrow glyph baselines a couple of pixels - lower than the text action buttons; nudge it up so they line up (and with - the filter field / title, which the buttons already match). */ -.paneltitlerow span.buttons .actionmenu-button { - position: relative; - top: -2px; -} - .paneltitlerow > span.buttons:has(~ span.buttons) { order: 1; margin-left: 0; From a35fc311c5370840d5422480d706615bcde74b8b Mon Sep 17 00:00:00 2001 From: Tobias Kuhn Date: Fri, 5 Jun 2026 15:40:02 +0200 Subject: [PATCH 20/33] feat: show signing key in Account section of own About tab Embed ProfileSigItem in ProfileAccountPanel so the current user's About tab also shows their public key and (in local mode) the local key-file path (migrating profile feature B). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../nanodash/component/ProfileAccountPanel.html | 1 + .../nanodash/component/ProfileAccountPanel.java | 3 +++ 2 files changed, 4 insertions(+) diff --git a/src/main/java/com/knowledgepixels/nanodash/component/ProfileAccountPanel.html b/src/main/java/com/knowledgepixels/nanodash/component/ProfileAccountPanel.html index 962cc16b..206633a2 100644 --- a/src/main/java/com/knowledgepixels/nanodash/component/ProfileAccountPanel.html +++ b/src/main/java/com/knowledgepixels/nanodash/component/ProfileAccountPanel.html @@ -8,6 +8,7 @@

Account

+
diff --git a/src/main/java/com/knowledgepixels/nanodash/component/ProfileAccountPanel.java b/src/main/java/com/knowledgepixels/nanodash/component/ProfileAccountPanel.java index d6d74547..1b3c0294 100644 --- a/src/main/java/com/knowledgepixels/nanodash/component/ProfileAccountPanel.java +++ b/src/main/java/com/knowledgepixels/nanodash/component/ProfileAccountPanel.java @@ -69,6 +69,9 @@ protected void onSubmit() { add(form); add(new FeedbackPanel("feedback")); + + // Signing key: public key + (local mode) the local key-file path. + add(new ProfileSigItem("sigpart")); } } From 32d9bfed2715d11e6b4d9a41014f602044a086b6 Mon Sep 17 00:00:00 2001 From: Tobias Kuhn Date: Fri, 5 Jun 2026 16:42:06 +0200 Subject: [PATCH 21/33] feat: view-styled intro companion on own About tab; drop retract view action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Revert the introductions-view retract entry action: republish a read-only introductions-view (RAElH_0Za…, supersedes the action version) and point the constant at it. - AboutUserPanel: the logged-in user (with a local key) now gets the ProfileIntroItem companion *instead of* the read-only view (others keep the read-only view) — no more duplicate listing. - Restyle ProfileIntroItem to match the view tables: a Recommended Actions section (paneltitlerow header) holding the recommendation note + actions (incl. Create Introduction), then the "👋 Introductions" pseudo-view rendered as a table (date/location/keys/np + per-row retract/derive/ include-keys). - Style the Account header like Signing Key; fold the "multiple introductions" note into the retract bullet so no introductory text sits directly in front of the table. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../nanodash/component/AboutUserPanel.java | 24 +++++++-- .../component/ProfileAccountPanel.html | 2 +- .../nanodash/component/ProfileIntroItem.html | 50 ++++++++++--------- .../nanodash/component/ProfileIntroItem.java | 4 +- 4 files changed, 50 insertions(+), 30 deletions(-) diff --git a/src/main/java/com/knowledgepixels/nanodash/component/AboutUserPanel.java b/src/main/java/com/knowledgepixels/nanodash/component/AboutUserPanel.java index 0b5d91ac..8a87ce7e 100644 --- a/src/main/java/com/knowledgepixels/nanodash/component/AboutUserPanel.java +++ b/src/main/java/com/knowledgepixels/nanodash/component/AboutUserPanel.java @@ -4,6 +4,7 @@ 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; @@ -16,9 +17,11 @@ public class AboutUserPanel extends Panel { /** - * View that lists a user's introduction nanopublications. + * 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/RABbGzAaESdtU2ZG4Ar5fnwcUiV4kpFMp_k5p-6wOnc_s/introductions-view"; + 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 @@ -43,8 +46,21 @@ public AboutUserPanel(String id, String userIriString) { add(new EmptyPanel("account").setVisible(false)); } - View introView = View.get(INTRODUCTIONS_VIEW); - add(QueryResultTableBuilder.create("introductions", new QueryRef(introView.getQuery().getQueryId(), "user", userIriString), new ViewDisplay(introView)).build()); + // 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. diff --git a/src/main/java/com/knowledgepixels/nanodash/component/ProfileAccountPanel.html b/src/main/java/com/knowledgepixels/nanodash/component/ProfileAccountPanel.html index 206633a2..2fd055fc 100644 --- a/src/main/java/com/knowledgepixels/nanodash/component/ProfileAccountPanel.html +++ b/src/main/java/com/knowledgepixels/nanodash/component/ProfileAccountPanel.html @@ -1,7 +1,7 @@
-

Account

+

Account

logout

https://orcid.org/ diff --git a/src/main/java/com/knowledgepixels/nanodash/component/ProfileIntroItem.html b/src/main/java/com/knowledgepixels/nanodash/component/ProfileIntroItem.html index db4a55f0..5a91fc5a 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 bf4a88a0..e0968ef2 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)); From e5a82d3d3813abf75e2c822579c13a9a6d3e938a Mon Sep 17 00:00:00 2001 From: Tobias Kuhn Date: Mon, 8 Jun 2026 09:47:44 +0200 Subject: [PATCH 22/33] docs: design note for session-bound ("magic") query parameters Spec for auto-binding view-query placeholders (LOCALPUBKEY, SITEURL) from the session, plus the empty-into-required entry-action visibility rule, as the path to replace the custom ProfileIntroItem table with a proper view. Co-Authored-By: Claude Opus 4.8 (1M context) --- doc/magic-query-params.md | 305 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 305 insertions(+) create mode 100644 doc/magic-query-params.md diff --git a/doc/magic-query-params.md b/doc/magic-query-params.md new file mode 100644 index 00000000..6ec81667 --- /dev/null +++ b/doc/magic-query-params.md @@ -0,0 +1,305 @@ +# Session-bound ("magic") query parameters + +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. From c96c6c1278f34349559638497cc0742143423893 Mon Sep 17 00:00:00 2001 From: Tobias Kuhn Date: Mon, 8 Jun 2026 09:54:52 +0200 Subject: [PATCH 23/33] docs: consolidate doc/ into docs/; add status headers and index - Move the four notes from doc/ into docs/ (alongside userlist-views.md). - Add a **Status:** line (Implemented / In progress / Proposed) to each doc. - Add docs/README.md index with a status table. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/README.md | 16 ++++++++++++++++ {doc => docs}/custom-domains.md | 2 ++ {doc => docs}/draft-with-ai.md | 2 ++ {doc => docs}/magic-query-params.md | 2 ++ {doc => docs}/presets.md | 2 ++ docs/userlist-views.md | 2 ++ 6 files changed, 26 insertions(+) create mode 100644 docs/README.md rename {doc => docs}/custom-domains.md (99%) rename {doc => docs}/draft-with-ai.md (99%) rename {doc => docs}/magic-query-params.md (99%) rename {doc => docs}/presets.md (99%) diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 00000000..6a7d00b4 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,16 @@ +# 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 | +| [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 d58107cf..a8df0cf5 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 e1cead80..708b6322 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/doc/magic-query-params.md b/docs/magic-query-params.md similarity index 99% rename from doc/magic-query-params.md rename to docs/magic-query-params.md index 6ec81667..7be9d28c 100644 --- a/doc/magic-query-params.md +++ b/docs/magic-query-params.md @@ -1,5 +1,7 @@ # 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 diff --git a/doc/presets.md b/docs/presets.md similarity index 99% rename from doc/presets.md rename to docs/presets.md index bc3483d2..ff7940a8 100644 --- a/doc/presets.md +++ b/docs/presets.md @@ -1,5 +1,7 @@ # 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 diff --git a/docs/userlist-views.md b/docs/userlist-views.md index 629da8b9..62b6f6c0 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. From 2ab36e873725d14feaca54d4b1d7d22559cd5fc5 Mon Sep 17 00:00:00 2001 From: Tobias Kuhn Date: Mon, 8 Jun 2026 10:41:50 +0200 Subject: [PATCH 24/33] docs: add role-specific-views design note View displays visible only to a given role tier (Maintainer, ...) or specific role via a single gen:isVisibleTo predicate, riding nanopub-query's existing role-tier model. Records the AboutUserPanel preset/view-display action leak as a known gap to fix with the declarative mechanism. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/README.md | 1 + docs/role-specific-views.md | 213 ++++++++++++++++++++++++++++++++++++ 2 files changed, 214 insertions(+) create mode 100644 docs/role-specific-views.md diff --git a/docs/README.md b/docs/README.md index 6a7d00b4..da901b24 100644 --- a/docs/README.md +++ b/docs/README.md @@ -10,6 +10,7 @@ Status legend: ✅ Implemented · 🚧 In progress · 📋 Proposed | [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) | 📋 Proposed | View displays visible only to a given role tier (Maintainer, …) or specific role, via `gen:isVisibleTo` | | [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 | diff --git a/docs/role-specific-views.md b/docs/role-specific-views.md new file mode 100644 index 00000000..cf9c2fc6 --- /dev/null +++ b/docs/role-specific-views.md @@ -0,0 +1,213 @@ +# Role-specific views + +**Status:** 📋 Proposed + +A view display may declare that it is **visible 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 builds directly 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"). It is meant to be compatible with the session-bound query +parameters in [magic-query-params](magic-query-params.md); the two share the +`?_CURRENTUSER` binding for the optional server-side path. + +## Semantics & scope + +This is **relevance-gating, not a security boundary.** The view, its query, and +the underlying nanopubs are all public; hiding a maintainer-only view from a +normal visitor declutters their page, it does not protect data. That lets the +first cut filter **client-side** with role data the page already loads. + +Applies to **spaces** and **maintained resources** (which resolve a governing +space via `getSpace()`). On a user page (`IndividualAgent`) there is no space, so +role-gating degrades to the existing owner-vs-visitor check. + +## 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. View +visibility 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 an **"Everyone" floor below Observer**: + +``` +Everyone (rank 0, = no triple) < Observer (1) < Member (2) < Maintainer (3) < Admin (4) +``` + +"Everyone" means literally anyone, including logged-out viewers with no role — +distinct from Observer, which is the lowest *assigned* tier (self-attestation is +still a deliberate step). **"Everyone" is not a nanopub-query role type** and is +not added to that repo's tier set — those are *grant* tiers, and you never +"grant everybody." It exists only as the absence-of-restriction default here. + +## The predicate + +A single predicate carries both targeting modes; its object is either a tier IRI +or a specific role IRI: + +```turtle + gen:isVisibleTo gen:MaintainerRole . # tier: this tier or above + gen:isVisibleTo <…/newsletterEditorRole> . # specific role IRI +# (no gen:isVisibleTo triple) # Everyone (default, backward-compatible) +``` + +Disambiguation is trivial and safe: the tier IRIs are a fixed known set +(`gen:AdminRole` / `gen:MaintainerRole` / `gen:MemberRole` / `gen:ObserverRole`); +any other IRI is a specific role. Multiple `gen:isVisibleTo` triples are **OR**. + +An explicit "Everyone" option in the authoring UI simply *omits* the predicate — +no IRI is minted for it. (A Nanodash-local sentinel would only be worth it if we +ever needed to distinguish "deliberately public" from "unset," which for +visibility does not appear to matter.) + +## Matching + +``` +visible(viewer, space, display): + reqs = display.isVisibleTo // set of IRIs; empty => Everyone + if reqs empty -> visible + if space == null -> visible only if viewer is the page owner + for X in reqs: + if X is a tier IRI and userTier(viewer, space) >= rank(X) -> visible // tier threshold + if X is a role IRI and viewerHoldsRole(viewer, space, X) -> visible // specific role + hidden +``` + +- **`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 a view gated to that role — the page shows exactly what each + role is entitled to. The management escape hatch is **self-assignment**: an + admin can grant any non-admin-tier role (and every custom role is + maintainer/member/observer tier), so an admin who needs a specific-role view + simply publishes a role-instantiation for themselves. No special-case code. + +## Where the assignment lives + +Primary carrier: the **`ViewDisplay`** nanopub — it already models "show this +view for this resource" with per-display modifiers (`appliesTo`, page size, +structural position, deactivation). Visibility is one more modifier, so the +underlying View stays reusable and unrestricted elsewhere. + +Two parallels for consistency (same predicate, same matching): + +- **View default** — a `View` may carry a default `gen:isVisibleTo` that a + `ViewDisplay` overrides, mirroring how View defaults page size / width / + position flow into ViewDisplay. +- **Preset-bundled view** — a preset's `gen:hasView` / `gen:hasTopLevelView` + reference may carry the same visibility so bundled views gate too. + +## What Nanodash needs (all additive — it has no tier awareness today) + +1. **Learn tiers.** `get-space-roles` (or `get-space-members`) must return + `roleType` (= `npa:hasRoleType`); `SpaceMemberRole` gains a `tier` field. This + dovetails with the in-progress grlc spaces-repo migration — same direction, + consuming the materialized `npass:` state. +2. **Tier IRIs + rank** in `KPXL_TERMS` (`AdminRole` / `MaintainerRole` / + `MemberRole` / `ObserverRole`) and a small rank helper with the Everyone floor. +3. **Parse `gen:isVisibleTo`** in `ViewDisplay` (and `View` for defaults). +4. **`Space` helpers** `userTier(iri)` (max tier held) and + `viewerHoldsRole(iri, roleIri)`, plus `ViewDisplay.isVisibleTo(...)`. +5. **Apply the filter** (see next). + +## Filter point — and the magic-param tie-in + +**v1 (standalone — implement here first).** Filter in the existing view-display +aggregation in `AbstractResourceWithProfile` (the `get-view-displays` consumer, +where latest-wins / deactivation already happen). After aggregation, drop +displays where `isVisibleTo(currentUser, governingSpace)` is false. Uses +already-loaded `Space` role data; no query changes; no dependency on the +magic-param work. + +**v2 (compatible evolution).** Push the filter server-side into +`get-view-displays` by binding the viewer identity as the `?_CURRENTUSER` magic +parameter from [magic-query-params](magic-query-params.md) — the same binding +hook. The spaces repo already materializes member → role → tier, so the JOIN +(viewer's instantiations vs the display's required role/tier, honoring the tier +order) is cheap. Same vocabulary and data model; only the enforcement point +moves. + +## How it layers with action-gating + +Three independent layers, coarse → fine, all over the same role model: + +1. **View-display visibility** (this doc) — whole section shows/hides by viewer + role/tier. +2. **Result/entry action gating** — `ButtonList` admin/member routing, plus the + empty-into-required rule from [magic-query-params](magic-query-params.md). +3. **Per-row** visibility via conditional query binding. + +A view can be member-visible yet carry an admin-only action button; the layers +compose. + +## Known gaps this closes + +Today's action gating is incidental — it depends on how each table happens to be +wired, not on anything the view declares — so it is already inconsistent. A +concrete example to fix when this lands: + +- On a user's About page (`AboutUserPanel`), the **profile** table passes + `resourceWithProfile(IndividualAgent…)`, so its "update profile image / license" + actions route to `ButtonList`'s admin slot and are correctly owner-only. But + the **presets** and **view-displays** tables (`AboutUserPanel.java:74-78`) are + built *without* `resourceWithProfile`, so their "add preset…" / "add view + display…" actions fall into the unconditional regular-button slot + (`QueryResult.java:61`, `ButtonList.java:24-26`) and **leak onto other users' + About pages**. Publishing would be ignored server-side (authorized-agents-only), + but the button should not be shown. + +Left as-is deliberately: rather than patch the per-call wiring, the declarative +mechanism makes the view/action declare its required role once, so every table +honours it regardless of construction — removing this whole class of +wiring-dependent inconsistency. + +## Touch points + +| Change | File | +| --- | --- | +| Tier IRIs + rank, `gen:isVisibleTo` term | `vocabulary/KPXL_TERMS.java` | +| `roleType` / tier field | `SpaceMemberRole.java`, `get-space-roles` (or `get-space-members`) query | +| `userTier` / `viewerHoldsRole` | `domain/Space.java` | +| Parse `gen:isVisibleTo` (+ View default) | `ViewDisplay.java`, `View.java` | +| `isVisibleTo(...)` + apply filter | `ViewDisplay.java`, `domain/AbstractResourceWithProfile.java` | +| Visibility selector in display-admin UI | `AddViewDisplayButton` / display authoring | + +## Phasing + +1. **Consume tiers**: tier IRIs + rank in `KPXL_TERMS`; `SpaceMemberRole.tier` + (and the `get-space-roles`/`get-space-members` column). +2. **Parse + match + client-side filter** in `AbstractResourceWithProfile` — + functional end-to-end here. +3. **Authoring UI**: a visibility selector ("Everyone" / tier / specific role) + on the display-admin control. +4. *(Later, with magic params)* server-side filter in `get-view-displays` via + `?_CURRENTUSER`. + +## 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 see this view display" — which that design explicitly leaves +to Nanodash. No new server-side role type is introduced (the Everyone floor is a +Nanodash-side default, not a tier). From f10bc9fe94a3ac488c6e247231ea9032ba19c62c Mon Sep 17 00:00:00 2001 From: Tobias Kuhn Date: Mon, 8 Jun 2026 11:14:15 +0200 Subject: [PATCH 25/33] feat: role-tier model + gen:isVisibleTo view-display visibility filter Implements steps 1-4 of docs/role-specific-views.md: - KPXL_TERMS: role-tier IRIs (AdminRole/MaintainerRole/MemberRole/ ObserverRole) and the gen:isVisibleTo predicate. - SpaceMemberRole: tier field (parsed from the get-space-roles roleType column, defaulting to ObserverRole), tier ranking (admin>maintainer> member>observer>everyone) and isTier/tierRank helpers; the built-in ADMIN_ROLE carries the AdminRole tier. - Space: consume the roleType column; userTier(iri) and viewerHoldsRole(iri, role) lookups. - View/ViewDisplay: parse gen:isVisibleTo; ViewDisplay.isVisibleTo(...) matches a tier threshold or a specific role (no admin override), falling back to the view default; empty => everyone. - AbstractResourceWithProfile.getViewDisplays: drop displays the viewer is not entitled to, after the latest-wins/deactivation pass. Session-safe via NanodashSession.getCurrentUserIriOrNull() and fails closed off-request. - Tests for the tier ranking/parsing. Inert until a view display declares gen:isVisibleTo; maintainer/member tiers need the get-space-roles roleType republish (P0). Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/README.md | 2 +- docs/role-specific-views.md | 2 +- .../nanodash/NanodashSession.java | 11 +++ .../nanodash/SpaceMemberRole.java | 80 ++++++++++++++++++- .../com/knowledgepixels/nanodash/View.java | 14 ++++ .../knowledgepixels/nanodash/ViewDisplay.java | 40 ++++++++++ .../domain/AbstractResourceWithProfile.java | 16 ++++ .../nanodash/domain/Space.java | 38 ++++++++- .../nanodash/vocabulary/KPXL_TERMS.java | 17 ++++ .../nanodash/SpaceMemberRoleTest.java | 59 ++++++++++++++ 10 files changed, 274 insertions(+), 5 deletions(-) diff --git a/docs/README.md b/docs/README.md index da901b24..b28e7268 100644 --- a/docs/README.md +++ b/docs/README.md @@ -10,7 +10,7 @@ Status legend: ✅ Implemented · 🚧 In progress · 📋 Proposed | [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) | 📋 Proposed | View displays visible only to a given role tier (Maintainer, …) or specific role, via `gen:isVisibleTo` | +| [role-specific-views](role-specific-views.md) | 🚧 In progress | View displays visible only to a given role tier (Maintainer, …) or specific role, via `gen:isVisibleTo` | | [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 | diff --git a/docs/role-specific-views.md b/docs/role-specific-views.md index cf9c2fc6..e2297372 100644 --- a/docs/role-specific-views.md +++ b/docs/role-specific-views.md @@ -1,6 +1,6 @@ # Role-specific views -**Status:** 📋 Proposed +**Status:** 🚧 In progress — tier model + `gen:isVisibleTo` parsing/matching + client-side filter implemented (steps 1–4); pending the `get-space-roles` `roleType` republish (P0), authoring UI, and the server-side filter. A view display may declare that it is **visible only to viewers holding a given role, or a given role tier (class)**, relative to the resource's governing diff --git a/src/main/java/com/knowledgepixels/nanodash/NanodashSession.java b/src/main/java/com/knowledgepixels/nanodash/NanodashSession.java index b2f5b3da..854311b7 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/SpaceMemberRole.java b/src/main/java/com/knowledgepixels/nanodash/SpaceMemberRole.java index 914552d8..f9160cef 100644 --- a/src/main/java/com/knowledgepixels/nanodash/SpaceMemberRole.java +++ b/src/main/java/com/knowledgepixels/nanodash/SpaceMemberRole.java @@ -20,6 +20,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 +46,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 +57,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 +146,56 @@ 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.ADMIN_ROLE_TYPE.equals(iri) + || KPXL_TERMS.MAINTAINER_ROLE.equals(iri) + || KPXL_TERMS.MEMBER_ROLE.equals(iri) + || KPXL_TERMS.OBSERVER_ROLE.equals(iri); + } + /** * Add the role parameters to the given multimap. * @@ -137,7 +213,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 62a90fcd..34e47d5f 100644 --- a/src/main/java/com/knowledgepixels/nanodash/View.java +++ b/src/main/java/com/knowledgepixels/nanodash/View.java @@ -141,6 +141,7 @@ public static View get(String id, boolean resolveLatest) { private Map actionTemplateQueryMappingMap = new HashMap<>(); private Map labelMap = new HashMap<>(); private IRI viewType; + private Set visibleTo = new HashSet<>(); private View(String id, Nanopub nanopub) { this.id = id; @@ -185,6 +186,8 @@ private View(String id, Nanopub nanopub) { displayWidth = columnWidths.get(objIri); } else if (st.getPredicate().equals(KPXL_TERMS.HAS_STRUCTURAL_POSITION) && st.getObject() instanceof Literal objL) { structuralPosition = objL.stringValue(); + } else if (st.getPredicate().equals(KPXL_TERMS.IS_VISIBLE_TO) && st.getObject() instanceof IRI objIri) { + visibleTo.add(objIri); } } else if (st.getPredicate().equals(KPXL_TERMS.HAS_ACTION_TEMPLATE)) { Template template = TemplateData.get().getTemplate(st.getObject().stringValue()); @@ -289,6 +292,17 @@ public String getStructuralPosition() { return structuralPosition; } + /** + * Gets the default visibility restriction of this view: the set of role-tier + * or specific-role IRIs a viewer must hold to see it. Empty means visible to + * everyone. A {@link ViewDisplay} overrides this with its own restriction. + * + * @return the set of {@code gen:isVisibleTo} IRIs (never null) + */ + public Set getVisibleTo() { + return visibleTo; + } + /** * 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 3a8d1253..09db857d 100644 --- a/src/main/java/com/knowledgepixels/nanodash/ViewDisplay.java +++ b/src/main/java/com/knowledgepixels/nanodash/ViewDisplay.java @@ -1,5 +1,6 @@ package com.knowledgepixels.nanodash; +import com.knowledgepixels.nanodash.domain.Space; import com.knowledgepixels.nanodash.vocabulary.KPXL_TERMS; import org.eclipse.rdf4j.model.IRI; import org.eclipse.rdf4j.model.Literal; @@ -33,6 +34,7 @@ public class ViewDisplay implements Serializable, Comparable { private Set appliesTo = new HashSet<>(); private Set appliesToClasses = new HashSet<>(); private Set appliesToNamespaces = new HashSet<>(); + private Set visibleTo = new HashSet<>(); private IRI resource; /** @@ -195,6 +197,8 @@ private ViewDisplay(String id, Nanopub nanopub, String latestViewIri) { appliesToClasses.add(objIri); } else if (st.getPredicate().equals(KPXL_TERMS.APPLIES_TO) && st.getObject() instanceof IRI objIri) { appliesTo.add(objIri.stringValue()); + } else if (st.getPredicate().equals(KPXL_TERMS.IS_VISIBLE_TO) && st.getObject() instanceof IRI objIri) { + visibleTo.add(objIri); } } } @@ -325,6 +329,42 @@ public String getTitle() { return null; } + /** + * Whether this view display should be shown to the given viewer, per its + * {@code gen:isVisibleTo} restriction (falling back to the displayed view's + * default when the display declares none). An empty restriction means + * visible to everyone. + * + *

A role-tier IRI matches when the viewer's highest role 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 restrictions are OR-ed. + * There is no admin override for specific roles — an admin who needs such a + * view self-assigns the role.

+ * + * @param viewer the viewer's agent IRI, or null if logged out + * @param governingSpace the space whose roles govern visibility, or null + * (e.g. a user page) — then a restricted display is + * shown only to the page owner + * @param viewerIsOwner whether the viewer owns the resource this display is + * for (used only when there is no governing space) + * @return true if the display should be shown + */ + public boolean isVisibleTo(IRI viewer, Space governingSpace, boolean viewerIsOwner) { + Set reqs = (visibleTo.isEmpty() && view != null) ? view.getVisibleTo() : visibleTo; + if (reqs == null || reqs.isEmpty()) return true; + if (governingSpace == null) return viewerIsOwner; + if (viewer == null) return false; + for (IRI req : reqs) { + if (SpaceMemberRole.isTier(req)) { + if (governingSpace.userTier(viewer) >= SpaceMemberRole.tierRank(req)) return true; + } else if (governingSpace.viewerHoldsRole(viewer, req)) { + return true; + } + } + return false; + } + @Override public int compareTo(ViewDisplay other) { return this.getStructuralPosition().compareTo(other.getStructuralPosition()); diff --git a/src/main/java/com/knowledgepixels/nanodash/domain/AbstractResourceWithProfile.java b/src/main/java/com/knowledgepixels/nanodash/domain/AbstractResourceWithProfile.java index 1fd9e8a4..9d7ab6d4 100644 --- a/src/main/java/com/knowledgepixels/nanodash/domain/AbstractResourceWithProfile.java +++ b/src/main/java/com/knowledgepixels/nanodash/domain/AbstractResourceWithProfile.java @@ -1,6 +1,7 @@ package com.knowledgepixels.nanodash.domain; import com.knowledgepixels.nanodash.ApiCache; +import com.knowledgepixels.nanodash.NanodashSession; import com.knowledgepixels.nanodash.NanodashThreadPool; import com.knowledgepixels.nanodash.QueryApiAccess; import com.knowledgepixels.nanodash.ViewDisplay; @@ -249,6 +250,14 @@ private List getViewDisplays(boolean toplevel, String resourceId, S List viewDisplays = new ArrayList<>(); Set viewKinds = new HashSet<>(); + // Role-based visibility (docs/role-specific-views.md): a display restricted + // via gen:isVisibleTo is shown only to viewers holding the required role + // tier or specific role in the governing space. For a space-less resource + // (e.g. a user page) a restricted display is shown only to the page owner. + Space governingSpace = (this instanceof Space s) ? s : getSpace(); + IRI viewer = NanodashSession.getCurrentUserIriOrNull(); + boolean viewerIsOwner = viewer != null && (this instanceof IndividualAgent ia) && ia.isCurrentUser(); + // Results are sorted by date (most recent first); only the most recent per view-kind is considered for (ViewDisplay vd : getViewDisplays()) { IRI kind = vd.getViewKindIri(); @@ -263,6 +272,13 @@ private List getViewDisplays(boolean toplevel, String resourceId, S continue; } + // Drop displays this viewer is not entitled to see. Done after the + // per-view-kind latest-wins pick above, so a hidden latest display + // does not fall back to an older, more-visible version of the same kind. + if (!vd.isVisibleTo(viewer, governingSpace, viewerIsOwner)) { + continue; + } + if (!toplevel && vd.hasType(KPXL_TERMS.TOP_LEVEL_VIEW_DISPLAY)) { // Deprecated // do nothing diff --git a/src/main/java/com/knowledgepixels/nanodash/domain/Space.java b/src/main/java/com/knowledgepixels/nanodash/domain/Space.java index 8e69e8f3..3ffab18f 100644 --- a/src/main/java/com/knowledgepixels/nanodash/domain/Space.java +++ b/src/main/java/com/knowledgepixels/nanodash/domain/Space.java @@ -254,6 +254,42 @@ public boolean isMember(IRI userId) { return currentData().users.containsKey(userId); } + /** + * Get the highest role tier the given user holds in this space, as a numeric + * rank for threshold comparisons (admin {@literal >} maintainer {@literal >} + * member {@literal >} observer). A user holding no role gets + * {@link SpaceMemberRole#EVERYONE_RANK}. + * + * @param userId The IRI of the user. + * @return the highest tier rank held, or the "everyone" floor if none. + */ + public int userTier(IRI userId) { + Set roles = getMemberRoles(userId); + if (roles == null || roles.isEmpty()) return SpaceMemberRole.EVERYONE_RANK; + int max = SpaceMemberRole.EVERYONE_RANK; + for (SpaceMemberRoleRef ref : roles) { + max = Math.max(max, ref.getRole().getTierRank()); + } + return max; + } + + /** + * Check whether the given user holds the specific role (by role IRI) in this + * space. + * + * @param userId The IRI of the user. + * @param roleIri The specific role IRI. + * @return true if the user holds that exact role, false otherwise. + */ + public boolean viewerHoldsRole(IRI userId, IRI roleIri) { + Set roles = getMemberRoles(userId); + if (roles == null) return false; + for (SpaceMemberRoleRef ref : roles) { + if (ref.getRole().getId().equals(roleIri)) return true; + } + return false; + } + /** * Check if a public key is associated with an admin of this space. * @@ -390,7 +426,7 @@ private static void loadRoles(List roles, Map 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"); + + /** + * 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/test/java/com/knowledgepixels/nanodash/SpaceMemberRoleTest.java b/src/test/java/com/knowledgepixels/nanodash/SpaceMemberRoleTest.java index 28c182e6..9386659f 100644 --- a/src/test/java/com/knowledgepixels/nanodash/SpaceMemberRoleTest.java +++ b/src/test/java/com/knowledgepixels/nanodash/SpaceMemberRoleTest.java @@ -2,6 +2,7 @@ import com.google.common.collect.ArrayListMultimap; import com.google.common.collect.Multimap; +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; @@ -91,4 +92,62 @@ 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()); + } + } \ No newline at end of file From 1d0c376663c7d2e152cc56f01fe5a879d9aee463 Mon Sep 17 00:00:00 2001 From: Tobias Kuhn Date: Mon, 8 Jun 2026 11:24:46 +0200 Subject: [PATCH 26/33] chore: point GET_SPACE_ROLES at the roleType-returning query republish The get-space-roles query nanopub was republished (RAKJFw-x..., supersedes RAERgisN...) to also return ?roleType (npa:hasRoleType), so SpaceMemberRole can pick up each role's tier for gen:isVisibleTo gating. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/main/java/com/knowledgepixels/nanodash/QueryApiAccess.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/knowledgepixels/nanodash/QueryApiAccess.java b/src/main/java/com/knowledgepixels/nanodash/QueryApiAccess.java index 897d4e4d..c1db0f80 100644 --- a/src/main/java/com/knowledgepixels/nanodash/QueryApiAccess.java +++ b/src/main/java/com/knowledgepixels/nanodash/QueryApiAccess.java @@ -83,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); From ecda3df99518bc8f55c8f3e8d1a2e1f52dde23b8 Mon Sep 17 00:00:00 2001 From: Tobias Kuhn Date: Mon, 8 Jun 2026 11:53:23 +0200 Subject: [PATCH 27/33] feat: gate view action buttons by role via gen:isVisibleTo on action nodes Pivot from whole-view visibility to per-action gating, per the reworked docs/role-specific-views.md. - View: parse gen:isVisibleTo on action nodes -> getActionVisibleTo(actionIri). - SpaceMemberRole.isViewerEntitled(...): tier-threshold / specific-role match (no admin override), plus a convenience overload resolving viewer/space/owner from the rendered resource. A user page is a degenerate space whose sole admin is the owner: tier-gated actions show only to the owner, specific-role gates match nobody (observers can be added later). - Filter actions in all five renderers (additive; undeclared actions unchanged): QueryResultTable(Builder), QueryResultList(Builder), QueryResultPlainParagraphBuilder. - Remove the whole-view filter (AbstractResourceWithProfile) and ViewDisplay/ View view-level visibility parsing. - Tests for the tier ranking and isViewerEntitled matching. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/README.md | 2 +- docs/role-specific-views.md | 270 ++++++++---------- .../nanodash/SpaceMemberRole.java | 66 +++++ .../com/knowledgepixels/nanodash/View.java | 23 +- .../knowledgepixels/nanodash/ViewDisplay.java | 40 --- .../nanodash/component/QueryResultList.java | 3 + .../component/QueryResultListBuilder.java | 2 + .../QueryResultPlainParagraphBuilder.java | 4 + .../nanodash/component/QueryResultTable.java | 4 + .../component/QueryResultTableBuilder.java | 15 +- .../domain/AbstractResourceWithProfile.java | 16 -- .../nanodash/SpaceMemberRoleTest.java | 57 ++++ 12 files changed, 281 insertions(+), 221 deletions(-) diff --git a/docs/README.md b/docs/README.md index b28e7268..ba1fa2b2 100644 --- a/docs/README.md +++ b/docs/README.md @@ -10,7 +10,7 @@ Status legend: ✅ Implemented · 🚧 In progress · 📋 Proposed | [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) | 🚧 In progress | View displays visible only to a given role tier (Maintainer, …) or specific role, via `gen:isVisibleTo` | +| [role-specific-views](role-specific-views.md) | 🚧 In progress | 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 | diff --git a/docs/role-specific-views.md b/docs/role-specific-views.md index e2297372..4385dbc6 100644 --- a/docs/role-specific-views.md +++ b/docs/role-specific-views.md @@ -1,29 +1,34 @@ -# Role-specific views +# Role-specific view actions -**Status:** 🚧 In progress — tier model + `gen:isVisibleTo` parsing/matching + client-side filter implemented (steps 1–4); pending the `get-space-roles` `roleType` republish (P0), authoring UI, and the server-side filter. +**Status:** 🚧 In progress — per-action `gen:isVisibleTo` gating implemented (tier model, matching, and filtering in the action renderers), and the `get-space-roles` `roleType` republish (P0) is published. Remaining: an authoring template field for `gen:isVisibleTo`, and the optional server-side path. -A view display may declare that it is **visible 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. +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 builds directly on the role-tier model that **nanopub-query already -defines and materializes** (see +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"). It is meant to be compatible with the session-bound query -parameters in [magic-query-params](magic-query-params.md); the two share the -`?_CURRENTUSER` binding for the optional server-side path. +§"Role types"). ## Semantics & scope -This is **relevance-gating, not a security boundary.** The view, its query, and -the underlying nanopubs are all public; hiding a maintainer-only view from a -normal visitor declutters their page, it does not protect data. That lets the -first cut filter **client-side** with role data the page already loads. +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. -Applies to **spaces** and **maintained resources** (which resolve a governing -space via `getSpace()`). On a user page (`IndividualAgent`) there is no space, so -role-gating degrades to the existing owner-vs-visitor check. +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 @@ -40,9 +45,9 @@ materializes it per space as `npa:hasRoleType ` alongside `npa:role | `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. View -visibility by tier is exactly such a privilege, so the query layer hands us the -tier and this feature decides what to do with it. +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 @@ -53,161 +58,126 @@ Everyone (rank 0, = no triple) < Observer (1) < Member (2) < Maintainer (3) < Ad ``` "Everyone" means literally anyone, including logged-out viewers with no role — -distinct from Observer, which is the lowest *assigned* tier (self-attestation is -still a deliberate step). **"Everyone" is not a nanopub-query role type** and is -not added to that repo's tier set — those are *grant* tiers, and you never -"grant everybody." It exists only as the absence-of-restriction default here. +distinct from Observer, the lowest *assigned* tier. **"Everyone" is not a +nanopub-query role type** (those are *grant* tiers, and you never "grant +everybody"); it exists only as the absence-of-restriction default here. -## The predicate +## The predicate — on the action node -A single predicate carries both targeting modes; its object is either a tier IRI -or a specific role IRI: +`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 - gen:isVisibleTo gen:MaintainerRole . # tier: this tier or above - gen:isVisibleTo <…/newsletterEditorRole> . # specific role IRI -# (no gen:isVisibleTo triple) # Everyone (default, backward-compatible) +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 +# (no triple) # Everyone (default, additive) ``` -Disambiguation is trivial and safe: the tier IRIs are a fixed known set -(`gen:AdminRole` / `gen:MaintainerRole` / `gen:MemberRole` / `gen:ObserverRole`); -any other IRI is a specific role. Multiple `gen:isVisibleTo` triples are **OR**. - -An explicit "Everyone" option in the authoring UI simply *omits* the predicate — -no IRI is minted for it. (A Nanodash-local sentinel would only be worth it if we -ever needed to distinguish "deliberately public" from "unset," which for -visibility does not appear to matter.) +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 ``` -visible(viewer, space, display): - reqs = display.isVisibleTo // set of IRIs; empty => Everyone - if reqs empty -> visible - if space == null -> visible only if viewer is the page owner +isViewerEntitled(reqs, viewer, governingSpace, viewerIsOwner): + if reqs empty -> entitled (Everyone) + 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 userTier(viewer, space) >= rank(X) -> visible // tier threshold - if X is a role IRI and viewerHoldsRole(viewer, space, X) -> visible // specific role - hidden + 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 a view gated to that role — the page shows exactly what each - role is entitled to. The management escape hatch is **self-assignment**: an - admin can grant any non-admin-tier role (and every custom role is - maintainer/member/observer tier), so an admin who needs a specific-role view - simply publishes a role-instantiation for themselves. No special-case code. - -## Where the assignment lives - -Primary carrier: the **`ViewDisplay`** nanopub — it already models "show this -view for this resource" with per-display modifiers (`appliesTo`, page size, -structural position, deactivation). Visibility is one more modifier, so the -underlying View stays reusable and unrestricted elsewhere. - -Two parallels for consistency (same predicate, same matching): - -- **View default** — a `View` may carry a default `gen:isVisibleTo` that a - `ViewDisplay` overrides, mirroring how View defaults page size / width / - position flow into ViewDisplay. -- **Preset-bundled view** — a preset's `gen:hasView` / `gen:hasTopLevelView` - reference may carry the same visibility so bundled views gate too. - -## What Nanodash needs (all additive — it has no tier awareness today) - -1. **Learn tiers.** `get-space-roles` (or `get-space-members`) must return - `roleType` (= `npa:hasRoleType`); `SpaceMemberRole` gains a `tier` field. This - dovetails with the in-progress grlc spaces-repo migration — same direction, - consuming the materialized `npass:` state. -2. **Tier IRIs + rank** in `KPXL_TERMS` (`AdminRole` / `MaintainerRole` / - `MemberRole` / `ObserverRole`) and a small rank helper with the Everyone floor. -3. **Parse `gen:isVisibleTo`** in `ViewDisplay` (and `View` for defaults). -4. **`Space` helpers** `userTier(iri)` (max tier held) and - `viewerHoldsRole(iri, roleIri)`, plus `ViewDisplay.isVisibleTo(...)`. -5. **Apply the filter** (see next). - -## Filter point — and the magic-param tie-in - -**v1 (standalone — implement here first).** Filter in the existing view-display -aggregation in `AbstractResourceWithProfile` (the `get-view-displays` consumer, -where latest-wins / deactivation already happen). After aggregation, drop -displays where `isVisibleTo(currentUser, governingSpace)` is false. Uses -already-loaded `Space` role data; no query changes; no dependency on the -magic-param work. - -**v2 (compatible evolution).** Push the filter server-side into -`get-view-displays` by binding the viewer identity as the `?_CURRENTUSER` magic -parameter from [magic-query-params](magic-query-params.md) — the same binding -hook. The spaces repo already materializes member → role → tier, so the JOIN -(viewer's instantiations vs the display's required role/tier, honoring the tier -order) is cheap. Same vocabulary and data model; only the enforcement point -moves. - -## How it layers with action-gating - -Three independent layers, coarse → fine, all over the same role model: - -1. **View-display visibility** (this doc) — whole section shows/hides by viewer - role/tier. -2. **Result/entry action gating** — `ButtonList` admin/member routing, plus the - empty-into-required rule from [magic-query-params](magic-query-params.md). -3. **Per-row** visibility via conditional query binding. - -A view can be member-visible yet carry an admin-only action button; the layers -compose. - -## Known gaps this closes - -Today's action gating is incidental — it depends on how each table happens to be -wired, not on anything the view declares — so it is already inconsistent. A -concrete example to fix when this lands: - -- On a user's About page (`AboutUserPanel`), the **profile** table passes - `resourceWithProfile(IndividualAgent…)`, so its "update profile image / license" - actions route to `ButtonList`'s admin slot and are correctly owner-only. But - the **presets** and **view-displays** tables (`AboutUserPanel.java:74-78`) are - built *without* `resourceWithProfile`, so their "add preset…" / "add view - display…" actions fall into the unconditional regular-button slot - (`QueryResult.java:61`, `ButtonList.java:24-26`) and **leak onto other users' - About pages**. Publishing would be ignored server-side (authorized-agents-only), - but the button should not be shown. - -Left as-is deliberately: rather than patch the per-call wiring, the declarative -mechanism makes the view/action declare its required role once, so every table -honours it regardless of construction — removing this whole class of -wiring-dependent inconsistency. + 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 + +Today's incidental routing already leaks: on a user's About page +(`AboutUserPanel.java:74-78`) the **presets** and **view-displays** tables are +built without `resourceWithProfile`, so their "add preset…" / "add view display…" +actions fall into the unconditional regular bucket (`QueryResult.java:61`, +`ButtonList.java:24-26`) and show on *other* users' pages. Declaring +`gen:isVisibleTo` on those actions (e.g. owner/admin only) gates them regardless +of how the table was wired — the declarative fix for this class of bug. ## Touch points -| Change | File | -| --- | --- | -| Tier IRIs + rank, `gen:isVisibleTo` term | `vocabulary/KPXL_TERMS.java` | -| `roleType` / tier field | `SpaceMemberRole.java`, `get-space-roles` (or `get-space-members`) query | -| `userTier` / `viewerHoldsRole` | `domain/Space.java` | -| Parse `gen:isVisibleTo` (+ View default) | `ViewDisplay.java`, `View.java` | -| `isVisibleTo(...)` + apply filter | `ViewDisplay.java`, `domain/AbstractResourceWithProfile.java` | -| Visibility selector in display-admin UI | `AddViewDisplayButton` / display authoring | +| 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 publish | todo | +| Server-side path (optional) | `get-view-displays` / action queries + `?_CURRENTUSER` | todo | ## Phasing -1. **Consume tiers**: tier IRIs + rank in `KPXL_TERMS`; `SpaceMemberRole.tier` - (and the `get-space-roles`/`get-space-members` column). -2. **Parse + match + client-side filter** in `AbstractResourceWithProfile` — - functional end-to-end here. -3. **Authoring UI**: a visibility selector ("Everyone" / tier / specific role) - on the display-admin control. -4. *(Later, with magic params)* server-side filter in `get-view-displays` via - `?_CURRENTUSER`. +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** — add an optional `gen:isVisibleTo` tier picker to the + view-creation template (`…declaring-a-resource-view`), so authors set it when + defining a view. Republish; no Nanodash change (templates are discovered + dynamically). Specific-role targeting stays an advanced/hand-authored case. +4. *(Optional, later)* server-side enforcement via the `?_CURRENTUSER` magic + parameter, once [magic-query-params](magic-query-params.md) lands. ## 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 +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 see this view display" — which that design explicitly leaves -to Nanodash. No new server-side role type is introduced (the Everyone floor is a +holding role R) may use this action" — which that design explicitly leaves to +Nanodash. No new server-side role type is introduced (the Everyone floor is a Nanodash-side default, not a tier). diff --git a/src/main/java/com/knowledgepixels/nanodash/SpaceMemberRole.java b/src/main/java/com/knowledgepixels/nanodash/SpaceMemberRole.java index f9160cef..f8811945 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; /** @@ -196,6 +199,69 @@ public static boolean isTier(IRI 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; + 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. * diff --git a/src/main/java/com/knowledgepixels/nanodash/View.java b/src/main/java/com/knowledgepixels/nanodash/View.java index 34e47d5f..9d4a03f5 100644 --- a/src/main/java/com/knowledgepixels/nanodash/View.java +++ b/src/main/java/com/knowledgepixels/nanodash/View.java @@ -141,7 +141,7 @@ public static View get(String id, boolean resolveLatest) { private Map actionTemplateQueryMappingMap = new HashMap<>(); private Map labelMap = new HashMap<>(); private IRI viewType; - private Set visibleTo = new HashSet<>(); + private Map> actionVisibleToMap = new HashMap<>(); private View(String id, Nanopub nanopub) { this.id = id; @@ -186,8 +186,6 @@ private View(String id, Nanopub nanopub) { displayWidth = columnWidths.get(objIri); } else if (st.getPredicate().equals(KPXL_TERMS.HAS_STRUCTURAL_POSITION) && st.getObject() instanceof Literal objL) { structuralPosition = objL.stringValue(); - } else if (st.getPredicate().equals(KPXL_TERMS.IS_VISIBLE_TO) && st.getObject() instanceof IRI objIri) { - visibleTo.add(objIri); } } else if (st.getPredicate().equals(KPXL_TERMS.HAS_ACTION_TEMPLATE)) { Template template = TemplateData.get().getTemplate(st.getObject().stringValue()); @@ -198,6 +196,11 @@ private View(String id, Nanopub nanopub) { actionTemplatePartFieldMap.put((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()); + } 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)) { @@ -293,14 +296,16 @@ public String getStructuralPosition() { } /** - * Gets the default visibility restriction of this view: the set of role-tier - * or specific-role IRIs a viewer must hold to see it. Empty means visible to - * everyone. A {@link ViewDisplay} overrides this with its own restriction. + * 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). * - * @return the set of {@code gen:isVisibleTo} IRIs (never null) + * @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 getVisibleTo() { - return visibleTo; + public Set getActionVisibleTo(IRI actionIri) { + return actionVisibleToMap.getOrDefault(actionIri, Collections.emptySet()); } /** diff --git a/src/main/java/com/knowledgepixels/nanodash/ViewDisplay.java b/src/main/java/com/knowledgepixels/nanodash/ViewDisplay.java index 09db857d..3a8d1253 100644 --- a/src/main/java/com/knowledgepixels/nanodash/ViewDisplay.java +++ b/src/main/java/com/knowledgepixels/nanodash/ViewDisplay.java @@ -1,6 +1,5 @@ package com.knowledgepixels.nanodash; -import com.knowledgepixels.nanodash.domain.Space; import com.knowledgepixels.nanodash.vocabulary.KPXL_TERMS; import org.eclipse.rdf4j.model.IRI; import org.eclipse.rdf4j.model.Literal; @@ -34,7 +33,6 @@ public class ViewDisplay implements Serializable, Comparable { private Set appliesTo = new HashSet<>(); private Set appliesToClasses = new HashSet<>(); private Set appliesToNamespaces = new HashSet<>(); - private Set visibleTo = new HashSet<>(); private IRI resource; /** @@ -197,8 +195,6 @@ private ViewDisplay(String id, Nanopub nanopub, String latestViewIri) { appliesToClasses.add(objIri); } else if (st.getPredicate().equals(KPXL_TERMS.APPLIES_TO) && st.getObject() instanceof IRI objIri) { appliesTo.add(objIri.stringValue()); - } else if (st.getPredicate().equals(KPXL_TERMS.IS_VISIBLE_TO) && st.getObject() instanceof IRI objIri) { - visibleTo.add(objIri); } } } @@ -329,42 +325,6 @@ public String getTitle() { return null; } - /** - * Whether this view display should be shown to the given viewer, per its - * {@code gen:isVisibleTo} restriction (falling back to the displayed view's - * default when the display declares none). An empty restriction means - * visible to everyone. - * - *

A role-tier IRI matches when the viewer's highest role 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 restrictions are OR-ed. - * There is no admin override for specific roles — an admin who needs such a - * view self-assigns the role.

- * - * @param viewer the viewer's agent IRI, or null if logged out - * @param governingSpace the space whose roles govern visibility, or null - * (e.g. a user page) — then a restricted display is - * shown only to the page owner - * @param viewerIsOwner whether the viewer owns the resource this display is - * for (used only when there is no governing space) - * @return true if the display should be shown - */ - public boolean isVisibleTo(IRI viewer, Space governingSpace, boolean viewerIsOwner) { - Set reqs = (visibleTo.isEmpty() && view != null) ? view.getVisibleTo() : visibleTo; - if (reqs == null || reqs.isEmpty()) return true; - if (governingSpace == null) return viewerIsOwner; - if (viewer == null) return false; - for (IRI req : reqs) { - if (SpaceMemberRole.isTier(req)) { - if (governingSpace.userTier(viewer) >= SpaceMemberRole.tierRank(req)) return true; - } else if (governingSpace.viewerHoldsRole(viewer, req)) { - return true; - } - } - return false; - } - @Override public int compareTo(ViewDisplay other) { return this.getStructuralPosition().compareTo(other.getStructuralPosition()); diff --git a/src/main/java/com/knowledgepixels/nanodash/component/QueryResultList.java b/src/main/java/com/knowledgepixels/nanodash/component/QueryResultList.java index 2bf7a5b5..67533fce 100644 --- a/src/main/java/com/knowledgepixels/nanodash/component/QueryResultList.java +++ b/src/main/java/com/knowledgepixels/nanodash/component/QueryResultList.java @@ -200,6 +200,9 @@ protected void populateItem(Item item) { if (view != null && !view.getViewEntryActionList().isEmpty()) { 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. + 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/QueryResultListBuilder.java b/src/main/java/com/knowledgepixels/nanodash/component/QueryResultListBuilder.java index 54920e05..d8536b27 100644 --- a/src/main/java/com/knowledgepixels/nanodash/component/QueryResultListBuilder.java +++ b/src/main/java/com/knowledgepixels/nanodash/component/QueryResultListBuilder.java @@ -89,6 +89,7 @@ public Component build() { View view = viewDisplay.getView(); if (view != null) { for (IRI actionIri : view.getViewResultActionList()) { + if (!SpaceMemberRole.isViewerEntitled(view.getActionVisibleTo(actionIri), resourceWithProfile)) continue; Template t = view.getTemplateForAction(actionIri); if (t == null) continue; String targetField = view.getTemplateTargetFieldForAction(actionIri); @@ -133,6 +134,7 @@ public Component getApiResultComponent(String markupId, ApiResponse response) { View view = viewDisplay.getView(); if (view != null) { for (IRI actionIri : view.getViewResultActionList()) { + if (!SpaceMemberRole.isViewerEntitled(view.getActionVisibleTo(actionIri), resourceWithProfile)) 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/QueryResultPlainParagraphBuilder.java b/src/main/java/com/knowledgepixels/nanodash/component/QueryResultPlainParagraphBuilder.java index 19350c44..4244e102 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/QueryResultTable.java b/src/main/java/com/knowledgepixels/nanodash/component/QueryResultTable.java index d129c547..dde31a03 100644 --- a/src/main/java/com/knowledgepixels/nanodash/component/QueryResultTable.java +++ b/src/main/java/com/knowledgepixels/nanodash/component/QueryResultTable.java @@ -193,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 65448f5a..34b7cb41 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,7 +110,7 @@ public Component build() { } table.setResourceWithProfile(resourceWithProfile); table.setPageResource(resourceWithProfile); - addViewActions(table, viewDisplay, queryRef, id, contextId); + addViewActions(table, viewDisplay, queryRef, id, contextId, resourceWithProfile); table.add(new AttributeAppender("class", colClass)); return table; } else { @@ -123,7 +124,7 @@ public Component getApiResultComponent(String markupId, ApiResponse response) { } table.setResourceWithProfile(resourceWithProfile); table.setPageResource(resourceWithProfile); - addViewActions(table, viewDisplay, queryRef, id, contextId); + addViewActions(table, viewDisplay, queryRef, id, contextId, resourceWithProfile); return table; } }; @@ -134,7 +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); + addViewActions(table, viewDisplay, queryRef, id, contextId, resourceWithProfile); table.add(new AttributeAppender("class", colClass)); return table; } else { @@ -143,7 +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); + addViewActions(table, viewDisplay, queryRef, id, contextId, resourceWithProfile); return table; } }; @@ -165,10 +166,14 @@ public Component getApiResultComponent(String markupId, ApiResponse response) { * @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) { + 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); diff --git a/src/main/java/com/knowledgepixels/nanodash/domain/AbstractResourceWithProfile.java b/src/main/java/com/knowledgepixels/nanodash/domain/AbstractResourceWithProfile.java index 9d7ab6d4..1fd9e8a4 100644 --- a/src/main/java/com/knowledgepixels/nanodash/domain/AbstractResourceWithProfile.java +++ b/src/main/java/com/knowledgepixels/nanodash/domain/AbstractResourceWithProfile.java @@ -1,7 +1,6 @@ package com.knowledgepixels.nanodash.domain; import com.knowledgepixels.nanodash.ApiCache; -import com.knowledgepixels.nanodash.NanodashSession; import com.knowledgepixels.nanodash.NanodashThreadPool; import com.knowledgepixels.nanodash.QueryApiAccess; import com.knowledgepixels.nanodash.ViewDisplay; @@ -250,14 +249,6 @@ private List getViewDisplays(boolean toplevel, String resourceId, S List viewDisplays = new ArrayList<>(); Set viewKinds = new HashSet<>(); - // Role-based visibility (docs/role-specific-views.md): a display restricted - // via gen:isVisibleTo is shown only to viewers holding the required role - // tier or specific role in the governing space. For a space-less resource - // (e.g. a user page) a restricted display is shown only to the page owner. - Space governingSpace = (this instanceof Space s) ? s : getSpace(); - IRI viewer = NanodashSession.getCurrentUserIriOrNull(); - boolean viewerIsOwner = viewer != null && (this instanceof IndividualAgent ia) && ia.isCurrentUser(); - // Results are sorted by date (most recent first); only the most recent per view-kind is considered for (ViewDisplay vd : getViewDisplays()) { IRI kind = vd.getViewKindIri(); @@ -272,13 +263,6 @@ private List getViewDisplays(boolean toplevel, String resourceId, S continue; } - // Drop displays this viewer is not entitled to see. Done after the - // per-view-kind latest-wins pick above, so a hidden latest display - // does not fall back to an older, more-visible version of the same kind. - if (!vd.isVisibleTo(viewer, governingSpace, viewerIsOwner)) { - continue; - } - if (!toplevel && vd.hasType(KPXL_TERMS.TOP_LEVEL_VIEW_DISPLAY)) { // Deprecated // do nothing diff --git a/src/test/java/com/knowledgepixels/nanodash/SpaceMemberRoleTest.java b/src/test/java/com/knowledgepixels/nanodash/SpaceMemberRoleTest.java index 9386659f..9f546648 100644 --- a/src/test/java/com/knowledgepixels/nanodash/SpaceMemberRoleTest.java +++ b/src/test/java/com/knowledgepixels/nanodash/SpaceMemberRoleTest.java @@ -2,6 +2,7 @@ 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; @@ -10,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; @@ -150,4 +152,59 @@ void adminRoleHasAdminTier() { 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 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 From d91479bbd126ffc60a9e243ca7eed818bf0ef1dd Mon Sep 17 00:00:00 2001 From: Tobias Kuhn Date: Mon, 8 Jun 2026 13:38:16 +0200 Subject: [PATCH 28/33] feat: add gen:EveryoneRole visibility sentinel; record view template MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The view-creation template can't leave the per-action gen:isVisibleTo statement optional inside its repeated action group, so it emits an explicit default. Add gen:EveryoneRole (rank-0 "no restriction" sentinel — a Nanodash-side visibility tier, never a nanopub-query grant tier): - KPXL_TERMS.EVERYONE_ROLE; SpaceMemberRole.isTier includes it and isViewerEntitled short-circuits to visible (incl. anonymous) when present. - Tests for the everyone-to-all semantics. - Doc updated; records the published authoring template RA8_hijwsfGCryMYtjtEpec21ZSNY68-qmL0bHRWR0sWM (tier picker + SpaceMemberRole API + EveryoneRole default). Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/role-specific-views.md | 49 +++++++++++++------ .../nanodash/SpaceMemberRole.java | 8 ++- .../nanodash/vocabulary/KPXL_TERMS.java | 10 ++++ .../nanodash/SpaceMemberRoleTest.java | 16 ++++++ 4 files changed, 68 insertions(+), 15 deletions(-) diff --git a/docs/role-specific-views.md b/docs/role-specific-views.md index 4385dbc6..2560e93f 100644 --- a/docs/role-specific-views.md +++ b/docs/role-specific-views.md @@ -51,16 +51,21 @@ and this feature decides what to do with it. ### The visibility ladder -For matching, Nanodash ranks tiers, with an **"Everyone" floor below Observer**: +For matching, Nanodash ranks tiers, with a `gen:EveryoneRole` floor below Observer: ``` -Everyone (rank 0, = no triple) < Observer (1) < Member (2) < Maintainer (3) < Admin (4) +gen:EveryoneRole (0) < Observer (1) < Member (2) < Maintainer (3) < Admin (4) ``` -"Everyone" means literally anyone, including logged-out viewers with no role — -distinct from Observer, the lowest *assigned* tier. **"Everyone" is not a -nanopub-query role type** (those are *grant* tiers, and you never "grant -everybody"); it exists only as the absence-of-restriction default here. +`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 @@ -75,7 +80,8 @@ 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 -# (no triple) # Everyone (default, additive) +# 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 @@ -87,6 +93,7 @@ fixed tier set (`gen:AdminRole` / `gen:MaintainerRole` / `gen:MemberRole` / ``` 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 @@ -155,7 +162,7 @@ of how the table was wired — the declarative fix for this class of bug. | `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 publish | todo | +| `gen:isVisibleTo` field in the view-creation template | nanopub `RA8_hijwsfGCryMYtjtEpec21ZSNY68-qmL0bHRWR0sWM` | published | | Server-side path (optional) | `get-view-displays` / action queries + `?_CURRENTUSER` | todo | ## Phasing @@ -164,10 +171,23 @@ of how the table was wired — the declarative fix for this class of bug. `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** — add an optional `gen:isVisibleTo` tier picker to the - view-creation template (`…declaring-a-resource-view`), so authors set it when - defining a view. Republish; no Nanodash change (templates are discovered - dynamically). Specific-role targeting stays an advanced/hand-authored case. +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)* server-side enforcement via the `?_CURRENTUSER` magic parameter, once [magic-query-params](magic-query-params.md) lands. @@ -179,5 +199,6 @@ 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 (the Everyone floor is a -Nanodash-side default, not a tier). +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/src/main/java/com/knowledgepixels/nanodash/SpaceMemberRole.java b/src/main/java/com/knowledgepixels/nanodash/SpaceMemberRole.java index f8811945..a7a8e4e8 100644 --- a/src/main/java/com/knowledgepixels/nanodash/SpaceMemberRole.java +++ b/src/main/java/com/knowledgepixels/nanodash/SpaceMemberRole.java @@ -193,7 +193,8 @@ public static int tierRank(IRI tier) { * @return true if the IRI is a role tier */ public static boolean isTier(IRI iri) { - return KPXL_TERMS.ADMIN_ROLE_TYPE.equals(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); @@ -221,6 +222,11 @@ public static boolean isTier(IRI iri) { */ 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 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 5eba4953..2a3f0a3a 100644 --- a/src/main/java/com/knowledgepixels/nanodash/vocabulary/KPXL_TERMS.java +++ b/src/main/java/com/knowledgepixels/nanodash/vocabulary/KPXL_TERMS.java @@ -116,6 +116,16 @@ public class KPXL_TERMS { 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 diff --git a/src/test/java/com/knowledgepixels/nanodash/SpaceMemberRoleTest.java b/src/test/java/com/knowledgepixels/nanodash/SpaceMemberRoleTest.java index 9f546648..a26d2692 100644 --- a/src/test/java/com/knowledgepixels/nanodash/SpaceMemberRoleTest.java +++ b/src/test/java/com/knowledgepixels/nanodash/SpaceMemberRoleTest.java @@ -172,6 +172,22 @@ void userPageOwnerHoldsAdminTier() { } } + @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 From 503a073555e61a9764bfafcee2eec5dc6db35f8e Mon Sep 17 00:00:00 2001 From: Tobias Kuhn Date: Mon, 8 Jun 2026 13:41:35 +0200 Subject: [PATCH 29/33] docs: drop vestigial server-side enforcement; mark role-specific-views done Per-action gating is client-side relevance-gating over public data, so there is no server-side enforcement to do (that framing was a holdover from the whole-view design). Replace it with an optional performance-only note (have a query return the viewer's tier directly), and mark the feature Implemented. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/README.md | 2 +- docs/role-specific-views.md | 10 ++++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/README.md b/docs/README.md index ba1fa2b2..2adfa087 100644 --- a/docs/README.md +++ b/docs/README.md @@ -10,7 +10,7 @@ Status legend: ✅ Implemented · 🚧 In progress · 📋 Proposed | [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) | 🚧 In progress | View **action buttons** gated to a role tier (Maintainer, …) or specific role, via `gen:isVisibleTo` on the action node | +| [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 | diff --git a/docs/role-specific-views.md b/docs/role-specific-views.md index 2560e93f..b9af533e 100644 --- a/docs/role-specific-views.md +++ b/docs/role-specific-views.md @@ -1,6 +1,6 @@ # Role-specific view actions -**Status:** 🚧 In progress — per-action `gen:isVisibleTo` gating implemented (tier model, matching, and filtering in the action renderers), and the `get-space-roles` `roleType` republish (P0) is published. Remaining: an authoring template field for `gen:isVisibleTo`, and the optional server-side path. +**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 @@ -163,7 +163,6 @@ of how the table was wired — the declarative fix for this class of bug. | 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 | -| Server-side path (optional) | `get-view-displays` / action queries + `?_CURRENTUSER` | todo | ## Phasing @@ -188,8 +187,11 @@ of how the table was wired — the declarative fix for this class of bug. 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)* server-side enforcement via the `?_CURRENTUSER` magic - parameter, once [magic-query-params](magic-query-params.md) lands. +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 From a9802147daf29cf72ee2d5f05ede4e01c3a0becc Mon Sep 17 00:00:00 2001 From: Tobias Kuhn Date: Mon, 8 Jun 2026 13:55:35 +0200 Subject: [PATCH 30/33] fix: gate preset & view-display actions on About panels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The presets and view-displays tables in AboutUserPanel / AboutSpacePanel / AboutResourcePanel were built without resourceWithProfile, so their "add preset…" / "add view display…" result actions fell into ButtonList's ungated regular bucket and leaked onto other users' (and visitors') pages. Pass resourceWithProfile so the actions are owner/admin-gated — on user pages via the IndividualAgent admin bucket immediately, and on space/resource pages via the per-action gen:isVisibleTo filter (which needs the governing space resolved from the resource). Pairs with the gen:isVisibleTo gen:MaintainerRole republish of the preset-assignments-view and view-displays-view. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../nanodash/component/AboutResourcePanel.java | 4 ++-- .../knowledgepixels/nanodash/component/AboutSpacePanel.java | 4 ++-- .../knowledgepixels/nanodash/component/AboutUserPanel.java | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/knowledgepixels/nanodash/component/AboutResourcePanel.java b/src/main/java/com/knowledgepixels/nanodash/component/AboutResourcePanel.java index 9777c9b7..0690d3c4 100644 --- a/src/main/java/com/knowledgepixels/nanodash/component/AboutResourcePanel.java +++ b/src/main/java/com/knowledgepixels/nanodash/component/AboutResourcePanel.java @@ -21,10 +21,10 @@ 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)).id(resource.getId()).contextId(resource.getId()).build()); + 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)).id(resource.getId()).contextId(resource.getId()).build()); + 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.java b/src/main/java/com/knowledgepixels/nanodash/component/AboutSpacePanel.java index c94d99ab..72e05d16 100644 --- a/src/main/java/com/knowledgepixels/nanodash/component/AboutSpacePanel.java +++ b/src/main/java/com/knowledgepixels/nanodash/component/AboutSpacePanel.java @@ -42,10 +42,10 @@ public AboutSpacePanel(String id, Space space) { 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)).id(space.getId()).contextId(space.getId()).build()); + 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)).id(space.getId()).contextId(space.getId()).build()); + 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.java b/src/main/java/com/knowledgepixels/nanodash/component/AboutUserPanel.java index 8a87ce7e..bda0afef 100644 --- a/src/main/java/com/knowledgepixels/nanodash/component/AboutUserPanel.java +++ b/src/main/java/com/knowledgepixels/nanodash/component/AboutUserPanel.java @@ -72,10 +72,10 @@ public AboutUserPanel(String id, String userIriString) { .build()); View presetsView = View.get(AboutSpacePanel.PRESET_ASSIGNMENTS_VIEW); - add(QueryResultTableBuilder.create("presets", new QueryRef(presetsView.getQuery().getQueryId(), "resource", userIriString), new ViewDisplay(presetsView)).id(userIriString).contextId(userIriString).build()); + 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)).id(userIriString).contextId(userIriString).build()); + add(QueryResultTableBuilder.create("viewdisplays", new QueryRef(vdView.getQuery().getQueryId(), "resource", userIriString), new ViewDisplay(vdView)).resourceWithProfile(IndividualAgent.get(userIriString)).id(userIriString).contextId(userIriString).build()); } } From 480993615d533ec394149eee80fc4a9b8bb724fe Mon Sep 17 00:00:00 2001 From: Tobias Kuhn Date: Mon, 8 Jun 2026 13:56:31 +0200 Subject: [PATCH 31/33] docs: record the preset/view-display action-leak fix Republished preset-assignments-view (RAdznW...) and view-displays-view (RAZJUh...) with gen:isVisibleTo gen:MaintainerRole; panels pass resourceWithProfile. Mark the known gap as fixed. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/role-specific-views.md | 32 +++++++++++++++++++++++--------- 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/docs/role-specific-views.md b/docs/role-specific-views.md index b9af533e..02089713 100644 --- a/docs/role-specific-views.md +++ b/docs/role-specific-views.md @@ -142,15 +142,29 @@ never gated and result actions are owner-gated only on user pages. *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 - -Today's incidental routing already leaks: on a user's About page -(`AboutUserPanel.java:74-78`) the **presets** and **view-displays** tables are -built without `resourceWithProfile`, so their "add preset…" / "add view display…" -actions fall into the unconditional regular bucket (`QueryResult.java:61`, -`ButtonList.java:24-26`) and show on *other* users' pages. Declaring -`gen:isVisibleTo` on those actions (e.g. owner/admin only) gates them regardless -of how the table was wired — the declarative fix for this class of bug. +## 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, gating space/resource pages too: + `preset-assignments-view` → `RAdznWzBMUxWySxHpxf5daEOwAQDPbgNMBvRxv0MoJCEU`, + `view-displays-view` → `RAZJUhDTNbOsdF_N4kJCLzF1-NEg8oIRm_XaUBsKhxOLM` + (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. ## Touch points From 820f44e25d710ff93011c9af3e0b9859ca109979 Mon Sep 17 00:00:00 2001 From: Tobias Kuhn Date: Mon, 8 Jun 2026 15:01:04 +0200 Subject: [PATCH 32/33] fix: treat "void" action-field sentinel as not-set in View parsing View-creation templates can't leave a statement optional inside a repeated action group, so views now carry every action field with "void" for the not-applicable ones (its presence lets Nanodash repopulate the action group when superseding a view). Parse "void" as absent so it never becomes a bogus param_void in the action link. Pairs with republishing preset-assignments-view (RA4fgq...) and view-displays-view (RAl3LO...) with complete "void"-filled action blocks. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../com/knowledgepixels/nanodash/View.java | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/knowledgepixels/nanodash/View.java b/src/main/java/com/knowledgepixels/nanodash/View.java index 9d4a03f5..a07e60b0 100644 --- a/src/main/java/com/knowledgepixels/nanodash/View.java +++ b/src/main/java/com/knowledgepixels/nanodash/View.java @@ -191,11 +191,11 @@ 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 @@ -220,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. * From c00a307f97425bf0a70f1d7f07f02fd8e9332c7b Mon Sep 17 00:00:00 2001 From: Tobias Kuhn Date: Mon, 8 Jun 2026 16:26:24 +0200 Subject: [PATCH 33/33] docs: record view-displays-view fork merge + current view heads The view-displays-view chain had forked into two latest heads (a stale-base republish vs. a June-5 head with a newer list-view-displays query), so getLatestVersionId resolved ambiguously and the gate appeared inactive on some pages. Merged by superseding both heads (RABs0d67...). Update the recorded current heads and add a note to resolve actual heads before republishing a view. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/role-specific-views.md | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/docs/role-specific-views.md b/docs/role-specific-views.md index 02089713..89540390 100644 --- a/docs/role-specific-views.md +++ b/docs/role-specific-views.md @@ -156,9 +156,10 @@ Fixed in two parts: 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, gating space/resource pages too: - `preset-assignments-view` → `RAdznWzBMUxWySxHpxf5daEOwAQDPbgNMBvRxv0MoJCEU`, - `view-displays-view` → `RAZJUhDTNbOsdF_N4kJCLzF1-NEg8oIRm_XaUBsKhxOLM` + 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). @@ -166,6 +167,17 @@ 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 |