refactor: 完全重构为 Anki 兼容数据结构

- 用 Deck 替换 Folder
- 用 Note + Card 替换 Pair (双向复习)
- 添加 NoteType (卡片模板)
- 添加 Revlog (复习历史)
- 实现 SM-2 间隔重复算法
- 更新所有前端页面
- 添加数据库迁移
This commit is contained in:
2026-03-10 19:20:46 +08:00
parent 9b78fd5215
commit 57ad1b8699
72 changed files with 7107 additions and 2430 deletions

View File

@@ -0,0 +1,106 @@
import z from "zod";
import { generateValidator } from "@/utils/validate";
import { NoteKind } from "../../../generated/prisma/enums";
import {
schemaNoteTypeField,
schemaNoteTypeTemplate,
NoteTypeField,
NoteTypeTemplate,
} from "./note-type-repository-dto";
export const LENGTH_MIN_NOTE_TYPE_NAME = 1;
export const LENGTH_MAX_NOTE_TYPE_NAME = 100;
export const LENGTH_MAX_CSS = 50000;
const schemaNoteTypeFieldAction = z.object({
name: z.string().min(1).max(schemaNoteTypeField.name.maxLength),
ord: z.number().int(),
sticky: z.boolean(),
rtl: z.boolean(),
font: z.string().max(schemaNoteTypeField.font.maxLength).optional(),
size: z.number().int().min(schemaNoteTypeField.size.min).max(schemaNoteTypeField.size.max).optional(),
media: z.array(z.string()).optional(),
});
const schemaNoteTypeTemplateAction = z.object({
name: z.string().min(1).max(schemaNoteTypeTemplate.name.maxLength),
ord: z.number().int(),
qfmt: z.string().min(1).max(schemaNoteTypeTemplate.qfmt.maxLength),
afmt: z.string().min(1).max(schemaNoteTypeTemplate.afmt.maxLength),
bqfmt: z.string().max(schemaNoteTypeTemplate.bqfmt.maxLength).optional(),
bafmt: z.string().max(schemaNoteTypeTemplate.bafmt.maxLength).optional(),
did: z.number().int().optional(),
});
export const schemaActionInputCreateNoteType = z.object({
name: z.string().min(LENGTH_MIN_NOTE_TYPE_NAME).max(LENGTH_MAX_NOTE_TYPE_NAME),
kind: z.enum(["STANDARD", "CLOZE"]).optional(),
css: z.string().max(LENGTH_MAX_CSS).optional(),
fields: z.array(schemaNoteTypeFieldAction).min(1),
templates: z.array(schemaNoteTypeTemplateAction).min(1),
});
export type ActionInputCreateNoteType = z.infer<typeof schemaActionInputCreateNoteType>;
export const validateActionInputCreateNoteType = generateValidator(schemaActionInputCreateNoteType);
export const schemaActionInputUpdateNoteType = z.object({
id: z.number().int().positive(),
name: z.string().min(LENGTH_MIN_NOTE_TYPE_NAME).max(LENGTH_MAX_NOTE_TYPE_NAME).optional(),
kind: z.enum(["STANDARD", "CLOZE"]).optional(),
css: z.string().max(LENGTH_MAX_CSS).optional(),
fields: z.array(schemaNoteTypeFieldAction).min(1).optional(),
templates: z.array(schemaNoteTypeTemplateAction).min(1).optional(),
});
export type ActionInputUpdateNoteType = z.infer<typeof schemaActionInputUpdateNoteType>;
export const validateActionInputUpdateNoteType = generateValidator(schemaActionInputUpdateNoteType);
export const schemaActionInputGetNoteTypeById = z.object({
id: z.number().int().positive(),
});
export type ActionInputGetNoteTypeById = z.infer<typeof schemaActionInputGetNoteTypeById>;
export const validateActionInputGetNoteTypeById = generateValidator(schemaActionInputGetNoteTypeById);
export const schemaActionInputDeleteNoteType = z.object({
id: z.number().int().positive(),
});
export type ActionInputDeleteNoteType = z.infer<typeof schemaActionInputDeleteNoteType>;
export const validateActionInputDeleteNoteType = generateValidator(schemaActionInputDeleteNoteType);
export type ActionOutputNoteType = {
id: number;
name: string;
kind: NoteKind;
css: string;
fields: NoteTypeField[];
templates: NoteTypeTemplate[];
userId: string;
createdAt: Date;
updatedAt: Date;
};
export type ActionOutputCreateNoteType = {
success: boolean;
message: string;
data?: { id: number };
};
export type ActionOutputUpdateNoteType = {
success: boolean;
message: string;
};
export type ActionOutputGetNoteTypeById = {
success: boolean;
message: string;
data?: ActionOutputNoteType;
};
export type ActionOutputGetNoteTypesByUserId = {
success: boolean;
message: string;
data?: ActionOutputNoteType[];
};
export type ActionOutputDeleteNoteType = {
success: boolean;
message: string;
};

