diff --git a/src/main/java/com/knowledgepixels/nanodash/QueryApiAccess.java b/src/main/java/com/knowledgepixels/nanodash/QueryApiAccess.java index c51b161c..3ae810f5 100644 --- a/src/main/java/com/knowledgepixels/nanodash/QueryApiAccess.java +++ b/src/main/java/com/knowledgepixels/nanodash/QueryApiAccess.java @@ -403,6 +403,18 @@ public static String getLatestVersionId(String nanopubId) { return cached != null ? cached.getRight() : nanopubId; } + /** + * Drops the memoized latest-version lookup for a nanopub, so that the next + * {@link #getLatestVersionId(String)} goes back to the query API instead of answering + * from a memo that can be up to a minute old. For the places where the user explicitly + * asks for current data, such as {@link View#refreshLatestVersion(String)}. + * + * @param nanopubId The ID of the nanopublication. + */ + public static void forgetLatestVersion(String nanopubId) { + latestVersionMap.remove(nanopubId); + } + /** * Checks whether the given nanopublication has been loaded by the query services, * with a single cheap indexed lookup. A negative answer only means the instance that diff --git a/src/main/java/com/knowledgepixels/nanodash/View.java b/src/main/java/com/knowledgepixels/nanodash/View.java index 87261d2f..612dae42 100644 --- a/src/main/java/com/knowledgepixels/nanodash/View.java +++ b/src/main/java/com/knowledgepixels/nanodash/View.java @@ -200,7 +200,7 @@ public static View get(String id) { * @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"); + String npId = toNanopubId(id); if (!resolveLatest) { View exact = getExactVersion(id, npId); if (exact == null || exact.getGoverningSpace() == null || exact.getViewKindIri() == null) { @@ -223,6 +223,70 @@ public static View get(String id, boolean resolveLatest) { return resolved; } + /** + * Re-resolves the latest version of a view, going back to the query API instead of + * trusting what is memoized. This is what lets a view display's "refresh now" bring the + * view up to date and not just its results (issue #654): a memoized resolution + * is only re-checked once a minute in the background, and a display whose view was + * resolved server-side by the {@code get-view-displays} query carries an exact version + * that is never re-checked at all, so a newly published version of the view would + * otherwise not show up until the page's structure happened to be refreshed. + *

+ * Every memoized resolution leading to the given version is dropped along with the + * lookups behind it, so that pages reaching this view by another id — a built-in view is + * looked up by the id hard-coded for it, not by the version that id resolves to — + * re-resolve it on their next render too. + * + * @param id the id of the view version currently shown + * @return the view's current latest version, which is the given one when there is no + * newer version or the lookup fails, or null if the view cannot be loaded at all + */ + public static View refreshLatestVersion(String id) { + // The ids whose lookups are to be forgotten: the given one, plus every memo key + // that leads to it. + Set staleIds = new HashSet<>(); + staleIds.add(id); + for (Map.Entry> memo : latestResolvedViews.asMap().entrySet()) { + View memoized = memo.getValue().getRight(); + if (memo.getKey().equals(id) || (memoized != null && id.equals(memoized.getId()))) { + latestResolvedViews.invalidate(memo.getKey()); + staleIds.add(memo.getKey()); + } + } + for (String staleId : staleIds) forgetLatestVersionLookup(staleId); + View resolved = resolveLatestVersion(id, toNanopubId(id)); + if (resolved != null) { + latestResolvedViews.put(id, Pair.of(System.currentTimeMillis(), resolved)); + } + return resolved; + } + + /** + * Marks the version lookup behind a view id as outdated, so that the next resolution + * asks the API instead of answering from what it holds: the governed-version query for + * a view that floats within its space, the supersedes-chain lookup (its memo and its + * cached response both) for one that does not. + */ + private static void forgetLatestVersionLookup(String viewId) { + String npId = toNanopubId(viewId); + View pinned = getExactVersion(viewId, npId); + if (pinned != null && pinned.getGoverningSpace() != null && pinned.getViewKindIri() != null) { + ApiCache.clearCache(GovernedVersions.getQueryRef( + pinned.getViewKindIri().stringValue(), pinned.getGoverningSpace().stringValue()), 0); + } else { + QueryApiAccess.forgetLatestVersion(npId); + ApiCache.clearCache(new QueryRef(QueryApiAccess.GET_LATEST_VERSION_OF_NP, "np", npId), 0); + } + } + + /** + * The id of the nanopub a view id belongs to: the view id up to and including its + * artifact code. An id that is already a nanopub id is returned unchanged. + */ + private static String toNanopubId(String viewId) { + return viewId.replaceFirst("^(.*[^A-Za-z0-9-_]RA[A-Za-z0-9-_]{43})[^A-Za-z0-9-_].*$", "$1"); + } + /** * Resolves a view id to the latest version of its view definition, falling * back to the exact given version if the lookup fails or doesn't yield a @@ -276,7 +340,7 @@ private static View resolveGovernedVersion(View pinned) { String latestId = GovernedVersions.getLatestVersionIriSync( pinned.getViewKindIri().stringValue(), pinned.getGoverningSpace().stringValue()); if (latestId != null && !latestId.equals(pinned.getId())) { - String latestNpId = latestId.replaceFirst("^(.*[^A-Za-z0-9-_]RA[A-Za-z0-9-_]{43})[^A-Za-z0-9-_].*$", "$1"); + String latestNpId = toNanopubId(latestId); View resolved = getExactVersion(latestId, latestNpId); if (resolved != null) return resolved; } diff --git a/src/main/java/com/knowledgepixels/nanodash/component/menu/ViewDisplayMenu.java b/src/main/java/com/knowledgepixels/nanodash/component/menu/ViewDisplayMenu.java index 1d54331e..f2dff824 100644 --- a/src/main/java/com/knowledgepixels/nanodash/component/menu/ViewDisplayMenu.java +++ b/src/main/java/com/knowledgepixels/nanodash/component/menu/ViewDisplayMenu.java @@ -7,6 +7,7 @@ import com.knowledgepixels.nanodash.NavigationContext; import com.knowledgepixels.nanodash.QueryResult; import com.knowledgepixels.nanodash.Utils; +import com.knowledgepixels.nanodash.View; import com.knowledgepixels.nanodash.ViewDisplay; import com.knowledgepixels.nanodash.component.GuidedChoiceItem; import com.knowledgepixels.nanodash.component.RefreshingResultPanel; @@ -198,6 +199,10 @@ protected void onConfigure() { addToOwnLink.setVisible(showAddToOwn); addEntry("addToOwn", addToOwnLink); + // The version of the view definition this display is showing. A newer one can have + // been published since the page was built, which "refresh now" checks for below. + final String shownViewId = viewDisplay.getView() == null ? null : viewDisplay.getView().getId(); + // Refreshes this one view where it stands. Re-rendering the whole page would work too, // but it takes the reader back to the top of it, away from the view they were looking // at — and re-runs everything else on the page for a refresh they asked of one view. @@ -205,7 +210,36 @@ protected void onConfigure() { @Override public void onClick(Optional target) { ApiCache.clearCache(queryRef, 0); + // Bringing a view up to date is not only a matter of re-running its query: + // the view definition itself can have been superseded since this page was + // built, and neither the memoized resolution nor the version the page's + // structure resolved to would notice on their own (issue #654). + View latestView = shownViewId == null ? null : View.refreshLatestVersion(shownViewId); + if (latestView != null && !shownViewId.equals(latestView.getId())) { + // A new version can change everything the display is made of — its query, + // its columns, its actions, its width — which is more than the piece on + // screen can be patched into. The version in use comes from the page's + // structure (the get-view-displays query resolves it server-side), so the + // structure is what has to be asked again: the same route the page-level + // "refresh now" takes, with the current structure kept on screen under a + // spinner until the refreshed one lands. + AbstractResourceWithProfile r = pageResourceId.isEmpty() + ? null : AbstractResourceWithProfile.get(pageResourceId); + if (r != null) r.forceRefresh(0); + setResponsePage(getPage().getClass(), getPage().getPageParameters()); + return; + } QueryResult view = findParent(QueryResult.class); + if (view == null && target.isPresent()) { + // Not every view display puts results in the page. A query-form view + // shows a form, and the results it leads to live on the page it submits + // to, so there is nothing here to bring up to date: the query has just + // been marked outdated for that next submit, and the view definition has + // been re-checked above. Re-rendering the page on top of that would + // repaint everything for no visible change — which is what made these + // views, alone among the display types, flicker on every refresh. + return; + } // A view is not always what stands in the page: while it waits for its first // results it is inside Wicket's lazy-loading panel, and while it is being // brought up to date inside a RefreshingResultPanel. Either way the wrapper diff --git a/src/test/java/com/knowledgepixels/nanodash/ViewTest.java b/src/test/java/com/knowledgepixels/nanodash/ViewTest.java index efd9fd36..aa0c661c 100644 --- a/src/test/java/com/knowledgepixels/nanodash/ViewTest.java +++ b/src/test/java/com/knowledgepixels/nanodash/ViewTest.java @@ -1,13 +1,39 @@ package com.knowledgepixels.nanodash; +import org.apache.commons.lang3.tuple.Pair; +import org.eclipse.rdf4j.rio.RDFFormat; import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import org.nanopub.MalformedNanopubException; +import org.nanopub.Nanopub; +import org.nanopub.NanopubImpl; +import java.io.File; +import java.io.IOException; +import java.util.HashMap; import java.util.List; +import java.util.Map; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mockStatic; class ViewTest { + // Three versions of the same header view: the original a page might reference by a + // hard-coded id, the version that superseded it, and the one published after that. + private static final String NP_V0 = "https://w3id.org/np/RAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAV0"; + private static final String NP_V1 = "https://w3id.org/np/RAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAV1"; + private static final String NP_V2 = "https://w3id.org/np/RAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAV2"; + private static final String VIEW_V0 = NP_V0 + "/view"; + private static final String VIEW_V1 = NP_V1 + "/view"; + private static final String VIEW_V2 = NP_V2 + "/view"; + + private static Nanopub load(String fileName) throws MalformedNanopubException, IOException { + return new NanopubImpl(new File("src/test/resources/" + fileName), RDFFormat.TRIG); + } + @Test void parseMappingLiteralSplitsOnWhitespace() { // single mapping @@ -31,4 +57,61 @@ void parseMappingLiteralHandlesVoidAndEmpty() { assertEquals(List.of("a:foo"), View.parseMappingLiteral("a:foo void")); } + /** + * The point of issue #654: a view display shows the version its page resolved to, and + * that version can have been superseded since. Asking for the view to be refreshed has + * to go back to the API, even where a resolution was memoized moments ago. + */ + @Test + void refreshLatestVersionPicksUpASupersedingVersion() throws Exception { + Nanopub v1 = load("np-header-view-v1.trig"); + Nanopub v2 = load("np-header-view-v2.trig"); + try (MockedStatic utils = mockStatic(Utils.class); + MockedStatic api = mockStatic(QueryApiAccess.class); + MockedStatic cache = mockStatic(ApiCache.class)) { + utils.when(() -> Utils.getAsNanopub(NP_V1)).thenReturn(v1); + utils.when(() -> Utils.getAsNanopub(NP_V2)).thenReturn(v2); + api.when(() -> QueryApiAccess.getLatestVersionId(NP_V1)).thenReturn(NP_V2); + + View shown = View.get(VIEW_V1, false); + assertEquals("First version", shown.getTitle()); + + View refreshed = View.refreshLatestVersion(VIEW_V1); + + assertEquals(VIEW_V2, refreshed.getId()); + assertEquals("Second version", refreshed.getTitle()); + } + } + + /** + * A page can reach the same view by another id — a built-in view is looked up by the id + * hard-coded for it, which the memo maps to whatever that id resolves to. Refreshing the + * shown version has to drop those memos too, or the next render of such a page would put + * the superseded version back on screen. + */ + @Test + void refreshLatestVersionDropsMemosLeadingToTheRefreshedVersion() throws Exception { + Nanopub v0 = load("np-header-view-v0.trig"); + Nanopub v1 = load("np-header-view-v1.trig"); + Nanopub v2 = load("np-header-view-v2.trig"); + try (MockedStatic utils = mockStatic(Utils.class); + MockedStatic api = mockStatic(QueryApiAccess.class); + MockedStatic cache = mockStatic(ApiCache.class)) { + utils.when(() -> Utils.getAsNanopub(NP_V0)).thenReturn(v0); + utils.when(() -> Utils.getAsNanopub(NP_V1)).thenReturn(v1); + utils.when(() -> Utils.getAsNanopub(NP_V2)).thenReturn(v2); + api.when(() -> QueryApiAccess.getLatestVersionId(NP_V1)).thenReturn(NP_V2); + + // The hard-coded id V0 was resolved to V1 a moment ago and memoized as such. + Map> memo = new HashMap<>(); + memo.put(VIEW_V0, Pair.of(System.currentTimeMillis(), View.get(VIEW_V1, false))); + View.importResolvedViews(memo, Long.MAX_VALUE); + assertTrue(View.isCached(VIEW_V0)); + + View.refreshLatestVersion(VIEW_V1); + + assertFalse(View.isCached(VIEW_V0)); + } + } + } diff --git a/src/test/resources/np-header-view-v0.trig b/src/test/resources/np-header-view-v0.trig new file mode 100644 index 00000000..fc6e3cc1 --- /dev/null +++ b/src/test/resources/np-header-view-v0.trig @@ -0,0 +1,35 @@ +@prefix this: . +@prefix sub: . +@prefix np: . +@prefix gen: . +@prefix dct: . +@prefix npx: . +@prefix xsd: . +@prefix rdfs: . +@prefix orcid: . +@prefix prov: . + +sub:Head { + this: a np:Nanopublication; + np:hasAssertion sub:assertion; + np:hasProvenance sub:provenance; + np:hasPublicationInfo sub:pubinfo . +} + +# The original version of the view, the id a page with a hard-coded view reference looks +# it up by; superseded by np-header-view-v1.trig. +sub:assertion { + sub:view a gen:ResourceView, gen:HeaderView; + rdfs:label "header-view"; + dct:title "Original version" . +} + +sub:provenance { + sub:assertion prov:wasAttributedTo orcid:0000-0002-1267-0234 . +} + +sub:pubinfo { + this: dct:created "2026-08-28T08:00:00.000Z"^^xsd:dateTime; + dct:creator orcid:0000-0002-1267-0234; + npx:embeds sub:view . +} diff --git a/src/test/resources/np-header-view-v1.trig b/src/test/resources/np-header-view-v1.trig new file mode 100644 index 00000000..9fdce523 --- /dev/null +++ b/src/test/resources/np-header-view-v1.trig @@ -0,0 +1,35 @@ +@prefix this: . +@prefix sub: . +@prefix np: . +@prefix gen: . +@prefix dct: . +@prefix npx: . +@prefix xsd: . +@prefix rdfs: . +@prefix orcid: . +@prefix prov: . + +sub:Head { + this: a np:Nanopublication; + np:hasAssertion sub:assertion; + np:hasProvenance sub:provenance; + np:hasPublicationInfo sub:pubinfo . +} + +# A header view, i.e. the one display type that carries no query (issue #572), so that +# loading it does not pull in a query nanopub. +sub:assertion { + sub:view a gen:ResourceView, gen:HeaderView; + rdfs:label "header-view"; + dct:title "First version" . +} + +sub:provenance { + sub:assertion prov:wasAttributedTo orcid:0000-0002-1267-0234 . +} + +sub:pubinfo { + this: dct:created "2026-08-28T09:00:00.000Z"^^xsd:dateTime; + dct:creator orcid:0000-0002-1267-0234; + npx:embeds sub:view . +} diff --git a/src/test/resources/np-header-view-v2.trig b/src/test/resources/np-header-view-v2.trig new file mode 100644 index 00000000..26b39c05 --- /dev/null +++ b/src/test/resources/np-header-view-v2.trig @@ -0,0 +1,35 @@ +@prefix this: . +@prefix sub: . +@prefix np: . +@prefix gen: . +@prefix dct: . +@prefix npx: . +@prefix xsd: . +@prefix rdfs: . +@prefix orcid: . +@prefix prov: . + +sub:Head { + this: a np:Nanopublication; + np:hasAssertion sub:assertion; + np:hasProvenance sub:provenance; + np:hasPublicationInfo sub:pubinfo . +} + +# The superseding version of np-header-view-v1.trig. +sub:assertion { + sub:view a gen:ResourceView, gen:HeaderView; + rdfs:label "header-view"; + dct:title "Second version" . +} + +sub:provenance { + sub:assertion prov:wasAttributedTo orcid:0000-0002-1267-0234 . +} + +sub:pubinfo { + this: dct:created "2026-08-28T10:00:00.000Z"^^xsd:dateTime; + dct:creator orcid:0000-0002-1267-0234; + npx:embeds sub:view; + npx:supersedes . +}