aboutsummaryrefslogtreecommitdiffhomepage
path: root/src/client/db
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2025-12-31 01:29:39 +0900
committernsfisis <nsfisis@gmail.com>2025-12-31 01:29:39 +0900
commitc1d24e24449808e4235fa586fbeb5760a36bc6bb (patch)
treefb829349a2a6b33b5cf63f52d089efac24e1ec7b /src/client/db
parent264c64090070dc87c4b61077934929e80d4d0142 (diff)
downloadkioku-c1d24e24449808e4235fa586fbeb5760a36bc6bb.tar.gz
kioku-c1d24e24449808e4235fa586fbeb5760a36bc6bb.tar.zst
kioku-c1d24e24449808e4235fa586fbeb5760a36bc6bb.zip
feat(client): add note-related tables to client IndexedDB
Add LocalNoteType, LocalNoteFieldType, LocalNote, and LocalNoteFieldValue interfaces and tables to the client database for Anki-compatible note system. Update LocalCard interface with noteId and isReversed fields. Includes Dexie schema version 2 with upgrade handler for existing cards. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Diffstat (limited to 'src/client/db')
-rw-r--r--src/client/db/index.test.ts264
-rw-r--r--src/client/db/index.ts123
-rw-r--r--src/client/db/repositories.ts7
3 files changed, 392 insertions, 2 deletions
diff --git a/src/client/db/index.test.ts b/src/client/db/index.test.ts
index c1a62f5..a30f94b 100644
--- a/src/client/db/index.test.ts
+++ b/src/client/db/index.test.ts
@@ -6,8 +6,13 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
CardState,
db,
+ FieldType,
type LocalCard,
type LocalDeck,
+ type LocalNote,
+ type LocalNoteFieldType,
+ type LocalNoteFieldValue,
+ type LocalNoteType,
type LocalReviewLog,
Rating,
} from "./index";
@@ -18,6 +23,10 @@ describe("KiokuDatabase", () => {
await db.decks.clear();
await db.cards.clear();
await db.reviewLogs.clear();
+ await db.noteTypes.clear();
+ await db.noteFieldTypes.clear();
+ await db.notes.clear();
+ await db.noteFieldValues.clear();
});
afterEach(async () => {
@@ -25,6 +34,10 @@ describe("KiokuDatabase", () => {
await db.decks.clear();
await db.cards.clear();
await db.reviewLogs.clear();
+ await db.noteTypes.clear();
+ await db.noteFieldTypes.clear();
+ await db.notes.clear();
+ await db.noteFieldValues.clear();
});
describe("database initialization", () => {
@@ -119,6 +132,8 @@ describe("KiokuDatabase", () => {
const testCard: LocalCard = {
id: "card-1",
deckId: "deck-1",
+ noteId: null,
+ isReversed: null,
front: "Question",
back: "Answer",
state: CardState.New,
@@ -289,6 +304,251 @@ describe("KiokuDatabase", () => {
});
});
+ describe("noteTypes table", () => {
+ const testNoteType: LocalNoteType = {
+ id: "note-type-1",
+ userId: "user-1",
+ name: "Basic",
+ frontTemplate: "{{Front}}",
+ backTemplate: "{{Back}}",
+ isReversible: false,
+ createdAt: new Date("2024-01-01"),
+ updatedAt: new Date("2024-01-01"),
+ deletedAt: null,
+ syncVersion: 0,
+ _synced: false,
+ };
+
+ it("should add and retrieve a note type", async () => {
+ await db.noteTypes.add(testNoteType);
+ const retrieved = await db.noteTypes.get("note-type-1");
+ expect(retrieved).toEqual(testNoteType);
+ });
+
+ it("should find note types by userId", async () => {
+ await db.noteTypes.add(testNoteType);
+ await db.noteTypes.add({
+ ...testNoteType,
+ id: "note-type-2",
+ userId: "user-2",
+ });
+
+ const userNoteTypes = await db.noteTypes
+ .where("userId")
+ .equals("user-1")
+ .toArray();
+ expect(userNoteTypes).toHaveLength(1);
+ expect(userNoteTypes[0]?.id).toBe("note-type-1");
+ });
+
+ it("should find unsynced note types", async () => {
+ await db.noteTypes.add(testNoteType);
+ await db.noteTypes.add({
+ ...testNoteType,
+ id: "note-type-2",
+ _synced: true,
+ });
+
+ const unsynced = await db.noteTypes.filter((nt) => !nt._synced).toArray();
+ expect(unsynced).toHaveLength(1);
+ expect(unsynced[0]?.id).toBe("note-type-1");
+ });
+
+ it("should update a note type", async () => {
+ await db.noteTypes.add(testNoteType);
+ await db.noteTypes.update("note-type-1", {
+ name: "Updated Name",
+ isReversible: true,
+ });
+
+ const updated = await db.noteTypes.get("note-type-1");
+ expect(updated?.name).toBe("Updated Name");
+ expect(updated?.isReversible).toBe(true);
+ });
+ });
+
+ describe("noteFieldTypes table", () => {
+ const testNoteFieldType: LocalNoteFieldType = {
+ id: "field-type-1",
+ noteTypeId: "note-type-1",
+ name: "Front",
+ order: 0,
+ fieldType: FieldType.Text,
+ createdAt: new Date("2024-01-01"),
+ updatedAt: new Date("2024-01-01"),
+ deletedAt: null,
+ syncVersion: 0,
+ _synced: false,
+ };
+
+ it("should add and retrieve a note field type", async () => {
+ await db.noteFieldTypes.add(testNoteFieldType);
+ const retrieved = await db.noteFieldTypes.get("field-type-1");
+ expect(retrieved).toEqual(testNoteFieldType);
+ });
+
+ it("should find note field types by noteTypeId", async () => {
+ await db.noteFieldTypes.add(testNoteFieldType);
+ await db.noteFieldTypes.add({
+ ...testNoteFieldType,
+ id: "field-type-2",
+ noteTypeId: "note-type-2",
+ });
+
+ const fields = await db.noteFieldTypes
+ .where("noteTypeId")
+ .equals("note-type-1")
+ .toArray();
+ expect(fields).toHaveLength(1);
+ expect(fields[0]?.id).toBe("field-type-1");
+ });
+
+ it("should find unsynced note field types", async () => {
+ await db.noteFieldTypes.add(testNoteFieldType);
+ await db.noteFieldTypes.add({
+ ...testNoteFieldType,
+ id: "field-type-2",
+ _synced: true,
+ });
+
+ const unsynced = await db.noteFieldTypes
+ .filter((nft) => !nft._synced)
+ .toArray();
+ expect(unsynced).toHaveLength(1);
+ expect(unsynced[0]?.id).toBe("field-type-1");
+ });
+ });
+
+ describe("notes table", () => {
+ const testNote: LocalNote = {
+ id: "note-1",
+ deckId: "deck-1",
+ noteTypeId: "note-type-1",
+ createdAt: new Date("2024-01-01"),
+ updatedAt: new Date("2024-01-01"),
+ deletedAt: null,
+ syncVersion: 0,
+ _synced: false,
+ };
+
+ it("should add and retrieve a note", async () => {
+ await db.notes.add(testNote);
+ const retrieved = await db.notes.get("note-1");
+ expect(retrieved).toEqual(testNote);
+ });
+
+ it("should find notes by deckId", async () => {
+ await db.notes.add(testNote);
+ await db.notes.add({
+ ...testNote,
+ id: "note-2",
+ deckId: "deck-2",
+ });
+
+ const deckNotes = await db.notes
+ .where("deckId")
+ .equals("deck-1")
+ .toArray();
+ expect(deckNotes).toHaveLength(1);
+ expect(deckNotes[0]?.id).toBe("note-1");
+ });
+
+ it("should find notes by noteTypeId", async () => {
+ await db.notes.add(testNote);
+ await db.notes.add({
+ ...testNote,
+ id: "note-2",
+ noteTypeId: "note-type-2",
+ });
+
+ const typeNotes = await db.notes
+ .where("noteTypeId")
+ .equals("note-type-1")
+ .toArray();
+ expect(typeNotes).toHaveLength(1);
+ expect(typeNotes[0]?.id).toBe("note-1");
+ });
+
+ it("should find unsynced notes", async () => {
+ await db.notes.add(testNote);
+ await db.notes.add({
+ ...testNote,
+ id: "note-2",
+ _synced: true,
+ });
+
+ const unsynced = await db.notes.filter((n) => !n._synced).toArray();
+ expect(unsynced).toHaveLength(1);
+ expect(unsynced[0]?.id).toBe("note-1");
+ });
+ });
+
+ describe("noteFieldValues table", () => {
+ const testNoteFieldValue: LocalNoteFieldValue = {
+ id: "field-value-1",
+ noteId: "note-1",
+ noteFieldTypeId: "field-type-1",
+ value: "Test value",
+ createdAt: new Date("2024-01-01"),
+ updatedAt: new Date("2024-01-01"),
+ syncVersion: 0,
+ _synced: false,
+ };
+
+ it("should add and retrieve a note field value", async () => {
+ await db.noteFieldValues.add(testNoteFieldValue);
+ const retrieved = await db.noteFieldValues.get("field-value-1");
+ expect(retrieved).toEqual(testNoteFieldValue);
+ });
+
+ it("should find note field values by noteId", async () => {
+ await db.noteFieldValues.add(testNoteFieldValue);
+ await db.noteFieldValues.add({
+ ...testNoteFieldValue,
+ id: "field-value-2",
+ noteId: "note-2",
+ });
+
+ const noteFieldValues = await db.noteFieldValues
+ .where("noteId")
+ .equals("note-1")
+ .toArray();
+ expect(noteFieldValues).toHaveLength(1);
+ expect(noteFieldValues[0]?.id).toBe("field-value-1");
+ });
+
+ it("should find note field values by noteFieldTypeId", async () => {
+ await db.noteFieldValues.add(testNoteFieldValue);
+ await db.noteFieldValues.add({
+ ...testNoteFieldValue,
+ id: "field-value-2",
+ noteFieldTypeId: "field-type-2",
+ });
+
+ const typeFieldValues = await db.noteFieldValues
+ .where("noteFieldTypeId")
+ .equals("field-type-1")
+ .toArray();
+ expect(typeFieldValues).toHaveLength(1);
+ expect(typeFieldValues[0]?.id).toBe("field-value-1");
+ });
+
+ it("should find unsynced note field values", async () => {
+ await db.noteFieldValues.add(testNoteFieldValue);
+ await db.noteFieldValues.add({
+ ...testNoteFieldValue,
+ id: "field-value-2",
+ _synced: true,
+ });
+
+ const unsynced = await db.noteFieldValues
+ .filter((nfv) => !nfv._synced)
+ .toArray();
+ expect(unsynced).toHaveLength(1);
+ expect(unsynced[0]?.id).toBe("field-value-1");
+ });
+ });
+
describe("constants", () => {
it("should export CardState enum", () => {
expect(CardState.New).toBe(0);
@@ -303,5 +563,9 @@ describe("KiokuDatabase", () => {
expect(Rating.Good).toBe(3);
expect(Rating.Easy).toBe(4);
});
+
+ it("should export FieldType enum", () => {
+ expect(FieldType.Text).toBe("text");
+ });
});
});
diff --git a/src/client/db/index.ts b/src/client/db/index.ts
index 4c381d2..5318b17 100644
--- a/src/client/db/index.ts
+++ b/src/client/db/index.ts
@@ -25,6 +25,50 @@ export const Rating = {
export type RatingType = (typeof Rating)[keyof typeof Rating];
/**
+ * Field types for note fields
+ */
+export const FieldType = {
+ Text: "text",
+} as const;
+
+export type FieldTypeType = (typeof FieldType)[keyof typeof FieldType];
+
+/**
+ * Local note type stored in IndexedDB
+ * Defines the structure of notes (fields and card templates)
+ */
+export interface LocalNoteType {
+ id: string;
+ userId: string;
+ name: string;
+ frontTemplate: string;
+ backTemplate: string;
+ isReversible: boolean;
+ createdAt: Date;
+ updatedAt: Date;
+ deletedAt: Date | null;
+ syncVersion: number;
+ _synced: boolean;
+}
+
+/**
+ * Local note field type stored in IndexedDB
+ * Defines a field within a note type
+ */
+export interface LocalNoteFieldType {
+ id: string;
+ noteTypeId: string;
+ name: string;
+ order: number;
+ fieldType: FieldTypeType;
+ createdAt: Date;
+ updatedAt: Date;
+ deletedAt: Date | null;
+ syncVersion: number;
+ _synced: boolean;
+}
+
+/**
* Local deck stored in IndexedDB
* Includes _synced flag for offline sync tracking
*/
@@ -42,12 +86,44 @@ export interface LocalDeck {
}
/**
+ * Local note stored in IndexedDB
+ * Contains field values for a note type
+ */
+export interface LocalNote {
+ id: string;
+ deckId: string;
+ noteTypeId: string;
+ createdAt: Date;
+ updatedAt: Date;
+ deletedAt: Date | null;
+ syncVersion: number;
+ _synced: boolean;
+}
+
+/**
+ * Local note field value stored in IndexedDB
+ * Contains the value for a specific field in a note
+ */
+export interface LocalNoteFieldValue {
+ id: string;
+ noteId: string;
+ noteFieldTypeId: string;
+ value: string;
+ createdAt: Date;
+ updatedAt: Date;
+ syncVersion: number;
+ _synced: boolean;
+}
+
+/**
* Local card stored in IndexedDB
* Includes _synced flag for offline sync tracking
*/
export interface LocalCard {
id: string;
deckId: string;
+ noteId: string | null;
+ isReversed: boolean | null;
front: string;
back: string;
@@ -91,13 +167,17 @@ export interface LocalReviewLog {
/**
* Kioku local database using Dexie (IndexedDB wrapper)
*
- * This database stores decks, cards, and review logs locally for offline support.
+ * This database stores decks, cards, notes, and review logs locally for offline support.
* Each entity has a _synced flag to track whether it has been synchronized with the server.
*/
export class KiokuDatabase extends Dexie {
decks!: EntityTable<LocalDeck, "id">;
cards!: EntityTable<LocalCard, "id">;
reviewLogs!: EntityTable<LocalReviewLog, "id">;
+ noteTypes!: EntityTable<LocalNoteType, "id">;
+ noteFieldTypes!: EntityTable<LocalNoteFieldType, "id">;
+ notes!: EntityTable<LocalNote, "id">;
+ noteFieldValues!: EntityTable<LocalNoteFieldValue, "id">;
constructor() {
super("kioku");
@@ -120,6 +200,47 @@ export class KiokuDatabase extends Dexie {
// reviewedAt: for ordering reviews
reviewLogs: "id, cardId, userId, reviewedAt",
});
+
+ // Version 2: Add note-related tables for Anki-compatible note system
+ this.version(2)
+ .stores({
+ decks: "id, userId, updatedAt",
+ // Add noteId index for filtering cards by note
+ cards: "id, deckId, noteId, updatedAt, due, state",
+ reviewLogs: "id, cardId, userId, reviewedAt",
+
+ // Note types define the structure of notes (templates and fields)
+ // userId: for filtering by user
+ noteTypes: "id, userId, updatedAt",
+
+ // Note field types define the fields for a note type
+ // noteTypeId: for filtering fields by note type
+ noteFieldTypes: "id, noteTypeId, updatedAt",
+
+ // Notes contain field values for a note type
+ // deckId: for filtering notes by deck
+ // noteTypeId: for filtering notes by note type
+ notes: "id, deckId, noteTypeId, updatedAt",
+
+ // Note field values contain the actual field data
+ // noteId: for filtering values by note
+ // noteFieldTypeId: for filtering values by field type
+ noteFieldValues: "id, noteId, noteFieldTypeId, updatedAt",
+ })
+ .upgrade((tx) => {
+ // Migrate existing cards to have noteId and isReversed as null
+ return tx
+ .table("cards")
+ .toCollection()
+ .modify((card) => {
+ if (card.noteId === undefined) {
+ card.noteId = null;
+ }
+ if (card.isReversed === undefined) {
+ card.isReversed = null;
+ }
+ });
+ });
}
}
diff --git a/src/client/db/repositories.ts b/src/client/db/repositories.ts
index 1a03f93..a2f0b41 100644
--- a/src/client/db/repositories.ts
+++ b/src/client/db/repositories.ts
@@ -171,6 +171,8 @@ export const localCardRepository = {
data: Omit<
LocalCard,
| "id"
+ | "noteId"
+ | "isReversed"
| "state"
| "due"
| "stability"
@@ -185,11 +187,14 @@ export const localCardRepository = {
| "deletedAt"
| "syncVersion"
| "_synced"
- >,
+ > &
+ Partial<Pick<LocalCard, "noteId" | "isReversed">>,
): Promise<LocalCard> {
const now = new Date();
const card: LocalCard = {
id: uuidv4(),
+ noteId: null,
+ isReversed: null,
...data,
state: CardState.New,
due: now,