372 lines
10 KiB
TypeScript
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(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, "'")
|
|
.replace(/&/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))
|
|
}
|