View File

@@ -0,0 +1,255 @@
"use server";
import { auth } from "@/auth";
import { headers } from "next/headers";
import { createLogger } from "@/lib/logger";
import { ValidateError } from "@/lib/errors";
import {
ActionInputCreateNoteType,
ActionInputUpdateNoteType,
ActionInputDeleteNoteType,
ActionOutputCreateNoteType,
ActionOutputUpdateNoteType,
ActionOutputGetNoteTypeById,
ActionOutputGetNoteTypesByUserId,
ActionOutputDeleteNoteType,
validateActionInputCreateNoteType,
validateActionInputUpdateNoteType,
validateActionInputDeleteNoteType,
} from "./note-type-action-dto";
import {
serviceCreateNoteType,
serviceUpdateNoteType,
serviceGetNoteTypeById,
serviceGetNoteTypesByUserId,
serviceDeleteNoteType,
} from "./note-type-service";
import {
DEFAULT_BASIC_NOTE_TYPE_FIELDS,
DEFAULT_BASIC_NOTE_TYPE_TEMPLATES,
DEFAULT_BASIC_NOTE_TYPE_CSS,
DEFAULT_CLOZE_NOTE_TYPE_FIELDS,
DEFAULT_CLOZE_NOTE_TYPE_TEMPLATES,
DEFAULT_CLOZE_NOTE_TYPE_CSS,
} from "./note-type-repository-dto";
const log = createLogger("note-type-action");
export async function actionCreateNoteType(
input: ActionInputCreateNoteType,
): Promise<ActionOutputCreateNoteType> {
try {
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user?.id) {
return {
success: false,
message: "Unauthorized",
};
}
const validated = validateActionInputCreateNoteType(input);
const id = await serviceCreateNoteType({
name: validated.name,
kind: validated.kind,
css: validated.css,
fields: validated.fields,
templates: validated.templates,
userId: session.user.id,
});
return {
success: true,
message: "Note type created successfully",
data: { id },
};
} catch (e) {
if (e instanceof ValidateError) {
return {
success: false,
message: e.message,
};
}
log.error("Create note type failed", { error: e });
return {
success: false,
message: "Failed to create note type",
};
}
}
export async function actionUpdateNoteType(
input: ActionInputUpdateNoteType,
): Promise<ActionOutputUpdateNoteType> {
try {
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user?.id) {
return {
success: false,
message: "Unauthorized",
};
}
const validated = validateActionInputUpdateNoteType(input);
await serviceUpdateNoteType({
id: validated.id,
name: validated.name,
kind: validated.kind,
css: validated.css,
fields: validated.fields,
templates: validated.templates,
userId: session.user.id,
});
return {
success: true,
message: "Note type updated successfully",
};
} catch (e) {
if (e instanceof ValidateError) {
return {
success: false,
message: e.message,
};
}
log.error("Update note type failed", { error: e });
return {
success: false,
message: "Failed to update note type",
};
}
}
export async function actionGetNoteTypeById(
id: number,
): Promise<ActionOutputGetNoteTypeById> {
try {
const noteType = await serviceGetNoteTypeById({ id });
if (!noteType) {
return {
success: false,
message: "Note type not found",
};
}
return {
success: true,
message: "Note type retrieved successfully",
data: noteType,
};
} catch (e) {
log.error("Get note type failed", { error: e });
return {
success: false,
message: "Failed to retrieve note type",
};
}
}
export async function actionGetNoteTypesByUserId(): Promise<ActionOutputGetNoteTypesByUserId> {
try {
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user?.id) {
return {
success: false,
message: "Unauthorized",
};
}
const noteTypes = await serviceGetNoteTypesByUserId({
userId: session.user.id,
});
return {
success: true,
message: "Note types retrieved successfully",
data: noteTypes,
};
} catch (e) {
log.error("Get note types failed", { error: e });
return {
success: false,
message: "Failed to retrieve note types",
};
}
}
export async function actionDeleteNoteType(
input: ActionInputDeleteNoteType,
): Promise<ActionOutputDeleteNoteType> {
try {
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user?.id) {
return {
success: false,
message: "Unauthorized",
};
}
const validated = validateActionInputDeleteNoteType(input);
await serviceDeleteNoteType({
id: validated.id,
userId: session.user.id,
});
return {
success: true,
message: "Note type deleted successfully",
};
} catch (e) {
if (e instanceof ValidateError) {
return {
success: false,
message: e.message,
};
}
log.error("Delete note type failed", { error: e });
return {
success: false,
message: "Failed to delete note type",
};
}
}
export async function actionCreateDefaultBasicNoteType(): Promise<ActionOutputCreateNoteType> {
return actionCreateNoteType({
name: "Basic Vocabulary",
kind: "STANDARD",
css: DEFAULT_BASIC_NOTE_TYPE_CSS,
fields: DEFAULT_BASIC_NOTE_TYPE_FIELDS,
templates: DEFAULT_BASIC_NOTE_TYPE_TEMPLATES,
});
}
export async function actionCreateDefaultClozeNoteType(): Promise<ActionOutputCreateNoteType> {
return actionCreateNoteType({
name: "Cloze",
kind: "CLOZE",
css: DEFAULT_CLOZE_NOTE_TYPE_CSS,
fields: DEFAULT_CLOZE_NOTE_TYPE_FIELDS,
templates: DEFAULT_CLOZE_NOTE_TYPE_TEMPLATES,
});
}
export async function actionGetDefaultBasicNoteTypeTemplate() {
return {
name: "Basic Vocabulary",
kind: "STANDARD" as const,
css: DEFAULT_BASIC_NOTE_TYPE_CSS,
fields: DEFAULT_BASIC_NOTE_TYPE_FIELDS,
templates: DEFAULT_BASIC_NOTE_TYPE_TEMPLATES,
};
}
export async function actionGetDefaultClozeNoteTypeTemplate() {
return {
name: "Cloze",
kind: "CLOZE" as const,
css: DEFAULT_CLOZE_NOTE_TYPE_CSS,
fields: DEFAULT_CLOZE_NOTE_TYPE_FIELDS,
templates: DEFAULT_CLOZE_NOTE_TYPE_TEMPLATES,
};
}

