Initial commit
This commit is contained in:
@@ -0,0 +1,363 @@
|
||||
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 {
|
||||
TemplateFieldMapping,
|
||||
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'
|
||||
])
|
||||
|
||||
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 parseStoredFieldMappings(value: string): TemplateFieldMapping {
|
||||
try {
|
||||
const parsed = JSON.parse(value)
|
||||
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
||||
? Object.fromEntries(
|
||||
Object.entries(parsed).filter(
|
||||
(entry): entry is [string, string] =>
|
||||
typeof entry[0] === 'string' && typeof entry[1] === 'string'
|
||||
)
|
||||
)
|
||||
: {}
|
||||
} 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),
|
||||
fieldMappings: parseStoredFieldMappings(entity.fieldMappings),
|
||||
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 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[^>]*name="([^"]+)"/g)) {
|
||||
const rawName = shapeMatch[1]
|
||||
const name = decodeXml(rawName)
|
||||
|
||||
if (!BUILT_IN_PPT_PLACEHOLDERS.has(name) && !/^field[:_-]/i.test(name)) {
|
||||
continue
|
||||
}
|
||||
|
||||
upsertPlaceholder(
|
||||
placeholdersByName,
|
||||
name.replace(/^field[:_-]/i, ''),
|
||||
isPictureShape(slideXml, shapeMatch.index ?? 0) ? 'image' : 'text'
|
||||
)
|
||||
}
|
||||
|
||||
for (const placeholderMatch of slideXml.matchAll(/\{\{\s*([^{}\s][^{}]*?)\s*\}\}/g)) {
|
||||
upsertPlaceholder(placeholdersByName, placeholderMatch[1], 'text')
|
||||
}
|
||||
}
|
||||
|
||||
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 placeholderNames = new Set(placeholders.map((placeholder) => placeholder.name))
|
||||
const previousMappings = existingTemplate
|
||||
? parseStoredFieldMappings(existingTemplate.fieldMappings)
|
||||
: {}
|
||||
const inputMappings = input.fieldMappings ?? previousMappings
|
||||
const fieldMappings = Object.fromEntries(
|
||||
Object.entries(inputMappings)
|
||||
.map(([placeholder, field]) => [placeholder.trim(), field.trim()])
|
||||
.filter(([placeholder, field]) => placeholderNames.has(placeholder) && field.length > 0)
|
||||
)
|
||||
|
||||
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: JSON.stringify(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))
|
||||
}
|
||||
Reference in New Issue
Block a user