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
9 changes: 9 additions & 0 deletions apps/studio/src/ui/graph/GraphNodePane.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
3 changes: 2 additions & 1 deletion apps/studio/src/ui/graph/GraphNodePane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,8 @@ export function GraphNodePane({
{labels.get(node.id)}
</h3>
<p>
<code class="graph-iri">{node.id}</code>
{/* A blank node's id is a number the graph gives it: its term is what its document calls it. */}
<code class="graph-iri">{node.term ?? node.id}</code>
</p>
{note !== null && <p>{t(note)}</p>}
<dl>
Expand Down
13 changes: 13 additions & 0 deletions apps/studio/src/ui/graph/GraphScreen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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" } });
Expand Down
5 changes: 4 additions & 1 deletion apps/studio/src/ui/graph/GraphScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand Down
27 changes: 25 additions & 2 deletions apps/studio/src/ui/graph/graphModel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [],
};
Expand Down Expand Up @@ -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"),
Expand Down
19 changes: 12 additions & 7 deletions apps/studio/src/ui/graph/graphModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

/**
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<number>();
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) {
Expand Down
48 changes: 48 additions & 0 deletions apps/studio/src/ui/graph/layout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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));
Expand Down
27 changes: 21 additions & 6 deletions apps/studio/src/ui/graph/layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,12 +196,27 @@ function append<K, V>(map: Map<K, V[]>, 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<string, GraphNode>();
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),
),
};
}
Expand Down Expand Up @@ -907,8 +922,8 @@ export function createLayout(graph: InstanceGraph): Layout {
const identities = new WeakMap<InstanceGraph, string>();

/**
* 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.
Expand All @@ -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);
Expand Down
16 changes: 9 additions & 7 deletions docs/deck-library.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 7 additions & 4 deletions docs/studio.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
49 changes: 44 additions & 5 deletions packages/domain/src/instanceGraph.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<string, string> {
return Object.fromEntries(of.nodes.map((each) => [each.id, each.kind]));
}
Expand Down Expand Up @@ -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", () => {
Expand Down
Loading
Loading