aboutsummaryrefslogtreecommitdiffhomepage
path: root/src/shared
diff options
context:
space:
mode:
Diffstat (limited to 'src/shared')
-rw-r--r--src/shared/card-generator.test.ts146
-rw-r--r--src/shared/card-generator.ts101
2 files changed, 247 insertions, 0 deletions
diff --git a/src/shared/card-generator.test.ts b/src/shared/card-generator.test.ts
new file mode 100644
index 0000000..8c78ca5
--- /dev/null
+++ b/src/shared/card-generator.test.ts
@@ -0,0 +1,146 @@
+import { describe, expect, it } from "vitest";
+import { generateCardsForNote, renderCardTemplate } from "./card-generator";
+
+describe("renderCardTemplate", () => {
+ it("substitutes a single placeholder", () => {
+ const fields = new Map([["Front", "Hello"]]);
+ expect(renderCardTemplate("{{Front}}", fields)).toBe("Hello");
+ });
+
+ it("substitutes multiple placeholders", () => {
+ const fields = new Map([
+ ["Front", "Q"],
+ ["Back", "A"],
+ ]);
+ expect(renderCardTemplate("{{Front}} -> {{Back}}", fields)).toBe("Q -> A");
+ });
+
+ it("substitutes the same placeholder repeated in a template", () => {
+ const fields = new Map([["Word", "x"]]);
+ expect(renderCardTemplate("{{Word}}{{Word}}", fields)).toBe("xx");
+ });
+
+ it("leaves unknown placeholders unchanged", () => {
+ const fields = new Map([["Front", "Q"]]);
+ expect(renderCardTemplate("{{Front}} {{Missing}}", fields)).toBe(
+ "Q {{Missing}}",
+ );
+ });
+
+ it("replaces placeholders with empty strings when value is empty", () => {
+ const fields = new Map([["Front", ""]]);
+ expect(renderCardTemplate("[{{Front}}]", fields)).toBe("[]");
+ });
+});
+
+const noteTypeBase = {
+ frontTemplate: "{{Front}}",
+ backTemplate: "{{Back}}",
+ isReversible: false,
+};
+
+const fieldTypes = [
+ { id: "ft-front", name: "Front" },
+ { id: "ft-back", name: "Back" },
+];
+
+const fieldValues = [
+ { noteFieldTypeId: "ft-front", value: "Question" },
+ { noteFieldTypeId: "ft-back", value: "Answer" },
+];
+
+describe("generateCardsForNote", () => {
+ it("returns one card when isReversible is false", () => {
+ const cards = generateCardsForNote({
+ noteType: noteTypeBase,
+ fieldTypes,
+ fieldValues,
+ });
+
+ expect(cards).toHaveLength(1);
+ expect(cards[0]).toMatchObject({
+ isReversed: false,
+ front: "Question",
+ back: "Answer",
+ });
+ });
+
+ it("returns two cards when isReversible is true and swaps templates", () => {
+ const cards = generateCardsForNote({
+ noteType: { ...noteTypeBase, isReversible: true },
+ fieldTypes,
+ fieldValues,
+ });
+
+ expect(cards).toHaveLength(2);
+ expect(cards[0]).toMatchObject({
+ isReversed: false,
+ front: "Question",
+ back: "Answer",
+ });
+ expect(cards[1]).toMatchObject({
+ isReversed: true,
+ front: "Answer",
+ back: "Question",
+ });
+ });
+
+ it("initialises FSRS state to a fresh new-card payload", () => {
+ const now = new Date("2026-05-02T10:00:00Z");
+ const [card] = generateCardsForNote({
+ noteType: noteTypeBase,
+ fieldTypes,
+ fieldValues,
+ now,
+ });
+
+ expect(card).toMatchObject({
+ state: 0,
+ stability: 0,
+ difficulty: 0,
+ elapsedDays: 0,
+ scheduledDays: 0,
+ reps: 0,
+ lapses: 0,
+ });
+ expect(card?.due.getTime()).toBe(now.getTime());
+ });
+
+ it("treats missing field values as empty strings", () => {
+ const [card] = generateCardsForNote({
+ noteType: noteTypeBase,
+ fieldTypes,
+ fieldValues: [{ noteFieldTypeId: "ft-front", value: "Q only" }],
+ });
+
+ expect(card?.front).toBe("Q only");
+ expect(card?.back).toBe("{{Back}}");
+ });
+
+ it("ignores field values with unknown field type ids", () => {
+ const [card] = generateCardsForNote({
+ noteType: noteTypeBase,
+ fieldTypes,
+ fieldValues: [
+ ...fieldValues,
+ { noteFieldTypeId: "ft-unknown", value: "ghost" },
+ ],
+ });
+
+ expect(card?.front).toBe("Question");
+ expect(card?.back).toBe("Answer");
+ });
+
+ it("returns independent due Date instances per card", () => {
+ const now = new Date("2026-05-02T10:00:00Z");
+ const cards = generateCardsForNote({
+ noteType: { ...noteTypeBase, isReversible: true },
+ fieldTypes,
+ fieldValues,
+ now,
+ });
+
+ expect(cards[0]?.due).not.toBe(cards[1]?.due);
+ expect(cards[0]?.due).not.toBe(now);
+ });
+});
diff --git a/src/shared/card-generator.ts b/src/shared/card-generator.ts
new file mode 100644
index 0000000..f824e3c
--- /dev/null
+++ b/src/shared/card-generator.ts
@@ -0,0 +1,101 @@
+/**
+ * Shared card generation logic used by both server (note repository) and client
+ * (offline-first IndexedDB writes). Pure functions: produces card payloads from
+ * a note type and field values without touching any storage layer.
+ */
+
+const NEW_CARD_STATE = 0;
+
+export interface NoteTypeForGeneration {
+ frontTemplate: string;
+ backTemplate: string;
+ isReversible: boolean;
+}
+
+export interface FieldTypeForGeneration {
+ id: string;
+ name: string;
+}
+
+export interface FieldValueForGeneration {
+ noteFieldTypeId: string;
+ value: string;
+}
+
+export interface GeneratedCard {
+ isReversed: boolean;
+ front: string;
+ back: string;
+ state: number;
+ due: Date;
+ stability: number;
+ difficulty: number;
+ elapsedDays: number;
+ scheduledDays: number;
+ reps: number;
+ lapses: number;
+}
+
+export function renderCardTemplate(
+ template: string,
+ fields: Map<string, string>,
+): string {
+ let result = template;
+ for (const [name, value] of fields) {
+ result = result.replace(new RegExp(`\\{\\{${name}\\}\\}`, "g"), value);
+ }
+ return result;
+}
+
+export function generateCardsForNote(input: {
+ noteType: NoteTypeForGeneration;
+ fieldTypes: FieldTypeForGeneration[];
+ fieldValues: FieldValueForGeneration[];
+ now?: Date;
+}): GeneratedCard[] {
+ const { noteType, fieldTypes, fieldValues, now = new Date() } = input;
+
+ const fieldMap = new Map<string, string>();
+ for (const fv of fieldValues) {
+ const ft = fieldTypes.find((f) => f.id === fv.noteFieldTypeId);
+ if (ft) {
+ fieldMap.set(ft.name, fv.value);
+ }
+ }
+
+ const generated: GeneratedCard[] = [
+ buildCard(noteType, fieldMap, false, now),
+ ];
+ if (noteType.isReversible) {
+ generated.push(buildCard(noteType, fieldMap, true, now));
+ }
+ return generated;
+}
+
+function buildCard(
+ noteType: NoteTypeForGeneration,
+ fields: Map<string, string>,
+ isReversed: boolean,
+ now: Date,
+): GeneratedCard {
+ const frontTemplate = isReversed
+ ? noteType.backTemplate
+ : noteType.frontTemplate;
+ const backTemplate = isReversed
+ ? noteType.frontTemplate
+ : noteType.backTemplate;
+
+ return {
+ isReversed,
+ front: renderCardTemplate(frontTemplate, fields),
+ back: renderCardTemplate(backTemplate, fields),
+ state: NEW_CARD_STATE,
+ due: new Date(now),
+ stability: 0,
+ difficulty: 0,
+ elapsedDays: 0,
+ scheduledDays: 0,
+ reps: 0,
+ lapses: 0,
+ };
+}