Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/studio/src/test/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export const capitals: LibraryDeck = {
keywords: {},
title: { en: "Capitals" },
cardCount: 3,
languages: [],
authors: [],
direction: "front-to-back",
sources: [],
Expand Down
1 change: 1 addition & 0 deletions apps/studio/src/ui/CardWorkbenchContainer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,7 @@ describe("CardWorkbenchContainer", () => {
...firstRelease(url),
title: { en: "Solid" },
cardCount: 1,
languages: [],
authors: [],
direction: "front-to-back",
sources: [],
Expand Down
1 change: 1 addition & 0 deletions apps/studio/src/ui/DeckTableContainer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ describe("DeckTableContainer", () => {
...firstRelease(course.sourceUrl),
title: { en: "Solid" },
cardCount: 5,
languages: [],
authors: [],
direction: "front-to-back",
sources: [],
Expand Down
1 change: 1 addition & 0 deletions apps/studio/src/ui/GroupsContainer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ const solid: LibraryDeck = {
...firstRelease(RELEASE),
title: { en: "Solid" },
cardCount: 1,
languages: [],
authors: [],
direction: "front-to-back",
sources: [],
Expand Down
27 changes: 22 additions & 5 deletions docs/deck-library.md
Original file line number Diff line number Diff line change
Expand Up @@ -784,11 +784,28 @@ flowchart LR
format-4 release's untagged keywords stay untagged, their language
unknown. A series or release
that does not fit its shape is left out.
- The library screen lists each deck's name and card count with a
checkbox, filtered by **topic** (checkboxes of the topics the decks
name, a broader topic finding the narrower: "Languages" finds the
Swedish decks) and by a **search** of names, descriptions and keywords,
in every language (`filterLibraryDecks` in [domain/library.ts](../packages/domain/src/library.ts)).
- The library screen lists each deck's name, card count and the
languages of its cards with a checkbox, filtered by **topic**
(checkboxes of the topics the decks name, a broader topic finding the
narrower: "Languages" finds the Swedish decks), by **card languages**
(checkboxes of the languages the decks state, a deck shown when its
cards are in every one checked) and by a **search** of names,
descriptions and keywords, in every language (`filterLibraryDecks` in
[domain/library.ts](../packages/domain/src/library.ts)). The checked
topics and languages and the search are kept until a reload, so a look
at a deck's page and back finds the list as it was left.
- A deck's card languages are its current release's `dcterms:language`
(`LibraryDeck.languages`), read by `toLibraryDecks` from the index and
by `toStandaloneLibraryDeck` from a release added from a link: IRIs of
the EU's language table, in the order the release gives them, none
when it states none (the shape does not ask for them). Each is named
in the app's language with `Intl.DisplayNames`, through its language
tag ("KOR" is "ko", `languageTagOf` in
[domain/euLanguages.ts](../packages/domain/src/euLanguages.ts), which
tags every language `EU_LANGUAGES` lists); one not on that list is
left out. They are not worked out from the cards, which would mean
reading every release before the list can show. The row, the deck's
page and its preview name them.
The search ignores case and Unicode form: query and text are compared
composed (NFC), so "사람" or "é" pasted decomposed (NFD), as macOS
file names and some PDFs hold text, still finds the deck; and Arabic
Expand Down
7 changes: 5 additions & 2 deletions docs/user-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -246,8 +246,10 @@ All of this is described in [browsing.md](browsing.md).
## The deck library

**Deck library**, under the deck list, lists ready-made decks
([deck-library.md](deck-library.md#in-the-app)). Search by name,
description or keyword, or narrow the list to topics. Many of the decks
([deck-library.md](deck-library.md#in-the-app)), each with the
languages its cards are in. Search by name, description or keyword, or
narrow the list to topics, or to the decks whose cards are in the
languages you check. Many of the decks
teach a language: Arabic, English, Finnish, French, German, Italian,
Japanese, Korean, Portuguese, Spanish and Swedish. There are four
Arabic decks: Basic Arabic and the most common nouns, verbs and
Expand Down Expand Up @@ -514,6 +516,7 @@ screenshot of each.
| Grouping decks | full-learner | 09–11 |
| Statistics, and a card's history | full-learner | 15–15b |
| Previewing and importing a library deck | full-learner | 16–17 |
| Narrowing the deck library to the language of the cards | [library-languages.journey.ts](../e2e/journeys/journeys/library-languages.journey.ts) | 02–04 |
| Describing a deck in two languages, and switching language | full-learner | 18–20b |
| Showing a deck's cards in another language than the app's, and back | full-learner | 20c–20d |
| Checking the instance (developer mode) | full-learner | 21 |
Expand Down
49 changes: 49 additions & 0 deletions e2e/journeys/journeys/library-languages.journey.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// SPDX-FileCopyrightText: 2026 Anton Wiklund and Solid Memo contributors
//
// SPDX-License-Identifier: MIT

import { expect, test } from "../fixtures.ts";
import { BRIGHTEST_STARS } from "../pages/Library.ts";

/** The one library deck whose cards are in Japanese (decks/index.ttl). */
const JAPANESE_BASICS = "Basic Japanese";

/**
* The deck library narrowed to the language of the cards
* (docs/deck-library.md, In the app): a guest checks Japanese under
* "Card languages" and finds only the deck whose cards are in it, its
* row naming its languages; its page names them too, and back in the
* library the language is still checked, until it is unchecked.
*
* What it catches: the index's dcterms:language not read, or read into
* names the browser cannot give (an EU language IRI with no tag), and a
* filter lost on the way to a deck's page and back.
*/
test("the deck library narrowed to the language of the cards", async ({ app }) => {
await app.step("01 · Visit Solid Memo and try it as a guest", async () => {
await app.onboarding.visit();
await app.onboarding.tryAsGuest();
});

await app.step("02 · In the deck library, check Japanese: only its deck is left", async () => {
await app.library.open();
await app.library.expectDeck(BRIGHTEST_STARS.en);
await app.library.expectRowLanguages(BRIGHTEST_STARS.en, ["English", "Swedish"]);
await app.library.checkCardLanguage("Japanese");
await expect.poll(() => app.library.shownDecks()).toEqual([JAPANESE_BASICS]);
await app.library.expectRowLanguages(JAPANESE_BASICS, ["Japanese", "English", "Swedish"]);
});

await app.step("03 · Its page names the languages of its cards", async () => {
await app.library.openDeck(JAPANESE_BASICS);
await app.libraryDeck.expectCardLanguages(["Japanese", "English", "Swedish", "Korean"]);
});

await app.step("04 · Back in the library, Japanese is still checked; uncheck it", async () => {
await app.chrome.breadcrumb("breadcrumbs.library");
await app.library.expectCardLanguageChecked("Japanese");
await expect.poll(() => app.library.shownDecks()).toEqual([JAPANESE_BASICS]);
await app.library.uncheckCardLanguage("Japanese");
await app.library.expectDeck(BRIGHTEST_STARS.en);
});
});
45 changes: 45 additions & 0 deletions e2e/journeys/pages/Library.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,51 @@ export class Library extends Screen {
});
}

/** The decks the list shows, by name, in its order. */
async shownDecks(): Promise<string[]> {
return this.page.getByRole("main").locator(".library-deck-name").allInnerTexts();
}

/** A box of "Card languages", by the language's name in the app's language. */
private cardLanguage(name: string): Locator {
return this.page
.getByRole("group", { name: this.t("library.cardLanguages"), exact: true })
.getByRole("checkbox", { name, exact: true });
}

/** Checks a language under "Card languages": the list keeps the decks with cards in it. */
async checkCardLanguage(name: string): Promise<void> {
await this.intent(`Check the card language ${name}`, async () => {
await this.cardLanguage(name).check();
await expect(this.cardLanguage(name)).toBeChecked();
});
}

/** Unchecks a language under "Card languages". */
async uncheckCardLanguage(name: string): Promise<void> {
await this.intent(`Uncheck the card language ${name}`, async () => {
await this.cardLanguage(name).uncheck();
await expect(this.cardLanguage(name)).not.toBeChecked();
});
}

async expectCardLanguageChecked(name: string): Promise<void> {
await this.intent(`Check ${name} is still checked under the card languages`, async () => {
await expect(this.cardLanguage(name)).toBeChecked();
});
}

/** The languages a deck's row names beside its size: `names`, in the deck's order. */
async expectRowLanguages(title: string, names: string[]): Promise<void> {
await this.intent(`Check the row of ${title} names its card languages`, async () => {
const row = this.page
.getByRole("main")
.getByRole("listitem")
.filter({ has: this.page.getByRole("link", { name: title, exact: true }) });
await expect(row).toContainText(names.join(", "));
});
}

/** Opens a deck's page (LibraryDeck) by its name. */
async openDeck(title: string): Promise<void> {
await this.intent(`Open the library deck ${title}`, async () => {
Expand Down
11 changes: 11 additions & 0 deletions e2e/journeys/pages/LibraryDeck.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,17 @@ export class LibraryDeck extends Screen {
).toBeVisible();
}

/** The page's facts name the languages of the deck's cards, `names` among them. */
async expectCardLanguages(names: string[]): Promise<void> {
await this.intent("Check the deck's page names its card languages", async () => {
const term = this.page.getByRole("term").filter({
hasText: this.t("libraryDeck.cardLanguages", { count: 2 }),
});
const languages = term.locator("xpath=following-sibling::dd[1]");
for (const name of names) await expect(languages).toContainText(name);
});
}

/**
* "Copy link": the link to the deck that names no instance, as the app
* wrote it to the clipboard. Only Chromium lets a page read the
Expand Down
1 change: 1 addition & 0 deletions e2e/pod/src/courses.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ const course: LibraryDeck = {
keywords: content.keywords,
title: content.title,
cardCount: cards.length,
languages: [],
authors: content.authors,
direction: "front-to-back",
sources: [],
Expand Down
1 change: 1 addition & 0 deletions e2e/pod/src/deckUpgrade.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ function libraryOf(...published: LibraryDeckContent[]): DeckLibrary {
keywords: current.keywords,
title: current.title,
cardCount: current.cards.length,
languages: [],
authors: current.authors,
direction: current.direction,
sources: [],
Expand Down
1 change: 1 addition & 0 deletions e2e/pod/src/guestTransfer.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ const course: LibraryDeck = {
keywords: {},
title: content.title,
cardCount: 1,
languages: [],
authors: content.authors,
direction: "front-to-back",
sources: [],
Expand Down
1 change: 1 addition & 0 deletions e2e/pod/src/metadata.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ const course: LibraryDeck = {
keywords: {},
title: content.title,
cardCount: 0,
languages: [],
authors: [],
direction: "front-to-back",
sources: [],
Expand Down
1 change: 1 addition & 0 deletions packages/application/src/testing/fakePorts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ export const libraryDeck: LibraryDeck = {
...firstRelease("https://solid-memo.com/decks/capitals/v1.ttl"),
title: { en: "Capitals" },
cardCount: 1,
languages: [],
authors: ["Anton Wiklund"],
license: "https://creativecommons.org/publicdomain/zero/1.0/",
direction: "front-to-back",
Expand Down
29 changes: 29 additions & 0 deletions packages/domain/src/euLanguages.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// SPDX-FileCopyrightText: 2026 Anton Wiklund and Solid Memo contributors
//
// SPDX-License-Identifier: MIT

import { describe, expect, it } from "vitest";
import { EU_LANGUAGES } from "@solid-memo/vocab/concepts.generated";
import { languageTagOf } from "./euLanguages";

const LANGUAGE = "http://publications.europa.eu/resource/authority/language/";

describe("languageTagOf", () => {
it("gives every language of the reference data a tag of its own", () => {
const tags = EU_LANGUAGES.map((concept) => languageTagOf(concept.iri));
for (const [index, tag] of tags.entries()) {
expect(tag, EU_LANGUAGES[index]!.code).toMatch(/^[a-z]{2}$/);
}
expect(new Set(tags).size).toBe(EU_LANGUAGES.length);
});

it("tags the table's languages by their two-letter codes", () => {
expect(languageTagOf(`${LANGUAGE}ENG`)).toBe("en");
expect(languageTagOf(`${LANGUAGE}KOR`)).toBe("ko");
});

it("has no tag for an IRI the reference data does not describe", () => {
expect(languageTagOf(`${LANGUAGE}NOR`)).toBeUndefined();
expect(languageTagOf("https://example.org/swedish")).toBeUndefined();
});
});
36 changes: 36 additions & 0 deletions packages/domain/src/euLanguages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// SPDX-FileCopyrightText: 2026 Anton Wiklund and Solid Memo contributors
//
// SPDX-License-Identifier: MIT

import { EU_LANGUAGES } from "@solid-memo/vocab/concepts.generated";

/**
* The language tag (BCP 47, lower case, as the app keeps them) of each
* language of the EU's table a release may state (EU_LANGUAGES), by its
* code there.
*/
const TAGS: Readonly<Record<string, string>> = {
ARA: "ar",
DEU: "de",
ELL: "el",
ENG: "en",
FIN: "fi",
FRA: "fr",
ITA: "it",
JPN: "ja",
KOR: "ko",
LAT: "la",
POR: "pt",
SPA: "es",
SWE: "sv",
};

/**
* The language tag of a language of the EU's table, by its IRI
* (".../authority/language/KOR" is "ko"): undefined for an IRI the
* reference data does not describe.
*/
export function languageTagOf(iri: string): string | undefined {
const concept = EU_LANGUAGES.find((one) => one.iri === iri);
return concept === undefined ? undefined : TAGS[concept.code];
}
Loading
Loading