aboutsummaryrefslogtreecommitdiffhomepage
path: root/src
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-05-02 11:24:16 +0900
committernsfisis <nsfisis@gmail.com>2026-05-02 11:24:16 +0900
commitd9b78a9fa440d84c6cd0c1f2a6ebb43df895ccdf (patch)
treeb7a287338bfbebf9082f800faeab6bd93b195cb3 /src
parent7ca9941982a7d7a4c126d215770ce71ad2f7f427 (diff)
downloadkioku-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')
-rw-r--r--src/server/repositories/note.ts110
-rw-r--r--src/shared/card-generator.test.ts146
-rw-r--r--src/shared/card-generator.ts101
3 files changed, 275 insertions, 82 deletions
diff --git a/src/server/repositories/note.ts b/src/server/repositories/note.ts
index 6f607cf..22c760d 100644
--- a/src/server/repositories/note.ts
+++ b/src/server/repositories/note.ts
@@ -1,7 +1,7 @@
import { and, eq, isNull, sql } from "drizzle-orm";
+import { generateCardsForNote } from "../../shared/card-generator.js";
import { db } from "../db/index.js";
import {
- CardState,
cards,
noteFieldTypes,
noteFieldValues,
@@ -122,27 +122,15 @@ export const noteRepository: NoteRepository = {
}
const createdCards: Card[] = [];
-
- const normalCard = await createCardForNote(
- deckId,
- note.id,
- noteType[0],
- fieldValuesResult,
+ const generatedCards = generateCardsForNote({
+ noteType: noteType[0],
fieldTypes,
- false,
- );
- createdCards.push(normalCard);
+ fieldValues: fieldValuesResult,
+ });
- if (noteType[0].isReversible) {
- const reversedCard = await createCardForNote(
- deckId,
- note.id,
- noteType[0],
- fieldValuesResult,
- fieldTypes,
- true,
- );
- createdCards.push(reversedCard);
+ for (const generated of generatedCards) {
+ const card = await insertGeneratedCard(deckId, note.id, generated);
+ createdCards.push(card);
}
return {
@@ -357,26 +345,13 @@ export const noteRepository: NoteRepository = {
}
}
- // Create normal card
- await createCardForNote(
- deckId,
- note.id,
- cached.noteType,
- fieldValuesResult,
- cached.fieldTypes,
- false,
- );
-
- // Create reversed card if reversible
- if (cached.noteType.isReversible) {
- await createCardForNote(
- deckId,
- note.id,
- cached.noteType,
- fieldValuesResult,
- cached.fieldTypes,
- true,
- );
+ const generatedCards = generateCardsForNote({
+ noteType: cached.noteType,
+ fieldTypes: cached.fieldTypes,
+ fieldValues: fieldValuesResult,
+ });
+ for (const generated of generatedCards) {
+ await insertGeneratedCard(deckId, note.id, generated);
}
created++;
@@ -392,48 +367,27 @@ export const noteRepository: NoteRepository = {
},
};
-async function createCardForNote(
+async function insertGeneratedCard(
deckId: string,
noteId: string,
- noteType: { frontTemplate: string; backTemplate: string },
- fieldValues: NoteFieldValue[],
- fieldTypes: { id: string; name: string }[],
- isReversed: boolean,
+ generated: ReturnType<typeof generateCardsForNote>[number],
): Promise<Card> {
- const fieldMap = new Map<string, string>();
- for (const fv of fieldValues) {
- const fieldType = fieldTypes.find((ft) => ft.id === fv.noteFieldTypeId);
- if (fieldType) {
- fieldMap.set(fieldType.name, fv.value);
- }
- }
-
- const frontTemplate = isReversed
- ? noteType.backTemplate
- : noteType.frontTemplate;
- const backTemplate = isReversed
- ? noteType.frontTemplate
- : noteType.backTemplate;
-
- const front = renderTemplate(frontTemplate, fieldMap);
- const back = renderTemplate(backTemplate, fieldMap);
-
const [card] = await db
.insert(cards)
.values({
deckId,
noteId,
- isReversed,
- front,
- back,
- state: CardState.New,
- due: new Date(),
- stability: 0,
- difficulty: 0,
- elapsedDays: 0,
- scheduledDays: 0,
- reps: 0,
- lapses: 0,
+ isReversed: generated.isReversed,
+ front: generated.front,
+ back: generated.back,
+ state: generated.state,
+ due: generated.due,
+ stability: generated.stability,
+ difficulty: generated.difficulty,
+ elapsedDays: generated.elapsedDays,
+ scheduledDays: generated.scheduledDays,
+ reps: generated.reps,
+ lapses: generated.lapses,
})
.returning();
@@ -443,11 +397,3 @@ async function createCardForNote(
return card;
}
-
-function renderTemplate(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;
-}
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,
+ };
+}