diff --git a/apps/studio/src/test/fixtures.ts b/apps/studio/src/test/fixtures.ts index c6b41066..100ab8e7 100644 --- a/apps/studio/src/test/fixtures.ts +++ b/apps/studio/src/test/fixtures.ts @@ -60,6 +60,7 @@ export const capitals: LibraryDeck = { keywords: {}, title: { en: "Capitals" }, cardCount: 3, + languages: [], authors: [], direction: "front-to-back", sources: [], diff --git a/apps/studio/src/ui/CardWorkbenchContainer.test.tsx b/apps/studio/src/ui/CardWorkbenchContainer.test.tsx index 3662abeb..20d7f754 100644 --- a/apps/studio/src/ui/CardWorkbenchContainer.test.tsx +++ b/apps/studio/src/ui/CardWorkbenchContainer.test.tsx @@ -207,6 +207,7 @@ describe("CardWorkbenchContainer", () => { ...firstRelease(url), title: { en: "Solid" }, cardCount: 1, + languages: [], authors: [], direction: "front-to-back", sources: [], diff --git a/apps/studio/src/ui/DeckTableContainer.test.tsx b/apps/studio/src/ui/DeckTableContainer.test.tsx index 23a82d7f..612937f0 100644 --- a/apps/studio/src/ui/DeckTableContainer.test.tsx +++ b/apps/studio/src/ui/DeckTableContainer.test.tsx @@ -150,6 +150,7 @@ describe("DeckTableContainer", () => { ...firstRelease(course.sourceUrl), title: { en: "Solid" }, cardCount: 5, + languages: [], authors: [], direction: "front-to-back", sources: [], diff --git a/apps/studio/src/ui/GroupsContainer.test.tsx b/apps/studio/src/ui/GroupsContainer.test.tsx index bafa4313..40fbef0c 100644 --- a/apps/studio/src/ui/GroupsContainer.test.tsx +++ b/apps/studio/src/ui/GroupsContainer.test.tsx @@ -21,6 +21,7 @@ const solid: LibraryDeck = { ...firstRelease(RELEASE), title: { en: "Solid" }, cardCount: 1, + languages: [], authors: [], direction: "front-to-back", sources: [], diff --git a/docs/deck-library.md b/docs/deck-library.md index e00df1e8..a088a6b8 100644 --- a/docs/deck-library.md +++ b/docs/deck-library.md @@ -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 diff --git a/docs/user-guide.md b/docs/user-guide.md index ffa15752..e1f2adc8 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -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 @@ -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 | diff --git a/e2e/journeys/journeys/library-languages.journey.ts b/e2e/journeys/journeys/library-languages.journey.ts new file mode 100644 index 00000000..c6f83cb9 --- /dev/null +++ b/e2e/journeys/journeys/library-languages.journey.ts @@ -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); + }); +}); diff --git a/e2e/journeys/pages/Library.ts b/e2e/journeys/pages/Library.ts index 45862e2e..c2266233 100644 --- a/e2e/journeys/pages/Library.ts +++ b/e2e/journeys/pages/Library.ts @@ -68,6 +68,51 @@ export class Library extends Screen { }); } + /** The decks the list shows, by name, in its order. */ + async shownDecks(): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { await this.intent(`Open the library deck ${title}`, async () => { diff --git a/e2e/journeys/pages/LibraryDeck.ts b/e2e/journeys/pages/LibraryDeck.ts index 22792f39..1fe509e5 100644 --- a/e2e/journeys/pages/LibraryDeck.ts +++ b/e2e/journeys/pages/LibraryDeck.ts @@ -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 { + 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 diff --git a/e2e/pod/src/courses.integration.test.ts b/e2e/pod/src/courses.integration.test.ts index 8b69e280..2717c103 100644 --- a/e2e/pod/src/courses.integration.test.ts +++ b/e2e/pod/src/courses.integration.test.ts @@ -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: [], diff --git a/e2e/pod/src/deckUpgrade.integration.test.ts b/e2e/pod/src/deckUpgrade.integration.test.ts index e61a2634..12312a53 100644 --- a/e2e/pod/src/deckUpgrade.integration.test.ts +++ b/e2e/pod/src/deckUpgrade.integration.test.ts @@ -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: [], diff --git a/e2e/pod/src/guestTransfer.integration.test.ts b/e2e/pod/src/guestTransfer.integration.test.ts index b8f7b62a..42567ce1 100644 --- a/e2e/pod/src/guestTransfer.integration.test.ts +++ b/e2e/pod/src/guestTransfer.integration.test.ts @@ -78,6 +78,7 @@ const course: LibraryDeck = { keywords: {}, title: content.title, cardCount: 1, + languages: [], authors: content.authors, direction: "front-to-back", sources: [], diff --git a/e2e/pod/src/metadata.integration.test.ts b/e2e/pod/src/metadata.integration.test.ts index 0b863e9f..73b305ec 100644 --- a/e2e/pod/src/metadata.integration.test.ts +++ b/e2e/pod/src/metadata.integration.test.ts @@ -80,6 +80,7 @@ const course: LibraryDeck = { keywords: {}, title: content.title, cardCount: 0, + languages: [], authors: [], direction: "front-to-back", sources: [], diff --git a/packages/application/src/testing/fakePorts.ts b/packages/application/src/testing/fakePorts.ts index 2798a74b..5449c76b 100644 --- a/packages/application/src/testing/fakePorts.ts +++ b/packages/application/src/testing/fakePorts.ts @@ -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", diff --git a/packages/domain/src/euLanguages.test.ts b/packages/domain/src/euLanguages.test.ts new file mode 100644 index 00000000..1af70d06 --- /dev/null +++ b/packages/domain/src/euLanguages.test.ts @@ -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(); + }); +}); diff --git a/packages/domain/src/euLanguages.ts b/packages/domain/src/euLanguages.ts new file mode 100644 index 00000000..2f7a0302 --- /dev/null +++ b/packages/domain/src/euLanguages.ts @@ -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> = { + 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]; +} diff --git a/packages/domain/src/library.test.ts b/packages/domain/src/library.test.ts index 2bd39994..01a351ad 100644 --- a/packages/domain/src/library.test.ts +++ b/packages/domain/src/library.test.ts @@ -5,8 +5,10 @@ import { describe, expect, it } from "vitest"; import type { Deck } from "./deck"; import { + deckCardLanguages, filterLibraryDecks, isCopyOf, + libraryCardLanguages, libraryCopiesOf, newestRelease, offersNewerRelease, @@ -19,6 +21,8 @@ import { const TOPIC = "https://solid-memo.com/ns/vocab/topics.ttl#"; const EDUC = "http://publications.europa.eu/resource/authority/data-theme/EDUC"; +const LANGUAGE = "http://publications.europa.eu/resource/authority/language/"; +const [ENG, SWE, KOR] = [`${LANGUAGE}ENG`, `${LANGUAGE}SWE`, `${LANGUAGE}KOR`]; function libraryDeck( name: string, @@ -33,6 +37,7 @@ function libraryDeck( releases: [], title: { en: name }, cardCount: 1, + languages: [], authors: [], direction: "front-to-back", sources: [], @@ -44,9 +49,11 @@ function libraryDeck( const capitals = libraryDeck("capitals", [`${TOPIC}geography`], { description: { en: "Every country's capital." }, + languages: [ENG, SWE], }); const nouns = libraryDeck("swedish-nouns", [`${TOPIC}swedish`], { keywords: { en: ["Vocabulary"], sv: ["Ordförråd"] }, + languages: [SWE, ENG], }); const http = libraryDeck("http", [`${TOPIC}computing`]); @@ -195,46 +202,99 @@ describe("topicsOf and topicLabels", () => { }); }); +describe("deckCardLanguages and libraryCardLanguages", () => { + const korean = libraryDeck("korean", [], { languages: [KOR, `${LANGUAGE}NOR`, SWE] }); + + it("tag a deck's card languages in its order, leaving out those the reference data does not describe", () => { + expect(deckCardLanguages(nouns)).toEqual([ + { iri: SWE, tag: "sv" }, + { iri: ENG, tag: "en" }, + ]); + expect(deckCardLanguages(korean).map((language) => language.tag)).toEqual(["ko", "sv"]); + expect(deckCardLanguages(http)).toEqual([]); + }); + + it("list each language any deck's cards are in once, in the EU table's order", () => { + expect(libraryCardLanguages([korean, nouns, http]).map((language) => language.tag)).toEqual([ + "en", + "sv", + "ko", + ]); + expect(libraryCardLanguages([http])).toEqual([]); + }); +}); + describe("filterLibraryDecks", () => { const decks = [capitals, nouns, http]; it("keeps every deck when nothing is chosen or typed", () => { - expect(filterLibraryDecks(decks, { topics: [], query: " " })).toEqual(decks); + expect(filterLibraryDecks(decks, { topics: [], languages: [], query: " " })).toEqual(decks); }); it("keeps the decks about every chosen topic, a narrower topic included", () => { - expect(filterLibraryDecks(decks, { topics: [`${TOPIC}languages`], query: "" })).toEqual([ + expect( + filterLibraryDecks(decks, { topics: [`${TOPIC}languages`], languages: [], query: "" }), + ).toEqual([nouns]); + expect( + filterLibraryDecks(decks, { topics: [`${TOPIC}geography`], languages: [], query: "" }), + ).toEqual([capitals]); + expect( + filterLibraryDecks(decks, { + topics: [`${TOPIC}geography`, `${TOPIC}computing`], + languages: [], + query: "", + }), + ).toEqual([]); + }); + + it("keeps the decks with cards in every chosen language", () => { + const korean = libraryDeck("korean", [], { languages: [KOR, SWE] }); + const all = [...decks, korean]; + expect(filterLibraryDecks(all, { topics: [], languages: [SWE], query: "" })).toEqual([ + capitals, nouns, + korean, ]); - expect(filterLibraryDecks(decks, { topics: [`${TOPIC}geography`], query: "" })).toEqual([ - capitals, + expect(filterLibraryDecks(all, { topics: [], languages: [KOR, SWE], query: "" })).toEqual([ + korean, ]); + expect(filterLibraryDecks(all, { topics: [], languages: [KOR, ENG], query: "" })).toEqual([]); expect( - filterLibraryDecks(decks, { topics: [`${TOPIC}geography`, `${TOPIC}computing`], query: "" }), - ).toEqual([]); + filterLibraryDecks(all, { topics: [`${TOPIC}swedish`], languages: [ENG], query: "" }), + ).toEqual([nouns]); }); it("keeps the decks whose name, description or keywords contain the query, whatever the case", () => { - expect(filterLibraryDecks(decks, { topics: [], query: "HTTP" })).toEqual([http]); - expect(filterLibraryDecks(decks, { topics: [], query: "capital" })).toEqual([capitals]); - expect(filterLibraryDecks(decks, { topics: [], query: "vocabulary" })).toEqual([nouns]); + expect(filterLibraryDecks(decks, { topics: [], languages: [], query: "HTTP" })).toEqual([http]); + expect(filterLibraryDecks(decks, { topics: [], languages: [], query: "capital" })).toEqual([ + capitals, + ]); + expect(filterLibraryDecks(decks, { topics: [], languages: [], query: "vocabulary" })).toEqual([ + nouns, + ]); }); it("searches the keywords in every language, not only the reader's", () => { - expect(filterLibraryDecks(decks, { topics: [], query: "ordförråd" })).toEqual([nouns]); + expect(filterLibraryDecks(decks, { topics: [], languages: [], query: "ordförråd" })).toEqual([ + nouns, + ]); }); it("finds composed text with a decomposed query, and decomposed text with a composed one", () => { const korean = libraryDeck("korean-nouns", [`${TOPIC}korean`], { keywords: { ko: ["사람"] } }); const decomposed = libraryDeck("decomposed", [], { description: { en: "Cafe\u0301 words" } }); expect( - filterLibraryDecks([...decks, korean], { topics: [], query: "사람".normalize("NFD") }), + filterLibraryDecks([...decks, korean], { + topics: [], + languages: [], + query: "사람".normalize("NFD"), + }), ).toEqual([korean]); - expect(filterLibraryDecks([...decks, decomposed], { topics: [], query: "CAFÉ" })).toEqual([ - decomposed, - ]); - expect(filterLibraryDecks(decks, { topics: [], query: "ordfo\u0308rra\u030Ad" })).toEqual([ - nouns, - ]); + expect( + filterLibraryDecks([...decks, decomposed], { topics: [], languages: [], query: "CAFÉ" }), + ).toEqual([decomposed]); + expect( + filterLibraryDecks(decks, { topics: [], languages: [], query: "ordfo\u0308rra\u030Ad" }), + ).toEqual([nouns]); }); }); diff --git a/packages/domain/src/library.ts b/packages/domain/src/library.ts index 940a6b29..c6dc377f 100644 --- a/packages/domain/src/library.ts +++ b/packages/domain/src/library.ts @@ -2,8 +2,9 @@ // // SPDX-License-Identifier: MIT -import { TOPICS } from "@solid-memo/vocab/concepts.generated"; +import { EU_LANGUAGES, TOPICS } from "@solid-memo/vocab/concepts.generated"; import type { CardContent, Deck, DeckDirection } from "./deck"; +import { languageTagOf } from "./euLanguages"; import { allKeywords, type LangTexts } from "./keywords"; import type { LangText } from "./langText"; import { librarySeriesUrlOf } from "./libraryLayout"; @@ -33,6 +34,12 @@ export interface LibraryDeck { /** The deck's title, in every language it states it in (one of them English). */ title: LangText; cardCount: number; + /** + * The languages the current release's cards are in (dcterms:language), + * by their IRIs in the EU's table, in the order the release gives them: + * none when it states none, as a release published elsewhere may. + */ + languages: string[]; authors: string[]; license?: string; /** A sentence or two about the deck, in every language it states it in. */ @@ -225,19 +232,46 @@ function isAbout(deck: LibraryDeck, topic: string): boolean { ); } +/** A language a library deck's cards are in: its IRI in the EU's table and its language tag. */ +export interface CardLanguage { + iri: string; + tag: string; +} + +/** The languages a deck's cards are in, in its order: those the reference data describes (languageTagOf), any other left out. */ +export function deckCardLanguages(deck: Pick): CardLanguage[] { + return deck.languages.flatMap((iri) => { + const tag = languageTagOf(iri); + return tag === undefined ? [] : [{ iri, tag }]; + }); +} + +/** The languages the decks' cards are in, to filter the library by: each any deck states, in the EU table's order. */ +export function libraryCardLanguages(decks: readonly LibraryDeck[]): CardLanguage[] { + const stated = new Set(decks.flatMap((deck) => deck.languages)); + return deckCardLanguages({ + languages: EU_LANGUAGES.map((concept) => concept.iri).filter((iri) => stated.has(iri)), + }); +} + /** - * The decks about every chosen topic whose title, description or - * keywords, in any language, contain the query (case-insensitively, - * whatever its Unicode form: see search.ts); all of them when nothing is - * chosen or typed. + * The decks about every chosen topic, with cards in every chosen + * language, whose title, description or keywords, in any language, + * contain the query (case-insensitively, whatever its Unicode form: see + * search.ts); all of them when nothing is chosen or typed. */ export function filterLibraryDecks( decks: readonly LibraryDeck[], - { topics, query }: { topics: readonly string[]; query: string }, + { + topics, + languages, + query, + }: { topics: readonly string[]; languages: readonly string[]; query: string }, ): LibraryDeck[] { return decks.filter( (deck) => topics.every((topic) => isAbout(deck, topic)) && + languages.every((language) => deck.languages.includes(language)) && matchesQuery( [ ...Object.values(deck.title), diff --git a/packages/domain/src/release/draftListing.test.ts b/packages/domain/src/release/draftListing.test.ts index 83fe7ba1..586457a8 100644 --- a/packages/domain/src/release/draftListing.test.ts +++ b/packages/domain/src/release/draftListing.test.ts @@ -27,6 +27,7 @@ describe("draftLibraryDeck", () => { keywords: {}, title: { en: "Solid" }, cardCount: 3, + languages: [], authors: [], direction: "front-to-back", createdAt: NOW, @@ -51,6 +52,7 @@ describe("draftLibraryDeck", () => { creator: [of("anna"), "https://someone.example/#me"], theme: ["https://theme.example/"], keyword: { en: ["word"] }, + language: ["http://publications.europa.eu/resource/authority/language/SWE"], wasDerivedFrom: ["https://source.example/"], }, agents: [{ id: "anna", data: { name: "Anna" } }], @@ -85,6 +87,7 @@ describe("draftLibraryDeck", () => { releases: [{ url: DRAFT, version: "1", issued: NOW, notes: "First" }], themes: ["https://theme.example/"], keywords: { en: ["word"] }, + languages: ["http://publications.europa.eu/resource/authority/language/SWE"], authors: ["Anna", "https://someone.example/#me"], license: "https://creativecommons.org/publicdomain/zero/1.0/", description: { en: "About" }, diff --git a/packages/domain/src/release/draftListing.ts b/packages/domain/src/release/draftListing.ts index 098c16b2..ed2a4c4a 100644 --- a/packages/domain/src/release/draftListing.ts +++ b/packages/domain/src/release/draftListing.ts @@ -43,6 +43,7 @@ export function draftLibraryDeck(draft: ReleaseDraft): LibraryDeck { keywords: copyKeywords(root.keyword), title: root.title ?? {}, cardCount: draft.cards.filter((card) => card.data.deprecated !== true).length, + languages: [...root.language], authors: root.creator.map((agent) => names.get(agent) ?? agent), ...(root.license === undefined ? {} : { license: root.license }), ...(root.description === undefined ? {} : { description: root.description }), @@ -78,6 +79,7 @@ export function draftLibraryContent(draft: ReleaseDraft): LibraryDeckContent { url, releases: _releases, cardCount: _cardCount, + languages: _languages, sources: _sources, createdAt: _createdAt, isCourse, diff --git a/packages/solid/src/mappers/libraryMapper.test.ts b/packages/solid/src/mappers/libraryMapper.test.ts index 60fe1e76..02d1c43b 100644 --- a/packages/solid/src/mappers/libraryMapper.test.ts +++ b/packages/solid/src/mappers/libraryMapper.test.ts @@ -30,6 +30,7 @@ const CC0 = "https://creativecommons.org/publicdomain/zero/1.0/"; const SERIES = "https://solid-memo.com/decks/index.ttl#capitals"; const EDUC = "http://publications.europa.eu/resource/authority/data-theme/EDUC"; const FLAG = "https://flagcdn.com/af.svg"; +const LANGUAGE = "http://publications.europa.eu/resource/authority/language/"; function thing( url: string, @@ -71,6 +72,7 @@ const INDEX_TURTLE = ` dcterms:issued "2026-09-22T10:00:00Z"^^xsd:dateTime ; dcterms:publisher <#solid-memo> ; sm:studyDirection sm:bidirectional ; dcat:theme <${EDUC}>, ; dcat:keyword "capitals" ; + dcterms:language <${LANGUAGE}SWE>, <${LANGUAGE}ENG> ; dcat:version "2" ; adms:versionNotes "Added Norway." ; dcat:inSeries <#capitals> ; dcat:isVersionOf <#capitals> ; dcat:distribution ; prov:wasDerivedFrom <${WIKIPEDIA}>, , <${WIKIDATA}> . @@ -113,6 +115,7 @@ describe("toLibraryDecks", () => { ], title: { en: "Capitals" }, cardCount: 243, + languages: [`${LANGUAGE}SWE`, `${LANGUAGE}ENG`], authors: ["Anton ", "https://solid-memo.com/decks/capitals/v2.ttl#gone"], license: CC0, description: { en: "Capitals of the world." }, diff --git a/packages/solid/src/mappers/libraryMapper.ts b/packages/solid/src/mappers/libraryMapper.ts index cbaedd3b..498a5b4c 100644 --- a/packages/solid/src/mappers/libraryMapper.ts +++ b/packages/solid/src/mappers/libraryMapper.ts @@ -104,6 +104,7 @@ function toLibraryDeck( .sort((a, b) => Number(a.version) - Number(b.version)), title: release.title, cardCount: getInteger(currentThing!, SM.cardCount) ?? 0, + languages: [...release.language], authors: release.creator.map((agent) => names.get(agent) ?? agent), ...(release.license === undefined ? {} : { license: release.license }), description: release.description, @@ -238,6 +239,7 @@ export function toStandaloneLibraryDeck(url: string, dataset: SolidDataset): Lib .map((one) => (one.url === subject ? { ...one, url } : one)) .sort((a, b) => Number(a.version) - Number(b.version)), cardCount: cards.filter((card) => card.retired !== true).length, + languages: [...release.language], ...(release.created === undefined ? {} : { createdAt: release.created }), sources: release.wasDerivedFrom.map((sourceUrl) => toLibrarySource(sourceUrl, getThing(dataset, sourceUrl)), diff --git a/packages/solid/src/releaseFromLink.test.ts b/packages/solid/src/releaseFromLink.test.ts index cb0a8678..58845204 100644 --- a/packages/solid/src/releaseFromLink.test.ts +++ b/packages/solid/src/releaseFromLink.test.ts @@ -64,6 +64,10 @@ describe("readRelease", () => { title: { en: "Capitals", sv: "Huvudstäder" }, description: { en: "Capitals of the world.", sv: "Världens huvudstäder." }, cardCount: 2, + languages: [ + "http://publications.europa.eu/resource/authority/language/ENG", + "http://publications.europa.eu/resource/authority/language/SWE", + ], authors: ["Alice"], license: "https://creativecommons.org/publicdomain/zero/1.0/", direction: "front-to-back", diff --git a/packages/ui/src/i18n/de.json b/packages/ui/src/i18n/de.json index 18ef4dfa..85492f1c 100644 --- a/packages/ui/src/i18n/de.json +++ b/packages/ui/src/i18n/de.json @@ -831,6 +831,7 @@ "intro": "Fertige Kartenstapel. Öffne einen, um mehr darüber zu lesen, oder kreuze mehrere an und importiere sie als eigene Kopien in diese Instanz.", "empty": "Die Bibliothek hat noch keine Stapel.", "topics": "Themen", + "cardLanguages": "Sprachen der Karten", "search": "Suche", "searchPlaceholder": "Name, Beschreibung oder Schlagwort", "noMatch": "Kein Stapel passt.", @@ -874,6 +875,10 @@ "one": "Thema", "other": "Themen" }, + "cardLanguages": { + "one": "Sprache der Karten", + "other": "Sprachen der Karten" + }, "keywords": "Schlagwörter", "release": "Ausgabe", "version": "Version {version}", @@ -911,6 +916,7 @@ "back": "Zurück zur Bibliothek", "empty": "Dieser Stapel hat keine Karten.", "hint": "Zufällige Karten aus dem Stapel. Nichts wird gespeichert: Importiere den Stapel, um ihn zu lernen.", + "cardLanguages": "Karten auf {languages}.", "nextCard": "Nächste Karte", "reveal": "Antwort anzeigen", "loading": "Karten werden geladen…" diff --git a/packages/ui/src/i18n/en.json b/packages/ui/src/i18n/en.json index 99da0f79..ef00c9c2 100644 --- a/packages/ui/src/i18n/en.json +++ b/packages/ui/src/i18n/en.json @@ -831,6 +831,7 @@ "intro": "Ready-made decks. Open one to read about it, or check several and import them into this instance as your own copies.", "empty": "The library has no decks yet.", "topics": "Topics", + "cardLanguages": "Card languages", "search": "Search", "searchPlaceholder": "Name, description or keyword", "noMatch": "No deck matches.", @@ -874,6 +875,10 @@ "one": "Topic", "other": "Topics" }, + "cardLanguages": { + "one": "Card language", + "other": "Card languages" + }, "keywords": "Keywords", "release": "Release", "version": "Version {version}", @@ -911,6 +916,7 @@ "back": "Back to library", "empty": "This deck has no cards.", "hint": "Random cards from the deck. Nothing is recorded: import the deck to study it.", + "cardLanguages": "Cards in {languages}.", "nextCard": "Next card", "reveal": "Reveal", "loading": "Loading cards…" diff --git a/packages/ui/src/i18n/es.json b/packages/ui/src/i18n/es.json index 0a363375..73c5f2a8 100644 --- a/packages/ui/src/i18n/es.json +++ b/packages/ui/src/i18n/es.json @@ -873,6 +873,7 @@ "intro": "Mazos ya preparados. Abre uno para ver de qué trata, o marca varios e impórtalos a esta instancia como copias tuyas.", "empty": "La biblioteca aún no tiene mazos.", "topics": "Temas", + "cardLanguages": "Idiomas de las tarjetas", "search": "Buscar", "searchPlaceholder": "Nombre, descripción o palabra clave", "noMatch": "Ningún mazo coincide.", @@ -919,6 +920,11 @@ "many": "Temas", "other": "Temas" }, + "cardLanguages": { + "one": "Idioma de las tarjetas", + "many": "Idiomas de las tarjetas", + "other": "Idiomas de las tarjetas" + }, "keywords": "Palabras clave", "release": "Publicación", "version": "Versión {version}", @@ -958,6 +964,7 @@ "back": "Volver a la biblioteca", "empty": "Este mazo no tiene tarjetas.", "hint": "Tarjetas al azar del mazo. No se registra nada: importa el mazo para estudiarlo.", + "cardLanguages": "Tarjetas en {languages}.", "nextCard": "Siguiente tarjeta", "reveal": "Mostrar respuesta", "loading": "Cargando tarjetas…" diff --git a/packages/ui/src/i18n/fr.json b/packages/ui/src/i18n/fr.json index a9a3b40d..d2619a9c 100644 --- a/packages/ui/src/i18n/fr.json +++ b/packages/ui/src/i18n/fr.json @@ -873,6 +873,7 @@ "intro": "Des paquets prêts à l’emploi. Ouvrez-en un pour en savoir plus, ou cochez-en plusieurs et importez-les dans cette instance comme vos propres copies.", "empty": "La bibliothèque n’a pas encore de paquets.", "topics": "Thèmes", + "cardLanguages": "Langues des cartes", "search": "Rechercher", "searchPlaceholder": "Nom, description ou mot-clé", "noMatch": "Aucun paquet ne correspond.", @@ -919,6 +920,11 @@ "many": "Thèmes", "other": "Thèmes" }, + "cardLanguages": { + "one": "Langue des cartes", + "many": "Langues des cartes", + "other": "Langues des cartes" + }, "keywords": "Mots-clés", "release": "Version", "version": "Version {version}", @@ -958,6 +964,7 @@ "back": "Retour à la bibliothèque", "empty": "Ce paquet ne contient aucune carte.", "hint": "Des cartes du paquet tirées au hasard. Rien n’est enregistré : importez le paquet pour l’étudier.", + "cardLanguages": "Cartes en {languages}.", "nextCard": "Carte suivante", "reveal": "Afficher la réponse", "loading": "Chargement des cartes…" diff --git a/packages/ui/src/i18n/ko.json b/packages/ui/src/i18n/ko.json index c28eb2f2..75404264 100644 --- a/packages/ui/src/i18n/ko.json +++ b/packages/ui/src/i18n/ko.json @@ -789,6 +789,7 @@ "intro": "바로 쓸 수 있는 덱이에요. 덱을 열어 자세히 보거나, 여러 개를 선택해 이 인스턴스에 내 복사본으로 가져올 수 있어요.", "empty": "라이브러리에 아직 덱이 없어요.", "topics": "주제", + "cardLanguages": "카드 언어", "search": "검색", "searchPlaceholder": "이름, 설명 또는 키워드", "noMatch": "일치하는 덱이 없어요.", @@ -829,6 +830,9 @@ "topics": { "other": "주제" }, + "cardLanguages": { + "other": "카드 언어" + }, "keywords": "키워드", "release": "릴리스", "version": "버전 {version}", @@ -864,6 +868,7 @@ "back": "덱 라이브러리로 돌아가기", "empty": "이 덱에는 카드가 없어요.", "hint": "덱에서 무작위로 고른 카드예요. 아무것도 기록되지 않아요. 학습하려면 덱을 가져와 주세요.", + "cardLanguages": "카드 언어: {languages}", "nextCard": "다음 카드", "reveal": "정답 보기", "loading": "카드를 불러오는 중…" diff --git a/packages/ui/src/i18n/sv.json b/packages/ui/src/i18n/sv.json index c0e1dcd3..02461bf6 100644 --- a/packages/ui/src/i18n/sv.json +++ b/packages/ui/src/i18n/sv.json @@ -831,6 +831,7 @@ "intro": "Färdiga kortlekar. Öppna en för att läsa om den, eller kryssa i flera och importera dem till den här instansen som egna kopior.", "empty": "Biblioteket har inga kortlekar än.", "topics": "Ämnen", + "cardLanguages": "Kortens språk", "search": "Sök", "searchPlaceholder": "Namn, beskrivning eller nyckelord", "noMatch": "Ingen kortlek matchar.", @@ -874,6 +875,10 @@ "one": "Ämne", "other": "Ämnen" }, + "cardLanguages": { + "one": "Kortens språk", + "other": "Kortens språk" + }, "keywords": "Nyckelord", "release": "Utgåva", "version": "Version {version}", @@ -911,6 +916,7 @@ "back": "Tillbaka till biblioteket", "empty": "Kortleken har inga kort.", "hint": "Slumpade kort ur kortleken. Inget sparas: importera kortleken för att studera den.", + "cardLanguages": "Korten är på {languages}.", "nextCard": "Nästa kort", "reveal": "Visa svar", "loading": "Läser in kort…" diff --git a/packages/ui/src/style.css b/packages/ui/src/style.css index 2f0ed3cd..95b3f7f4 100644 --- a/packages/ui/src/style.css +++ b/packages/ui/src/style.css @@ -2037,17 +2037,20 @@ html.drag-active { margin-top: 0.35rem; } -.library-topics { +.library-topics, +.library-languages { display: flex; flex-wrap: wrap; gap: 0.25rem 1rem; } -.library-topics legend { +.library-topics legend, +.library-languages legend { width: 100%; } -.library-topics label.checkbox-option { +.library-topics label.checkbox-option, +.library-languages label.checkbox-option { display: inline-flex; align-items: center; gap: 0.35rem; @@ -2124,8 +2127,8 @@ html.drag-active { } /* As in the deck list: the checkbox beside the name, which wraps, - and the size under the name, followed by the imported badge, - which wraps below it when there is no room. */ + and the size under the name, followed by the card languages and the + imported badge, which wrap below it when there is no room. */ .library-deck-meta { grid-column: 2; grid-row: 2; @@ -2149,6 +2152,13 @@ html.drag-active { font-variant-numeric: tabular-nums; } +/* A deck's card languages may be many: they shrink and wrap, a name at a time. */ +.library-list .library-card-languages { + flex-shrink: 1; + min-width: 0; + white-space: normal; +} + .provenance { display: block; margin: -0.25rem 0 0.75rem; diff --git a/packages/ui/src/test/course.ts b/packages/ui/src/test/course.ts index 4b7fdbb5..0c9972d3 100644 --- a/packages/ui/src/test/course.ts +++ b/packages/ui/src/test/course.ts @@ -27,6 +27,7 @@ export const courseLibraryDeck: LibraryDeck = { ...firstRelease(RELEASE), title: { en: "Solid fundamentals", sv: "Solids grunder" }, cardCount: 5, + languages: [], authors: [], direction: "front-to-back", sources: [], diff --git a/packages/ui/src/ui/ImportUrlContainer.test.tsx b/packages/ui/src/ui/ImportUrlContainer.test.tsx index 4c8be9ec..80636a6d 100644 --- a/packages/ui/src/ui/ImportUrlContainer.test.tsx +++ b/packages/ui/src/ui/ImportUrlContainer.test.tsx @@ -25,6 +25,7 @@ const rivers: LibraryDeck = { title: { en: "Rivers" }, description: { en: "The longest rivers." }, cardCount: 3, + languages: [], authors: ["Bob"], license: "https://creativecommons.org/publicdomain/zero/1.0/", direction: "front-to-back", diff --git a/packages/ui/src/ui/LibraryBrowserContainer.test.tsx b/packages/ui/src/ui/LibraryBrowserContainer.test.tsx index ecd52cb2..23e2a66e 100644 --- a/packages/ui/src/ui/LibraryBrowserContainer.test.tsx +++ b/packages/ui/src/ui/LibraryBrowserContainer.test.tsx @@ -16,6 +16,7 @@ const capitals: LibraryDeck = { ...firstRelease("https://solid-memo.com/decks/capitals/v1.ttl"), title: { en: "Capitals" }, cardCount: 1, + languages: [], authors: [], direction: "front-to-back", sources: [], diff --git a/packages/ui/src/ui/LibraryBrowserScreen.test.tsx b/packages/ui/src/ui/LibraryBrowserScreen.test.tsx index d8266c5e..6dd8ea59 100644 --- a/packages/ui/src/ui/LibraryBrowserScreen.test.tsx +++ b/packages/ui/src/ui/LibraryBrowserScreen.test.tsx @@ -14,6 +14,7 @@ const capitals: LibraryDeck = { ...firstRelease("https://solid-memo.com/decks/capitals/v1.ttl"), title: { en: "Capitals" }, cardCount: 2, + languages: [], authors: [], direction: "front-to-back", sources: [], diff --git a/packages/ui/src/ui/LibraryContainer.test.tsx b/packages/ui/src/ui/LibraryContainer.test.tsx index 81c1808b..98c2b196 100644 --- a/packages/ui/src/ui/LibraryContainer.test.tsx +++ b/packages/ui/src/ui/LibraryContainer.test.tsx @@ -24,6 +24,7 @@ const capitals: LibraryDeck = { ...firstRelease("https://solid-memo.com/decks/capitals/v1.ttl"), title: { en: "Capitals" }, cardCount: 2, + languages: [], authors: ["Anton Wiklund"], license: "https://creativecommons.org/publicdomain/zero/1.0/", direction: "front-to-back", @@ -34,6 +35,7 @@ const rivers: LibraryDeck = { ...firstRelease("https://solid-memo.com/decks/rivers/v1.ttl"), title: { en: "Rivers" }, cardCount: 1, + languages: [], authors: [], direction: "front-to-back", sources: [], @@ -68,7 +70,8 @@ function renderContainer(useCases: UseCases) { describe("LibraryContainer", () => { // The library remembers its checked decks and filters per instance; each test starts afresh. afterEach(() => { - for (const part of ["selected", "topics", "query"]) forget(`library:${instance.url}:${part}`); + for (const part of ["selected", "topics", "languages", "query"]) + forget(`library:${instance.url}:${part}`); }); it("shows a loading state, then the library", async () => { diff --git a/packages/ui/src/ui/LibraryDeckContainer.test.tsx b/packages/ui/src/ui/LibraryDeckContainer.test.tsx index 917aeff8..ec195f2e 100644 --- a/packages/ui/src/ui/LibraryDeckContainer.test.tsx +++ b/packages/ui/src/ui/LibraryDeckContainer.test.tsx @@ -23,6 +23,7 @@ const capitals: LibraryDeck = { ...firstRelease("https://solid-memo.com/decks/capitals/v1.ttl"), title: { en: "Capitals" }, cardCount: 2, + languages: [], authors: ["Anton Wiklund"], direction: "front-to-back", sources: [], diff --git a/packages/ui/src/ui/LibraryDeckScreen.test.tsx b/packages/ui/src/ui/LibraryDeckScreen.test.tsx index e864d367..d8fd5d2c 100644 --- a/packages/ui/src/ui/LibraryDeckScreen.test.tsx +++ b/packages/ui/src/ui/LibraryDeckScreen.test.tsx @@ -22,6 +22,7 @@ const capitals: LibraryDeck = { ...firstRelease("https://solid-memo.com/decks/capitals/v1.ttl"), title: { en: "Capitals of the world" }, cardCount: 243, + languages: [], authors: ["Anton Wiklund"], license: CC0, description: { en: `Every country and its capital. Compiled from ${WIKIPEDIA}.` }, @@ -123,6 +124,17 @@ describe("LibraryDeckScreen", () => { expect(screen.queryByText("Keywords", { selector: "dt" })).toBeNull(); }); + it("names the languages its cards are in, in the deck's order, the singular for one", () => { + const LANGUAGE = "http://publications.europa.eu/resource/authority/language/"; + const { unmount } = renderScreen({ + deck: { ...capitals, languages: [`${LANGUAGE}SWE`, `${LANGUAGE}ENG`] }, + }); + expect(fact("Card languages")).toHaveTextContent(/^Swedish, English$/); + unmount(); + renderScreen({ deck: { ...capitals, languages: [`${LANGUAGE}KOR`] } }); + expect(fact("Card language")).toHaveTextContent(/^Korean$/); + }); + it("uses the singular for one topic", () => { renderScreen({ deck: { ...capitals, themes: ["https://solid-memo.com/ns/vocab/topics.ttl#geography"] }, @@ -222,6 +234,7 @@ describe("LibraryDeckScreen", () => { ...firstRelease(capitals.url), title: { en: "Rivers" }, cardCount: 1, + languages: [], authors: [], direction: "front-to-back", sources: [], diff --git a/packages/ui/src/ui/LibraryDeckScreen.tsx b/packages/ui/src/ui/LibraryDeckScreen.tsx index cd9a6336..e8e70791 100644 --- a/packages/ui/src/ui/LibraryDeckScreen.tsx +++ b/packages/ui/src/ui/LibraryDeckScreen.tsx @@ -8,6 +8,7 @@ import { keywordsIn } from "@solid-memo/domain/keywords"; import { topicLabels, type LibraryDeck, type LibrarySource } from "@solid-memo/domain/library"; import type { ReportPlace } from "@solid-memo/domain/mistakeReport"; import { AuthorNames } from "./AuthorName"; +import { cardLanguageNames } from "./cardLanguageNames"; import { ErrorMessage } from "./ErrorMessage"; import { ExternalLink } from "./ExternalLink"; import { useI18n, type ErrorText } from "./i18n"; @@ -17,8 +18,8 @@ import { ReaderText, ReaderTexts } from "./ReaderText"; import { ReportMistake } from "./ReportMistake"; /** - * One library deck in full — its blurb, what it is about (its keywords - * only those in the reader's language), who made it + * One library deck in full — its blurb, the languages its cards are in, + * what it is about (its keywords only those in the reader's language), who made it * and under what terms, when it was made and last changed, which * release it is and what changed in it, and what it was compiled from — * with a look at its cards and an import button for this deck alone. @@ -68,8 +69,10 @@ export function LibraryDeckScreen({ /** Where a mistake in the release is reported (UseCases.reportPlace); none while it is read. */ reportPlace?: ReportPlace; }) { - const { t, locale, readerText, readerLang, formatDate, directionLabel } = useI18n(); + const { t, locale, readerText, readerLang, formatDate, directionLabel, languageParts } = + useI18n(); const topics = topicLabels(deck.themes); + const languages = cardLanguageNames(deck, languageParts); /** Only those in the reader's language: none in it, no keywords shown. */ const keywords = keywordsIn(deck.keywords, locale); const current = deck.releases.find((release) => release.url === deck.url); @@ -105,6 +108,12 @@ export function LibraryDeckScreen({ )}
{t("libraryDeck.size")}
{t("common.cardCount", { count: deck.cardCount })}
+ {languages.length > 0 && ( + <> +
{t("libraryDeck.cardLanguages", { count: languages.length })}
+
{languages.join(", ")}
+ + )}
{t("libraryDeck.studied")}
{deck.direction === "bidirectional" diff --git a/packages/ui/src/ui/LibraryPreviewContainer.test.tsx b/packages/ui/src/ui/LibraryPreviewContainer.test.tsx index af9a313f..92ff64b6 100644 --- a/packages/ui/src/ui/LibraryPreviewContainer.test.tsx +++ b/packages/ui/src/ui/LibraryPreviewContainer.test.tsx @@ -16,6 +16,7 @@ const capitals: LibraryDeck = { ...firstRelease("https://solid-memo.com/decks/capitals/v1.ttl"), title: { en: "Capitals" }, cardCount: 3, + languages: [], authors: [], direction: "front-to-back", sources: [], @@ -82,6 +83,25 @@ describe("LibraryPreviewContainer", () => { expect(listLibraryCards).toHaveBeenCalledWith(capitals); }); + it("names the languages the deck says its cards are in", async () => { + renderContainer({ + deck: { + ...capitals, + languages: [ + "http://publications.europa.eu/resource/authority/language/ENG", + "http://publications.europa.eu/resource/authority/language/SWE", + ], + }, + }); + expect(await screen.findByText("Cards in English, Swedish.")).toBeInTheDocument(); + }); + + it("names no card languages for a deck that states none", async () => { + renderContainer(); + await screen.findByRole("heading", { name: "Preview: Capitals" }); + expect(screen.queryByText(/^Cards in/)).toBeNull(); + }); + it("shows the question's note only once the answer is revealed", async () => { const noted = [{ ...card("Sweden", "Stockholm"), frontNote: { en: "A kingdom." } }]; renderContainer({ useCases: makeUseCasesFake({ listLibraryCards: vi.fn(async () => noted) }) }); diff --git a/packages/ui/src/ui/LibraryPreviewContainer.tsx b/packages/ui/src/ui/LibraryPreviewContainer.tsx index 2f6e6458..c35e3486 100644 --- a/packages/ui/src/ui/LibraryPreviewContainer.tsx +++ b/packages/ui/src/ui/LibraryPreviewContainer.tsx @@ -7,6 +7,7 @@ import { useQuery } from "@tanstack/react-query"; import type { UseCases } from "@solid-memo/application/useCases"; import { activeCards, studyDirections } from "@solid-memo/domain/deck"; import type { LibraryDeck } from "@solid-memo/domain/library"; +import { cardLanguageNames } from "./cardLanguageNames"; import { ErrorMessage } from "./ErrorMessage"; import { useI18n } from "./i18n"; import { LibraryPreviewScreen, type PreviewPrompt } from "./LibraryPreviewScreen"; @@ -32,7 +33,7 @@ export function LibraryPreviewContainer({ /** Uniform [0, 1) source picking the next card. */ random?: () => number; }) { - const { t, readerText, readerLang, errorText } = useI18n(); + const { t, readerText, readerLang, errorText, languageParts } = useI18n(); const cardsQuery = useQuery({ queryKey: queryKeys.libraryCards(deck.url), queryFn: () => useCases.listLibraryCards(deck), @@ -65,6 +66,7 @@ export function LibraryPreviewContainer({ deckName={readerText(deck.title)} deckLang={readerLang(deck.title)} deckHref={deckHref} + cardLanguages={cardLanguageNames(deck, languageParts)} prompt={prompts.length === 0 ? null : prompts[index]} turn={turn} onNext={next} diff --git a/packages/ui/src/ui/LibraryPreviewScreen.tsx b/packages/ui/src/ui/LibraryPreviewScreen.tsx index 6af32d42..e3b23e35 100644 --- a/packages/ui/src/ui/LibraryPreviewScreen.tsx +++ b/packages/ui/src/ui/LibraryPreviewScreen.tsx @@ -16,12 +16,14 @@ export interface PreviewPrompt { /** * A try of a library deck before importing it: its cards one at a time, * as study shows them, but with nothing graded or recorded — after the - * answer comes the next card, for as long as the user likes. + * answer comes the next card, for as long as the user likes. The + * languages the deck says its cards are in are named above them. */ export function LibraryPreviewScreen({ deckName, deckLang, deckHref, + cardLanguages, prompt, turn, onNext, @@ -32,6 +34,8 @@ export function LibraryPreviewScreen({ deckLang?: string; /** URL of the deck's page; its name links there. */ deckHref: string; + /** The languages the deck's cards are in, named in the app's language; none when it states none. */ + cardLanguages: string[]; /** null when the deck has no cards. */ prompt: PreviewPrompt | null; /** Counts the cards shown, so each one starts unrevealed, even a repeat. */ @@ -59,6 +63,11 @@ export function LibraryPreviewScreen({ ) : ( <>

{t("libraryPreview.hint")}

+ {cardLanguages.length > 0 && ( +

+ {t("libraryPreview.cardLanguages", { languages: cardLanguages.join(", ") })} +

+ )} )} diff --git a/packages/ui/src/ui/LibraryScreen.test.tsx b/packages/ui/src/ui/LibraryScreen.test.tsx index b64873cb..d269b3f7 100644 --- a/packages/ui/src/ui/LibraryScreen.test.tsx +++ b/packages/ui/src/ui/LibraryScreen.test.tsx @@ -16,6 +16,7 @@ const capitals: LibraryDeck = { ...firstRelease("https://solid-memo.com/decks/capitals/v1.ttl"), title: { en: "Capitals of the world" }, cardCount: 243, + languages: [], authors: ["Anton Wiklund"], license: "https://creativecommons.org/publicdomain/zero/1.0/", description: { en: "Every country and its capital, from Wikipedia." }, @@ -27,6 +28,7 @@ const rivers: LibraryDeck = { ...firstRelease("https://solid-memo.com/decks/rivers/v1.ttl"), title: { en: "Rivers" }, cardCount: 1, + languages: [], authors: [], direction: "front-to-back", sources: [], @@ -151,6 +153,59 @@ describe("LibraryScreen", () => { expect(screen.queryByRole("group", { name: "Topics" })).toBeNull(); }); + describe("card languages", () => { + const LANGUAGE = "http://publications.europa.eu/resource/authority/language/"; + const english = { ...capitals, languages: [`${LANGUAGE}ENG`, `${LANGUAGE}SWE`] }; + const korean = { + ...rivers, + title: { en: "Korean nouns" }, + languages: [`${LANGUAGE}KOR`, `${LANGUAGE}SWE`, "https://example.org/unknown"], + }; + + it("names the languages of each deck's cards beside its size, in the deck's order", () => { + renderScreen({ decks: [english, korean, { ...rivers, url: "none" }] }); + expect(screen.getByText("English, Swedish")).toBeInTheDocument(); + expect(screen.getByText("Korean, Swedish")).toBeInTheDocument(); + }); + + it("offers the languages the decks state, by name, in the language the user reads", () => { + renderScreen({ decks: [english, korean] }, "sv"); + const group = screen.getByRole("group", { name: "Kortens språk" }); + expect([...group.querySelectorAll("label")].map((l) => l.textContent)).toEqual([ + "engelska", + "koreanska", + "svenska", + ]); + }); + + it("narrows the list to the decks with cards in every checked language", () => { + renderScreen({ decks: [english, korean] }); + fireEvent.click(screen.getByRole("checkbox", { name: "Swedish" })); + expect(screen.getByText("2 decks")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("checkbox", { name: "Korean" })); + expect(screen.queryByRole("checkbox", { name: "Capitals of the world" })).toBeNull(); + expect(screen.getByRole("checkbox", { name: "Korean nouns" })).toBeInTheDocument(); + expect(screen.getByText("1 of 2 decks")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("checkbox", { name: "English" })); + expect(screen.getByText("No deck matches.")).toBeInTheDocument(); + }); + + it("remembers the checked languages between visits", () => { + const memoryKey = `library-test-languages-${++renders}`; + const { unmount } = renderScreen({ decks: [english, korean], memoryKey }); + fireEvent.click(screen.getByRole("checkbox", { name: "Korean" })); + unmount(); + renderScreen({ decks: [english, korean], memoryKey }); + expect(screen.getByRole("checkbox", { name: "Korean" })).toBeChecked(); + expect(screen.queryByRole("checkbox", { name: "Capitals of the world" })).toBeNull(); + }); + + it("offers no languages when no deck states one", () => { + renderScreen(); + expect(screen.queryByRole("group", { name: "Card languages" })).toBeNull(); + }); + }); + it("searches names, descriptions and keywords, in every language, and says when nothing matches", () => { renderScreen({ decks: [capitals, { ...rivers, keywords: { en: ["water"], sv: ["vattendrag"] } }], diff --git a/packages/ui/src/ui/LibraryScreen.tsx b/packages/ui/src/ui/LibraryScreen.tsx index 245dcd26..0ff9ab23 100644 --- a/packages/ui/src/ui/LibraryScreen.tsx +++ b/packages/ui/src/ui/LibraryScreen.tsx @@ -3,7 +3,13 @@ // SPDX-License-Identifier: MIT import { useEffect, useRef, useState } from "preact/hooks"; -import { filterLibraryDecks, topicsOf, type LibraryDeck } from "@solid-memo/domain/library"; +import { + filterLibraryDecks, + libraryCardLanguages, + topicsOf, + type LibraryDeck, +} from "@solid-memo/domain/library"; +import { cardLanguageNames } from "./cardLanguageNames"; import { ErrorMessage } from "./ErrorMessage"; import { LibraryIcon } from "./icons"; import { useI18n, type I18n, type ErrorText } from "./i18n"; @@ -30,10 +36,12 @@ export function forgetLibrarySelection(memoryKey: string): void { /** * The deck library: ready-made decks to copy into the current instance. * Any number can be checked and imported in one go. The list can be - * narrowed to topics (checkboxes, from Solid Memo's topics scheme) and - * by a search of names, descriptions and keywords, in every language - * (filterLibraryDecks). A row says only the - * deck's name and size; clicking it (anywhere but the checkbox and the + * narrowed to topics (checkboxes, from Solid Memo's topics scheme), to + * the languages the cards are in (checkboxes of those 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). A row says only the deck's name, its size and + * the languages of its cards; clicking it (anywhere but the checkbox and the * Preview button) opens the deck's own page, where it is described in * full and can be imported on its own. Preview tries its cards first. * A deck or course published elsewhere is added from a link to it @@ -73,11 +81,12 @@ export function LibraryScreen({ error: ErrorText | null; onImport: (decks: LibraryDeck[]) => void; }) { - const { t, tx } = useI18n(); + const { t, tx, locale, languageParts } = useI18n(); const [selectedUrls, setSelectedUrls] = useRemembered(`${memoryKey}:selected`, []); const [topics, setTopics] = useRemembered(`${memoryKey}:topics`, []); + const [languages, setLanguages] = useRemembered(`${memoryKey}:languages`, []); const [query, setQuery] = useRemembered(`${memoryKey}:query`, ""); - const shown = filterLibraryDecks(decks, { topics, query }); + const shown = filterLibraryDecks(decks, { topics, languages, query }); // A deck the filters hide stays checked for when it shows again, but is // not imported: the import never includes what the user cannot see. A // course is never imported: it is started from its page, and its cards @@ -86,6 +95,9 @@ export function LibraryScreen({ (deck) => deck.isCourse !== true && selectedUrls.includes(deck.url), ); const available = topicsOf(decks); + const availableLanguages = libraryCardLanguages(decks) + .map((language) => ({ iri: language.iri, name: languageParts(language.tag).name })) + .sort((a, b) => a.name.localeCompare(b.name, locale)); const count = shown.length === decks.length ? t("library.deckCount", { count: decks.length }) @@ -98,11 +110,11 @@ export function LibraryScreen({ if (!filtered.current) return; const timer = setTimeout(() => setAnnounced(result), ANNOUNCE_DELAY_MS); return () => clearTimeout(timer); - }, [result, topics, query]); + }, [result, topics, languages, query]); - function toggleTopic(topic: string, checked: boolean) { + function toggleIn(set: typeof setTopics, value: string, checked: boolean) { filtered.current = true; - setTopics((current) => (checked ? [...current, topic] : current.filter((t) => t !== topic))); + set((current) => (checked ? [...current, value] : current.filter((one) => one !== value))); } function toggle(deck: LibraryDeck, checked: boolean) { @@ -143,13 +155,30 @@ export function LibraryScreen({ toggleTopic(topic.iri, e.currentTarget.checked)} + onChange={(e) => toggleIn(setTopics, topic.iri, e.currentTarget.checked)} /> ))} )} + {availableLanguages.length > 0 && ( +
+ {t("library.cardLanguages")} + {availableLanguages.map((language) => ( + + ))} +
+ )} void; }) { - const { t, readerText } = useI18n(); + const { t, readerText, languageParts } = useI18n(); + const languages = cardLanguageNames(deck, languageParts); return ( <> {/* A 44px target above the row's link, so a near miss @@ -246,6 +277,9 @@ export function LibraryDeckRow({ {t("common.cardCount", { count: deck.cardCount })} + {languages.length > 0 && ( + {languages.join(", ")} + )} {deck.isCourse === true && {t("library.course")}} {imported && {t("library.alreadyImported")}} diff --git a/packages/ui/src/ui/Workspace.test.tsx b/packages/ui/src/ui/Workspace.test.tsx index 9c4db87f..028ccb2b 100644 --- a/packages/ui/src/ui/Workspace.test.tsx +++ b/packages/ui/src/ui/Workspace.test.tsx @@ -802,6 +802,7 @@ describe("Workspace", () => { decks: [], deckCount: 0, cardCount: 0, + languages: [], reviewCount: 0, preferencesOutdated: outdated, instanceOutdated: false, @@ -1212,6 +1213,7 @@ describe("Workspace", () => { ...firstRelease(deck.sourceUrl!), title: { en: "Capitals" }, cardCount: 3, + languages: [], authors: [], direction: "front-to-back" as const, sources: [], @@ -1264,6 +1266,7 @@ describe("Workspace", () => { ...firstRelease(libraryUrl), title: { en: "Capitals" }, cardCount: 3, + languages: [], authors: ["Anton Wiklund"], description: { en: "Every capital." }, direction: "front-to-back" as const, @@ -1320,6 +1323,7 @@ describe("Workspace", () => { ...firstRelease(libraryUrl), title: { en: "Capitals" }, cardCount: 11, + languages: [], authors: [], direction: "front-to-back" as const, sources: [], @@ -1366,6 +1370,7 @@ describe("Workspace", () => { ...firstRelease(libraryUrl), title: { en: "Capitals" }, cardCount: 1, + languages: [], authors: [], direction: "front-to-back" as const, sources: [], @@ -1414,6 +1419,7 @@ describe("Workspace", () => { ...firstRelease(libraryUrl), title: { en: "Capitals" }, cardCount: 1, + languages: [], authors: [], direction: "front-to-back" as const, sources: [], @@ -1471,6 +1477,7 @@ describe("Workspace", () => { ...firstRelease(libraryUrl), title: { en: "Capitals" }, cardCount: 1, + languages: [], authors: [], direction: "front-to-back" as const, sources: [], @@ -1508,6 +1515,7 @@ describe("Workspace", () => { ...firstRelease(libraryUrl), title: { en: "Capitals" }, cardCount: 1, + languages: [], authors: [], direction: "front-to-back" as const, sources: [], @@ -1540,6 +1548,7 @@ describe("Workspace", () => { ...firstRelease(release), title: { en: "Pods" }, cardCount: 2, + languages: [], authors: [], direction: "front-to-back" as const, sources: [], @@ -1585,6 +1594,7 @@ describe("Workspace", () => { ...firstRelease(release), title: { en: "Rivers" }, cardCount: 2, + languages: [], authors: [], direction: "front-to-back" as const, sources: [], @@ -1642,6 +1652,7 @@ describe("Workspace", () => { ], title: { en: "Capitals" }, cardCount: 3, + languages: [], authors: [], direction: "front-to-back" as const, sources: [], @@ -1661,6 +1672,7 @@ describe("Workspace", () => { ...firstRelease(releaseUrl), title: { en: "Capitals" }, cardCount: 3, + languages: [], authors: [], direction: "front-to-back" as const, sources: [], diff --git a/packages/ui/src/ui/cardLanguageNames.ts b/packages/ui/src/ui/cardLanguageNames.ts new file mode 100644 index 00000000..a8b845aa --- /dev/null +++ b/packages/ui/src/ui/cardLanguageNames.ts @@ -0,0 +1,18 @@ +// SPDX-FileCopyrightText: 2026 Anton Wiklund and Solid Memo contributors +// +// SPDX-License-Identifier: MIT + +import { deckCardLanguages, type LibraryDeck } from "@solid-memo/domain/library"; +import type { I18n } from "./i18n"; + +/** + * The languages a library deck's cards are in, each named in the app's + * language ("Swedish", "svenska"), in the deck's order: those the app + * knows (deckCardLanguages), none when the deck states none. + */ +export function cardLanguageNames( + deck: Pick, + languageParts: I18n["languageParts"], +): string[] { + return deckCardLanguages(deck).map((language) => languageParts(language.tag).name); +}