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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 2 additions & 12 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,17 +61,7 @@ If available (e.g. via MCP), always use context7 when I need code generation, se

# SPARQL Queries

These should be defined in `frontend/src/lib/queries`. Each query has its own query file \*.rq which must contain a comment that starts with its description at the top and then list its placeholders, then the actual SPARQL content. Example of header comment:

```
# Search for nanopubs of a specific RDF type.
# Excludes invalidated and superseded nanopubs.
#
# Placeholder: `?_searchTerm` - the user's search string.
# Placeholder: `?_rdfType` - URI: the full type.
```

Each query also has a \*.d.ts file which is (re)generated automatically using `npm run generate:query-types` in the frontend workspace. That same npm script will also generate `frontend/src/lib/queries/index.ts` barrel exports for convenient usage. A query can be run (including binding parameters) using functions in `frontend/src/lib/sparql.ts`, usually via `executeBindSparql()`. When using those query execution functions, especially within a React hook that could get run twice in dev mode (React Strict Mode), make sure you implement AbortSignal as per the comment above where `executeBindSparql()` is defined.
SPARQL queries are used to query the nanopublication repositories. Look in [agent-docs/SPARQL.md](agent-docs/SPARQL.md) for more guidance about SPARQL queries for nanopubs.

# Library preferences

