Solid Memo keeps everything a learner makes and studies in their own Solid Pod, as linked data files in one folder, the instance. This page says which file holds what, how the app finds the files after a login, and how it writes them without losing a change made on another device.
The code is in packages/solid/src/: the vocabulary in
vocab.ts, discovery in
typeIndex.ts. The app reads and
writes the pod by the Solid
Protocol, and finds Solid Memo's
data through the type indexes,
an editor's draft
(standards.md).
Solid Memo mints its own terms under
https://solid-memo.com/ns/vocab/v1.ttl# (prefix sm: below); no
existing RDF vocabulary covers spaced repetition.
ns/vocab/v1.ttl defines them
(vocab.md), and a SHACL shape under
ns/shapes/ says what a valid subject of each class
looks like, version by version (shapes.md). The site
publishes both, and the app's constants, record types and descriptors
are generated from them. Readers ignore unknown triples, and writers
never delete triples they don't understand.
Every subject Solid Memo writes carries sm:formatVersion. A subject
with neither that stamp nor a Solid Memo class is another app's, unless
it's the catalog or a subject of Solid Memo's names it as its creator,
publisher or distribution. Another scheduler's review state is another
app's too. The data check only warns about them
(validation.md).
flowchart LR
accTitle: From pod data to the domain model
accDescr: A subject is read as a versioned shape record, migrated to the latest record and mapped to the domain model, and a model is written back the same way in reverse.
thing["Subject<br/>(client/rdf.ts)"] -->|readVersioned| record["shape record<br/>e.g. CardV1"]
record -->|migrate| latest["latest record<br/>CardV2"]
latest -->|cardFromRecord| model["domain model<br/>Card"]
model -->|cardToRecord| latest
latest -->|recordThing| thing
flowchart LR
accTitle: The discovery chain
accDescr: From the WebID, the app follows the storage and the private and public type indexes to the instance container, and to the registrations other apps read.
W[WebID] -->|pim:storage| S[Storage root]
W -->|solid:privateTypeIndex<br/>directly or via pim:preferencesFile| PTI[Private type index]
W -->|solid:publicTypeIndex| PUB[Public type index]
PTI -->|solid:TypeRegistration<br/>forClass sm:Instance| I1[Instance container]
PUB -->|solid:TypeRegistration| I2[Instance container]
PTI -.->|"forClass dcat:Catalog, sm:Deck,<br/>sm:Card, sm:ReviewState, sm:Answer,<br/>sm:Deck (the drafts)"| D1[The instance's data,<br/>for other apps]
PUB -.->|"forClass dcat:Catalog, sm:Deck,<br/>sm:Card"| D1
-
Storage:
pim:storagetriples in the (extended) profile, viagetPodUrlAll; else the Solid Protocol's Link-header walk-up from the WebID URL (rel="type"targetingpim:Storage, capital S); else a URL typed by hand. 0, 1 or N storages are handled. -
Instances: one
solid:TypeRegistrationper instance,solid:forClass sm:Instance, in the private type index by default, itsdcterms:titlenaming the instance (harmless extra triples): the only registration Solid Memo reads to find its instances. Reading acceptssolid:instanceContainerorsolid:instancenaming an http(s) address with no query, fragment or dot segment (..,%2e%2ein any case;isInstanceAddressin instanceLayout.ts). The address is taken as written: another spelling of a folder (its host's case, a name outside ASCII) is that folder (below). An instance created, attached by URL or moved to from a guest's pod is registered at its address as a request sends it (instanceAddressOf):HTTPS://Pod.example:443/kortlekar-åishttps://pod.example/kortlekar-%C3%A5/. Text that's no http(s) address isn't an instance to attach (notAnInstanceNoMeta). -
Each class of an instance's data has a registration of its own, titled with the instance's name, for other apps (examples below):
- the catalog:
dcat:Catalog,solid:instanceits subjectcatalog.ttl#catalog; - the decks:
sm:Deck,solid:instancecatalog.ttl; - the cards:
sm:Card,solid:instanceContainerdecks/; - the review states:
sm:ReviewState,reviews/; - the answers:
sm:Answer,history/; - the drafts of releases:
sm:Deckagain,solid:instanceContainerdrafts/(a draft's release document is a deck too, told from the instance's decks by where it is).
Per the Type Indexes spec,
solid:instancenames the one resource holding the class's subjects, andsolid:instanceContainera container whose documents hold them. The catalog's, decks' and cards' registrations go in each index that registers the instance. The review states', answers' and drafts' go in the private index only, even for an instance registered publicly, and without one nowhere. They're written:- when an instance is created;
- by a format update once it updated every document (a failure there leaves the update done);
- when a guest's study moves into the pod as a new instance (guest-mode.md);
- by Register what is missing under Findable by other apps in Preferences (not offered to a guest), which lists each registration that belongs in an index and whether it's there. A linked index that can't be read is left out of the list and said to be unreadable, and nothing is added to it.
They're never written on opening an instance, which would fight other editors of the index and add back what the user removed. Attaching an instance by URL writes only its
sm:Instanceregistration; adding a guest's study to an instance writes none. - the catalog:
-
Writing a type index saves it with
If-Matchthe version read; on a 412 it tries again, three attempts in all (changeIndexin typeIndex.ts). A registration is added only when no equal one is there: the same class, naming the same resource under either predicate, the container with or without its trailing slash. typeIndex.integration.test.ts reads every registration back from real servers. -
Type index links are read from the WebID subject in the WebID document and in its extended profile documents (
rdfs:seeAlso/foaf:isPrimaryTopicOf), pluspim:preferencesFilefor the private one. -
Missing indexes are normal on Community Solid Server pods. The app warns the user, who chooses to create the private index (a document under
<storage>settings/plus a profile link) or to register publicly. The index goes in a storage the WebID leads to (never one typed by hand): the instance's, else the first, never the server of an instance elsewhere (attached by URL, or in a storage typed by hand). Without such a storage the index isn't created (typeIndexNoStorage,ownStorageForintypeIndex.ts). The link goes in the WebID document when it's writable, else in the first extended profile that accepts it (on Inrupt PodSpaces the WebID document onid.inrupt.comis read-only, and<storage>profileis writable). An index document already at the target URL is adopted, not overwritten. If registration fails, instance creation fails loudly, with no local fallback, and the new container is cleaned up. For an instance registered publicly, the review states' and answers' registrations go to the private index afterward; a refusal there doesn't fail the creation, and leaves them for Register what is missing. -
Deleting an instance deletes what Solid Memo wrote in its container and nothing else (
deleteInstanceDatain instanceData.ts), then removes its registrations of every class from both type indexes (below). In order:- each deck's cards and reviews documents as the catalog names them, if they're the deck's: they say so, or they're in the instance's own folder (below);
- the answer log's months (
history/<YYYY-MM>.ttl); - the drafts the catalog links that are in the instance's
drafts/(Drafts and releases); preferences.ttl,palettes.ttl,digest.ttlandcatalog.ttl, in that order, so a failed delete can be retried and find the decks and drafts again. A catalog the pod serves but that can't be read is kept, with the decks' documents and the drafts; the rest goes;decks/,reviews/,history/anddrafts/, each only if then empty;meta.ttl, last of the documents, so a half-deleted instance still attaches by URL;- the container, only if then empty.
From each document only what's Solid Memo's of its kind is removed (Write discipline,
removeOwned):- a deck's cards, their distractors, its review states and the document's saying it's the deck's;
- a month's answers (
sm:Answer); - the preferences (
sm:Preferences); - the palettes (
sm:Palette,sm:ColorSet); - the digest's receipts, schedules and month summaries;
- the catalog's subjects (the catalog, its decks, groups, agents and distributions);
- the instance's
#it; - a draft's subjects and what they lead to.
A document holding another app's subjects too is kept with those alone, and the result names it (
keptDocuments), for an instance registered at the pod's root too. A document's ACL goes with it (the Solid Protocol has the server delete it). A document that's missing, or goes before the delete, counts as deleted.Anything else in the folder is another app's, and kept, as are the releases published from the instance (
releases/), which others may have added by their address, and every container on the folder's path. A notice (KeptFolderNotice) links the kept folder, says why it was kept (another app's files, or the releases,keptReleases) and how many documents were kept for another app's data. The delete's confirmation says both are kept. Data goes before registration, so a failure leaves the instance listed and the delete retryable.Every blocking server the end-to-end tests start (testing.md), and Pivot and Community Solid Server 8, keep another app's file and its folder, and delete a folder holding only Solid Memo's documents with its access rules (foreignData.integration.test.ts). Solid-Nextcloud lists a folder's ACL among its contents, so there such a folder is kept, its access rules in it. The advisory servers'
expected-failures.jsonsays what they fail, and why.
In the type index, the instance's own registration, by which Solid Memo finds it:
<#sm-inst-9f3c1a> a solid:TypeRegistration ;
solid:forClass sm:Instance ;
solid:instanceContainer <https://pod.example/solid-memo/main/> ;
dcterms:title "Japanese study" .Beside it, one registration for each class of its data: its catalog as a DCAT catalog, its decks, and the containers of its cards, review states and answers.
<#sm-cat-4b7d2e> a solid:TypeRegistration ;
solid:forClass dcat:Catalog ;
solid:instance <https://pod.example/solid-memo/main/catalog.ttl#catalog> ;
dcterms:title "Japanese study" .
<#sm-deck-1c8e40> a solid:TypeRegistration ;
solid:forClass sm:Deck ;
solid:instance <https://pod.example/solid-memo/main/catalog.ttl> ;
dcterms:title "Japanese study" .
<#sm-card-7a21d9> a solid:TypeRegistration ;
solid:forClass sm:Card ;
solid:instanceContainer <https://pod.example/solid-memo/main/decks/> ;
dcterms:title "Japanese study" .and, in the private type index only:
<#sm-review-52f0b3> a solid:TypeRegistration ;
solid:forClass sm:ReviewState ;
solid:instanceContainer <https://pod.example/solid-memo/main/reviews/> ;
dcterms:title "Japanese study" .
<#sm-answer-e9d417> a solid:TypeRegistration ;
solid:forClass sm:Answer ;
solid:instanceContainer <https://pod.example/solid-memo/main/history/> ;
dcterms:title "Japanese study" .
<#sm-draft-3d90c6> a solid:TypeRegistration ;
solid:forClass sm:Deck ;
solid:instanceContainer <https://pod.example/solid-memo/main/drafts/> ;
dcterms:title "Japanese study" .meta.ttl makes a container self-describing: attach-by-URL reads it to
recover an instance that lost its registration. Deleting an instance
removes the sm:Instance registration by its container, and each other
by the exact IRI it names (<container>catalog.ttl#catalog,
<container>catalog.ttl, <container>decks/, <container>reviews/,
<container>history/, <container>drafts/). None is removed by what
lies under the container, where another app may have registered its own
data. A registration that registers something else too keeps it, and
loses only the link to the instance's data.
<storage>solid-memo/<name>/ (default path; user-editable)
├── meta.ttl #it: a sm:Instance; dcterms:title; dcterms:created;
│ on an instance an earlier version's format
│ update made, dcterms:replaces and
│ dcterms:modified, ignored and kept;
│ sm:formatVersion 2
├── preferences.ttl #it: a sm:Preferences (created with the instance,
│ FSRS-6 its scheduler; an instance made before
│ has none until its first save, and reads as
│ SM-2): study caps, sm:answerScale,
│ sm:developerMode, sm:invalidDataPolicy, sm:theme,
│ sm:studyScheduler (sm:sm2 or sm:fsrs6),
│ sm:desiredRetention,
│ sm:schedulerInvitationDismissed, sm:palette
│ (a concept of sm:Palettes, or another IRI,
│ kept), sm:textSize, sm:formatVersion 6;
│ whether logging in skips the Connected
│ screen (sm:connectedScreenSkipped true,
│ outside the shape)
├── palettes.ttl the palettes the user designed, if any: one
│ sm:Palette per palette (#palette-<id>),
│ dcterms:title, sm:basedOn, sm:lightColors and
│ sm:darkColors, its sm:ColorSet subjects beside
│ it (#palette-<id>-light, #palette-<id>-dark),
│ a #rrggbb color for each role it sets;
│ sm:formatVersion 1 (docs/theme.md)
├── catalog.ttl one subject per deck (titles live ONLY here), a
│ sm:Deck and dcat:Dataset: sm:formatVersion 6,
│ dcterms:description, sm:studyDirection, optional
│ dcterms:creator/license, dcat:theme,
│ dcat:keyword (language-tagged),
│ prov:wasDerivedFrom, the deck's own study caps
│ (sm:deckNewCardsPerDay/MaxReviewsPerDay), and
│ for a course the chapters completed
│ (sm:completedChapter); the language its
│ card text (and a course's text) is shown
│ in first (sm:deckCardLanguage, outside the
│ shape);
│ beside each deck its
│ dcat:Distribution (#deck-X-cards) and the
│ foaf:Agent nodes of its creators (#agent-…);
│ and the deck groups (#group-…), a sm:DeckGroup
│ and dcat:Catalog each: sm:formatVersion 1;
│ on the catalog, its drafts (sm:releaseDraft)
├── decks/<deckId>.ttl card corpus: one sm:Card per fragment (slow churn),
│ sm:front/back text and/or sm:frontImage/backImage
│ IRIs, optional sm:frontImageDescription/
│ backImageDescription (a picture's alt text),
│ sm:frontNote/backNote under each
│ side and sm:frontLabel/backLabel above it,
│ dcterms:subject (what it is about), each
│ with sm:formatVersion; a retired card
│ (owl:deprecated true) is kept but not studied;
│ a card names its wrong options
│ (sm:distractor, and schema:suggestedAnswer),
│ sm:Distractor subjects beside it; a retired
│ one (owl:deprecated true) is kept but never
│ offered; the document itself (<>)
│ dcterms:isPartOf the deck's entry
├── reviews/<deckId>.ttl SM-2 state: one sm:ReviewState per card and
│ direction (fast churn) — #<cardId> front→back,
│ #<cardId>@back-to-front the other way — naming
│ its card (sm:reviewOf), its direction
│ (sm:reviewDirection) and sm:scheduler sm:sm2;
│ optional sm:previous* snapshot = state before
│ the day's first review (restored by "reset the
│ day"); sm:formatVersion 2. Beside it, once FSRS-6
│ has scheduled the prompt, its FSRS-6 state:
│ sm:scheduler sm:fsrs6, #<cardId>@fsrs6 or
│ #<cardId>@back-to-front@fsrs6, stability,
│ difficulty, due day, its own snapshot;
│ sm:formatVersion 1; the document itself (<>)
│ dcterms:isPartOf the deck's entry
├── history/<YYYY-MM>.ttl the answer log (below): one sm:Answer per grade
│ given in study or a course that month,
│ appended, never edited; sm:formatVersion 1
├── drafts/<name>/v<N>/ a draft of a release, linked from the catalog
│ (sm:releaseDraft): release.ttl, its root (the
│ draft deck, sm:formatVersion 1), agents, sources
│ and how it was made; chapter-<id>.ttl per
│ chapter; cards.ttl, the cards no chapter asks
│ ([below](#drafts-and-releases))
├── releases/<name>/v<N>.ttl a release published from a draft, linked
│ from the catalog (sm:publishedRelease): one
│ document, readable by everyone, never written
│ again (the user may publish elsewhere instead)
└── digest.ttl derived data (below): a sm:DocumentReceipt per document
(#receipt-<path>), a sm:DeckSchedule per deck
(#schedule-<path>) and a sm:MonthSummary per past
month of the answer log (#month-<path>), each
stamped with the versions it was learned from;
sm:formatVersion 1 (a receipt or a month
summary), 2 (a schedule)
A deck's documents are found through its catalog entry
(sm:cardsDocument, sm:reviewsDocument), never by name: an earlier
version's library upgrade moved them to decks/<deckId>-<uuid>.ttl and
reviews/<deckId>-<uuid>.ttl. Today's upgrade writes them where they
are (migrations).
A catalog, a type index and every link in them are untrusted data
(markdown.md). An address they name is used only
where Solid Memo keeps such documents, or, for a deck's documents, only
as they allow (below). The address and the folder are first taken as a
request sends them (new URL(…).href, canonicalUrlOf), which resolves
a dot segment (.., %2e%2e in any case) and spells the host and any
character outside ASCII one way. Then the address is checked to be in
the folder (isDocumentIn in
instanceLayout.ts; the
graph does the same). An instance written otherwise,
https://Pod.example/kortlekar-å/, keeps its decks, and a link that
climbs out of it doesn't lead there.
After the folder, an address may hold nothing a server might read as a step out of it, though a request leaves it as it is:
- a query or a fragment;
- a
;; - an encoded slash, backslash or percent sign (
%2F,%5C,%25); - a NUL, an overlong UTF-8 byte (
%C0,%C1) or a%uescape; - a segment that begins with a dot, or an empty one (
//, which a client may merge into one slash).
No document Solid Memo names has any.
-
A deck's documents are read wherever its entry names them. Their folders and names are free: a user or a tool may rename
decks/andreviews/, move the documents elsewhere in the pod, or lay the pod out flat, with the instance at the pod's root. A deck can be used when its entry is a subject of the instance'scatalog.ttland each document is at an address a deck's may be (isDeckInPlace,isDeckDocumentAddress). That's plain http(s), with nothing a server might read as a step elsewhere (as above). It isn't a folder, an access control or metadata resource (.acl,.acr,.meta), or one of the instance's own documents (its meta, preferences, palettes, catalog and digest,history/,drafts/,releases/). New decks are made indecks/andreviews/. -
A deck's document is written to, or deleted, only when it says it is the deck's: the document states
<> dcterms:isPartOf <catalog.ttl#deck-X>, naming this deck's entry (ownsDocument; below), which only someone who may write the document can state. -
An older document that doesn't say so (from an earlier version, a repair or a copy) is the deck's in the instance's own folder, unless that folder is the root of a pod or a storage (typed
pim:Storage). There the next write states it. One that names any other catalog's entry (such as a nested instance's) isn't the deck's: the app never adds a second owner on its own, and refuses the write (deckDocumentUnclaimed). The data check doesn't read a document in the instance's own folder to list it as unclaimed. The folder is asked once a session; when the pod doesn't answer (an error or a refusal,cannotCheck), the write fails and can be retried, and the folder is asked again; the document isn't taken for unclaimed. A refusal before anything is sent,deckDocumentUnclaimedamong them, leaves a library upgrade saying nothing was changed. A document not yet made is made only in the instance's folder.Anywhere else the app never states it on its own: the document is unclaimed. The deck is read and studied, but each answer, edit or removal that would touch the document is refused with
deckDocumentUnclaimedbefore anything is sent, and removing the deck removes only its entry. The instance check names such a deck with its documents' addresses, in the data check and the Studio's Health screen, with "These are this deck's documents". Once the user confirms, each document is made to say it's the deck's: one write made only while it's as read (If-Match), or only where there's none (claimDeckDocuments;createDeckDocumentGuardin deckPlace.ts). Removing a deck, or deleting its instance, takes out a document's cards and review states only when every deck it names is leaving; otherwise only the leaving deck'sdcterms:isPartOfgoes (deckDocumentSubjectsin ownership.ts). -
Any other deck is left alone. It isn't listed (
toDecksin deckMapper.ts), so nothing reads, writes or deletes through it, whatever the invalid data policy. The instance check reports its entry as not conforming (DeckPlace). Removing it there, like any problem with no repair, removes the entry from the catalog and no document it names. A deck handed to the adapters from anywhere else is held to the same rule: a write through it isdeckGone, with nothing written, and removing it removes only its entry (removeDecksin solidDeckRepository.ts). -
A draft the catalog links (
sm:releaseDraft) is one only at<instance>drafts/<name>/v<N>/release.ttl, in the instance'sdrafts/as a request sends it and with no dot segment (draftPlaceOf). A name that's a dot segment is no draft's. -
A release the catalog links (
sm:publishedRelease) is listed only at an http(s) address named as a release is,…/<name>/v<N>.ttl, written as a request sends it, like the folder it's published in (studio.md). It's made public again only when the catalog lists it, its document is a release, and it's in the instance or in a storage of the user's that holds the instance. An instance attached from another's pod may list the user's own.
Reading a whole deck is one GET (the N+1 problem: Solid servers have no batch requests or SPARQL), and a study session never rewrites card content.
graph LR
accTitle: An instance's documents
accDescr: A deck in the catalog links its creator, its distribution, its cards document and its reviews document, and each review state points to its card.
C["catalog.ttl#deck-X<br/>a sm:Deck, dcat:Dataset<br/>dcterms:title, dcterms:description<br/>sm:formatVersion<br/>sm:studyDirection<br/>sm:cardsDocument<br/>sm:reviewsDocument"]
A["catalog.ttl#agent-…<br/>a foaf:Agent<br/>foaf:name, foaf:mbox"]
X["catalog.ttl#deck-X-cards<br/>a dcat:Distribution<br/>dcat:accessURL"]
C -->|dcterms:creator| A
C -->|dcat:distribution| X
D["decks/deck-X.ttl#card-N<br/>a sm:Card<br/>sm:front / sm:back<br/>sm:frontImage / sm:backImage<br/>sm:formatVersion<br/>owl:deprecated (retired)"]
R["reviews/deck-X.ttl#card-N<br/>reviews/deck-X.ttl#card-N@back-to-front<br/>a sm:ReviewState<br/>SM-2 fields<br/>sm:reviewDirection, sm:scheduler"]
C -->|sm:cardsDocument| D
C -->|sm:reviewsDocument| R
D -.->|the document: dcterms:isPartOf| C
R -->|sm:reviewOf, else the same fragment id| D
-
The catalog holds one subject per deck with its title and links to the two documents, so the deck list renders from one fetch. A deck's title and description are language-tagged text, one value per language, in any language the user states since deck format 5 (format 4 required English; i18n.md). Since deck format 3 a deck is a DCAT dataset (
dcat:Dataset, vocab.md, validation.md):- It always has a description (
dcterms:description): what it covers and where its content came from. It's shown on the deck page, its URLs as links. A deck without one gets"Flashcards: <title>."@enand"Kortlek: <title>."@sv(app-written text for any title). - It may name its authors (
dcterms:creator), each afoaf:Agentnode#agent-<slug>beside the deck, withfoaf:nameand amailto:foaf:mbox, shown asName <email>. The slug is the author folded to ASCII,unnamedwhen nothing is left of it. An author the folding loses letters or digits of, such as 김민수 or "Kim 민수", or that has none to lose, such as "—", gets six hex digits of a hash of the whole author after it (#agent-unnamed-5a5e6e,#agent-kim-ad54ba). Two such authors stay two agents. An agent is read by the IRI the deck names (one written under an older rule is replaced on the deck's next save). - It may name a license (
dcterms:license, a URL), topics (dcat:theme, concepts of the topics scheme) and keywords (dcat:keyword), language-tagged since deck format 6, several per language; untagged ones saved before are kept, their language unknown. - Its cards document is its
dcat:distribution(#deck-X-cards, withdcat:accessURL). - A deck copied from the deck library, or from a
release added from a link, names the
release it came from with
prov:wasDerivedFrom(dcterms:sourcebefore format 3), wherever that release is; it's read, never written.
Agents no deck names any more go with the deck that named them. The Studio edits the authors and the license (
setDeckProvenance, deckProvenance.ts). Two authors that would share an agent node are refused, and a license is one ofKNOWN_LICENSES(license.ts) or the one the deck has. - It always has a description (
-
Card sides: each side is text (
sm:front/sm:back, a literal), a picture (sm:frontImage/sm:backImage, always an IRI; a string there is ignored) or both; a side with neither makes the subject not a card. A side's text is language-tagged, one value per language (zxxfor codes, numbers and symbols), or, as saved before card format 5, one untagged literal of unknown language, never both; untagged text is kept only while untouched. A note under a side (sm:frontNote/sm:backNote) and the label above it (sm:frontLabel/sm:backLabel, the front's since card format 6) are language-tagged text in any language.Pictures are shown only when their URL is http(s). A picture may have a language-tagged description (
sm:frontImageDescription/sm:backImageDescription), read to whoever can't see it, in its place, while its side is asked, so it never gives the answer away. Without one it's named by its side ("Picture on the front of the card").A card may say how its texts are written,
sm:textFormat(sm:markdownorsm:plainText, vocab.md), for the sides, the notes, the labels and the card's distractors, never the pictures' descriptions; without it the text is plain. A card copied from a library release keeps what it's about,dcterms:subject(card format 7, deck-library.md), which the app neither shows nor edits. Every edit and copy keeps both, an import or a course question joining the deck included. An edit trims plain text; text in any other format loses only the blank lines before it and the white space after it, so the spaces that start a Markdown code block survive.Wherever a side is shown whole (study, a library preview, a card's page, a library card), a tap, or Enter or Space on the button over it, enlarges its picture to the viewport's width or height, keeping its shape. Another tap, Escape, the focus moving on or a scroll puts it back (ZoomableImage). In study, Space on a picture's button doesn't reveal the answer; a grade's key still answers.
-
Direction: a deck's
sm:studyDirectionis a concept ofsm:StudyDirections:sm:frontToBack,sm:backToFrontorsm:bidirectional(every card asked both ways). Formats 1 and 2 used the stringsm:direction, absent meaning front→back. It's changed in the Browser; a library deck brings its own. -
Study pace: a deck may set its own daily limits,
sm:deckNewCardsPerDayandsm:deckMaxReviewsPerDay, in place of the instance'ssm:newCardsPerDayandsm:maxReviewsPerDay; a limit it doesn't set follows the preferences. -
Card language: a deck may name the language its card text is shown in first,
sm:deckCardLanguage, one lower-case language tag. Its fronts, backs, notes, labels, picture descriptions and wrong options then rank that language ahead of the app's; a card without text in it shows as usual (i18n.md). On a course's deck it ranks the course's text too, on the course's own screens: the course's title and description, its chapters' titles and descriptions and its steps' theory. The keywords, and a plain deck's title and description, don't follow it.Like
sm:positionandsm:completedChapterit belongs to no shape (DeckV6is unchanged). Every other write of the deck keeps it as the entry has it (withDeck), so a deck read before it changed can't undo it. OnlysetCardLanguagesets or removes it, on the entry as it is then (If-Match), ascompleteChapteradds a chapter. A new entry (an import, a guest's deck moved) is written with the deck's, and a reader drops a value that names no language. It's chosen from the languages its cards state, and a release drafted from the deck leaves it behind.The study pace and the card language are set on the deck's own Preferences page (
#/deck-preferences, from the deck's page). A library release never sets them, and a library upgrade keeps the copy's. -
Format versions: every subject the app writes carries
sm:formatVersion, the version of its class's shape it conforms to. The instance is at 1, decks at 3 (DCAT), cards at 2 (pictures), review states at 2, preferences at 6 (the palette and the text size) and deck schedules at 2 (the scheduler). The catalog, agent and distribution nodes, and a palette of the user's own and its color sets, are at 1. Readers treat a missing version as 1, read older versions as they are, and pass a newer one through unchanged. Bringing a pod up to date is the user's call (migrations.md). A developer can check an instance against the shapes in the browser. -
Cards are hash-fragment subjects (
#card-<uuid>, or the library's own ids such as#swedenfor imported decks) inside one document per deck. Fragment ids are generated once at creation and never re-derived. -
Review state lives in a separate document per deck, one subject per card and direction:
#<cardId>for front→back (as was every state written before directions existed), and#<cardId>@back-to-frontfor the other way. Since vocabulary 1.16 every state the app writes says what it's of:sm:reviewOfnames the card (<cards document>#<cardId>),sm:reviewDirectionthe direction (sm:frontToBackorsm:backToFront) andsm:scheduler sm:sm2the algorithm of its fields. A reader joins a state to its card in reviewStateMapper.ts and reviewRecord.ts:- What the state says wins. The card is the one
sm:reviewOfnames and the direction the onesm:reviewDirectionnames; what the state leaves out comes from the fragment rule above (no@back-to-frontsuffix: front→back). A state another app named#state-7f3ais read when it names its card, and#card-1naming card 2 is a state of card 2. - A link into a cards document the deck had names its card too
(an earlier library
upgrade moved the cards).
sm:reviewOfinto any document beside the cards named<deckId>.ttlor<deckId>-<…>.ttlis read by its fragment. The state's next write names the current cards document. - What isn't read: a state whose
sm:reviewOfnames a subject outside the deck's cards documents (another deck's card, or the document's own subject). Nor one whose direction is neither way (saysm:bidirectional), or of another scheduler: any value ofsm:schedulerbut thesm:sm2andsm:fsrs6IRIs, a literal too. Absent means SM-2. Another scheduler's state is never read, written over or removed, the check only warns about it, and the app schedules the card beside it. - An FSRS-6 state is the app's own (vocabulary 1.21): an
sm:ReviewStatenamingsm:scheduler sm:fsrs6and nothing else, beside the SM-2 state of the same prompt. The app keeps that one up to date on every review, whichever scheduler sets the due days (srs.md). It's read by itssm:reviewOfandsm:reviewDirectionalone, which its shape requires, and written at#<cardId>@fsrs6(front→back) or#<cardId>@back-to-front@fsrs6, from which the fragment rule reads no card. One per card and direction counts, by SM-2's rule (the one at the subject the app names for it first), and only while fresh (the SM-2 state has the samesm:lastReviewedAt). A stale one (an older app graded SM-2 alone, the day was reset, the card forgotten) and an orphan (no SM-2 state beside it) set no due day; the next review under FSRS-6 replaces it, estimated from the SM-2 state. A change to the SM-2 state that keeps its last review goes unseen. An older app's Set due date in the Studio moves the SM-2 due day alone, and under FSRS-6 the FSRS-6 due day still counts until the card's next review. - One state per card and direction. Where several are of the same, the one at the subject the fragment rule names counts, else the first by subject IRI (code-unit order); the others stay, unread.
- Writing a state goes onto the subject read for it, so a review
of another app's
#state-7f3aedits it in place. A state not yet in the document goes to the subject the fragment rule names, unless that subject is another's (another card's state, another scheduler's, any other app's subject); then it goes to a new#review-<uuid>that names its card. A subject the fragment rule names for the same card and direction that can't be read (half a snapshot, say) is written over. An FSRS-6 state goes the same way, to the subject named for it. A grade under FSRS-6 writes both states in the one PATCH it makes under SM-2; under SM-2 the FSRS-6 state is left as it is, and goes stale. foreignData.integration.test.ts reviews, on real servers, a state another app named its own way, beside another scheduler's state. - Resetting the day removes only the state read for each card and direction it undoes; a second state of the same stays, and is read from then on. FSRS-6's states of the day go back in the same save: restored to their snapshot, or removed when introduced that day or without one (srs.md).
Card edits and review updates never touch each other's documents.
- What the state says wins. The card is the one
-
Cards and reviews documents are created lazily on first write. Deck removal deletes both documents and the catalog subject. Card removal removes every
sm:ReviewStateof the card, both ways and read or not (SM-2's bysm:reviewOfor the fragment rule, FSRS-6's by their links), and no other subject (another scheduler's state stays). The Studio's edits do the same (studio.md), as does a card moved away or forgotten (applyReviewChangeswithevery). -
The cards and reviews documents say whose they are. Every write of a deck's cards or reviews document by the deck's own operations adds
<> dcterms:isPartOf <catalog.ttl#deck-X>once, on the document's own subject, never on a card or a review state. Those operations are:- import;
- adding, editing or removing a card, and stating its languages;
- an answer, and a day reset;
- a course's question joining the deck;
- the format update's rewrite of outdated cards and states;
- a library upgrade's write of the cards and states;
- a guest's deck added to an instance.
It tells Solid Memo it may change the document (above). An import or a library upgrade reads and writes the cards and states as without it, and a release is never written. A repair from the data check and a copy of the instance (a guest's study moved into a new instance) leave the document as it was. One untouched since vocabulary 1.16 (a reviews document, since this version) doesn't say it yet. Another
dcterms:isPartOfthere stays (another app may point two decks at one document). One naming another deck's entry is added to only when the user claims the document for this deck too. The subject has no class, so no shape checks it. When it says no more than that, it's Solid Memo's (isDeckBackLinkin ownership.ts), and the data check doesn't list it as a stray.
An instance's URL is permanent, and so is every document's and subject's in it: the format update and the library upgrade write the documents where they are, and change no registration (the format update only adds those missing). Another app may keep a link to the instance, a deck, a card or a review state.
catalog.ttl#catalog is a dcat:Catalog (shape CatalogV1) of the
instance's decks. It has a dcterms:title (the instance's name), a
dcterms:description and a dcterms:publisher: the pod owner's WebID,
described in the same document as a foaf:Agent with their profile's
foaf:name. Its dcat:themeTaxonomy names the topics scheme and the EU
data themes, and its dcat:dataset each deck. It may state a license
(dcterms:license), which the document then types a
dcterms:LicenseDocument, as DCAT-AP asks; a license it no longer
states loses that type.
Saving a deck adds only that deck's link, and removing a deck removes
only its own. A dataset another app listed in the catalog stays listed
through every deck save and every write of the whole catalog. The one
link a whole write drops is one to a subject of the catalog's own
document that's no dcat:Dataset, left by a deck removed some other
way (it would fail DCAT-AP's class check). A member another app
described in the document keeps its link whatever its class; the check
only warns about it
(validation.md). A dataset in
another document isn't held to its class
(validation.md), and an
instance listing one stays valid on real servers
(foreignData.integration.test.ts).
The catalog is written when an instance is created, and by the format
update for an instance made before there were catalogs;
its registration as the Discovery chain says. The
whole document conforms to DCAT-AP, a course's deck typed
schema:Course too (Courses), and a test holds what the app
writes to it.
The instance's name is in the meta document's dcterms:title, the
catalog's dcterms:title, and the dcterms:title of every registration
of the instance's data in each type index (its sm:Instance one names
it in the instance list). Renaming it in the Studio (renameInstance,
studio.md) writes them all, each document
read and written again on a 412. A catalog description the old name
gave (defaultCatalogDescription) follows the new name; one the user
wrote stays.
A deck's description, topics and keywords are edited in its Browser ("Describe deck") and on its about screen in the Studio, the keywords as one comma-separated list per language the user states. The description is required, as DCAT-AP asks. Only the keyword languages the user states in an edit are checked and stored in their canonical form ("iw" → "he"). A tag the deck already has is kept as stored, even one another app wrote that the app wouldn't accept from the user.
The user arranges the deck list into named groups that can nest. A group
is a subject of catalog.ttl beside the decks, a sm:DeckGroup and a
dcat:Catalog (shape DeckGroupV1), never a dcat:Dataset, listing
sub-catalogs with dcat:catalog and datasets with dcat:dataset, as
DCAT-AP allows. The order is Solid Memo's own sm:position (DCAT's
dcat:prev/next are series versions, and the library uses them as
such).
<#catalog> a dcat:Catalog ; …
dcat:dataset <#deck-a>, <#deck-b>, <#deck-c> ; # still every deck
dcat:catalog <#group-x1> . # the top-level groups
<#group-x1> a sm:DeckGroup, dcat:Catalog ;
sm:formatVersion 1 ;
dcterms:title "Languages"@en ; # one name, in the UI's language
dcterms:description "Deck group: Languages."@en, "Kortleksgrupp: Languages."@sv ; # generated
dcterms:publisher <https://alice.example/profile/card#me> ; # the catalog's
dcat:dataset <#deck-a>, <#deck-b> ;
dcat:catalog <#group-y2> ;
sm:position 0 .
<#deck-a> sm:position 0 .- The catalog still lists every deck with
dcat:dataset; a group lists only its own members, and the catalog'sdcat:catalogthe top-level groups. An edit writes only the links to the decks and groups the arrangement places; a member link to a dataset or catalog in another document, or to one the app can't read, stays. - A deck or group is in one parent: the catalog (top level) or one group; a deck no group lists is at the top level.
- Positions count within one parent, decks and groups in one sequence, 0 first. Members sort by position, those without one last, in document order. The first arrangement of a level writes positions 0…n−1 for every member at that level; new and imported decks get none.
- An empty group stays until the user removes it; removing a group moves its members, in order, into its place in its parent.
sm:positionon a deck and the catalog'sdcat:catalogbelong to no shape (DeckV6andCatalogV1are unchanged), so writing a deck or the catalog keeps them.
What isn't one valid tree is read as one, not refused. A link to
nothing, from a group to itself, or of the wrong kind (a dcat:dataset
link to a group, a dcat:catalog link to a deck) is ignored. A node
several parents list goes to the group with the lowest URL, except a
group the catalog's dcat:catalog lists, which stays at the top level.
A cycle is broken by moving its lowest-URL group to the top level. A
negative position counts as none; on a tie of positions, decks come
before groups, each kind in document order. A group in a format newer
than the app's makes the arrangement read-only. So does, under the
"Set invalid data aside" policy (sm:blockSubject, the default), a
catalog or group that doesn't conform, or that check still running
(validation.md). A group the
app can't read is never edited: its members show at the top level, and
the catalog's dcat:catalog keeps its link.
Writing an arrangement. The screen sends an edit, not a finished
layout (DeckTreeEdit in
deckTree.ts). An edit moves a
node after a sibling, combines two into a new group, renames or removes
a group, or gathers decks at the end of a group (the Studio's bulk
move).
Keeping a guest's study grafts
new groups around decks the tree has at the end of the top level, a
no-op once one of its groups is there. The repository
(editDeckTree in
solidDeckRepository.ts)
applies the edit to catalog.ttl as the pod holds it then:
- read, apply, write once: what changed is written in one PATCH with
If-Match(write discipline); an edit that changes nothing sends nothing; - on 412 the edit is applied again to the document as it is now,
three attempts in all, so another tab's change is kept. A move names
its neighbor, not an index, and the screen mints a new group's URL
(
newDeckGroup), so an edit means the same on the fresh tree, and a retried combine is a no-op. An edit that no longer fits (its group or node gone) fails withdeckTreeChanged; - a PATCH, never a whole PUT: where the pod has no strong ETag, a PUT would undo what changed since the read;
- only the groups written and the catalog are checked before the
write (
checkWrite), so a deck set aside beside the one moved doesn't stop the move; - removing a deck removes it from every group's
dcat:datasettoo, in the same write; its siblings keep their positions, and the next arrangement closes the gap.
Several decks at once. The Studio changes several decks in one write
of catalog.ttl (studio.md). A move into a group is one
gather edit, which leaves a deck already in the group where it is, so
a retry changes nothing. A
pace or a direction is one save of every entry (saveDecks). Removing
decks first removes their cards and review states from their documents,
deleting each document that then holds nothing else (one holding
another app's data too is kept, keptDocuments), then their entries in
one save (removeDecks).
Any card may have wrong options, shown beside its back in a
multiple-choice question (courses.md). Each is an
sm:Distractor subject of the card's document, typed schema:Answer
too, with sm:formatVersion 1, its text (sm:distractorText) and,
optionally, why it's wrong (sm:distractorNote). The card names each
with sm:distractor and, since vocabulary 1.16,
schema:suggestedAnswer (below), in step through every
write of a card (bulk edits, moves, copies and imports, a course's
question joining the deck included). Solid Memo's card editor and the
Studio's card inspector edit them
(studio.md; the domain is
distractors.ts):
- A new one is written once it has text, as
#<card>-d<n>,none more than the highest such id the card or the release the deck came from has used. An id the release published is never used again (a learner's answers may name it,sm:chosenDistractor); one deleted before it was published may be. - Retiring one writes
owl:deprecated trueon its subject, and restoring it removes that. A retired one stays named by the card, is read marked retired (toDistractor) and kept by every write, and only a question's choices leave it out (choicesOf). - Deleting one removes its subject and the card's link to it, in the one write of the card, and only one the deck's release never published; one it did is retired instead.
- Each change is a write of the card (
updateCard): the card and its distractors are written, those it names no more removed, and triples the app doesn't know kept (withDistractors). - A wrong option not in the back's languages is a warning in a pod's deck, never a refusal.
A course is a library release with an outline of chapters and steps. Its state uses the documents above and no new type-index entries:
- The outline stays in the release. Chapters and steps are never
copied: the app reads them from the release the deck's
prov:wasDerivedFromnames. - The deck holds only the cards answered. Starting a course writes
an empty deck. A question answered for the first time writes its card,
its
sm:Distractorsubjects and its first review state, under the release's fragment ids (#q-why-solid-1a,#q-why-solid-1a-d1), and itssm:textFormatwhen the release states one. The app reads onlysm:distractor; a write of the card adds or removes only theschema:suggestedAnswers that are its own distractors, so another app's stay.schema:suggestedAnswerbelongs to no shape, and only the copy in the pod states it, never a library release. Removing a card removes the distractors it names. - The entry says it is a course's deck. Starting a course types its
catalog entry
schema:Coursetoo, besidesm:Deckanddcat:Dataset, as its release is typed (Deck.course). The deck shape asks only that the entry be adcat:Datasetand isn't closed, so the deck format didn't move and every write of the entry keeps the type. A deck added from a guest's study and a deck file carry it, a file without the progress too. A copy made before the type lacks it; the library's index, or the copy's own release, tells it a course. - Completed chapters are on the catalog entry: one
sm:completedChapter <release#ch-…>per chapter whose final review was passed, kept by every write of the entry. It's added with an If-Match PATCH, three attempts in all, as a deck group edit is. A deck added from a guest's study (addDeck) gets the guest's completed chapters with its new entry, and the Studio marks a chapter not done, or restarts the course, the same way (setCompletedChapters), a write that passes the shape check. - Answers go to the answer log, with
sm:answerMode. - Everything else is derived: a step is done when each card it's checked by has a review state, and a chapter opens when the one before it is completed.
# catalog.ttl
<#deck-x> a solid-memo:Deck , dcat:Dataset ; …
prov:wasDerivedFrom <https://solid-memo.com/decks/solid-fundamentals/v1.ttl> ;
solid-memo:completedChapter <https://solid-memo.com/decks/solid-fundamentals/v1.ttl#ch-why-solid> .Vocabulary 1.18 and its shapes let a creator write a release in their own pod, as a draft, before publishing it (vocab.md, studio.md):
-
A draft is a container of the instance,
drafts/<name>/v<N>/(the version it drafts), holdingrelease.ttl: the release's root, the draft deck, which is the document itself (<>) as a release is its document. It holds the deck's agents, its distribution, its sources, how it was made (itsprov:Activitysubjects, license documents) and the series it describes;chapter-<id>.ttlfor each chapter: the chapter, its steps, the cards they and the chapter ask, and those cards' distractors;cards.ttl: the cards no chapter asks (a plain deck's, or questions not yet placed) and their distractors.
A subject keeps its fragment id in whichever document it's in, and moves with its place (a question moved to another chapter goes to that chapter's document); what names it names it where it is. A chapter's document left with nothing in it is deleted. Anything else in the container (
isDraftDocumentNamein draftLayout.ts) is another app's, and left alone. Every id of a draft is letters, digits,.,_and-, starting with a letter or digit (DRAFT_ID), so a chapter's document is always in the draft's container. A draft whose documents name a subject otherwise is neither opened nor written (draftIdUnsupported). -
Links on the catalog.
sm:releaseDraftnames a draft's release document, andsm:publishedReleasea release published from the instance. Likesm:newcomerCourseon the library's catalog, they belong to no shape, soCatalogV1keeps them through every write. Only a link to a draft's place in the same instance is followed. A learner's app readssm:publishedReleasein a creator's shared catalog to find newer versions of a release it added from a link. -
A draft is checked as a draft. Its deck, chapters and steps are checked against the draft shapes, which don't yet ask what only a published release needs; its cards and distractors as anywhere else (validation.md), on every write and in the instance's check. What no shape describes (how the release was made, its sources) is written as it is, and kept through every edit; the Studio's release screen writes it as the library's releases do (deck-library.md, studio.md). A draft names the release it was published as with
sm:releasedAs, and is then no longer edited. -
Writes follow the write discipline. Making a draft puts each document only where none is (
If-None-Match: *), the release document first, then links it from the catalog (If-Match, read and made again on a 412). A release document already there means the name is taken and nothing's written; the Studio tries the next name. Any later failure deletes what was written before the failure is shown, so no draft is left unlinked.An edit is one write of each document it changes, made only if the document is as it was read (
If-Match; a new chapter's document, only where none is). When one changed elsewhere, it's made again on the draft as it is then, three times in all. An edit made from a read that another edit has overtaken is refused (changedElsewhere). Each document an edit changes is one whole PUT,If-Match: Community Solid Server's in-memory store cuts text short after a PATCH, and node-solid-server stores a boolean a PATCH inserts as false. A server with no strong ETag gets noIf-Match, so there an edit made elsewhere meanwhile is written over.Deleting a draft removes its subjects from its documents, the release document last, deleting each document that then holds nothing else; subjects nothing of the draft leads to are another app's and kept with their document (
keptDocuments). Then it deletes the draft's container and the ones above it (drafts/<name>/,drafts/) where they're then empty, then the link. -
A release is frozen. It's written once, at library deck format 6, and describes its own series and publisher (deck-library.md).
assemblemakes a draft a release's text, with every subject moved to the release's address,@basethat address, and its release time set. -
Publishing a release (solidReleasePublisher.ts, studio.md) puts that text, as it is, at
releases/<name>/v<N>.ttlof the instance, or in another folder the user chooses, in one PUT only where nothing is (If-None-Match: *). A 412 isreleaseTaken, unless the document there states the same statements, itsdcterms:issuedanddcterms:modifiedaside, and no other draft of the instance names it: then publishing goes on from there. Next, only<release>.aclor<release>.acr(glossary), on the release's origin, gets public read and nothing else, and no other access control changes. Where a server names another one as the document's, such as the folder's, that one's left as it is and the release stays private. Then the catalog links it (sm:publishedRelease, If-Match, read and made again on a 412), and the draft names it (sm:releasedAs), If-Match on the version the draft was checked and assembled at (changedElsewhere). A failure after the PUT is finished by publishing again at the same address. The app never writes a release again, nor deletes it, and registers it in no type index: the catalog's link is how its author's releases are found.
Every grade given in study is kept for the statistics
(statistics.ts): activity by
study day, streaks, how well reviews were remembered, in all and month
by month (monthlyRetention), and the running total of prompts
introduced (introducedOverTime). How many prompts are new, young and
mature, and the reviews due ahead, come from the review states, not the
log (srs.md): a review state says only
where a card stands now, and the log is the history. Once a day has
answers, a strip above the deck list says what the day came to
(todayOf, statistics.md). The log is source data:
nothing could rebuild it.
-
One document per study month for the whole instance,
history/<YYYY-MM>.ttl(historyUrlOf, by the study day's month, so a day is never split). A year of statistics is twelve GETs whatever the number of decks; reading it again is a304for each month that didn't change. The today strip keeps a summary of each past month in the digest. -
One subject per answer,
#answer-<time>-<random>(for one added from a guest's study, followed by the id of the deck it was added to; answer.ts). It names:- the deck's catalog entry and the card it was given to; either may since be removed, and a removed deck's answers stay, as a removed deck;
- the direction, and the SM-2 grade, whichever answer scale gave it;
- when it was given, and the study day it counts toward, fixed then so a later day-boundary change doesn't move it;
- the prompt's interval before (absent on its first answer, which introduced it) and after.
An answer to a course question says how it was given (
sm:answerMode sm:multipleChoice) and, when it was wrong, which wrong option was chosen (sm:chosenDistractor, the distractor in the deck's cards document). An answer given in study says neither, which means recalled. Since vocabulary 1.19 an answer may say how long it took, in milliseconds the app was shown. That's from the prompt to the answer revealed, or to the option chosen (sm:recallMillis), and from there to the grade (sm:gradeMillis), each left out above ten minutes. It may say the day the prompt was due before it (sm:priorDue, absent on a first answer), and the device's offset from UTC then, in minutes east of Greenwich (sm:utcOffsetMinutes). Each is optional, and an older app ignores them (migrations.md). -
The intervals and due day are those of the scheduler that set them. The grade is always the SM-2 quality, but the interval before and after an answer and the day it was due come from what set the prompt's due day then (
effectiveStates). Under FSRS-6 that's its fresh FSRS-6 state, else the SM-2 state its first FSRS-6 review is estimated from. The log's format doesn't change. -
Added without reading (
appendToDocument): one insert-only PATCH, with no precondition, since an answer names a subject no other writer does. Every server tested creates the document and its container when missing, and keeps every one of several concurrent inserts (e2e/pod/src/history.integration.test.ts). A guest's study added to an instance brings its answers the same way: a month's in one PATCH, or several where one would pass 64 KiB (appendAll). Each id is the guest's followed by the new deck's, so adding it again to the same deck changes nothing. Adding it to another deck (the guest's deck added anew) makes an entry of its own, never one naming two decks. -
After the review, not in its way:
recordReviewsaves the review state, then queues the answer, with its response times (statistics.md), to be added in the background. One that fails waits, with those after it, for the next answer, the end of the session or the statistics page. An answer still queued when the tab closes is lost; its review isn't. -
Resetting the day removes that deck's answers of the day (
removeDay: read, remove, save with If-Match, again on 412). Undoing an answer in a session removes that one answer by its id (removeAnswer, the same way), or drops it while it still waits to be added (srs.md). Nothing else removes answers. -
A card moved to another deck leaves its answers behind. A Studio move never rewrites the log: past answers keep naming the source deck and card, and in the target the card's history starts again, though its review state may come along. A copy's history starts in the target; the original keeps its own.
-
Read whole by the Studio (
loadAnswerLog): every month, for a card's history, its lapses and the deck's leeches (studio.md). The statistics (getStatistics) read the months of their range. Both go through one cache (readLogMonths), which keeps each month's answers in the page with the version they were read at; a month is read again only once it changed (readMonthSince, a conditional GET). A read adds to the cache and never replaces it; a month the log no longer lists is dropped (statistics.md). The Studio names a deck's past answers by their card's id in its current cards document (deckAnswers), which a library upgrade may have moved. -
Checked in the full check only (validation.md), since the current month changes every session.
digest.ttl lets a visit skip what hasn't changed. It holds only what
Solid Memo learned of the other documents, each fact stamped with the
version (ETag) of the document it was learned from
(studyDigest.ts):
- a receipt per document: at that version it conformed to the shapes
(
sm:conformedTonames the rules, a hash of the shape files the site was built with) and/or nothing in it was in an older format (sm:latestFormat); - a schedule per deck, from given versions of its cards and reviews
documents: prompts due by study day (
sm:dueOnDay "2026-10-01 12"), prompts never reviewed, and the reviews and introductions of the day it was computed on. With today's preferences they give the deck list the same counts asbuildStudyQueue(srs.md). It names the direction, the day boundary and the scheduler it was computed with (sm:scheduledWith, the preferences'sm:studyScheduler), and holds only while all three are the deck's and the preferences' (switching scheduler writes no review); - a month summary per past study month of the answer
log that the today strip looks back over, from a
given version of its
history/<YYYY-MM>.ttl(sm:summaryOf,sm:summarizedVersion). It holds each study day's answers, new cards, scored reviews and lapses (sm:studyDayCounts "2026-09-29 12 3 8 1"). It holds the month's reviews of young prompts and how many were remembered (sm:youngReviewCount,sm:youngRecalledCount), and how many recalled answers took each whole second (sm:recallSecondsCount "4 7", a minute or more counted at 60). The strip shows these and estimates the time left today from them (statistics.md). A summary needs no day boundary, and a month no longer looked back over is dropped.
sequenceDiagram
accTitle: Counting a deck through the digest
accDescr: The app reads the digest, then asks for each deck's cards with the version the digest holds, and the pod answers 304 when nothing changed or 200 with the document when it did.
participant App
participant Pod
App->>Pod: GET digest.ttl
App->>Pod: GET decks/d.ttl If-None-Match: <its version in the digest>
alt unchanged
Pod-->>App: 304 — counts from the schedule, no check
else changed (or no digest yet)
Pod-->>App: 200 + the document — counts and check from it
App->>Pod: PATCH digest.ttl (in the background)
end
- A stale digest is never wrong, only slow. Every fact is used only after the pod says its document is still at the stamped version. A document changed elsewhere is read and checked in full, and the digest repaired with what that read learned; nothing else keeps it up to date.
- When a study session ends (End session, leaving the screen, or the page being hidden) the deck's schedule and its reviews document's receipt are brought up to date.
- Writes are edits like any other (If-Match; write check). Changes learned while a write is under way go in the next one. On a 412 it's read and changed again, six times in all, waiting longer before each (50 ms, then 100 ms, …), since a page learning a check's documents writes the digest several times running. A failed write is dropped: the next visit learns it again.
- Read leniently. A subject that doesn't fit its shape is left out, and relearned. The digest isn't one of the documents an instance is checked by.
- It is as good as the pod's ETags. Community Solid Server 6 stamps
its ETags in whole seconds, so a document changed elsewhere in the
same second as Solid Memo read it is taken for unchanged until it
changes again (7 stamps milliseconds). The same holds for
If-Match(below). - A pod without ETags gains nothing. node-solid-server (5.x, 6.0.0) gives no ETag on a read, so nothing is kept, and every visit reads everything.
- The format update doesn't write the digest; what it says of the documents it writes is relearned on the next visit.
The Studio saves a deck as a file, and makes a deck of a file
(studio.md): Turtle or JSON-LD, one deck
to a file. The domain names its parts
(deckFile.ts), and the
DeckArchive port reads and writes it
(solidDeckArchive.ts).
An export holds triples as the pod has them, at the pod's IRIs:
- the deck's entry in
catalog.ttl, its distribution and the agents it names as its creators, its card language (sm:deckCardLanguage) among them, but not itssm:position, nor itssm:completedChapters 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.
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,
and its @context is the prefixes the Turtle uses too (sm, dcterms,
dcat and so on), written in full.
An import reads the file as the pod is read, with the same mappers:
- the file must hold one deck (
sm:Deck) that fits its shape; - its cards are the cards of its cards document, its review states those of its reviews document, of cards it holds;
- an older format is brought up to date as it's read
(migrations.md), and the screen says how many parts
were. A deck, card or review state in a newer format than the app
reads is refused (
deckFileTooNew, notwrittenByNewerApp, which refuses to save over pod data a newer version wrote), and nothing is written; - a subject of these kinds that can't be read is left out, and the screen says how many were;
- relative IRIs are resolved against
https://file.solid-memo.invalid/and the file's name (DECK_FILE_BASE); - a JSON-LD context named by its address (or
@import) is refused, as reading it would fetch that address; - a triple with a term Turtle can't write as it is is left out (below).
The deck is then written as a new deck of the instance (importedDeck).
It keeps the file's deck id, so a deck exported, removed and imported
again comes back at its URLs, and the log's answers name it again. It
takes a fresh id when the instance has a deck by that id or one using a
document of that name, or when the id or its distribution's
(<id>-cards) is already a subject of the catalog. So does an id the
app wouldn't give (deck-…), so no deck lands on #catalog, a group
or an agent. Its
cards keep their ids.
Only what Solid Memo knows of the deck and its cards is written, as any
deck: triples another app put there are in the file, but not imported.
Each card names its wrong options with sm:distractor and
schema:suggestedAnswer, each typed sm:Distractor and
schema:Answer. Each review state names its card in the new cards
document (sm:reviewOf), its direction and sm:sm2. Its FSRS-6 states
come too, as the app's own; a state of another scheduler isn't
imported. The deck needs no type-index registration of its own
(Registrations). The writes follow the write
discipline: the cards document is created
(If-None-Match), the reviews document written, and then the deck's entry
added to the catalog, each checked against the shapes.
What the data model holds that Solid Memo's screens don't edit. Most of it's shown in Solid Memo; the Studio closes the gaps one by one.
- Edited in the Studio:
- a deck's authors and license, and its course progress (a chapter marked not done, the course restarted);
- the instance's name, and the catalog's description and license;
- a card's wrong options and review state (forgotten, or given a due day), and the deck a card is in (moved or copied to another);
- edits of many cards at once, and several library copies upgraded at once;
- a deck exported to a file or imported from one;
- a draft of a release: what it says of itself, a course's chapters, steps and questions and their order, and its cards' wrong options.
- Shown only:
- the answer log, as statistics, and in the Studio as a card's history, the wrong options chosen, lapses and leeches;
- the schedule in the digest, as counts, and in the Studio as a forecast;
- the check of the shapes, in developer mode, and in the Studio as a deck's or the instance's health;
- a draft's release check, and a preview of its listing in the library.
- Not shown: a catalog's
dcterms:modified, which the app keeps but never writes; a library copy's release provenance beyond its authors, license and sources.
-
Writes go only where Solid Memo writes. Every pod write passes the pod fence (boundaries.md), which refuses, before anything is sent, a write to an address that's unsafe to write: one a server might read as another, or an access control or metadata resource (
.acl,.acr,.meta). No access control is written but a release's own, by the release publisher (a resource without its own ACL inherits its ancestors' access; a malformed one replaces that inheritance and can lock the owner out (WAC) or expose data). Otherwise access control stays whatever the user's server dictates. -
No write holds a term Turtle can't write as it is. Such a term is an IRI with a control character, a space or one of
<>"{}|^`\. So is a blank node label or a language tag outside Turtle's grammar, text typedrdf:langStringwithout a language, a base direction, or a quoted triple. JSON-LD can hold such a term, and the writers don't escape it, so a>would end an IRI and the rest be read as statements no check saw. A PATCH, a PUT, an insert-only PATCH, a release's Turtle and an export are refused withunwritableTermbefore anything is sent (writableTerms.ts). An imported deck leaves such triples out; a release read from a file is refused, as a draft keeps its triples as they are. -
Mutations always follow read → modify → save (datasets.ts:
readDatasetorgetSolidDatasetOrNull, thensaveDataset). The save sends a PATCH of the delta, or for a large edit one PUT of the whole document as read and edited (below), so unknown triples survive. Only brand-new documents are saved fromcreateSolidDataset(). The one exception is the answer log: answers are added unread, with an insert-only PATCH. -
A card moved to another deck in the Studio is written afresh in the target with only what Solid Memo knows of it. Its subject and its wrong options' are removed from the source, triples another app put on them included. A copy leaves those triples in the source and doesn't carry them over.
-
Every write states what it expects to find, as an HTTP precondition:
- an edit is sent with
If-Match: <the ETag the document was read with>; if the document changed since, the pod answers 412 and nothing is written. An edit is a PATCH, or one PUT of the whole document when the PATCH would be larger than 64 KiB (a PATCH larger than 100 kB fails on node-solid-server, and a format update of a large deck exceeds it). WithoutIf-Match(no strong ETag, below), such a PUT undoes a change made elsewhere since the read. Two edits are always one PUT (saveDataset'swhole): stating the language of a deck's untagged card sides (stateCardLanguages), and the Studio's bulk edits of cards (editCards, studio.md). Community Solid Server's in-memory store cuts a document short after a PATCH holding text beyond ASCII. A save of the preferences is always one PUT too, since node-solid-server stores a boolean a PATCH inserts as false (the preferences hold two, the developer settings and "Not now" to switching to FSRS-6); - a creation is sent with
If-None-Match: *(by the Solid client library, behindclient/resources.ts, for datasets and containers, by the copier for files); had something appeared there meanwhile, 412; - deleting a document read before sends
If-Matchtoo. Every removal of Solid Memo's data from a document (deleting an instance, a deck or a draft, or taking back what a failed creation wrote) goes throughremoveOwnedindatasets.ts. It has a rule for what of each kind of document is Solid Memo's (ownership.ts). It reads the document afresh, takes out Solid Memo's subjects of that kind, less any a newer version of the app wrote, and the blank nodes only they name. It deletes the document only when nothing else is left in it; otherwise it writes back what's left: a PATCH ofDELETE DATA, or, where a blank node goes, one PUT of the rest, eachIf-Matchits read. A document that isn't RDF, or holds nothing of Solid Memo's, is kept and nothing is sent. On a 412 it reads and removes again, once; a second 412 ischangedElsewhere. Where the pod gives no strong ETag, the delete and the PATCH go withoutIf-Match; a PUT, or a PATCH too large to send as one, isn't sent, and the document is kept as it is. A deck's cards or reviews document is removed from only when it's the deck's (where an instance's documents are). A deck another tab adds meanwhile is missed, and keeps the folder in place.
A 412 surfaces as a
PreconditionFailedErrornaming the document ("changed elsewhere … Reload and try again"). Nothing retries on its own, except the writes that re-apply a change to the document as it is then:- the digest;
- an edit of the deck groups, and the Studio's edits of metadata (a deck's authors and license, the instance's name, the catalog, a course's chapters completed), three times in all;
- a bulk edit of cards, planned again on the cards as they are, three times in all, stopping when that plan isn't the one previewed.
A grade's edit of the reviews document is made from the document as last read or written, at the version that left (srs.md); on a 412 it's read and graded again, once. Weak ETags (
W/"…") are never sent inIf-Match, whose comparison is strong; a document the pod gives no ETag is written withoutIf-Match. When the pod answers a write made on a precondition (If-Match, orIf-None-Match: *) with a strong ETag, the app keeps the written dataset at that version, as a read keeps what it read. The next edit sendsIf-Matchwith it, and the next readIf-None-Match. Without it (Community Solid Server 7 answers a PATCH with none), a dataset saved is written again withoutIf-Match, and the next read downloads the document. node-solid-server gives no ETag on a read and ignoresIf-Match; 5.7.4 ignoresIf-None-Match: *too, which 5.8.8 and 6.0.0 enforce. There no edit is conditional: a PATCH still keeps a change made elsewhere, a large edit's PUT doesn't. Community Solid Server 6's whole-second ETags miss a change made in the same second as the read (as in the digest). The end-to-end tests hold all of this against real servers, that one included (testing.md). - an edit is sent with
-
An edit's PATCH body is written by Solid Memo (
patchBodyin client/sparqlUpdate.ts, sent bywriteDocumentinclient/resources.ts), not by@inrupt/solid-client: one triple a line, a space before each.. node-solid-server's parser fails on a triple whose closing.touches the term before it (<#a> <#b> 1.}), as@inrupt/solid-clientwrites them. That server finds a decimal only as JavaScript spells its number (1,2.5), though it serves a whole one as1.0and another app may have written2.50. A PATCH it refuses (409) for a deletion spelled as read is sent once more, its deleted decimals so spelled. A triple gone is refused the second time too; one found then holds the same number. -
A document is downloaded once while it is unchanged, though a screen asks for it several times (a deck's cards for its study queue, the format check and the data check). A read already under way is shared, and a document read before is asked for with
If-None-Match: <its ETag>; on 304 the dataset read then is returned (datasets are immutable). A write to the document forgets both, unless it kept what it wrote at the version the pod answered with (above). It's kept in memory for the open page only. Logged in, the browser's own cache doesn't do this: without it a login downloaded every deck three times. That cache is never asked (cache: "no-store"), since it may keep a document the pod gives a modification time and noCache-Controlfor days, and answer a plain read with it. -
Reading builds the dataset in one pass (linearDataset.ts):
@inrupt/solid-clientcopies the whole graph for every quad, which took 9 s for a deck of 3,000 cards. -
404 is a normal state for not-yet-created documents; repositories treat it as empty, not as an error.
-
What Solid Memo didn't write, it doesn't delete or unlink. A write touches only the triples it changes, and a delete only resources Solid Memo knows it wrote (deleting an instance). A guest's deck may be added to a newly created instance: when that fails before the deck's entry names them, its cards and reviews documents are deleted, each as read, with
If-Match. Only a folder Solid Memo made whole, at a URL it found free, and nothing names yet is deleted recursively: the guest's study moved into a new instance, when that fails half-way or a closed tab leaves it behind. -
An update writes each document in one edit, held to its read. The format update and the library upgrade write each document where it is, in one edit made only if the document is still at the version read (
If-Match). The edit is made from that same read or, for an upgrade's cards, from the changes planned on it (migrations.md). A document whose write fails is as it was, and nothing is put back. No write puts an older format over a subject a newer version of the app wrote (migrations.md).