View File

@@ -0,0 +1,181 @@
import { NoteKind } from "../../../generated/prisma/enums";
// ============================================
// Field Schema (Anki flds structure)
// ============================================
export interface NoteTypeField {
name: string;
ord: number;
sticky: boolean;
rtl: boolean;
font?: string;
size?: number;
media?: string[];
}
export const schemaNoteTypeField = {
name: { minLength: 1, maxLength: 50 },
font: { maxLength: 100 },
size: { min: 8, max: 72 },
};
// ============================================
// Template Schema (Anki tmpls structure)
// ============================================
export interface NoteTypeTemplate {
name: string;
ord: number;
qfmt: string;
afmt: string;
bqfmt?: string;
bafmt?: string;
did?: number;
}
export const schemaNoteTypeTemplate = {
name: { minLength: 1, maxLength: 100 },
qfmt: { maxLength: 10000 },
afmt: { maxLength: 10000 },
bqfmt: { maxLength: 10000 },
bafmt: { maxLength: 10000 },
};
// ============================================
// Repository Input Types
// ============================================
export interface RepoInputCreateNoteType {
name: string;
kind?: NoteKind;
css?: string;
fields: NoteTypeField[];
templates: NoteTypeTemplate[];
userId: string;
}
export interface RepoInputUpdateNoteType {
id: number;
name?: string;
kind?: NoteKind;
css?: string;
fields?: NoteTypeField[];
templates?: NoteTypeTemplate[];
}
export interface RepoInputGetNoteTypeById {
id: number;
}
export interface RepoInputGetNoteTypesByUserId {
userId: string;
}
export interface RepoInputDeleteNoteType {
id: number;
}
export interface RepoInputCheckNotesExist {
noteTypeId: number;
}
// ============================================
// Repository Output Types
// ============================================
export type RepoOutputNoteType = {
id: number;
name: string;
kind: NoteKind;
css: string;
fields: NoteTypeField[];
templates: NoteTypeTemplate[];
userId: string;
createdAt: Date;
updatedAt: Date;
};
export type RepoOutputNoteTypeOwnership = {
userId: string;
};
export type RepoOutputNotesExistCheck = {
exists: boolean;
count: number;
};
// ============================================
// Default Note Types
// ============================================
export const DEFAULT_BASIC_NOTE_TYPE_FIELDS: NoteTypeField[] = [
{ name: "Word", ord: 0, sticky: false, rtl: false, font: "Arial", size: 20 },
{ name: "Definition", ord: 1, sticky: false, rtl: false, font: "Arial", size: 20 },
{ name: "IPA", ord: 2, sticky: false, rtl: false, font: "Arial", size: 20 },
{ name: "Example", ord: 3, sticky: false, rtl: false, font: "Arial", size: 20 },
];
export const DEFAULT_BASIC_NOTE_TYPE_TEMPLATES: NoteTypeTemplate[] = [
{
name: "Word → Definition",
ord: 0,
qfmt: "{{Word}}<br>{{IPA}}",
afmt: "{{FrontSide}}<hr id=answer>{{Definition}}<br><br>{{Example}}",
},
{
name: "Definition → Word",
ord: 1,
qfmt: "{{Definition}}",
afmt: "{{FrontSide}}<hr id=answer>{{Word}}<br>{{IPA}}",
},
];
export const DEFAULT_BASIC_NOTE_TYPE_CSS = `.card {
font-family: Arial, sans-serif;
font-size: 20px;
text-align: center;
color: #333;
background-color: #fff;
}
.card1 {
background-color: #e8f4f8;
}
.card2 {
background-color: #f8f4e8;
}
hr {
border: none;
border-top: 1px solid #ccc;
margin: 20px 0;
}`;
export const DEFAULT_CLOZE_NOTE_TYPE_FIELDS: NoteTypeField[] = [
{ name: "Text", ord: 0, sticky: false, rtl: false, font: "Arial", size: 20 },
{ name: "Extra", ord: 1, sticky: false, rtl: false, font: "Arial", size: 20 },
];
export const DEFAULT_CLOZE_NOTE_TYPE_TEMPLATES: NoteTypeTemplate[] = [
{
name: "Cloze",
ord: 0,
qfmt: "{{cloze:Text}}",
afmt: "{{cloze:Text}}<br><br>{{Extra}}",
},
];
export const DEFAULT_CLOZE_NOTE_TYPE_CSS = `.card {
font-family: Arial, sans-serif;
font-size: 20px;
text-align: center;
color: #333;
background-color: #fff;
}
.cloze {
font-weight: bold;
color: #0066cc;
}`;