Expand All @@ -82,7 +72,7 @@ Each query also has a \*.d.ts file which is (re)generated automatically using `n

Under `frontend/src/pages/np/create/components/templates/` you will find a number of user-friendly forms for creating popular types of nanopubs using templates (which are themselves nanopubs, listed in `frontend/src/pages/np/create/components/templates/registry-metadata.ts`).

The lightweight library `formedible` provides simplified form generation functionality with reduced boilerplate (using TanStack Form, Zod and shadcn/ui). Use this approach for complex forms and templates for creating Nanopublications, where possible. More information, including examples, are availble in the file `agent-docs/FORMEDIBLE.md`. You can also follow some of the existing forms under `frontend/src/pages/np/create/components/templates/` as examples but feel free to improve on it.
The lightweight library `formedible` provides simplified form generation functionality with reduced boilerplate (using TanStack Form, Zod and shadcn/ui). Use this approach for complex forms and templates for creating Nanopublications, where possible. More information, including examples, are availble in the file [agent-docs/FORMEDIBLE.md](agent-docs/FORMEDIBLE.md). You can also follow some of the existing forms under `frontend/src/pages/np/create/components/templates/` as examples but feel free to improve on it.

The forms gather the required information defined in the template ("placeholders"), then pass that to the `generateNanopublication()` function which generates the "statements" (RDF triples) and the rest of the output nanopub, including signing it and generating a globally unique trustyHash to indentify it.

Expand Down
96 changes: 96 additions & 0 deletions agent-docs/SPARQL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# SPARQL Queries

These should be defined in [frontend/src/lib/queries](frontend/src/lib/queries). Each query has its own query file \*.rq which must contain a comment that starts with its description at the top and then list its placeholders, then the actual SPARQL content. Example of header comment:

```
# Search for nanopubs of a specific RDF type.
# Excludes invalidated and superseded nanopubs.
#
# Placeholder: `?_searchTerm` - the user's search string.
# Placeholder: `?_rdfType` - URI: the full type.
```

Each query also has a \*.d.ts file which is (re)generated automatically using `npm run generate:query-types` in the frontend workspace. That same npm script will also generate `frontend/src/lib/queries/index.ts` barrel exports for convenient usage. A query can be run (including binding parameters) using functions in `frontend/src/lib/sparql.ts`, usually via `executeBindSparql()`. When using those query execution functions, especially within a React hook that could get run twice in dev mode (React Strict Mode), make sure you implement AbortSignal as per the comment above where `executeBindSparql()` is defined.

## Graphs available

```
graph npa:graph
graph npa:networkGraph
graph ?pubinfo
graph ?assertion
```

The `?pubinfo` `?assertion` graphs are available in the `NANOPUB_SPARQL_ENDPOINT_FULL` endpoint.

The `npa:graph` (Admin) and `npa:networkGraph` are always available, see next sub-section.

## Admin graph

The SPARQL query endpoints provide an admin graph (`npa:graph`) which is an efficient way to query various important properties of nanopubs. Here is an outline of what the admin graph contains, as [csv](https://github.com/knowledgepixels/nanopub-query/blob/main/doc%2Fadmin-triple-table.csv):

```csv
Subject,Predicate,Object,Graph,Group,Comment
NANOPUB,npa:hasValidSignatureForPublicKey,FULL_PUBKEY,npa:graph,meta,full pubkey if signature is valid
NANOPUB,npa:hasValidSignatureForPublicKeyHash,PUBKEY_HASH,npa:graph,meta,hex-encoded SHA256 hash if signature is valid
NANOPUB,npx:signedBy,SIGNER,npa:graph,meta,ID of signer
NANOPUB1,RELATION,NANOPUB2,npa:networkGraph,meta,any inter-nanopub relation found in NANOPUB1
NANOPUB,npx:introduces,THING,npa:graph,meta,when such a triple is present in pubinfo of NANOPUB
NANOPUB,npx:describes,THING,npa:graph,meta,when such a triple is present in pubinfo of NANOPUB
NANOPUB,npx:embeds,THING,npa:graph,meta,when such a triple is present in pubinfo of NANOPUB
NANOPUB,npa:hasSubIri,SUB_IRI,npa:graph,meta,for any IRI minted in the namespace of the NANOPUB
NANOPUB1,npa:refersToNanopub,NANOPUB2,npa:networkGraph,meta,generic inter-nanopub relation
NANOPUB,npx:invalidates,INVALIDATED_NANOPUB,npa:graph,meta,if the NANOPUB retracts or supersedes another nanopub
NANOPUB,npx:retracts,RETRACTED_NANOPUB,npa:graph,meta,if the NANOPUB retracts another nanopub
NANOPUB,npx:supersedes,SUPERSEDED_NANOPUB,npa:graph,meta,if the NANOPUB supersedes another nanopub
NANOPUB,npa:hasHeadGraph,HEAD_GRAPH,npa:graph,meta,direct link to the head graph of the NANOPUB
NANOPUB,npa:hasGraph,GRAPH,npa:graph,meta,generic link to all four graphs of the given NANOPUB
NANOPUB,np:hasAssertion,ASSERTION_GRAPH,npa:graph,meta,direct link to the assertion graph of the NANOPUB
NANOPUB,np:hasProvenance,PROVENANCE_GRAPH,npa:graph,meta,direct link to the provenance graph of the NANOPUB
NANOPUB,np:hasPublicationInfo,PUBINFO_GRAPH,npa:graph,meta,direct link to the pubinfo graph of the NANOPUB
NANOPUB,npa:artifactCode,ARTIFACT_CODE,npa:graph,meta,artifact code starting with 'RA...'
NANOPUB,npa:isIntroductionOf,AGENT,npa:graph,meta,linking intro nanopub to the agent it is introducing
NANOPUB,npa:declaresPubkey,FULL_PUBKEY,npa:graph,meta,full pubkey declared by the given intro NANOPUB
NANOPUB,dct:created,CREATION_DATE,npa:graph,meta,normalized creation timestamp
NANOPUB,npx:hasNanopubType,NANOPUB_TYPE,npa:graph,meta,type of NANOPUB
NANOPUB,rdfs:label,LABEL,npa:graph,meta,label of NANOPUB
NANOPUB,dct:description,LABEL,npa:graph,meta,description of NANOPUB
NANOPUB,dct:creator,CREATOR,npa:graph,meta,creator of NANOPUB (can be several)
NANOPUB,pav:authoredBy,AUTHOR,npa:graph,meta,author of NANOPUB (can be several)
NANOPUB,npa:hasFilterLiteral,FILTER_LITERAL,npa:graph,literal,auxiliary literal for filtering by type and pubkey in text repo
REPO,npa:hasNanopubCount,NANOPUB_COUNT,npa:graph,admin,number of nanopubs loaded
REPO,npa:hasNanopubChecksum,NANOPUB_CHECKSUM,npa:graph,admin,checksum of all loaded nanopubs (order-independent XOR checksum on trusty URIs in Base64 notation)
NANOPUB,npa:hasLoadNumber,LOAD_NUMBER,npa:graph,admin,the sequential number at which this NANOPUB was loaded
NANOPUB,npa:hasLoadChecksum,LOAD_CHECKSUM,npa:graph,admin,the checksum of all loaded nanopubs after loading the given NANOPUB
NANOPUB,npa:hasLoadTimestamp,LOAD_TIMESTAMP,npa:graph,admin,the time point at which this NANOPUB was loaded
```

## Endpoints for query

For normal queries (no text search), always use `NANOPUB_SPARQL_ENDPOINT_FULL` as it provide the most data including access to the assertion, and pubinfo graphs.

For text search queries (Lucene), then `NANOPUB_SPARQL_ENDPOINT_TEXT` must be used instead, however in that case the assertion and pubinfo graphs are not available, not even via the admin graph.

## Title and Full text search (Lucene)

The `/text` endpoint (`NANOPUB_SPARQL_ENDPOINT_TEXT`) supports title and full text search using Lucene Sail:

```
PREFIX search: <http://www.openrdf.org/contrib/lucenesail#>

