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 apps/studio/src/ui/StudioWorkspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -665,6 +665,7 @@ export function StudioWorkspace({ useCases, session, banner, children }: Workspa
key={route.instanceUrl}
useCases={useCases}
instance={activeInstance!}
webId={session.webId}
chosen={route.deckUrls ?? []}
// Like Home's view, which decks are checked is no Back stop.
onChoose={(urls) =>
Expand Down
27 changes: 19 additions & 8 deletions apps/studio/src/ui/TransferContainer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ const file: DeckFile = {
content: { deck: capitals, cards: [makeCard(capitals, "a")], upgraded: [], dropped: [] },
};

const WEB_ID = "https://alice.example/profile/card#me";

function renderContainer(useCases: UseCases, chosen: readonly string[] = []) {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const invalidate = vi.spyOn(queryClient, "invalidateQueries");
Expand All @@ -30,6 +32,7 @@ function renderContainer(useCases: UseCases, chosen: readonly string[] = []) {
<TransferContainer
useCases={useCases}
instance={instanceA}
webId={WEB_ID}
chosen={chosen}
onChoose={onChoose}
cardsHref={(deck) => `#/cards?deck=${deck.id}`}
Expand All @@ -55,14 +58,22 @@ describe("TransferContainer", () => {
expect(await screen.findByText("Exporting Kanji N5 (1 of 2)…")).toBeInTheDocument();
finish();
expect(await screen.findByText(/^Handed 2 decks to your browser/)).toBeInTheDocument();
expect(useCases.exportDeckFile).toHaveBeenNthCalledWith(1, kanji, {
format: "turtle",
withProgress: false,
});
expect(useCases.exportDeckFile).toHaveBeenNthCalledWith(2, verbs, {
format: "turtle",
withProgress: false,
});
// Each read as the user only in the instance's pod: the use case is told which instance, and who.
const options = { format: "turtle", withProgress: false };
expect(useCases.exportDeckFile).toHaveBeenNthCalledWith(
1,
instanceA.url,
kanji,
options,
WEB_ID,
);
expect(useCases.exportDeckFile).toHaveBeenNthCalledWith(
2,
instanceA.url,
verbs,
options,
WEB_ID,
);
});

it("says why an export failed", async () => {
Expand Down
8 changes: 6 additions & 2 deletions apps/studio/src/ui/TransferContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ import { queryKeys } from "./queryKeys";
/**
* The import and export screen's data: the instance's decks as the user
* arranged them (Home's query). An export saves the decks one after
* another (UseCases.exportDeckFile), saying which it is at. Opening a
* another (UseCases.exportDeckFile, which reads a document outside the
* instance's pod as no one), saying which it is at. Opening a
* file reads it (openDeckFile); importing it (importDeckFile) writes the
* catalog, so it waits its turn with the other writes of the catalog
* (catalogScope), and every query of the instance's decks is read afresh
Expand All @@ -30,13 +31,16 @@ import { queryKeys } from "./queryKeys";
export function TransferContainer({
useCases,
instance,
webId,
chosen,
onChoose,
cardsHref,
healthHref,
}: {
useCases: UseCases;
instance: Instance;
/** The signed-in user: a deck's documents are exported as theirs only in the instance's pod. */
webId: string;
/** The URLs of the decks checked to export (the route's). */
chosen: readonly string[];
onChoose: (urls: readonly string[]) => void;
Expand Down Expand Up @@ -65,7 +69,7 @@ export function TransferContainer({
try {
for (const [index, deck] of decks.entries()) {
setExporting({ deck, index, total: decks.length });
await useCases.exportDeckFile(deck, options);
await useCases.exportDeckFile(instance.url, deck, options, webId);
}
} finally {
setExporting(null);
Expand Down
14 changes: 11 additions & 3 deletions docs/data-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -1633,9 +1633,17 @@ An export holds triples as the pod has them, at the pod's IRIs:
names as its creators, its card language (`sm:deckCardLanguage`) and
picture hosts (`sm:deckPictureHost`) among them, but not its `sm:position`, nor its `sm:completedChapter`s
unless the progress goes too;
- every subject of its cards document: its cards and their wrong
options, retired ones too, and whatever another app put there;
- with the progress, every subject of its reviews document.
- its cards (each `sm:Card` of its cards document) and the wrong
options they name, retired ones too, with whatever another app said
of them;
- with the progress, the review states of those cards in its reviews
document.

Nothing else of either document goes: a deck may name a document that
holds more than the deck. Each of the two documents is read as the user
only when it's the instance's (`isInInstancePod`), as a move or copy of
cards reads its source. An instance attached from someone else's pod
may name a document of the user's own. Any other is read as no one.

Nothing is rewritten: a deck still at an older format is exported at it.
A JSON-LD file is flattened: every subject is a node of its `@graph`,
Expand Down
4 changes: 3 additions & 1 deletion docs/studio.md
Original file line number Diff line number Diff line change
Expand Up @@ -1459,7 +1459,9 @@ goes too: the cards' review states and a course's completed chapters.
(`capitals.ttl`), one after another, the screen naming the deck it's
at. Export writes nothing to the pod. The use case `exportDeckFile`
reads the deck's documents (`DeckArchive.exportDeck`) and hands the
text to the browser to save (`FileExchange.save`).
text to the browser to save (`FileExchange.save`). It reads a document
as the user only when it's in the instance's pod, any other as no one,
and the file holds only the deck's own cards and review states.

Browsers ask before a page's second download, and save none of the
rest on a no. The app can't tell, so after several decks the screen
Expand Down
12 changes: 6 additions & 6 deletions e2e/pod/src/deckFiles.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ function page(fileExchange: FileExchange = files()) {
digestRepository: createSolidDigestRepository({ fetch: podFetch, checkWrite }),
answerLog: createSolidAnswerLog({ fetch: podFetch, checkWrite }),
ruleset: "e2e-rules",
deckArchive: createSolidDeckArchive({ fetch: podFetch }),
deckArchive: createSolidDeckArchive({ fetch: podFetch, publicFetch: podFetch }),
fileExchange,
});
}
Expand Down Expand Up @@ -152,7 +152,7 @@ async function seed(server: string) {
new Date(),
);
}
return { main, other, deck, useCases };
return { main, other, deck, useCases, webId };
}

/** A document as N-Triples: every IRI written out in full, whatever the server's Turtle abbreviates. */
Expand All @@ -174,10 +174,10 @@ const statesOf = async (deck: Deck) =>
describe.each(SERVERS)("decks as files on $name", ({ url: server }) => {
for (const format of DECK_FILE_FORMATS) {
it(`takes a deck through ${format} into another instance, its progress with it`, async () => {
const { other, deck } = await seed(server);
const { main, other, deck, webId } = await seed(server);
const exchange = files();
const useCases = page(exchange);
await useCases.exportDeckFile(deck, { format, withProgress: true });
await useCases.exportDeckFile(main.url, deck, { format, withProgress: true }, webId);
expect(exchange.saved).toHaveLength(1);
const file = (await useCases.openDeckFile())!;
expect(file).toMatchObject({
Expand Down Expand Up @@ -214,10 +214,10 @@ describe.each(SERVERS)("decks as files on $name", ({ url: server }) => {
}

it("imports a deck without its progress, and one exported and removed back at its URLs", async () => {
const { main, other, deck } = await seed(server);
const { main, other, deck, webId } = await seed(server);
const exchange = files();
const useCases = page(exchange);
await useCases.exportDeckFile(deck, { format: "turtle", withProgress: false });
await useCases.exportDeckFile(main.url, deck, { format: "turtle", withProgress: false }, webId);
const file = (await useCases.openDeckFile())!;
expect(file.content.reviews).toBeUndefined();
const fresh = await useCases.importDeckFile(other.url, file, { withProgress: false });
Expand Down
46 changes: 41 additions & 5 deletions packages/application/src/cards.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1115,18 +1115,54 @@ describe("cards", () => {

it("saves a deck as a file named after it", async () => {
const { deckArchive, fileExchange, useCases } = withFiles();
await useCases.exportDeckFile(deck, { format: "jsonld", withProgress: true });
expect(deckArchive.exportDeck).toHaveBeenCalledWith(deck, {
format: "jsonld",
withProgress: true,
});
await useCases.exportDeckFile(
instance.url,
deck,
{ format: "jsonld", withProgress: true },
session.webId,
);
// The instance's own documents: read as the user.
expect(deckArchive.exportDeck).toHaveBeenCalledWith(
deck,
{ format: "jsonld", withProgress: true },
{ asNoOne: { cards: false, reviews: false } },
);
expect(fileExchange.save).toHaveBeenCalledWith(
"kanji-n5.jsonld",
"application/ld+json",
"<#a> <#b> <#c> .",
);
});

it("exports a deck's documents as the user only in the instance's pod, any other as no one (GHSA-xcjw-3f3x-g9fc)", async () => {
const { deps, deckArchive, useCases } = withFiles();
vi.mocked(deps.storageGateway.discoverStorages).mockResolvedValue([
{ url: "https://alice.example/", source: "profile" },
{ url: "https://other.example/alice/", source: "profile" },
]);
const asNoOne = async (documents: Partial<Deck>) => {
deckArchive.exportDeck.mockClear();
await useCases.exportDeckFile(
instance.url,
{ ...deck, ...documents },
{ format: "turtle", withProgress: true },
session.webId,
);
return (deckArchive.exportDeck.mock.calls[0] as unknown[])[2];
};
await expect(
asNoOne({ cardsDocumentUrl: "https://alice.example/elsewhere/deck-1.ttl" }),
).resolves.toEqual({ asNoOne: { cards: false, reviews: false } });
expect(deps.storageGateway.discoverStorages).toHaveBeenCalledWith(session.webId);
// The user's own, but in a storage the instance is not in; anyone else's.
await expect(
asNoOne({ cardsDocumentUrl: "https://other.example/alice/private/notes.ttl" }),
).resolves.toEqual({ asNoOne: { cards: true, reviews: false } });
await expect(
asNoOne({ reviewsDocumentUrl: "https://elsewhere.example/reviews.ttl" }),
).resolves.toEqual({ asNoOne: { cards: false, reviews: true } });
});

it("reads the file the user picks, in the format its name says, at a base of its own", async () => {
const { deckArchive, fileExchange, useCases } = withFiles({
name: "kanji.jsonld",
Expand Down
28 changes: 22 additions & 6 deletions packages/application/src/cards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,11 +173,20 @@ export interface CardUseCases {
webId: string,
): Promise<CardTransferPlan>;
/**
* Save the deck as a file (domain/deckFile.ts, DeckArchive.exportDeck),
* in `options.format`, named after its title (deckFileName), with its
* progress when `options.withProgress`. Writes nothing to the pod.
* Save the deck of the instance as a file (domain/deckFile.ts,
* DeckArchive.exportDeck), in `options.format`, named after its title
* (deckFileName), with its progress when `options.withProgress`. Each
* of the deck's two documents is read as `webId` only when it is the
* instance's (isInInstancePod), any other as no one, as transferCards
* reads a source: the file holds what it reads. Writes nothing to the
* pod.
*/
exportDeckFile(deck: Deck, options: DeckFileOptions): Promise<void>;
exportDeckFile(
instanceUrl: string,
deck: Deck,
options: DeckFileOptions,
webId: string,
): Promise<void>;
/**
* The deck file the user picks (Turtle or JSON-LD, by its name, else
* its text: deckFileFormatOf), read as DeckArchive.readDeckFile reads
Expand Down Expand Up @@ -504,8 +513,15 @@ export function createCardUseCases({
}
}
},
async exportDeckFile(deck, options) {
const text = await deckArchive.exportDeck(deck, options);
async exportDeckFile(instanceUrl, deck, options, webId) {
// Saved to a file the user may hand on: each document read as the user only when it is the instance's.
const [cardsHere, reviewsHere] = await Promise.all([
isInInstancePod(storageGateway, instanceUrl, deck.cardsDocumentUrl, webId),
isInInstancePod(storageGateway, instanceUrl, deck.reviewsDocumentUrl, webId),
]);
const text = await deckArchive.exportDeck(deck, options, {
asNoOne: { cards: !cardsHere, reviews: !reviewsHere },
});
fileExchange.save(
deckFileName(deck, options.format),
DECK_FILE_TYPES[options.format].mediaType,
Expand Down
21 changes: 14 additions & 7 deletions packages/application/src/ports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -558,13 +558,20 @@ export type LinkedReleaseReads = Pick<
*/
export interface DeckArchive {
/**
* The deck's entry, with its agents and distribution, and every subject
* of its cards document, as the pod has them, at their IRIs; with
* `withProgress`, every subject of its reviews document and a course's
* completed chapters too. Throws deckGone when the catalog has no entry
* of it.
*/
exportDeck(deck: Deck, options: DeckFileOptions): Promise<string>;
* The deck's entry, with its agents and distribution, and its cards
* with the distractors they name, as the pod has them, at their IRIs;
* with `withProgress`, the review states of those cards and a course's
* completed chapters too. Nothing else of its cards and reviews
* documents. Each of the two documents `asNoOne` names is read as no
* one (as listCards reads one), any other as the user: the caller
* says which, so none is read as the user by default. Throws deckGone
* when the catalog has no entry of it.
*/
exportDeck(
deck: Deck,
options: DeckFileOptions,
reads: { asNoOne: { cards: boolean; reviews: boolean } },
): Promise<string>;
/**
* What a file holds: its one deck, read as the pod's are (an older
* format brought up to date, in memory), the cards of its cards
Expand Down
6 changes: 5 additions & 1 deletion packages/composition/src/appUseCases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -379,7 +379,11 @@ export function createAppUseCases({
ruleset,
// Its profile is written through the fences too, as every pod write is.
guestPod: createLocalGuestPod({ fetch: podFetch, store: guestStore }),
deckArchive: createSolidDeckArchive({ fetch: dataFetch }),
deckArchive: createSolidDeckArchive({
fetch: dataFetch,
// A deck's documents outside its instance's pod are exported as no one reads them.
publicFetch: createPodFence(foreignReads(noOneFetch)),
}),
fileExchange: createBrowserFileExchange(),
releaseDraftRepository: createSolidReleaseDraftRepository({
fetch: dataFetch,
Expand Down
8 changes: 6 additions & 2 deletions packages/solid/src/deckPlace.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1163,8 +1163,12 @@ describe("an instance whose address is spelled otherwise than a request sends it
const states = await p.reviews.listReviewStates(listed!);
expect(states.sm2.map((one) => one.repetitions)).toEqual([2]);

const archive = createSolidDeckArchive({ fetch: p.fetch });
const file = await archive.exportDeck(listed!, { format: "turtle", withProgress: true });
const archive = createSolidDeckArchive({ fetch: p.fetch, publicFetch: p.fetch });
const file = await archive.exportDeck(
listed!,
{ format: "turtle", withProgress: true },
{ asNoOne: { cards: false, reviews: false } },
);
const read = await archive.readDeckFile(file, "turtle", "https://example.org/deck");
expect(read.cards).toHaveLength(2);
expect(read.reviews).toHaveLength(1);
Expand Down
9 changes: 4 additions & 5 deletions packages/solid/src/malformedDue.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,11 +127,10 @@ describe("a due day that is no date, written back", () => {
sm:cardsDocument <decks/d.ttl> ; sm:reviewsDocument <reviews/d.ttl> .
<decks/d.ttl#c1> a sm:Card ; sm:front "F" ; sm:back "B" ; sm:formatVersion 1 .
${stateOf("c1", "zzz", 2).replace("<#c1>", "<reviews/d.ttl#c1>").replace("../decks", "decks")}`;
const content = await createSolidDeckArchive({ fetch: globalThis.fetch }).readDeckFile(
file,
"turtle",
deckFileBaseOf("d.ttl"),
);
const content = await createSolidDeckArchive({
fetch: globalThis.fetch,
publicFetch: globalThis.fetch,
}).readDeckFile(file, "turtle", deckFileBaseOf("d.ttl"));
const imported = importedDeck(INSTANCE, content, {
withProgress: true,
decks: [],
Expand Down
9 changes: 9 additions & 0 deletions packages/solid/src/mappers/reviewStateMapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,15 @@ function fsrsKeyOfSubject(thing: Subject, deck: ReviewedDeck): ReviewKey | null
return fsrsKeyOfLinks(deck, linksOf(thing));
}

/**
* The id of the card a subject typed sm:ReviewState is of, SM-2 or
* FSRS-6, by its links alone (keyOfSubject, fsrsKeyOfSubject); null for
* any other subject, and for a state of a card elsewhere.
*/
export function reviewedCardIdOf(thing: Subject, deck: ReviewedDeck): string | null {
return (keyOfSubject(thing, deck) ?? fsrsKeyOfSubject(thing, deck))?.cardId ?? null;
}

const sameKey = (a: ReviewKey, b: ReviewKey) =>
a.cardId === b.cardId && a.direction === b.direction;

Expand Down
Loading
Loading