Repository navigation
Expand file tree
/
Copy pathdeckFile.ts
More file actions
194 lines (177 loc) · 7.37 KB
/
Copy pathdeckFile.ts
File metadata and controls
194 lines (177 loc) · 7.37 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
// SPDX-FileCopyrightText: 2026 Anton Wiklund and Solid Memo contributors
//
// SPDX-License-Identifier: MIT
import {
boundedCompletedChapters,
CARD_FORMAT_VERSION,
DECK_FORMAT_VERSION,
type Card,
type Deck,
} from "./deck";
import { catalogUrlOf, documentsInUse, ensureTrailingSlash } from "./instanceLayout";
import { shown } from "./langText";
import type { FsrsState, ReviewState } from "./review";
/**
* A deck as a file (docs/studio.md#import-and-export): the deck's entry,
* its cards with their wrong options and, when asked, its review states,
* in Turtle or JSON-LD. An export holds the deck's triples as the pod
* has them, at the pod's IRIs. An import reads the file with this app's
* mappers, older formats brought up to date, and writes a new deck of
* the instance from it.
*/
export type DeckFileFormat = "turtle" | "jsonld";
export const DECK_FILE_FORMATS: readonly DeckFileFormat[] = ["turtle", "jsonld"];
/** Each format's media type, and the extension of the files it is written to. */
export const DECK_FILE_TYPES: Readonly<
Record<DeckFileFormat, { mediaType: string; extension: string }>
> = {
turtle: { mediaType: "text/turtle", extension: ".ttl" },
jsonld: { mediaType: "application/ld+json", extension: ".jsonld" },
};
/** What the file picker offers: both formats, by extension and media type. */
export const DECK_FILE_ACCEPT = ".ttl,.jsonld,.json,text/turtle,application/ld+json";
/**
* Where a file is taken to be, for the relative IRIs it holds: a file has
* no address of its own. `.invalid` is never anyone's host.
*/
export const DECK_FILE_BASE = "https://file.solid-memo.invalid/";
/** How a deck is exported: its format, and whether its review states (and a course's progress) go with it. */
export interface DeckFileOptions {
format: DeckFileFormat;
withProgress: boolean;
}
/** A subject read from a file in an older format, brought up to this app's. */
export interface FormatUpgrade {
kind: "deck" | "card" | "reviewState";
subject: string;
from: number;
to: number;
}
/** What a deck file holds, read with this app's mappers. */
export interface DeckFileContent {
/** The deck, as the file states it: its URLs the file's own. */
deck: Deck;
/** Its cards: those of its cards document that the file holds. */
cards: Card[];
/** The review states of its reviews document, of the cards it holds; absent when the file holds none. */
reviews?: ReviewState[];
/** The FSRS-6 states of its reviews document, of the cards it holds; absent when the file holds none. */
fsrsReviews?: FsrsState[];
/** The subjects in an older format, brought up to date as they were read. */
upgraded: FormatUpgrade[];
/** The subjects of a deck's kinds this app could not read, or that belong to none of the deck's documents: left out. */
dropped: string[];
}
/** A deck file the user opened: its name, its format and what it holds. */
export interface DeckFile {
name: string;
format: DeckFileFormat;
content: DeckFileContent;
}
/** The format of a file: by its extension, else JSON-LD when it starts as JSON does, else Turtle. */
export function deckFileFormatOf(name: string, text: string): DeckFileFormat {
const lower = name.toLowerCase();
if (lower.endsWith(".jsonld") || lower.endsWith(".json")) return "jsonld";
if (lower.endsWith(".ttl")) return "turtle";
return /^\s*[[{]/.test(text) ? "jsonld" : "turtle";
}
/** The base a file's relative IRIs are resolved against: DECK_FILE_BASE, then its name. */
export function deckFileBaseOf(name: string): string {
return `${DECK_FILE_BASE}${encodeURIComponent(name)}`;
}
/**
* The name a deck is saved under: its title (in English, else its first
* language) as lower-case letters, digits and dashes, accents dropped,
* then the format's extension; "deck" when nothing of the title is left.
*/
export function deckFileName(deck: Deck, format: DeckFileFormat): string {
const slug = shown(deck.title)
.normalize("NFKD")
.replace(/[̀-ͯ]/g, "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.slice(0, 60)
.replace(/^-+|-+$/g, "");
return `${slug === "" ? "deck" : slug}${DECK_FILE_TYPES[format].extension}`;
}
/**
* A deck id this app gives: `deck-`, then letters, digits, `-` and `_`.
* No other subject of the catalog document has one: not `#catalog`, a
* group (`#group-…`) or an agent (`#agent-…`).
*/
const DECK_ID = /^deck-[A-Za-z0-9_-]+$/;
/**
* The deck a file's deck becomes in the instance, with its cards and,
* with `withProgress`, its review states (SM-2's and FSRS-6's) and a
* course's completed chapters (none without; at most
* MAX_COMPLETED_CHAPTERS of them, those of the deck's release and its
* cards' kept first, boundedCompletedChapters). It keeps
* the file's id, so a deck exported and removed comes back at the same
* URLs, and the answers the log keeps of it name it again; unless the instance has a deck by that id,
* or one using a document of that name, or the id or its distribution's
* (`<id>-cards`, distributionUrlOf) is a deck's or a distribution's
* subject already, or the id is none this app would give: then it gets
* `freshId`. Its documents are the instance's
* own (`decks/<id>.ttl`, `reviews/<id>.ttl`). Cards keep their ids,
* their creation times and whether they are retired. A deck the file
* says nothing of the creation of is created `now`.
*/
export function importedDeck(
instanceUrl: string,
content: DeckFileContent,
{
withProgress,
decks,
freshId,
now,
}: { withProgress: boolean; decks: readonly Deck[]; freshId: string; now: string },
): { deck: Deck; cards: Card[]; reviews: ReviewState[]; fsrsReviews: FsrsState[] } {
const base = ensureTrailingSlash(instanceUrl);
const documentsOf = (id: string) => [`${base}decks/${id}.ttl`, `${base}reviews/${id}.ttl`];
const used = documentsInUse(decks);
// The catalog's subjects of the decks: each deck's own, and its distribution's.
const subjects = new Set(decks.flatMap((deck) => [deck.id, `${deck.id}-cards`]));
const file = content.deck;
const free =
DECK_ID.test(file.id) &&
!subjects.has(file.id) &&
!subjects.has(`${file.id}-cards`) &&
!documentsOf(file.id).some((url) => used.has(url));
const id = free ? file.id : freshId;
const [cardsDocumentUrl, reviewsDocumentUrl] = documentsOf(id) as [string, string];
const { completedChapters, ...rest } = file;
const deck: Deck = {
...rest,
id,
url: `${catalogUrlOf(base)}#${id}`,
cardsDocumentUrl,
reviewsDocumentUrl,
createdAt: file.createdAt === "" ? now : file.createdAt,
formatVersion: DECK_FORMAT_VERSION,
...(withProgress && completedChapters !== undefined
? {
completedChapters: boundedCompletedChapters(completedChapters, [
file.sourceUrl,
...content.cards.map((card) => card.fromRelease),
]),
}
: {}),
};
const cards = content.cards.map(
(card): Card => ({
...card,
url: `${cardsDocumentUrl}#${card.id}`,
formatVersion: CARD_FORMAT_VERSION,
}),
);
return {
deck,
cards,
reviews: withProgress ? (content.reviews ?? []) : [],
fsrsReviews: withProgress ? (content.fsrsReviews ?? []) : [],
};
}
/** Whether the file holds progress the import can keep: review states, or a course's completed chapters. */
export function hasProgress(content: DeckFileContent): boolean {
return (content.reviews?.length ?? 0) > 0 || (content.deck.completedChapters?.length ?? 0) > 0;
}