diff --git a/apps/studio/src/ui/graph/GraphNodePane.test.tsx b/apps/studio/src/ui/graph/GraphNodePane.test.tsx
index d40e63959..915e0a710 100644
--- a/apps/studio/src/ui/graph/GraphNodePane.test.tsx
+++ b/apps/studio/src/ui/graph/GraphNodePane.test.tsx
@@ -54,6 +54,15 @@ describe("GraphNodePane", () => {
expect(screen.getByText("Stockholm")).toHaveAttribute("lang", "sv");
});
+ it("shows a blank node's term in its document, not the number the graph gives it", () => {
+ const graph = smallGraph();
+ const blank = graph.nodes.find((node) => node.blank === true)!;
+ expect(blank.id).not.toBe("_:b0");
+ pane(blank.id, graph);
+ expect(screen.getByRole("heading", { name: "_:b0" })).toBeInTheDocument();
+ expect(document.querySelector(".graph-iri")).toHaveTextContent(/^_:b0$/);
+ });
+
it("says a value's language tag after it, as stated, and none for a value with none", () => {
const hintOf = (value: string) =>
within(list("Values")).getByText(value).closest("dd")!.querySelector("code.hint")
diff --git a/apps/studio/src/ui/graph/GraphNodePane.tsx b/apps/studio/src/ui/graph/GraphNodePane.tsx
index 87f4a3fc0..de8591afd 100644
--- a/apps/studio/src/ui/graph/GraphNodePane.tsx
+++ b/apps/studio/src/ui/graph/GraphNodePane.tsx
@@ -181,7 +181,8 @@ export function GraphNodePane({
{labels.get(node.id)}
- {node.id}
+ {/* A blank node's id is a number the graph gives it: its term is what its document calls it. */}
+ {node.term ?? node.id}
{note !== null &&
{t(note)}
}
diff --git a/apps/studio/src/ui/graph/GraphScreen.test.tsx b/apps/studio/src/ui/graph/GraphScreen.test.tsx
index 8c3a5d7ee..8767bd265 100644
--- a/apps/studio/src/ui/graph/GraphScreen.test.tsx
+++ b/apps/studio/src/ui/graph/GraphScreen.test.tsx
@@ -156,6 +156,19 @@ describe("GraphScreen", () => {
expect(onSelect).toHaveBeenCalledTimes(2);
});
+ it("finds a blank node by its term and its document's address, not the number the graph gives it", () => {
+ const graph = smallGraph();
+ const blank = graph.nodes.find((node) => node.blank === true)!;
+ show(graph);
+ const search = screen.getByRole("searchbox", { name: "Find a node" });
+ fireEvent.input(search, { target: { value: "_:b0" } });
+ expect(screen.getByRole("button", { name: "_:b0" })).toBeInTheDocument();
+ fireEvent.input(search, { target: { value: blank.document! } });
+ expect(screen.getByRole("button", { name: "_:b0" })).toBeInTheDocument();
+ fireEvent.input(search, { target: { value: blank.id } });
+ expect(screen.queryByRole("button", { name: "_:b0" })).toBeNull();
+ });
+
it("lists the first nodes found, and says how many more there are", () => {
show(largeGraph(20));
fireEvent.input(screen.getByRole("searchbox"), { target: { value: "card-1" } });
diff --git a/apps/studio/src/ui/graph/GraphScreen.tsx b/apps/studio/src/ui/graph/GraphScreen.tsx
index 2c40effdb..6fcfbd859 100644
--- a/apps/studio/src/ui/graph/GraphScreen.tsx
+++ b/apps/studio/src/ui/graph/GraphScreen.tsx
@@ -153,7 +153,10 @@ export function GraphScreen({
wanted === ""
? []
: filtered.nodes.filter((node) =>
- [node.id, labels.get(node.id)!].some((text) => folded(text).includes(wanted)),
+ // A blank node by its term and its document's address, not the number the graph gives it.
+ (node.term === undefined ? [node.id] : [node.term, node.document!])
+ .concat(labels.get(node.id)!)
+ .some((text) => folded(text).includes(wanted)),
);
const toggle = (group: NodeGroup) =>
diff --git a/apps/studio/src/ui/graph/graphModel.test.ts b/apps/studio/src/ui/graph/graphModel.test.ts
index b5fe38e6c..2b916e286 100644
--- a/apps/studio/src/ui/graph/graphModel.test.ts
+++ b/apps/studio/src/ui/graph/graphModel.test.ts
@@ -131,11 +131,14 @@ describe("labelOf and shortName", () => {
it("names a resource by its fragment, a blank node by its label, a term in short", () => {
expect(label(CARD)).toBe("sweden");
- expect(label(`_:b0@${CATALOG}`)).toBe("_:b0");
+ const inGraph = graph.nodes.find((node) => node.blank === true)!;
+ expect(labelOf(inGraph, instanceA.url, instanceA.name)).toBe("_:b0");
const blank: GraphNode = {
- id: "_:b0@https://pod.example/al@ice/solid-memo/main/meta.ttl",
+ id: "_:7",
kind: "resource",
blank: true,
+ term: "_:b0",
+ document: "https://pod.example/al@ice/solid-memo/main/meta.ttl",
types: [],
literals: [],
};
@@ -227,6 +230,26 @@ describe("distinctLabels", () => {
]);
});
+ it("names blank nodes of one label in two documents after their documents' files", () => {
+ const label = `_:${"b".repeat(16_381)}`;
+ const blank = (id: string, document: string): GraphNode => ({
+ ...of(id, "resource", document),
+ blank: true,
+ term: label,
+ });
+ const nodes = [
+ blank("_:0", `${root}decks/cards-0.ttl`),
+ blank("_:1", `${root}decks/cards-1.ttl`),
+ blank("_:2", `${root}reviews/cards-1.ttl`),
+ ];
+ const labels = new Map(nodes.map((node) => [node.id, labelOf(node, root, instanceA.name)]));
+ expect([...distinctLabels(nodes, labels, root).values()]).toEqual([
+ `cards-0#${label}`,
+ `decks/cards-1#${label}`,
+ `reviews/cards-1#${label}`,
+ ]);
+ });
+
it("names a folder named as another after its parent folder, else its path in the instance", () => {
const nodes = [
of(root, "container"),
diff --git a/apps/studio/src/ui/graph/graphModel.ts b/apps/studio/src/ui/graph/graphModel.ts
index bca26997b..1e86862e5 100644
--- a/apps/studio/src/ui/graph/graphModel.ts
+++ b/apps/studio/src/ui/graph/graphModel.ts
@@ -13,6 +13,7 @@
import { SM, SM_NS } from "@solid-memo/vocab/vocab.generated";
import type { GraphEdge, GraphNode, InstanceGraph } from "@solid-memo/domain/instanceGraph";
+import { PathMap } from "@solid-memo/domain/pathKeys";
import type { Point, Positions } from "./layout";
/**
@@ -174,8 +175,7 @@ export function labelOf(node: GraphNode, instanceUrl: string, instanceName: stri
return node.id.slice(node.id.lastIndexOf("/", node.id.length - 2) + 1);
if (node.kind === "document" && node.id.startsWith(instanceUrl))
return node.id.slice(instanceUrl.length);
- // A blank node's label holds no "@", its document's address may.
- if (node.blank === true) return node.id.slice(0, node.id.indexOf("@"));
+ if (node.term !== undefined) return node.term;
if (node.kind === "vocabularyLeaf" && shortName(node.id) !== node.id) return shortName(node.id);
const hash = node.id.indexOf("#");
if (hash !== -1 && hash < node.id.length - 1) return node.id.slice(hash + 1);
@@ -226,17 +226,22 @@ export function distinctLabels(
return each.id.slice(Math.max(parent + 1, instanceUrl.length));
};
const parentCounts = counted(sharedFolders.map(parentOf));
- const shortOf = (each: GraphNode) =>
- `${documentName(each.document!, instanceUrl).split("/").at(-1)}#${labels.get(each.id)}`;
- const shortCounts = counted(shared.map(shortOf));
+ // Counted by the file's name and the label, each a key of its own: joined, they could be longer than V8 hashes all of (pathKeys.ts).
+ const shortOf = (each: GraphNode) => [
+ documentName(each.document!, instanceUrl).split("/").at(-1)!,
+ labels.get(each.id)!,
+ ];
+ const shortCounts = new PathMap();
+ for (const short of shared.map(shortOf))
+ shortCounts.set(short, (shortCounts.get(short) ?? 0) + 1);
const distinct = new Map(labels);
for (const each of shared) {
const short = shortOf(each);
distinct.set(
each.id,
shortCounts.get(short)! > 1
- ? `${documentName(each.document!, instanceUrl)}#${labels.get(each.id)}`
- : short,
+ ? `${documentName(each.document!, instanceUrl)}#${short[1]}`
+ : short.join("#"),
);
}
for (const each of sharedFolders) {
diff --git a/apps/studio/src/ui/graph/layout.test.ts b/apps/studio/src/ui/graph/layout.test.ts
index c688d1692..e4a608eb4 100644
--- a/apps/studio/src/ui/graph/layout.test.ts
+++ b/apps/studio/src/ui/graph/layout.test.ts
@@ -395,6 +395,46 @@ describe("createLayout", () => {
}
});
+ it("breaks a cycle of blank nodes at the same node, whatever blank nodes other documents have", () => {
+ const doc = `${INSTANCE}d.ttl`;
+ const other = `${INSTANCE}a.ttl`;
+ const part = (from: string, to: string): DraftTriple => ({
+ subject: `_:${from}`,
+ predicate: IS_PART_OF,
+ object: { kind: "blank", value: to },
+ });
+ // The graph numbers blank nodes as it reads them: nine of another document read first make the cycle's "_:9" and "_:10".
+ const graph = (unrelated: boolean) =>
+ instanceGraphOf({
+ instanceUrl: INSTANCE,
+ storages: ["https://pod.example/"],
+ documents: [
+ ...(unrelated
+ ? [
+ found(
+ other,
+ Array.from({ length: 9 }, (_, k) => triple(`_:o${k}`, TYPE, SM.Card)),
+ ),
+ ]
+ : []),
+ found(doc, [part("p", "q"), part("q", "p")]),
+ ],
+ });
+ for (const unrelated of [false, true]) {
+ const built = graph(unrelated);
+ const { routes } = laidOut(built);
+ const [p, q] = ["_:p", "_:q"].map(
+ (term) => built.nodes.find((each) => each.term === term && each.document === doc)!.id,
+ ) as [string, string];
+ const of = (from: string, to: string) =>
+ routes.of(built.edges.find((each) => each.from === from && each.to === to)!);
+ // The first by its label falls back to its document, the other hangs under it.
+ expect(of(doc, p).tree).toBe(true);
+ expect(of(q, p).tree).toBe(true);
+ expect(of(doc, q).tree).toBe(false);
+ }
+ });
+
it("routes the edges to a term up the tree, those of a document's nodes meeting in the same parts", () => {
const graph = largeGraph(40);
const { routes } = laidOut(graph);
@@ -645,6 +685,14 @@ describe("graphIdentity", () => {
expect(graphIdentity(rekinded)).not.toBe(graphIdentity(graph));
});
+ it("differs for two graphs whose blank nodes differ only in label", () => {
+ const graphOf = (term: string): InstanceGraph => ({
+ nodes: [{ id: "_:0", kind: "resource", types: [], literals: [], blank: true, term }],
+ edges: [],
+ });
+ expect(graphIdentity(graphOf("_:a"))).not.toBe(graphIdentity(graphOf("_:b")));
+ });
+
it("differs for two graphs a short hash of them cannot tell apart", () => {
const [first, second] = collidingGraphs();
expect(shortMark(first)).toBe(shortMark(second));
diff --git a/apps/studio/src/ui/graph/layout.ts b/apps/studio/src/ui/graph/layout.ts
index 9409f4489..aee170f08 100644
--- a/apps/studio/src/ui/graph/layout.ts
+++ b/apps/studio/src/ui/graph/layout.ts
@@ -196,12 +196,27 @@ function append(map: Map, key: K, value: V): void {
else list.push(value);
}
-/** `graph`'s nodes and edges in order, whatever order they came in. */
+/**
+ * `graph`'s nodes and edges in order, whatever order they came in. A
+ * blank node's id is a number the graph gives it as it reads the
+ * documents, which other documents' blank nodes change: blank nodes
+ * are in order by their document, then their term, compared apart,
+ * never joined. Their ids all start "_:", as no IRI does, so they keep
+ * one place among the others.
+ */
function sortedOf(graph: InstanceGraph): InstanceGraph {
+ const blanks = new Map();
+ for (const node of graph.nodes) if (node.term !== undefined) blanks.set(node.id, node);
+ const byId = (a: string, b: string) => {
+ const [x, y] = [blanks.get(a), blanks.get(b)];
+ return x === undefined || y === undefined
+ ? order(a, b)
+ : order(x.document!, y.document!) || order(x.term!, y.term!);
+ };
return {
- nodes: [...graph.nodes].sort((a, b) => order(a.id, b.id)),
+ nodes: [...graph.nodes].sort((a, b) => byId(a.id, b.id)),
edges: [...graph.edges].sort(
- (a, b) => order(a.from, b.from) || order(a.to, b.to) || order(a.predicate, b.predicate),
+ (a, b) => byId(a.from, b.from) || byId(a.to, b.to) || order(a.predicate, b.predicate),
),
};
}
@@ -907,8 +922,8 @@ export function createLayout(graph: InstanceGraph): Layout {
const identities = new WeakMap();
/**
- * What a graph is, in full: every node's id and kind and every edge's
- * ends, predicate and kind, in order, as text that two graphs share only
+ * What a graph is, in full: every node's id, kind and blank label and
+ * every edge's ends, predicate and kind, in order, as text that two graphs share only
* when they are the same. So a layout kept from before is used only for
* the graph it was made of; a short hash of these could be made to match
* for another graph. Made once for each graph object.
@@ -917,7 +932,7 @@ export function graphIdentity(graph: InstanceGraph): string {
let identity = identities.get(graph);
if (identity === undefined) {
identity = JSON.stringify([
- graph.nodes.map((node) => [node.id, node.kind]),
+ graph.nodes.map((node) => [node.id, node.kind, node.term ?? null]),
graph.edges.map((edge) => [edge.from, edge.predicate, edge.to, edge.kind]),
]);
identities.set(graph, identity);
diff --git a/docs/deck-library.md b/docs/deck-library.md
index 8c3a29acc..de94bb2cf 100644
--- a/docs/deck-library.md
+++ b/docs/deck-library.md
@@ -1099,13 +1099,15 @@ flowchart LR
from them. An agent's URL is joined from a document and an author's
name, and a release from before format 3 names its agents by those
URLs: each is kept as a path of its pieces (`TextMap`, the same
- file). The guest's pod on the device stores a document as
- N-Triples lines, each joined from three terms, so it keys a line by
- the path of its pieces, each no longer than the bound (`TripleLines`
- in the `solid` package). An answer names its card and the wrong
- option chosen in the deck's cards document as it is now
- (`deckAnswers` in `cardHistory.ts`): an IRI joined from that document
- and a fragment. The answer statistics so count cards and wrong
+ file). The Studio's graph names a blank node by a number for its label
+ in its document (`instanceGraphOf` in `instanceGraph.ts`), never by
+ the label joined to the document's address. The guest's pod on the
+ device stores a document as N-Triples lines, each joined from three
+ terms, so it keys a line by the path of its pieces, each no longer
+ than the bound (`TripleLines` in the `solid` package). An answer names
+ its card and the wrong option chosen in the deck's cards document as
+ it is now (`deckAnswers` in `cardHistory.ts`): an IRI joined from that
+ document and a fragment. The answer statistics so count cards and wrong
options by their fragment ids alone. Merging a guest's answers into a
deck (`mergedAnswer` in `guest.ts`) joins IRIs the same way, but only
to write them, and a write refuses a term past the bound. A release
diff --git a/docs/studio.md b/docs/studio.md
index fcde4fcbc..077f365cd 100644
--- a/docs/studio.md
+++ b/docs/studio.md
@@ -496,7 +496,7 @@ others.
| `#/studio/diff?draft=…` | a draft's release diff ([below](#the-release-diff)). |
| `#/studio/release?draft=…[&field=…]` | the release's metadata and provenance ([below](#the-releases-metadata-and-provenance)), then its publishing ([below](#publishing-a-release)). |
| `#/studio/releases?instance=…` | the releases the instance published ([below](#releases)). |
-| `#/studio/graph?instance=…[&node=…]` | the graph ([below](#graph)). `node` is the node selected, by its IRI. |
+| `#/studio/graph?instance=…[&node=…]` | the graph ([below](#graph)). `node` is the node selected, by its IRI, a blank node by the number the graph gives it. |
`#/studio/` and its query count as `#/studio`. Anything else under
`#/studio`, `#/studio` among them, is the default route: the only
@@ -2516,7 +2516,8 @@ small square, and the table of edges names each.
- **Find a node**, by its name or its address, among the nodes the
filters show, beyond the neighbors too: ten at most, Enter selecting
- the first, the focus staying in the search.
+ the first, the focus staying in the search. A blank node is found by
+ its label or its document's address.
- **Cards**, **Wrong options**, **Review states**, **Answers** and
**Leaves** ("in your Pod", "outside your Pod", and found in no
document), each shown or hidden, all shown at first.
@@ -2570,7 +2571,8 @@ A leaf hangs by the first node that links to it. A choice that would
make a cycle falls back, at the first node of the cycle (by address)
that can, to what holds it, else to the first node that links to it.
If none can, that first node goes under the middle. Where the cycle is
-broken doesn't depend on nodes unrelated to it. No node hangs more than
+broken doesn't depend on nodes unrelated to it: blank nodes are in
+order by their document, then their label. No node hangs more than
16 steps from the middle: one that a chain of links (each part of the
next, say) would hang deeper hangs beside its parent instead. So no
edge's path is longer for a long chain, and laying it out takes no
@@ -2660,7 +2662,8 @@ line, and brought into sight.
**The aside** (Node) shows the node selected:
-- its address, its kind, the document that defines it, and its classes;
+- its address (a blank node's label), its kind, the document that
+ defines it, and its classes;
- the values the documents state of it, each with its language tag as
BCP 47 writes it (`@en`, `@sv` or `@en-GB`, though the data is read
in lower case) or its datatype (`xsd:date`). The document a value is
diff --git a/packages/domain/src/instanceGraph.test.ts b/packages/domain/src/instanceGraph.test.ts
index a9fab5f0c..413e10eb7 100644
--- a/packages/domain/src/instanceGraph.test.ts
+++ b/packages/domain/src/instanceGraph.test.ts
@@ -11,6 +11,7 @@ import {
type GraphNode,
type InstanceGraph,
} from "./instanceGraph";
+import { HASHED_CHARACTERS } from "./pathKeys";
import type { DraftTriple } from "./release/releaseDraft";
import type { ReleaseTerm } from "./release/releaseModel";
import { growth } from "./testing/growth";
@@ -50,6 +51,10 @@ function graph(documents: GraphDocument[], storages = [STORAGE]): InstanceGraph
function node(of: InstanceGraph, id: string): GraphNode | undefined {
return of.nodes.find((each) => each.id === id);
}
+/** The blank node `term` of `document`. */
+function blank(of: InstanceGraph, term: string, document: string): GraphNode | undefined {
+ return of.nodes.find((each) => each.term === term && each.document === document);
+}
function kinds(of: InstanceGraph): Record {
return Object.fromEntries(of.nodes.map((each) => [each.id, each.kind]));
}
@@ -160,18 +165,52 @@ describe("instanceGraphOf", () => {
triple(`${REVIEWS}#s`, "http://example.org/x", { kind: "blank", value: "e" }),
]),
]);
- expect(node(of, `_:b0@${CATALOG}`)).toMatchObject({
+ const inner = blank(of, "_:b0", CATALOG);
+ expect(inner).toMatchObject({
kind: "resource",
- document: CATALOG,
blank: true,
literals: [{ value: "Inner" }],
});
- expect(node(of, `_:b0@${CARDS}`)).toMatchObject({
- document: CARDS,
+ expect(blank(of, "_:b0", CARDS)).toMatchObject({
+ kind: "resource",
+ blank: true,
literals: [{ value: "Other" }],
});
+ expect(blank(of, "_:b0", CARDS)?.id).not.toBe(inner?.id);
+ expect(of.edges).toContainEqual({
+ from: `${CATALOG}#catalog`,
+ to: inner?.id,
+ predicate: "http://example.org/part",
+ kind: "link",
+ });
// Named only as an object, it is still its document's.
- expect(node(of, `_:e@${REVIEWS}`)).toMatchObject({ kind: "resource", document: REVIEWS });
+ expect(blank(of, "_:e", REVIEWS)).toMatchObject({ kind: "resource", blank: true });
+ });
+
+ it("gives blank nodes short ids, however long their labels and addresses", () => {
+ // V8 hashes a string past HASHED_CHARACTERS by its length alone: a
+ // blank node's label joined to its document's address would key the
+ // graph's maps, and the Studio's, in time growing with the square of
+ // their number. Labels as long as a term may be, in documents whose
+ // addresses are as long as each other.
+ const label = (n: number) =>
+ `b0_${"a".repeat(HASHED_CHARACTERS - 13)}${String(n).padStart(8, "0")}`;
+ const documents = [0, 1].map((d) =>
+ found(
+ `${INSTANCE}decks/cards-${d}.ttl`,
+ Array.from({ length: 50 }, (_, n) => triple(`_:${label(n)}`, TITLE, text(`${d} ${n}`))),
+ ),
+ );
+ expect(`_:${label(0)}`).toHaveLength(HASHED_CHARACTERS);
+ const of = graph(documents);
+ const blanks = of.nodes.filter((each) => each.blank);
+ expect(blanks).toHaveLength(100);
+ expect(new Set(blanks.map((each) => each.id)).size).toBe(100);
+ for (const each of of.nodes) expect(each.id.length).toBeLessThanOrEqual(HASHED_CHARACTERS);
+ for (const each of blanks) expect(each.id.length).toBeLessThan(16);
+ expect(blank(of, `_:${label(7)}`, `${INSTANCE}decks/cards-1.ttl`)?.literals).toEqual([
+ expect.objectContaining({ value: "1 7" }),
+ ]);
});
it("makes what is said elsewhere of a leaf the leaf's, said by that document", () => {
diff --git a/packages/domain/src/instanceGraph.ts b/packages/domain/src/instanceGraph.ts
index ff5273a6f..2a3dbf190 100644
--- a/packages/domain/src/instanceGraph.ts
+++ b/packages/domain/src/instanceGraph.ts
@@ -78,7 +78,10 @@ export interface GraphLiteral {
}
export interface GraphNode {
- /** The IRI; for a blank node, `_: