Files
growth_report/src/main/services/templateService.ts
T

372 lines
10 KiB
TypeScript

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<Repository<TemplateEntity>> {
const source = await getAppDataSource()
return source.getRepository<TemplateEntity>(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<void> {
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(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'")
.replace(/&amp;/g, '&')
}
function isPictureShape(slideXml: string, shapeNameIndex: number): boolean {
return (
slideXml.lastIndexOf('<p:pic', shapeNameIndex) >
slideXml.lastIndexOf('</p:pic>', 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<string, TemplatePlaceholder>,
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<TemplatePlaceholder[]> {
const templateBuffer = await readFile(filePath)
const zip = await JSZip.loadAsync(templateBuffer)
const placeholdersByName = new Map<string, TemplatePlaceholder>()
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(/<p:cNvPr[^>]*>/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<TemplatePlaceholder[]> {
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<TemplateItem[]> {
const repository = await getTemplateRepository()
const templates = await repository.find({
order: {
updatedAt: 'DESC'
}
})
return templates.map(mapTemplateEntity)
}
export async function saveTemplate(input: TemplateInput): Promise<TemplateItem> {
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<void> {
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<void> {
const repository = await getTemplateRepository()
const template = await repository.findOneBy({ id })
if (!template) {
return
}
await repository.delete({ id })
await removeFileIfExists(resolveTemplateFilePath(template.filePath))
}