diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-05-02 11:24:16 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-05-02 11:24:16 +0900 |
| commit | d9b78a9fa440d84c6cd0c1f2a6ebb43df895ccdf (patch) | |
| tree | b7a287338bfbebf9082f800faeab6bd93b195cb3 /src/shared | |
| parent | 7ca9941982a7d7a4c126d215770ce71ad2f7f427 (diff) | |
| download | kioku-d9b78a9fa440d84c6cd0c1f2a6ebb43df895ccdf.tar.gz kioku-d9b78a9fa440d84c6cd0c1f2a6ebb43df895ccdf.tar.zst kioku-d9b78a9fa440d84c6cd0c1f2a6ebb43df895ccdf.zip | |
refactor(notes): extract card generation into shared module
Pull pure card-generation logic (template rendering and the initial FSRS
state for new cards) out of the server note repository into
src/shared/card-generator.ts so both server and client can call it. The
server's note.create / createMany now delegate the rendering work to the
shared function and only insert the resulting payload, preserving the
existing behavior. Groundwork for offline-first note CRUD on the client.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'src/shared')
| -rw-r--r-- | src/shared/card-generator.test.ts | 146 | ||||
| -rw-r--r-- | src/shared/card-generator.ts | 101 |
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, + }; +} |