View File

@@ -0,0 +1,151 @@
import { prisma } from "@/lib/db";
import { createLogger } from "@/lib/logger";
import {
RepoInputCreateNoteType,
RepoInputUpdateNoteType,
RepoInputGetNoteTypeById,
RepoInputGetNoteTypesByUserId,
RepoInputDeleteNoteType,
RepoInputCheckNotesExist,
RepoOutputNoteType,
RepoOutputNoteTypeOwnership,
RepoOutputNotesExistCheck,
NoteTypeField,
NoteTypeTemplate,
} from "./note-type-repository-dto";
import { NoteKind } from "../../../generated/prisma/enums";
const log = createLogger("note-type-repository");
export async function repoCreateNoteType(
input: RepoInputCreateNoteType,
): Promise<number> {
const noteType = await prisma.noteType.create({
data: {
name: input.name,
kind: input.kind ?? NoteKind.STANDARD,
css: input.css ?? "",
fields: input.fields as unknown as object,
templates: input.templates as unknown as object,
userId: input.userId,
},
});
log.info("Created note type", { id: noteType.id, name: noteType.name });
return noteType.id;
}
export async function repoUpdateNoteType(
input: RepoInputUpdateNoteType,
): Promise<void> {
const updateData: {
name?: string;
kind?: NoteKind;
css?: string;
fields?: object;
templates?: object;
} = {};
if (input.name !== undefined) updateData.name = input.name;
if (input.kind !== undefined) updateData.kind = input.kind;
if (input.css !== undefined) updateData.css = input.css;
if (input.fields !== undefined)
updateData.fields = input.fields as unknown as object;
if (input.templates !== undefined)
updateData.templates = input.templates as unknown as object;
await prisma.noteType.update({
where: { id: input.id },
data: updateData,
});
log.info("Updated note type", { id: input.id });
}
export async function repoGetNoteTypeById(
input: RepoInputGetNoteTypeById,
): Promise<RepoOutputNoteType | null> {
const noteType = await prisma.noteType.findUnique({
where: { id: input.id },
});
if (!noteType) return null;
return {
id: noteType.id,
name: noteType.name,
kind: noteType.kind,
css: noteType.css,
fields: noteType.fields as unknown as NoteTypeField[],
templates: noteType.templates as unknown as NoteTypeTemplate[],
userId: noteType.userId,
createdAt: noteType.createdAt,
updatedAt: noteType.updatedAt,
};
}
export async function repoGetNoteTypesByUserId(
input: RepoInputGetNoteTypesByUserId,
): Promise<RepoOutputNoteType[]> {
const noteTypes = await prisma.noteType.findMany({
where: { userId: input.userId },
orderBy: { createdAt: "desc" },
});
return noteTypes.map((nt) => ({
id: nt.id,
name: nt.name,
kind: nt.kind,
css: nt.css,
fields: nt.fields as unknown as NoteTypeField[],
templates: nt.templates as unknown as NoteTypeTemplate[],
userId: nt.userId,
createdAt: nt.createdAt,
updatedAt: nt.updatedAt,
}));
}
export async function repoGetNoteTypeOwnership(
noteTypeId: number,
): Promise<RepoOutputNoteTypeOwnership | null> {
const noteType = await prisma.noteType.findUnique({
where: { id: noteTypeId },
select: { userId: true },
});
return noteType;
}
export async function repoDeleteNoteType(
input: RepoInputDeleteNoteType,
): Promise<void> {
await prisma.noteType.delete({
where: { id: input.id },
});
log.info("Deleted note type", { id: input.id });
}
export async function repoCheckNotesExist(
input: RepoInputCheckNotesExist,
): Promise<RepoOutputNotesExistCheck> {
const count = await prisma.note.count({
where: { noteTypeId: input.noteTypeId },
});
return {
exists: count > 0,
count,
};
}
export async function repoGetNoteTypeNameById(
noteTypeId: number,
): Promise<string | null> {
const noteType = await prisma.noteType.findUnique({
where: { id: noteTypeId },
select: { name: true },
});
return noteType?.name ?? null;
}

