| 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 |