Version 3.3.0 · All versions

These are the docs as released with Solid Memo 3.3.0. Read the latest docs.

Data model in the pod

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 again after a login, and how it writes them without losing a change made on another device.

Where Solid Memo stores data in a user's pod and how it finds it again. Implemented in packages/solid/src/ (vocabulary in vocab.ts, discovery in typeIndex.ts). The pod is read and written by the Solid Protocol, and Solid Memo's data is found through the type indexes, an editor's draft (standards.md).

Vocabulary and shapes

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. The terms are defined in ns/vocab/v1.ttl (vocab.md); what a valid subject of each class looks like, version by version, is a SHACL shape under ns/shapes/ (shapes.md). Both are published with the site, 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, that is not the catalog and that no subject of Solid Memo's names as its creator, publisher or distribution, is another app's, and so is a review state of another scheduler; the data check only warns about them (validation.md).

From pod data to the domain model
A Thing 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.
The diagram's source
flowchart LR
    accTitle: From pod data to the domain model
    accDescr: A Thing 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["Inrupt Thing"] -->|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

Discovery chain

The discovery chain
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.
The diagram's source
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

Registrations

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, so other applications find each kind without knowing Solid Memo: 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 all of its registrations: the sm:Instance one by its container, each other by the exact IRI it names (<container>catalog.ttl#catalog, <container>catalog.ttl, <container>decks/, <container>reviews/, <container>history/, <container>drafts/), never by what lies under the container, where another app may have registered data of its own. A registration that also registers something else keeps it, and loses only the link to the instance's data.

Instance layout

<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 on first explicit save):
│                        study caps, sm:answerScale, sm:developerMode,
│                        sm:invalidDataPolicy, sm:theme,
│                        sm:formatVersion 4
├── 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);
│                        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, 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
├── 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>) and a sm:DeckSchedule per deck
                         (#schedule-<path>), each stamped with the versions
                         it was learned from; sm:formatVersion 1

A deck's documents are found through its catalog entry (sm:cardsDocument, sm:reviewsDocument), never by name: a library upgrade by an earlier version of the app moved them to decks/<deckId>-<uuid>.ttl and reviews/<deckId>-<uuid>.ttl. This app's upgrade writes them where they are (migrations).

Decks and cards

Granularity is chosen around the N+1 problem (no batch requests, no SPARQL on Solid servers): reading a whole deck is one GET, and a study session never rewrites card content.

An instance's documents
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.
The diagram's source
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

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.

The catalog

catalog.ttl#catalog is a dcat:Catalog (shape CatalogV1) of the instance's decks: a dcterms:title (the instance's name), a dcterms:description, dcterms:publisher (the pod owner's WebID, described in the same document as a foaf:Agent with the foaf:name of their profile), the topics scheme and the EU data themes as dcat:themeTaxonomy, and a dcat:dataset per deck, kept in step as decks are added and removed. It may state a license (dcterms:license), which the same 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 writing the catalog itself keeps it too, beside every deck of the document. The one link the catalog loses when it is written whole is one to a subject of its own document that is no dcat:Dataset (left by a deck removed some other way): it would fail DCAT-AP's class check, and the catalog could not be written. A member another app described in the document keeps its link whatever its class: the check only warns about that link (validation.md). A dataset in another document is described there, so that class check does not hold it to its class (validation.md), and an instance listing one stays valid (held against real servers in foreignData.integration.test.ts). It is written when an instance is created, and by the format update for an instance made before there were catalogs. Its registration is written with the instance's when the instance is created or a guest's study moves into the pod as a new instance, and by the format update and Preferences when missing (Discovery chain). The whole document conforms to DCAT-AP (a test holds what the app writes to it).

The instance's name is kept in the meta document's dcterms:title, the catalog's dcterms:title, and, in each type index, the dcterms:title of every registration of the instance's data (Discovery chain): its sm:Instance registration, which names it in the instance list, and those of its catalog, decks, cards, review states and answers. Renaming it in the Studio (renameInstance, studio.md) writes all of them, each document read and written again on a 412. A catalog description that is the one 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 one comma-separated list per language the user states; the description is required, as DCAT-AP asks of every dataset. Only the keyword languages the user states in an edit are checked and stored in their canonical form ("iw" → "he"); a language tag the deck already has, even one another app wrote that the app would not accept from the user, is kept as stored.

Deck groups

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: DCAT nests catalogs, and DCAT-AP lets a catalog list sub-catalogs with dcat:catalog and datasets with dcat:dataset. DCAT has no ordered membership (dcat:prev/next are series versions, which the library uses as such), so the order is Solid Memo's own sm:position.

<#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 .

What is not one valid tree is read as one, rather than 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 the lowest-URL group in it to the top level. A negative position counts as none, and 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, and so, under the "Set invalid data aside" policy (sm:blockSubject, the default), does a catalog or group that does not conform, and so does every list while that check is still running (validation.md); a group the app cannot 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: move a node after a sibling, combine two into a new group, rename or remove a group, gather several decks at the end of a group, as the Studio's bulk move does, or, as keeping a guest's study does, graft new groups around decks the tree has at the end of the top level, which changes nothing once one of its groups is there), and the repository (editDeckTree in solidDeckRepository.ts) applies it to catalog.ttl as the pod holds it then:

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 deletes their cards and reviews documents first, then their entries in one save (removeDecks).

Wrong options

Any card may have wrong options, shown beside its back when it is asked as 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 is wrong (sm:distractorNote). The card names each with sm:distractor, and with schema:suggestedAnswer too (since vocabulary 1.16, below): every write of a card keeps the two in step, whoever makes it (the card editor, the Studio's inspector, bulk edits, moves, copies and imports, a course's question joining the deck). Solid Memo's card editor and the Studio's card inspector edit them (studio.md; the domain is distractors.ts):

Courses

A course is a library release with an outline of chapters and steps. Its state in the pod uses the documents above and no new type-index entries:

# 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> .

Drafts and releases

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):

The answer log

Every grade given in study is kept, so statistics can be computed (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, are not from the log but from the review states (srs.md). Once a day has answers, a slim strip above the deck list (where a session usually ends) says what the day came to (todayOf), led by the streak (with the record to beat, or a new record cheered), then a word of praise and the day's numbers (statistics.md says what each screen shows). A review state says only where a card stands now; the log is the history, and source data, since nothing could rebuild it.

The digest

digest.ttl lets a visit skip what has not changed since the last one. 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):

Counting a deck through the digest
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.
The diagram's source
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

Decks as files

The Studio saves a deck as a file, and makes a deck of a file (studio.md). The file is 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:

Nothing is rewritten: a deck still at an older format is exported at it. A JSON-LD file is flattened: every subject a node of its @graph, its @context 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 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 answers the log keeps of it 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 a subject of the catalog already. An id this app would not give (deck-…) takes a fresh one too, 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 when cards move to another deck: triples another app put there are in the file, but not imported. It is written as Solid Memo writes any deck: each card names its wrong options with sm:distractor and schema:suggestedAnswer, each typed sm:Distractor and schema:Answer, and each review state names its card in the new cards document (sm:reviewOf), its direction and sm:sm2; a state of another scheduler in the file is not imported. The deck needs no type-index registration of its own: the instance's registrations of its decks (catalog.ttl), cards (decks/) and review states (reviews/) cover it, as they cover a deck made in Solid Memo (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.

UI gaps

What the data model holds that no screen edits yet. Most of it is shown in Solid Memo; the Studio closes the gaps one by one.

Write discipline