View File

@@ -0,0 +1,60 @@
import { NoteKind } from "../../../generated/prisma/enums";
import { NoteTypeField, NoteTypeTemplate } from "./note-type-repository-dto";
export type ServiceInputCreateNoteType = {
name: string;
kind?: NoteKind;
css?: string;
fields: NoteTypeField[];
templates: NoteTypeTemplate[];
userId: string;
};
export type ServiceInputUpdateNoteType = {
id: number;
name?: string;
kind?: NoteKind;
css?: string;
fields?: NoteTypeField[];
templates?: NoteTypeTemplate[];
userId: string;
};
export type ServiceInputGetNoteTypeById = {
id: number;
};
export type ServiceInputGetNoteTypesByUserId = {
userId: string;
};
export type ServiceInputDeleteNoteType = {
id: number;
userId: string;
};
export type ServiceInputValidateFields = {
fields: NoteTypeField[];
};
export type ServiceInputValidateTemplates = {
templates: NoteTypeTemplate[];
fields: NoteTypeField[];
};
export type ServiceOutputNoteType = {
id: number;
name: string;
kind: NoteKind;
css: string;
fields: NoteTypeField[];
templates: NoteTypeTemplate[];
userId: string;
createdAt: Date;
updatedAt: Date;
};
export type ServiceOutputValidation = {
success: boolean;
errors: string[];
};

View File

