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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ Each query also has a \*.d.ts file which is (re)generated automatically using `n
# Library preferences

- Use `ky` rather than `fetch` where possible.
- Note that for some code that needs to run in the Zotero native XUL/Chrome sandbox for addons (a bare Mozilla SpiderMonkey JS engine), certain Web APIs may be unavailable and libraries that depend on them, such as `ky`, might not work. For example we use `fetch` in `frontend/src/lib/sparql.ts`. So in that case stick to simplified `fetch`, or other simplified APIs provided by that engine. Zotero addons can launch browser windows with full browser context, where everything, including `ky`, work as expected - but this is seperate context and data will need to be pass through callbacks and messages, see the existing bridge.js scripts etc.

# Forms for creating nanopublications using Templates

Expand Down
124 changes: 79 additions & 45 deletions frontend/src/lib/nanopub-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
Util,
Writer,
} from "n3";
import { NANOPUB_TYPES } from "./queries";
import {
extractSubjectProps,
fetchQuads,
Expand All @@ -14,6 +15,7 @@ import {
shrinkUri,
Statement,
} from "./rdf";
import { executeBindSparql, NANOPUB_SPARQL_ENDPOINT_FULL } from "./sparql";
import {
getNanopubHash,
getNanopubSuffix,
Expand Down Expand Up @@ -127,7 +129,9 @@ const WELL_KNOWN_URI_LABELS: Record<string, string> = {

/** Look up a well-known URI label, normalizing trailing slashes */
export function getWellKnownLabel(uri: string): string | undefined {
return WELL_KNOWN_URI_LABELS[uri] ?? WELL_KNOWN_URI_LABELS[uri.replace(/\/+$/, "")];
return (
WELL_KNOWN_URI_LABELS[uri] ?? WELL_KNOWN_URI_LABELS[uri.replace(/\/+$/, "")]
);
}

export const COMMON_LICENSES: Record<string, string> = {
Expand Down Expand Up @@ -368,50 +372,77 @@ export class NanopubStore extends N3Store {
href: q.object.value,
};
});
// Also check prov:wasAttributedTo in provenance
// const provCreators = this.matchPredicate(
// PROV("wasAttributedTo"),
// this.graphUris.provenance,
// ).map((q) => q.object.value);

// Find all applicable "types" "classes" and "tags" for this nanopub
const types: any = [];
this.match(
namedNode(this.graphUris.assertion!),
RDF("type"),
null,
namedNode(this.graphUris.assertion!),
).forEach((q) => {
types.push({
name:
this.findInternalLabel(namedNode(q.object.value)) ?? q.object.value,
href: q.object.value,
});
});
this.match(
namedNode(this.prefixes["this"]),
NPX("hasNanopubType"),
null,
namedNode(this.graphUris.pubinfo!),
).forEach((q) => {
types.push({
name:
this.findInternalLabel(namedNode(q.object.value)) ?? q.object.value,
href: q.object.value,
});
});
this.match(
namedNode(this.prefixes["this"]),
RDF("type"),
null,
namedNode(this.graphUris.pubinfo!),
).forEach((q) => {
types.push({
name:
this.findInternalLabel(namedNode(q.object.value)) ?? q.object.value,
href: q.object.value,
});
});
const types: Metadata["types"] = [];
const nanopubUri = this.prefixes["this"];

// Prefer the SPARQL query against the nanopub network index
// for authoritative npx:hasNanopubType information
// TODO: We could also us the query to get authoritative introduces, creator, date, title etc
// We always need a fallback to local in the case of non-published RDF loaded
if (nanopubUri) {
try {
const sparqlTypes = await executeBindSparql(
NANOPUB_TYPES,
{ nanopubUri },
NANOPUB_SPARQL_ENDPOINT_FULL,
);
if (sparqlTypes?.length < 1) {
throw new Error("Types not found");
}
for (const row of sparqlTypes) {
types.push({
name: this.findInternalLabel(namedNode(row.type)) ?? row.type,
href: row.type,
});
}
} catch {
// Fall back to local store matching for unpublished or unindexed nanopubs
this.match(
namedNode(nanopubUri),
NPX("hasNanopubType"),
null,
namedNode(this.graphUris.pubinfo!),
).forEach((q) => {
types.push({
name:
this.findInternalLabel(namedNode(q.object.value)) ??
q.object.value,
href: q.object.value,
});
});
// Also include rdf:type from the assertion graph (types of the assertion subject)
this.match(
namedNode(this.graphUris.assertion!),
RDF("type"),
null,
namedNode(this.graphUris.assertion!),
).forEach((q) => {
types.push({
name:
this.findInternalLabel(namedNode(q.object.value)) ??
q.object.value,
href: q.object.value,
});
});
// And rdf:type from the pubinfo graph
this.match(
namedNode(this.prefixes["this"]),
RDF("type"),
null,
namedNode(this.graphUris.pubinfo!),
).forEach((q) => {
types.push({
name:
this.findInternalLabel(namedNode(q.object.value)) ??
q.object.value,
href: q.object.value,
});
});
}
}

const introduces: IntroducedObject[] | undefined = this.match(
namedNode(this.prefixes["this"]),
NPX("introduces"),
Expand Down Expand Up @@ -538,7 +569,10 @@ export class NanopubStore extends N3Store {
new Date(this.metadata.created),
)
: "Unknown";
const type = this.metadata.types?.[0]?.name ?? "Unknown";
const types =
this.metadata.types && this.metadata.types.length > 0
? this.metadata.types
: [{ name: "Unknown", href: "" }];
const license = this.metadata.license
? (() => {
const label =
Expand Down Expand Up @@ -592,7 +626,7 @@ export class NanopubStore extends N3Store {
"",
`**Published:** ${published}`,
"",
`**Type:** \`${type}\``,
`**Types:** ${types.map((t) => `[${t.name}](${t.href})`).join(", ")}`,
"",
`**License:** ${license}`,
"",
Expand Down
1 change: 1 addition & 0 deletions frontend/src/lib/queries/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export { default as LATEST_BY_TEMPLATES } from "./latest-by-templates.rq";
export { default as NANOPUB_COMMENTS } from "./nanopub-comments.rq";
export { default as NANOPUB_REFERENCES } from "./nanopub-references.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";
export { default as SEARCH_NANOPUBS_BY_TEMPLATES } from "./search-nanopubs-by-templates.rq";
export { default as SEARCH_NANOPUBS_BY_TYPE } from "./search-nanopubs-by-type.rq";
Expand Down
14 changes: 14 additions & 0 deletions frontend/src/lib/queries/nanopub-types.rq
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Get all nanopub types for a given nanopublication.
# Returns the type URIs associated with the specified nanopub via npx:hasNanopubType.
#
# Placeholder: `?_nanopubUri` - URI: the URI of the nanopub.

