返回 oh-my-ppt
catalog.ts
根目录 / src / main / styles / catalog.ts
1 import type { PPTDatabase, StyleRow } from '../db/database'
2 import fs from 'fs'
3 import path from 'path'
4 import os from 'os'
5 import { unzipSync, zipSync } from 'fflate'
6 import {
7 atomicCopyDirectory,
8 readStylePackage,
9 styleRowToPackageJson,
10 type StylePackage,
11 writeStylePackage
12 } from './style-package'
13 import { getInstalledStylesPath } from './style-runtime'
14
15 export type StyleSource = 'builtin' | 'custom' | 'override'
16 type ImportedStyleSource = Exclude<StyleSource, 'builtin'>
17
18 export interface StylePreset {
19 id: string
20 label: string
21 aliases: string[]
22 description: string
23 fallbackPrompt: string
24 }
25
26 export interface LoadStyleSkillOptions {}
27
28 export interface StyleCatalogItem {
29 id: string
30 styleKey: string
31 label: string
32 description: string
33 category: string
34 source: StyleSource
35 editable: boolean
36 styleCase: string
37 imageGenerationPrompt: string
38 }
39
40 let _db: PPTDatabase | null = null
41
42 export function setStyleDb(db: PPTDatabase): void {
43 _db = db
44 }
45
46 function getDb(): PPTDatabase {
47 if (!_db) throw new Error('Style DB not initialized. Call setStyleDb() first.')
48 return _db
49 }
50
51 function normalize(input: string): string {
52 return input.trim().toLowerCase()
53 }
54
55 function normalizeAlias(alias: string): string {
56 return normalize(alias).replace(/\s+/g, '-')
57 }
58
59 function normalizeStyleId(styleId: string): string {
60 const normalized = normalize(styleId)
61 if (!/^[a-z0-9-]{3,40}$/.test(normalized)) {
62 throw new Error('styleId 仅允许小写字母/数字/连字符,长度 3-40。')
63 }
64 return normalized
65 }
66
67 function rowToPreset(row: StyleRow): StylePreset {
68 return {
69 id: row.id,
70 label: row.styleName,
71 aliases: JSON.parse(row.aliases || '[]'),
72 description: row.description,
73 fallbackPrompt: row.description
74 ? `Use ${row.style} style: ${row.description}`
75 : `Use ${row.style} style.`
76 }
77 }
78
79 function getUserStyleDir(styleId: string): string {
80 const root = getInstalledStylesPath()
81 if (!root) throw new Error('Style runtime not initialized.')
82 return path.join(root, 'user', styleId)
83 }
84
85 function getStylePackageDir(row: StyleRow): string {
86 const root = getInstalledStylesPath()
87 if (!root) throw new Error('Style runtime not initialized.')
88 if (row.packageDir) return path.join(root, row.packageDir)
89 return row.source === 'builtin' ? path.join(root, 'system', row.style) : path.join(root, 'user', row.id)
90 }
91
92 export function getStylePackageDirectory(styleId: string): string {
93 const db = getDb()
94 const id = normalizeStyleId(styleId)
95 const row = db.getStyleRowSync(id)
96 if (!row) throw new Error('style 不存在:' + styleId)
97 return getStylePackageDir(row)
98 }
99
100 export async function saveGeneratedStylePreview(
101 styleId: string,
102 previewHtml: string
103 ): Promise<{ previewPath: string }> {
104 const db = getDb()
105 const id = normalizeStyleId(styleId)
106 const row = db.getStyleRowSync(id)
107 if (!row) throw new Error('style 不存在:' + styleId)
108
109 const sourceDir = getStylePackageDir(row)
110 const sourcePackage = await readStylePackage(sourceDir)
111 if (sourcePackage.previewPath) {
112 throw new Error('该风格已有预览,无需重复生成。')
113 }
114
115 if (row.source === 'builtin') {
116 const targetDir = getUserStyleDir(id)
117 const tempRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'ohmyppt-style-preview-save-'))
118 const tempDir = path.join(tempRoot, id)
119 try {
120 await writeStylePackage({
121 dir: tempDir,
122 json: { ...sourcePackage.json, source: 'override' },
123 skillMarkdown: sourcePackage.skillMarkdown,
124 previewHtml
125 })
126 await atomicCopyDirectory(tempDir, targetDir)
127 await db.updateStyleRow(id, {
128 source: 'override',
129 packageDir: 'user/' + id
130 })
131 return { previewPath: path.join(targetDir, 'preview.html') }
132 } finally {
133 await fs.promises.rm(tempRoot, { recursive: true, force: true }).catch(() => undefined)
134 }
135 }
136
137 const tempRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'ohmyppt-style-preview-save-'))
138 const tempDir = path.join(tempRoot, id)
139 const pendingPreviewPath = path.join(sourceDir, `.preview-${path.basename(tempRoot)}.tmp`)
140 const previewPath = path.join(sourceDir, 'preview.html')
141 try {
142 await writeStylePackage({
143 dir: tempDir,
144 json: sourcePackage.json,
145 skillMarkdown: sourcePackage.skillMarkdown,
146 previewHtml
147 })
148 await fs.promises.copyFile(path.join(tempDir, 'preview.html'), pendingPreviewPath)
149 await fs.promises.rename(pendingPreviewPath, previewPath)
150 return { previewPath }
151 } finally {
152 await fs.promises.rm(pendingPreviewPath, { force: true }).catch(() => undefined)
153 await fs.promises.rm(tempRoot, { recursive: true, force: true }).catch(() => undefined)
154 }
155 }
156
157 async function writeUserStylePackage(
158 row: StyleRow,
159 options: { rootPath?: string } = {}
160 ): Promise<void> {
161 const skillMarkdown = String(row.styleSkill || '').trim()
162 if (!skillMarkdown) {
163 throw new Error(formatUserStylePackageBackfillError(row.id, 'style_skill 为空,无法生成 SKILL.md'))
164 }
165 const dir = options.rootPath ? path.join(options.rootPath, 'user', row.id) : getUserStyleDir(row.id)
166 let json: ReturnType<typeof styleRowToPackageJson>
167 try {
168 json = styleRowToPackageJson({
169 style: row.style,
170 styleName: row.styleName,
171 styleNameZh: row.styleNameZh || row.styleName,
172 styleNameEn: row.styleNameEn || '',
173 description: row.description,
174 category: row.category,
175 aliases: row.aliases,
176 source: row.source,
177 version: row.version,
178 styleCase: row.styleCase,
179 imageGenerationPrompt: row.imageGenerationPrompt
180 })
181 } catch (error) {
182 throw new Error(
183 formatUserStylePackageBackfillError(
184 row.id,
185 'style.json 无效:' + (error instanceof Error ? error.message : String(error))
186 )
187 )
188 }
189 await writeStylePackage({
190 dir,
191 json,
192 skillMarkdown
193 })
194 }
195
196 function formatUserStylePackageBackfillError(styleId: string, reason: string): string {
197 return '跳过用户风格包回填:' + styleId + '。原因:' + reason
198 }
199
200 export async function backfillUserStylePackagesFromDatabase(installedRootPath: string): Promise<{
201 scanned: number
202 created: number
203 skipped: number
204 failed: number
205 }> {
206 const db = getDb()
207 const rows = db
208 .listStyleRowsSync()
209 .filter((row) => row.active !== false && row.source !== 'builtin')
210 let created = 0
211 let skipped = 0
212 let failed = 0
213
214 for (const row of rows) {
215 try {
216 const packageDir = path.join(installedRootPath, 'user', row.id)
217 const hasPackage =
218 fs.existsSync(path.join(packageDir, 'style.json')) &&
219 fs.existsSync(path.join(packageDir, 'SKILL.md'))
220 if (hasPackage) {
221 skipped += 1
222 } else {
223 await writeUserStylePackage(row, { rootPath: installedRootPath })
224 created += 1
225 }
226 if (row.packageDir !== 'user/' + row.id) {
227 await db.updateStyleRow(row.id, { packageDir: 'user/' + row.id })
228 }
229 } catch (error) {
230 failed += 1
231 console.warn('[styles] failed to backfill user style package', {
232 styleId: row.id,
233 message: error instanceof Error ? error.message : String(error)
234 })
235 }
236 }
237
238 return { scanned: rows.length, created, skipped, failed }
239 }
240
241 export function resolveStylePreset(styleId?: string | null): StylePreset {
242 const db = _db
243 const rows = db ? db.listStyleRowsSync() : []
244 if (rows.length === 0) {
245 return {
246 id: 'minimal-white',
247 label: '极简白',
248 aliases: ['minimal', 'light'],
249 description: '极简白',
250 fallbackPrompt: 'Use minimal-white style.'
251 }
252 }
253
254 if (!styleId) {
255 const found = rows.find((r) => r.style === 'minimal-white')
256 return found ? rowToPreset(found) : rowToPreset(rows[0])
257 }
258
259 const normalized = normalize(styleId)
260 const exact = rows.find((r) => r.id === normalized || r.style === normalized)
261 if (exact) return rowToPreset(exact)
262
263 const fallback = rows.find((r) => r.style === 'minimal-white')
264 return fallback ? rowToPreset(fallback) : rowToPreset(rows[0])
265 }
266
267 export function resolveUsableStyleId(styleId?: string | null): string {
268 const db = getDb()
269 const rows = db.listStyleRowsSync().filter((row) => row.active !== false)
270 if (styleId) {
271 const normalized = normalize(styleId)
272 const byId = rows.find((row) => row.id === normalized)
273 if (byId) return byId.id
274 const byStyle = rows.find((row) => row.style === normalized)
275 if (byStyle) return byStyle.id
276 }
277
278 const fallback = rows.find((row) => row.style === 'minimal-white') || rows[0]
279 if (!fallback) throw new Error('styleId 不存在或不可用:')
280 return fallback.id
281 }
282
283 export function loadStyleSkill(styleId?: string | null): { preset: StylePreset; prompt: string } {
284 const db = getDb()
285 const preset = resolveStylePreset(styleId)
286 const row = db.getStyleRowSync(preset.id)
287 const prompt = row?.styleSkill?.trim() || preset.fallbackPrompt
288 return { preset, prompt }
289 }
290
291 export function listStyleCatalog(): StyleCatalogItem[] {
292 const db = getDb()
293 const rows = db.listStyleRowsSync().filter((row) => row.active !== false)
294 return rows.map((row) => ({
295 id: row.id,
296 styleKey: row.style,
297 label: row.styleName,
298 description: row.description,
299 category: row.category || (row.source === 'builtin' ? '内置' : '自定义'),
300 source: row.source as StyleSource,
301 editable: row.source !== 'builtin',
302 styleCase: row.styleCase,
303 imageGenerationPrompt: row.imageGenerationPrompt || ''
304 }))
305 }
306
307 export function getStyleDetail(styleId: string): {
308 id: string
309 styleKey: string
310 label: string
311 name: {
312 zh: string
313 en: string
314 }
315 description: string
316 aliases: string[]
317 styleSkill: string
318 source: StyleSource
319 editable: boolean
320 category: string
321 version: string
322 styleCase: string
323 imageGenerationPrompt: string
324 packageDir: string
325 } {
326 const db = getDb()
327 const normalizedId = normalizeStyleId(styleId)
328 const row = db.getStyleRowSync(normalizedId)
329 if (row) {
330 return {
331 id: row.id,
332 styleKey: row.style,
333 label: row.styleName,
334 name: {
335 zh: row.styleNameZh || row.styleName,
336 en: row.styleNameEn || ''
337 },
338 description: row.description,
339 aliases: JSON.parse(row.aliases || '[]'),
340 styleSkill: row.styleSkill,
341 source: row.source as StyleSource,
342 editable: row.source !== 'builtin',
343 category: row.category || (row.source === 'builtin' ? '内置' : '自定义'),
344 version: row.version,
345 styleCase: row.styleCase,
346 imageGenerationPrompt: row.imageGenerationPrompt || '',
347 packageDir: row.packageDir || ''
348 }
349 }
350 throw new Error(`风格不存在:${styleId}`)
351 }
352
353 export function hasStyleSkill(styleId: string): boolean {
354 const db = getDb()
355 const id = normalizeStyleId(styleId)
356 return Boolean(db.getStyleRowSync(id))
357 }
358
359 export async function upsertStyleSkill(input: {
360 id: string
361 label: string
362 description: string
363 category?: string
364 aliases?: string[]
365 prompt: string
366 styleCase?: string
367 imageGenerationPrompt?: string
368 }): Promise<{ id: string; source: StyleSource }> {
369 const db = getDb()
370 const id = normalizeStyleId(input.id)
371 const existing = db.getStyleRowSync(id)
372
373 const nextSource: StyleSource = existing
374 ? existing.source === 'builtin'
375 ? 'override'
376 : (existing.source as StyleSource)
377 : 'custom'
378
379 if (existing) {
380 await db.updateStyleRow(id, {
381 styleName: input.label.trim() || id,
382 styleNameZh: input.label.trim() || id,
383 description: input.description.trim(),
384 category: (input.category || '').trim() || (nextSource === 'builtin' ? '内置' : '自定义'),
385 aliases: (input.aliases || [])
386 .map((alias) => normalizeAlias(alias))
387 .filter((alias) => alias.length > 0 && alias !== id),
388 source: nextSource,
389 styleSkill: input.prompt.trim(),
390 styleCase: (input.styleCase || '').trim(),
391 ...(input.imageGenerationPrompt !== undefined
392 ? { imageGenerationPrompt: input.imageGenerationPrompt.trim() }
393 : {}),
394 packageDir: 'user/' + id
395 })
396 } else {
397 await db.createStyleRow({
398 id,
399 style: id,
400 styleName: input.label.trim() || id,
401 styleNameZh: input.label.trim() || id,
402 description: input.description.trim(),
403 category: (input.category || '').trim() || '自定义',
404 aliases: (input.aliases || [])
405 .map((alias) => normalizeAlias(alias))
406 .filter((alias) => alias.length > 0 && alias !== id),
407 source: nextSource,
408 styleSkill: input.prompt.trim(),
409 styleCase: (input.styleCase || '').trim(),
410 imageGenerationPrompt: input.imageGenerationPrompt?.trim() || '',
411 packageDir: 'user/' + id
412 })
413 }
414
415 const saved = db.getStyleRowSync(id)
416 if (saved && saved.source !== 'builtin') {
417 await writeUserStylePackage(saved)
418 }
419 return { id, source: nextSource }
420 }
421
422 export async function createStyleSkill(input: {
423 id: string
424 label: string
425 description: string
426 category?: string
427 aliases?: string[]
428 prompt: string
429 styleCase?: string
430 imageGenerationPrompt?: string
431 }): Promise<{ id: string; source: StyleSource }> {
432 const id = normalizeStyleId(input.id)
433 if (hasStyleSkill(id)) {
434 throw new Error(`style 已存在:${id}`)
435 }
436 return upsertStyleSkill(input)
437 }
438
439 export async function updateStyleSkill(input: {
440 id: string
441 label: string
442 description: string
443 category?: string
444 aliases?: string[]
445 prompt: string
446 styleCase?: string
447 imageGenerationPrompt?: string
448 }): Promise<{ id: string; source: StyleSource }> {
449 const id = normalizeStyleId(input.id)
450 if (!hasStyleSkill(id)) {
451 throw new Error(`style 不存在:${id}`)
452 }
453 return upsertStyleSkill(input)
454 }
455
456 export async function importStylePackageZip(
457 filePath: string
458 ): Promise<{ id: string; source: ImportedStyleSource }> {
459 const zipPath = String(filePath || '').trim()
460 if (!zipPath.toLowerCase().endsWith('.zip')) {
461 throw new Error('请选择 .zip 风格包。')
462 }
463 const zipData = await fs.promises.readFile(zipPath)
464 const files = unzipSync(new Uint8Array(zipData))
465 const packageFiles = normalizeStylePackageZipEntries(files)
466 const tempRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'ohmyppt-style-import-'))
467 const tempDir = path.join(tempRoot, packageFiles.rootName)
468 try {
469 await fs.promises.mkdir(tempDir, { recursive: true })
470 await Promise.all(
471 Object.entries(packageFiles.files).map(([name, data]) =>
472 fs.promises.writeFile(path.join(tempDir, name), Buffer.from(data))
473 )
474 )
475 return await importStylePackageDirectory(tempDir)
476 } finally {
477 await fs.promises.rm(tempRoot, { recursive: true, force: true })
478 }
479 }
480
481 export async function importStylePackageDirectory(
482 directoryPath: string
483 ): Promise<{ id: string; source: ImportedStyleSource }> {
484 const rawPath = String(directoryPath || '').trim()
485 if (!rawPath) throw new Error('请选择风格包文件夹。')
486 const sourceDir = path.resolve(rawPath)
487 const sourceStat = await fs.promises.stat(sourceDir)
488 if (!sourceStat.isDirectory()) throw new Error('请选择风格包文件夹。')
489 const directoryName = path.basename(sourceDir)
490 const stylePackage = await readStylePackage(sourceDir)
491 if (stylePackage.json.style !== directoryName) {
492 throw new Error('风格包目录名必须与 style.json 的 style 字段一致。')
493 }
494 return installStylePackage(stylePackage)
495 }
496
497 async function installStylePackage(
498 stylePackage: StylePackage
499 ): Promise<{ id: string; source: ImportedStyleSource }> {
500 const db = getDb()
501 const existing =
502 db.getStyleRowByStyleSync(stylePackage.json.style) ||
503 db.getStyleRowSync(stylePackage.json.style)
504 const id = existing?.id || stylePackage.json.style
505 const source: ImportedStyleSource =
506 existing?.source === 'builtin' ? 'override' : existing?.source || 'custom'
507 const json = {
508 ...stylePackage.json,
509 source
510 }
511 const previewHtml = stylePackage.previewPath
512 ? await fs.promises.readFile(stylePackage.previewPath, 'utf8')
513 : undefined
514
515 if (existing) {
516 await db.updateStyleRow(existing.id, {
517 styleName: json.name.zh,
518 styleNameZh: json.name.zh,
519 styleNameEn: json.name.en,
520 description: json.description,
521 category: json.category,
522 aliases: json.aliases,
523 source,
524 styleSkill: stylePackage.skillMarkdown,
525 version: json.version,
526 styleCase: json.styleCase,
527 imageGenerationPrompt: json.imageGeneration?.prompt || '',
528 packageDir: 'user/' + id,
529 active: true
530 })
531 } else {
532 await db.createStyleRow({
533 id,
534 style: json.style,
535 styleName: json.name.zh,
536 styleNameZh: json.name.zh,
537 styleNameEn: json.name.en,
538 description: json.description,
539 category: json.category,
540 aliases: json.aliases,
541 source,
542 styleSkill: stylePackage.skillMarkdown,
543 version: json.version,
544 styleCase: json.styleCase,
545 imageGenerationPrompt: json.imageGeneration?.prompt || '',
546 packageDir: 'user/' + id
547 })
548 }
549 await writeStylePackage({
550 dir: getUserStyleDir(id),
551 json,
552 skillMarkdown: stylePackage.skillMarkdown,
553 previewHtml
554 })
555 return { id, source }
556 }
557
558 export async function exportStylePackageZip(styleId: string, outputPath: string): Promise<{ filePath: string }> {
559 const db = getDb()
560 const id = normalizeStyleId(styleId)
561 const row = db.getStyleRowSync(id)
562 if (!row) throw new Error('style 不存在:' + styleId)
563 const packageDir = getStylePackageDir(row)
564 let stylePackage: StylePackage
565 try {
566 stylePackage = await readStylePackage(packageDir)
567 } catch (error) {
568 if ((error as NodeJS.ErrnoException).code === 'ENOENT' && row.source !== 'builtin') {
569 throw new Error('该风格没有可导出的 ZIP 包。请重新导入 ZIP 风格包,或编辑保存后再导出。')
570 }
571 throw error
572 }
573 const zipRoot = stylePackage.json.style
574 const zipFiles: Record<string, Uint8Array> = {
575 [zipRoot + '/style.json']: await fs.promises.readFile(path.join(packageDir, 'style.json')),
576 [zipRoot + '/SKILL.md']: await fs.promises.readFile(path.join(packageDir, 'SKILL.md'))
577 }
578 const previewPath = path.join(packageDir, 'preview.html')
579 if (stylePackage.previewPath && fs.existsSync(previewPath)) {
580 zipFiles[zipRoot + '/preview.html'] = await fs.promises.readFile(previewPath)
581 }
582 await fs.promises.writeFile(outputPath, Buffer.from(zipSync(zipFiles)))
583 return { filePath: outputPath }
584 }
585
586 export async function deleteStyleSkill(styleId: string): Promise<{ deleted: boolean }> {
587 const db = getDb()
588 const id = normalizeStyleId(styleId)
589 const existing = db.getStyleRowSync(id)
590 if (!existing) return { deleted: false }
591 await db.updateStyleRow(id, { active: false })
592 return { deleted: true }
593 }
594
595 function normalizeStylePackageZipEntries(files: Record<string, Uint8Array>): {
596 rootName: string
597 files: Record<'style.json' | 'SKILL.md', Uint8Array> & Partial<Record<'preview.html', Uint8Array>>
598 } {
599 const allowed = new Set(['style.json', 'SKILL.md', 'preview.html'])
600 const required = new Set(['style.json', 'SKILL.md'])
601 const entries = Object.entries(files).filter(([rawName]) => {
602 const name = rawName.replace(/\\/g, '/')
603 if (!name || name.endsWith('/')) return false
604 if (name.startsWith('__MACOSX/') || name.includes('/__MACOSX/')) return false
605 if (name.split('/').some((part) => part === '.DS_Store')) return false
606 return true
607 })
608 if (entries.length < 2 || entries.length > 3) {
609 throw new Error('风格包 ZIP 必须只包含一个目录,目录内必须有 style.json、SKILL.md,可选 preview.html。')
610 }
611
612 let rootName = ''
613 const normalized: Partial<Record<'style.json' | 'SKILL.md' | 'preview.html', Uint8Array>> = {}
614 for (const [rawName, data] of entries) {
615 const name = rawName.replace(/\\/g, '/')
616 if (name.startsWith('/') || name.includes('../')) {
617 throw new Error('风格包 ZIP 包含非法路径。')
618 }
619 const parts = name.split('/').filter(Boolean)
620 if (parts.length !== 2) {
621 throw new Error('风格包 ZIP 必须是单个 style 目录结构。')
622 }
623 if (!rootName) rootName = parts[0]
624 if (parts[0] !== rootName) {
625 throw new Error('风格包 ZIP 只能包含一个根目录。')
626 }
627 const fileName = parts[1]
628 if (!allowed.has(fileName)) {
629 throw new Error('风格包 ZIP 只能包含 style.json、SKILL.md、preview.html。')
630 }
631 normalized[fileName as 'style.json' | 'SKILL.md' | 'preview.html'] = data
632 }
633 for (const name of required) {
634 if (!normalized[name as 'style.json' | 'SKILL.md']) {
635 throw new Error('风格包缺少必需文件:' + name)
636 }
637 }
638 return {
639 rootName,
640 files: normalized as Record<'style.json' | 'SKILL.md', Uint8Array> &
641 Partial<Record<'preview.html', Uint8Array>>
642 }
643 }
644
644 lines TYPESCRIPT