@@ -0,0 +1,272 @@
import { createLogger } from "@/lib/logger";
import { ValidateError } from "@/lib/errors";
import {
repoCreateNoteType,
repoGetNoteTypeById,
repoGetNoteTypesByUserId,
repoUpdateNoteType,
repoDeleteNoteType,
repoGetNoteTypeOwnership,
repoCheckNotesExist,
} from "./note-type-repository";
import {
ServiceInputCreateNoteType,
ServiceInputUpdateNoteType,
ServiceInputGetNoteTypeById,
ServiceInputGetNoteTypesByUserId,
ServiceInputDeleteNoteType,
ServiceInputValidateFields,
ServiceInputValidateTemplates,
ServiceOutputNoteType,
ServiceOutputValidation,
} from "./note-type-service-dto";
import { schemaNoteTypeField, schemaNoteTypeTemplate } from "./note-type-repository-dto";
const log = createLogger("note-type-service");
export function serviceValidateFields(
input: ServiceInputValidateFields,
): ServiceOutputValidation {
const errors: string[] = [];
if (!Array.isArray(input.fields) || input.fields.length === 0) {
errors.push("Fields must be a non-empty array");
return { success: false, errors };
}
const seenNames = new Set<string>();
const seenOrds = new Set<number>();
for (let i = 0; i < input.fields.length; i++) {
const field = input.fields[i];
if (!field.name || field.name.trim().length === 0) {
errors.push(`Field ${i}: name is required`);
} else if (field.name.length > schemaNoteTypeField.name.maxLength) {
errors.push(`Field ${i}: name exceeds maximum length of ${schemaNoteTypeField.name.maxLength}`);
}
if (seenNames.has(field.name)) {
errors.push(`Field ${i}: duplicate field name "${field.name}"`);
}
seenNames.add(field.name);
if (typeof field.ord !== "number") {
errors.push(`Field ${i}: ord must be a number`);
} else if (seenOrds.has(field.ord)) {
errors.push(`Field ${i}: duplicate ordinal ${field.ord}`);
}
seenOrds.add(field.ord);
if (typeof field.sticky !== "boolean") {
errors.push(`Field ${i}: sticky must be a boolean`);
}
if (typeof field.rtl !== "boolean") {
errors.push(`Field ${i}: rtl must be a boolean`);
}
if (field.font && field.font.length > schemaNoteTypeField.font.maxLength) {
errors.push(`Field ${i}: font exceeds maximum length`);
}
if (field.size !== undefined && (field.size < schemaNoteTypeField.size.min || field.size > schemaNoteTypeField.size.max)) {
errors.push(`Field ${i}: size must be between ${schemaNoteTypeField.size.min} and ${schemaNoteTypeField.size.max}`);
}
}
return { success: errors.length === 0, errors };
}
export function serviceValidateTemplates(
input: ServiceInputValidateTemplates,
): ServiceOutputValidation {
const errors: string[] = [];
if (!Array.isArray(input.templates) || input.templates.length === 0) {
errors.push("Templates must be a non-empty array");
return { success: false, errors };
}
const fieldNames = new Set(input.fields.map((f) => f.name));
const seenNames = new Set<string>();
const seenOrds = new Set<number>();
const mustachePattern = /\{\{([^}]+)\}\}/g;
for (let i = 0; i < input.templates.length; i++) {
const template = input.templates[i];
if (!template.name || template.name.trim().length === 0) {
errors.push(`Template ${i}: name is required`);
} else if (template.name.length > schemaNoteTypeTemplate.name.maxLength) {
errors.push(`Template ${i}: name exceeds maximum length`);
}
if (seenNames.has(template.name)) {
errors.push(`Template ${i}: duplicate template name "${template.name}"`);
}
seenNames.add(template.name);
if (typeof template.ord !== "number") {
errors.push(`Template ${i}: ord must be a number`);
} else if (seenOrds.has(template.ord)) {
errors.push(`Template ${i}: duplicate ordinal ${template.ord}`);
}
seenOrds.add(template.ord);
if (!template.qfmt || template.qfmt.trim().length === 0) {
errors.push(`Template ${i}: qfmt (question format) is required`);
} else if (template.qfmt.length > schemaNoteTypeTemplate.qfmt.maxLength) {
errors.push(`Template ${i}: qfmt exceeds maximum length`);
}
if (!template.afmt || template.afmt.trim().length === 0) {
errors.push(`Template ${i}: afmt (answer format) is required`);
} else if (template.afmt.length > schemaNoteTypeTemplate.afmt.maxLength) {
errors.push(`Template ${i}: afmt exceeds maximum length`);
}
const qfmtMatches = template.qfmt.match(mustachePattern) || [];
const afmtMatches = template.afmt.match(mustachePattern) || [];
const allMatches = [...qfmtMatches, ...afmtMatches];
for (const match of allMatches) {
const content = match.slice(2, -2).trim();
if (content.startsWith("cloze:")) {
continue;
}
if (content === "FrontSide") {
continue;
}
if (content.startsWith("type:")) {
continue;
}
if (!fieldNames.has(content)) {
log.warn(`Template ${i}: unknown field reference "{{${content}}}"`);
}
}
}
return { success: errors.length === 0, errors };
}
export async function serviceCreateNoteType(
input: ServiceInputCreateNoteType,
): Promise<number> {
const fieldsValidation = serviceValidateFields({ fields: input.fields });
if (!fieldsValidation.success) {
throw new ValidateError(`Invalid fields: ${fieldsValidation.errors.join("; ")}`);
}
const templatesValidation = serviceValidateTemplates({
templates: input.templates,
fields: input.fields,
});
if (!templatesValidation.success) {
throw new ValidateError(`Invalid templates: ${templatesValidation.errors.join("; ")}`);
}
log.info("Creating note type", { name: input.name, userId: input.userId });
return repoCreateNoteType({
name: input.name,
kind: input.kind,
css: input.css,
fields: input.fields,
templates: input.templates,
userId: input.userId,
});
}
export async function serviceUpdateNoteType(
input: ServiceInputUpdateNoteType,
): Promise<void> {
const ownership = await repoGetNoteTypeOwnership(input.id);
if (!ownership) {
throw new ValidateError("Note type not found");
}
if (ownership.userId !== input.userId) {
throw new ValidateError("You do not have permission to update this note type");
}
if (input.fields) {
const fieldsValidation = serviceValidateFields({ fields: input.fields });
if (!fieldsValidation.success) {
throw new ValidateError(`Invalid fields: ${fieldsValidation.errors.join("; ")}`);
}
}
if (input.templates && input.fields) {
const templatesValidation = serviceValidateTemplates({
templates: input.templates,
fields: input.fields,
});
if (!templatesValidation.success) {
throw new ValidateError(`Invalid templates: ${templatesValidation.errors.join("; ")}`);
}
} else if (input.templates) {
const existing = await repoGetNoteTypeById({ id: input.id });
if (existing) {
const templatesValidation = serviceValidateTemplates({
templates: input.templates,
fields: existing.fields,
});
if (!templatesValidation.success) {
throw new ValidateError(`Invalid templates: ${templatesValidation.errors.join("; ")}`);
}
}
}
log.info("Updating note type", { id: input.id });
await repoUpdateNoteType({
id: input.id,
name: input.name,
kind: input.kind,
css: input.css,
fields: input.fields,
templates: input.templates,
});
}
export async function serviceGetNoteTypeById(
input: ServiceInputGetNoteTypeById,
): Promise<ServiceOutputNoteType | null> {
return repoGetNoteTypeById(input);
}
export async function serviceGetNoteTypesByUserId(
input: ServiceInputGetNoteTypesByUserId,
): Promise<ServiceOutputNoteType[]> {
return repoGetNoteTypesByUserId(input);
}
export async function serviceDeleteNoteType(
input: ServiceInputDeleteNoteType,
): Promise<void> {
const ownership = await repoGetNoteTypeOwnership(input.id);
if (!ownership) {
throw new ValidateError("Note type not found");
}
if (ownership.userId !== input.userId) {
throw new ValidateError("You do not have permission to delete this note type");
}
const notesCheck = await repoCheckNotesExist({ noteTypeId: input.id });
if (notesCheck.exists) {
throw new ValidateError(
`Cannot delete note type: ${notesCheck.count} notes are using this type`,
);
}
log.info("Deleting note type", { id: input.id });
await repoDeleteNoteType({ id: input.id });
}