?subj search:matches [
search:query "search terms...";
search:property my:property;
search:score ?score;
search:snippet ?snippet ] .
```

The ‘virtual’ properties in the search: namespace have the following meaning:

- `search:matches` – links the resource to be found with the following query statements (required)
- `search:query` – specifies the Lucene query (required)
- `search:property` – specifies the property to search. If omitted all properties are searched. Use `rdfs:label` for label-only search or `npa:hasFilterLiteral` for full-text search (optional)
- `search:score` – specifies a variable for the score (optional)
- `search:snippet` – specifies a variable for a highlighted snippet (optional)

More details about advanced queries (e.g. field boosting and per-field search) are available here if needed: https://raw.githubusercontent.com/eclipse-rdf4j/rdf4j/refs/heads/main/site/content/documentation/programming/lucene.md
31 changes: 17 additions & 14 deletions frontend/src/hooks/use-nanopub-search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
NANOPUB_SPARQL_ENDPOINT_FULL,
NANOPUB_SPARQL_ENDPOINT_TEXT,
} from "@/lib/sparql";
import { bestLabelForRow } from "@/lib/string-format";
import {
getTemplateUris,
paginateRows,
Expand Down Expand Up @@ -152,20 +153,22 @@ export function useNanopubSearch({

setHasMore(moreResultsAvailable);
setSearchResults(
visibleRows.map((row: any) => ({
np: row.np,
label: row.label || row.description || "",
date: new Date(row.date),
creator: row.creator || "",
types: row.types ? row.types.split("|") : [],
template: row.template,
maxScore:
row.maxScore != null ? parseFloat(row.maxScore) : undefined,
referenceCount:
row.referenceCount != null
? parseInt(row.referenceCount)
: undefined,
})),
visibleRows.map((row: any) => {
return {
np: row.np,
label: bestLabelForRow(row),
date: new Date(row.date),
creator: row.creator || "",
types: row.types ? row.types.split("|") : [],
template: row.template,
maxScore:
row.maxScore != null ? parseFloat(row.maxScore) : undefined,
referenceCount:
row.referenceCount != null
? parseInt(row.referenceCount)
: undefined,
};
}),
);
} catch (e: any) {
if (e?.name === "AbortError") return;
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/hooks/use-nanopub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ export function useNanopub(
let newStore: NanopubStore;

if (typeof uriOrStore === "string") {
newStore = await NanopubStore.load(uriOrStore);
newStore = await NanopubStore.load(uriOrStore, true);
} else {
newStore = uriOrStore;
}
Expand Down
106 changes: 102 additions & 4 deletions frontend/src/lib/nanopub-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
Util,
Writer,
} from "n3";
import { NANOPUB_TYPES } from "./queries";
import { NANOPUB_LABELS, NANOPUB_REFERS_TO, NANOPUB_TYPES } from "./queries";
import {
extractSubjectProps,
fetchQuads,
Expand Down Expand Up @@ -161,7 +161,7 @@ export class NanopubStore extends N3Store {
* (Technically this will load any RDF given a URL, not just nanopubs)
*
*/
static async load(url: string) {
static async load(url: string, fillLabelCache = false) {
const store = new NanopubStore();
const prefixes: any = {};
await fetchQuads(
Expand All @@ -172,6 +172,9 @@ export class NanopubStore extends N3Store {
store.prefixes = prefixes;
store.extractGraphUris();
await store.extractMetadata();
if (fillLabelCache) {
await store.fillLabelCacheFromRefersTo();
}

return store;
}
Expand All @@ -182,7 +185,7 @@ export class NanopubStore extends N3Store {
* (Technically this will load any RDF, not just nanopubs)
*
*/
static async loadString(rdf: string) {
static async loadString(rdf: string, fillLabelCache = false) {
const store = new NanopubStore();
const prefixes: any = {};
await parseRdf(
Expand All @@ -193,6 +196,9 @@ export class NanopubStore extends N3Store {
store.prefixes = prefixes;
store.extractGraphUris();
await store.extractMetadata();
if (fillLabelCache) {
await store.fillLabelCacheFromReferencedNanopubs();
}

return store;
}
Expand All @@ -210,6 +216,7 @@ export class NanopubStore extends N3Store {
if (uri) {
// Search the document internally, then the local labelCache, then a list of COMMON_LABELS
label =
this.labelCache[uri] ||
this.matchOne(namedNode(uri), NS.FOAF("name"), null, null)?.object
.value ||
this.matchOne(namedNode(uri), NS.NPTs("hasLabelFromApi"), null, null)
Expand Down Expand Up @@ -474,6 +481,21 @@ export class NanopubStore extends N3Store {
this.graphUris.pubinfo,
);

// This is a special workaround, as we used to fall back to using "NP created using..."
// if the NP was created using a template with no hasNanopubLabelPattern specified, and
// that was not ideal because all the NPs send up with the same name.
// So if we detect legacy nanopubs, try best effort to get the label of the first
// introduced subject, which is the newer strategy as of early May 2026.
// Also related: bestLabelForRow() helper function.
// Determine the title value, potentially overriding the default if it's a legacy placeholder
const defaultTitleValue = title?.object?.value || null;
const titleValue =
defaultTitleValue?.startsWith("NP created using") &&
introduces &&
introduces.length > 0
? (introduces.find((intro) => intro.label)?.label ?? defaultTitleValue)
: defaultTitleValue;

const license = this.matchOne(
namedNode(this.prefixes["this"]),
DCT("license"),
Expand All @@ -499,14 +521,90 @@ export class NanopubStore extends N3Store {
creators,
types,
introduces: introduces,
title: title?.object?.value || null,
title: titleValue,
assertionSubjects: unique(assertionSubjects),
license,
uri: this.prefixes["this"],
template,
};
}

/**
* Use the nanopub-refers-to SPARQL query to populate the labelCache
* with labels of nanopubs referred to by this nanopub.
* Suitable for published nanopubs loaded via URL.
*/
protected async fillLabelCacheFromRefersTo() {
const nanopubUri = this.metadata.uri ?? this.prefixes["this"];
if (!nanopubUri) return;

try {
const results = await executeBindSparql(
NANOPUB_REFERS_TO,
{ nanopubUri },
NANOPUB_SPARQL_ENDPOINT_FULL,
);
if (results) {
for (const row of results) {
if (row.refNanopub && row.label) {
this.labelCache[row.refNanopub] = row.label;
}
}
}
} catch {
// Silently ignore – label cache is best-effort
}
}

/**
* Scan the store for nanopub URIs referenced in quads (excluding this
* nanopub's own URI) and use a single SPARQL query with a VALUES clause
* to fetch their labels into the labelCache.
* Suitable for unpublished nanopubs loaded from a string, where the
* nanopub-refers-to network index query would not work.
*/
protected async fillLabelCacheFromReferencedNanopubs() {
const ownUri = this.metadata.uri ?? this.prefixes["this"];
const seenUris = new Set<string>();

// Collect unique nanopub URIs referenced as objects in quads
for (const quad of this.getQuads(null, null, null, null)) {
if (quad.object.termType === "NamedNode") {
const objUri = quad.object.value;
if (
objUri &&
objUri !== ownUri &&
isNanopubUri(objUri) &&
!seenUris.has(objUri)
) {
seenUris.add(objUri);
}
}
}

if (seenUris.size === 0) return;

// Build a VALUES list of URI references and fetch all labels in a single query
const valuesList = [...seenUris].map((uri) => `<${uri}>`).join(" ");

try {
const results = await executeBindSparql(
NANOPUB_LABELS,
{ nanopubUris: valuesList },
NANOPUB_SPARQL_ENDPOINT_FULL,
);
if (results) {
for (const row of results) {
if (row.nanopubUri && row.label) {
this.labelCache[row.nanopubUri] = row.label;
}
}
}
} catch {
// Silently ignore – label cache is best-effort
}
}

/**
* Get the citation as a string.
*/
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/lib/queries/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ export { default as LATEST_ALL } from "./latest-all.rq";
export { default as LATEST_BY_TEMPLATE } from "./latest-by-template.rq";
export { default as LATEST_BY_TEMPLATES } from "./latest-by-templates.rq";
export { default as NANOPUB_COMMENTS } from "./nanopub-comments.rq";
export { default as NANOPUB_LABEL } from "./nanopub-label.rq";
export { default as NANOPUB_LABELS } from "./nanopub-labels.rq";
export { default as NANOPUB_REFERENCES } from "./nanopub-references.rq";
export { default as NANOPUB_REFERS_TO } from "./nanopub-refers-to.rq";
export { default as NANOPUB_STATUS } from "./nanopub-status.rq";
export { default as NANOPUB_TYPES } from "./nanopub-types.rq";
export { default as SEARCH_NANOPUBS } from "./search-nanopubs.rq";
Expand Down
13 changes: 13 additions & 0 deletions frontend/src/lib/queries/nanopub-label.rq
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Get the label for a nanopub URI from the nanopub network.
# Returns the rdfs:label registered in the admin graph for the specified nanopub.
#
# Placeholder: `?_nanopubUri` - URI: the nanopub URI to look up the label for.

prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#>
prefix npa: <http://purl.org/nanopub/admin/>

select ?label where {
graph npa:graph {
?_nanopubUri rdfs:label ?label .
}
} limit 1
11 changes: 11 additions & 0 deletions frontend/src/lib/queries/nanopub-label.rq.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/**
* Get the label for a nanopub URI from the nanopub network. Returns the rdfs:label registered in the admin graph for the specified nanopub.
* @see ./nanopub-label.rq
* @generator Generated by `npm run generate:query-types`
*/
declare const query: import("../sparql").SparqlQuery<{
nanopubUri: "uri";
}, {
label: "string";
}>;
export default query;
14 changes: 14 additions & 0 deletions frontend/src/lib/queries/nanopub-labels.rq
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Get the labels for a set of nanopub URIs from the nanopub network.
# Uses a VALUES clause to efficiently look up multiple labels in a single query.
#
# Placeholder: `?_nanopubUris` - Raw: space-separated URIs in VALUES syntax, e.g. `<https://w3id.org/np/RAabc> <https://w3id.org/np/RAdef>`

prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#>
prefix npa: <http://purl.org/nanopub/admin/>

select ?nanopubUri ?label where {
graph npa:graph {
?nanopubUri rdfs:label ?label .
}
values ?nanopubUri { ?_nanopubUris }
}
Loading
Loading