prefix npa: <http://purl.org/nanopub/admin/>
prefix npx: <http://purl.org/nanopub/x/>

select distinct ?type
where {
graph npa:graph {
?_nanopubUri npx:hasNanopubType ?type .
}
}
11 changes: 11 additions & 0 deletions frontend/src/lib/queries/nanopub-types.rq.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/**
* Get all nanopub types for a given nanopublication. Returns the type URIs associated with the specified nanopub via npx:hasNanopubType.
* @see ./nanopub-types.rq
* @generator Generated by `npm run generate:query-types`
*/
declare const query: import("../sparql").SparqlQuery<{
nanopubUri: "uri";
}, {
type: "string";
}>;
export default query;
81 changes: 34 additions & 47 deletions frontend/src/lib/sparql.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,13 @@
/**
* SPARQL queries and utilities for querying the nanopub network.
*
* Endpoint: https://query.knowledgepixels.com/repo/text
* — Supports Lucene full-text search via openrdf lucenesail extensions.
*
* Endpoint: https://query.knowledgepixels.com/repo/full
* — Standard SPARQL endpoint for structured queries (no full-text search).
*/

import ky, { HTTPError } from "ky";

// Supports Lucene full-text search via openrdf lucenesail extensions.
export const NANOPUB_SPARQL_ENDPOINT_TEXT =
"https://query.knowledgepixels.com/repo/text";

// Standard SPARQL endpoint for structured queries (no full-text search).
export const NANOPUB_SPARQL_ENDPOINT_FULL =
"https://query.knowledgepixels.com/repo/full";

Expand Down Expand Up @@ -100,50 +95,42 @@ export async function executeSparql(
endpoint: string = NANOPUB_SPARQL_ENDPOINT_TEXT,
signal?: AbortSignal,
): Promise<Record<string, string>[]> {
try {
const res = await ky.post(endpoint, {
body: new URLSearchParams({ query }),
headers: {
Accept: "application/sparql-results+json",
"Content-Type": "application/x-www-form-urlencoded",
},
signal,
});
// Use plain fetch rather than ky, as this code may need to run in the Zotero
// addon XUL sandbox which has limited JS functionality as apposed to browser windows.
const res = await fetch(endpoint, {
method: "POST",
body: new URLSearchParams({ query }),
headers: {
Accept: "application/sparql-results+json",
"Content-Type": "application/x-www-form-urlencoded",
},
signal,
});

const data = await res.json<SparqlResults>();

return data.results.bindings.map((row) => {
const parsed: Record<string, string> = {};
for (const [k, v] of Object.entries(row)) {
parsed[k] = v.value;
}
return parsed;
});
} catch (err) {
// Handle HTTP errors (like 400 Bad Request) by extracting the response body
if (err instanceof HTTPError) {
const response = err.response;
let errorDetail: string;
if (!res.ok) {
let errorDetail: string;
try {
errorDetail = await res.text();
} catch {
errorDetail = res.statusText || `HTTP ${res.status}`;
}
// Prefer not to render HTML code (i.e. starts with "<"), as an error message
throw new Error(
errorDetail && !errorDetail.startsWith("<")
? errorDetail
: `SPARQL query failed: ${res.status} ${res.statusText}`,
);
}

try {
// Try to get the error details from the response body
errorDetail = await response.text();
} catch {
// If we can't read the body, fall back to status text
errorDetail = response.statusText || `HTTP ${response.status}`;
}
const data: SparqlResults = (await res.json()) as any;

// Create a new error with the detailed message, preserving the original as cause
throw new Error(
errorDetail ||
`SPARQL query failed: ${response.status} ${response.statusText}`,
{ cause: err },
);
return data?.results?.bindings?.map((row) => {
const parsed: Record<string, string> = {};
for (const [k, v] of Object.entries(row)) {
parsed[k] = v.value;
}

// Re-throw other errors (including AbortError)
throw err;
}
return parsed;
});
}

// =============================================================================
Expand Down
Loading
Loading