import { randomUUID } from 'crypto' import { access, copyFile, mkdir, readFile, unlink } from 'fs/promises' import { basename, extname, isAbsolute, join } from 'path' import 'reflect-metadata' import { app, dialog, shell } from 'electron' import JSZip from 'jszip' import type { Repository } from 'typeorm' import { TemplateEntitySchema, type TemplateEntity } from '../entities/TemplateEntity' import type { TemplateInput, TemplateItem, TemplatePlaceholder, TemplateSemester, TemplateType } from '../types/template' import { getAppDataSource } from './databaseService' const TEMPLATE_FOLDER_NAME = 'templates' const SUPPORTED_TEMPLATE_EXTENSIONS = ['.ppt', '.pptx', '.doc', '.docx', '.xls', '.xlsx'] const BUILT_IN_PPT_PLACEHOLDERS = new Set([ 'name', 'class', 'comments', 'teacher_name', 'english_name', 'sex', 'birthday', 'zodiac', 'friend', 'hobby', 'game', 'food', 'class_image', 'meImage', 'me_image', 'workImage1', 'work_image_1', 'workImage2', 'work_image_2' ]) const BUILT_IN_PPT_IMAGE_PLACEHOLDERS = ['class_image', 'meImage', 'workImage1', 'workImage2'] function getTemplateStoragePath(): string { return join(app.getPath('userData'), TEMPLATE_FOLDER_NAME) } function getStoredTemplateFilePath(id: string, extension: string): string { return join(TEMPLATE_FOLDER_NAME, `${id}${extension}`) } function resolveTemplateFilePath(filePath: string): string { if (!isAbsolute(filePath)) { return join(app.getPath('userData'), filePath) } return join(getTemplateStoragePath(), basename(filePath)) } async function getTemplateRepository(): Promise> { const source = await getAppDataSource() return source.getRepository(TemplateEntitySchema) } function normalizeTemplateType(type: string): TemplateType { return type === 'landscape' ? 'landscape' : 'portrait' } function normalizeTemplateSemester(semester: string): TemplateSemester { return semester === 'second' ? 'second' : 'first' } function parseStoredPlaceholders(value: string): TemplatePlaceholder[] { try { const parsed = JSON.parse(value) if (!Array.isArray(parsed)) { return [] } return parsed.filter( (item): item is TemplatePlaceholder => item && typeof item.name === 'string' && (item.kind === 'text' || item.kind === 'image') ) } catch { return [] } } function mapTemplateEntity(entity: TemplateEntity): TemplateItem { return { id: entity.id, name: entity.name, grade: entity.grade, semester: normalizeTemplateSemester(entity.semester), type: normalizeTemplateType(entity.type), originalFileName: entity.originalFileName, filePath: resolveTemplateFilePath(entity.filePath), fileExtension: entity.fileExtension, placeholders: parseStoredPlaceholders(entity.placeholders), createdAt: entity.createdAt, updatedAt: entity.updatedAt } } function ensureSupportedTemplateFile(filePath: string): string { const extension = extname(filePath).toLowerCase() if (!SUPPORTED_TEMPLATE_EXTENSIONS.includes(extension)) { throw new Error('仅支持 PPT、Word、Excel 模板文件') } return extension } async function removeFileIfExists(filePath: string): Promise { try { await unlink(filePath) } catch { // Removing an old copied template is best-effort; metadata remains the source of truth. } } function getSlidePaths(zip: JSZip): string[] { return Object.keys(zip.files) .filter((path) => /^ppt\/slides\/slide\d+\.xml$/.test(path)) .sort((first, second) => { const firstNumber = Number(first.match(/slide(\d+)\.xml$/)?.[1] ?? 0) const secondNumber = Number(second.match(/slide(\d+)\.xml$/)?.[1] ?? 0) return firstNumber - secondNumber }) } function decodeXml(value: string): string { return value .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/g, "'") .replace(/&/g, '&') } function isPictureShape(slideXml: string, shapeNameIndex: number): boolean { return ( slideXml.lastIndexOf(' slideXml.lastIndexOf('', shapeNameIndex) ) } function getXmlAttributeValue(xml: string, attributeName: string): string | null { const match = xml.match(new RegExp(`${attributeName}="([^"]*)"`)) return match ? decodeXml(match[1]) : null } function normalizePlaceholderName(name: string): string { return decodeXml(name).trim().replace(/^field[:_-]/i, '') } function getKnownPlaceholderName(name: string): string | null { const placeholderName = normalizePlaceholderName(name) if (BUILT_IN_PPT_PLACEHOLDERS.has(name) || BUILT_IN_PPT_PLACEHOLDERS.has(placeholderName)) { return placeholderName } return null } function upsertPlaceholder( placeholdersByName: Map, name: string, kind: TemplatePlaceholder['kind'] ): void { const trimmedName = decodeXml(name).trim() if (!trimmedName) { return } const existing = placeholdersByName.get(trimmedName) if (existing?.kind === 'image') { return } placeholdersByName.set(trimmedName, { name: trimmedName, kind }) } async function parsePptxPlaceholders(filePath: string): Promise { const templateBuffer = await readFile(filePath) const zip = await JSZip.loadAsync(templateBuffer) const placeholdersByName = new Map() for (const slidePath of getSlidePaths(zip)) { const slideFile = zip.file(slidePath) if (!slideFile) { continue } const slideXml = await slideFile.async('text') for (const shapeMatch of slideXml.matchAll(/]*>/g)) { const shapeXml = shapeMatch[0] const candidateNames = [ getXmlAttributeValue(shapeXml, 'name'), getXmlAttributeValue(shapeXml, 'descr'), getXmlAttributeValue(shapeXml, 'title') ].filter((value): value is string => Boolean(value)) const placeholderName = candidateNames .map(getKnownPlaceholderName) .find((value): value is string => Boolean(value)) if (!placeholderName) { continue } upsertPlaceholder( placeholdersByName, placeholderName, isPictureShape(slideXml, shapeMatch.index ?? 0) ? 'image' : 'text' ) } for (const placeholderMatch of slideXml.matchAll(/\{\{\s*([^{}\s][^{}]*?)\s*\}\}/g)) { upsertPlaceholder(placeholdersByName, placeholderMatch[1], 'text') } } for (const placeholderName of BUILT_IN_PPT_IMAGE_PLACEHOLDERS) { upsertPlaceholder(placeholdersByName, placeholderName, 'image') } return [...placeholdersByName.values()].sort((first, second) => first.name.localeCompare(second.name, 'zh-CN') ) } export async function parseTemplatePlaceholders(filePath: string): Promise { await access(filePath) const extension = ensureSupportedTemplateFile(filePath) if (extension !== '.pptx') { return [] } return parsePptxPlaceholders(filePath) } export async function selectTemplateFile(): Promise<{ filePath: string; fileName: string } | null> { const result = await dialog.showOpenDialog({ title: '选择模板文件', buttonLabel: '选择模板', properties: ['openFile'], filters: [ { name: 'Office 模板文件', extensions: ['ppt', 'pptx', 'doc', 'docx', 'xls', 'xlsx'] } ] }) if (result.canceled || result.filePaths.length === 0) { return null } const filePath = result.filePaths[0] ensureSupportedTemplateFile(filePath) return { filePath, fileName: basename(filePath) } } export async function loadTemplates(): Promise { const repository = await getTemplateRepository() const templates = await repository.find({ order: { updatedAt: 'DESC' } }) return templates.map(mapTemplateEntity) } export async function saveTemplate(input: TemplateInput): Promise { const name = input.name.trim() const grade = input.grade.trim() if (!name) { throw new Error('请输入模板名称') } if (!grade) { throw new Error('请输入适用年级') } await access(input.sourceFilePath) const repository = await getTemplateRepository() const existingTemplate = input.id ? await repository.findOneBy({ id: input.id }) : null const extension = ensureSupportedTemplateFile(input.sourceFilePath) const id = existingTemplate?.id ?? randomUUID() const now = new Date().toISOString() const nextStoredFilePath = getStoredTemplateFilePath(id, extension) const nextAbsoluteFilePath = resolveTemplateFilePath(nextStoredFilePath) const existingAbsoluteFilePath = existingTemplate ? resolveTemplateFilePath(existingTemplate.filePath) : null const shouldCopyFile = existingAbsoluteFilePath !== input.sourceFilePath if (shouldCopyFile) { await mkdir(getTemplateStoragePath(), { recursive: true }) await copyFile(input.sourceFilePath, nextAbsoluteFilePath) if (existingAbsoluteFilePath && existingAbsoluteFilePath !== nextAbsoluteFilePath) { await removeFileIfExists(existingAbsoluteFilePath) } } const placeholders = extension === '.pptx' ? await parsePptxPlaceholders(nextAbsoluteFilePath) : existingTemplate ? parseStoredPlaceholders(existingTemplate.placeholders) : [] const entity: TemplateEntity = { id, name, grade, semester: normalizeTemplateSemester(input.semester), type: normalizeTemplateType(input.type), originalFileName: basename(input.sourceFilePath), filePath: nextStoredFilePath, fileExtension: extension, placeholders: JSON.stringify(placeholders), fieldMappings: '{}', createdAt: existingTemplate?.createdAt ?? now, updatedAt: now } await repository.save(entity) return mapTemplateEntity(entity) } export async function openTemplate(filePath: string): Promise { const absoluteFilePath = resolveTemplateFilePath(filePath) await access(absoluteFilePath) const errorMessage = await shell.openPath(absoluteFilePath) if (errorMessage) { throw new Error(errorMessage) } } export async function deleteTemplate(id: string): Promise { const repository = await getTemplateRepository() const template = await repository.findOneBy({ id }) if (!template) { return } await repository.delete({ id }) await removeFileIfExists(resolveTemplateFilePath(template.filePath)) }