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