From e34c2ada38659ecbf6638aa19353edeca9830f37 Mon Sep 17 00:00:00 2001 From: liyachen Date: Thu, 16 Jul 2026 21:53:50 -0400 Subject: [PATCH] feat(sop): built-in SOPs on by default with per-mode customize fork - Empty sopPath now falls back to the mode's bundled SOP (master switch useBuiltinSops, default on); a filled path always wins, and a broken path falls back to built-in instead of dropping the analysis block. - Settings: Analysis SOPs section with per-row state line (built-in / custom / off) and a Customize button that forks the built-in into /SOPs and points the path at the copy. Install-all button removed. - sopPath accepts vault-relative paths (readFileSync resolves them). - Bundled SOP files carry no personal vault frontmatter (regression test). - All strings bilingual (en + zh). --- src/bundled-sops.ts | 60 +++++++------ src/clip-router.ts | 18 ++-- src/locales/en.json | 27 ++++-- src/locales/zh.json | 27 ++++-- src/main.ts | 28 ++++++- src/settings.ts | 97 +++++++++++++++------- src/sops/en/Cover Analysis SOP.md | 6 -- src/sops/en/Video Hook Analysis SOP.md | 6 -- src/sops/en/Video Keyframe Analysis SOP.md | 6 -- src/sops/zh/封面拆解学习 SOP.md | 6 -- src/sops/zh/视频Hook分析 SOP.md | 6 -- src/sops/zh/视频关键帧分析 SOP.md | 6 -- src/types.ts | 3 + tests/bundled-sops.test.ts | 93 ++++++++++++++------- tests/clip-router.test.ts | 52 ++++++++++++ tests/settings.test.ts | 3 + 16 files changed, 298 insertions(+), 146 deletions(-) diff --git a/src/bundled-sops.ts b/src/bundled-sops.ts index 9a5c61d..c871030 100644 --- a/src/bundled-sops.ts +++ b/src/bundled-sops.ts @@ -4,8 +4,31 @@ import keyframeZh from './sops/zh/视频关键帧分析 SOP.md'; import coverEn from './sops/en/Cover Analysis SOP.md'; import hookEn from './sops/en/Video Hook Analysis SOP.md'; import keyframeEn from './sops/en/Video Keyframe Analysis SOP.md'; +import type { ClipMode } from './types'; +import type { Language } from './i18n'; -export interface BundledSop { filename: string; content: string; } +interface BuiltinSop { filename: string; content: string; } + +// Screenshot mode has no built-in SOP on purpose: webpage screenshots are too +// varied for one analysis prompt; users attach their own via sopPath. +const BUILTIN: Partial>> = { + thumbnail: { + zh: { filename: '封面拆解学习 SOP.md', content: coverZh }, + en: { filename: 'Cover Analysis SOP.md', content: coverEn }, + }, + hook: { + zh: { filename: '视频Hook分析 SOP.md', content: hookZh }, + en: { filename: 'Video Hook Analysis SOP.md', content: hookEn }, + }, + keyframe: { + zh: { filename: '视频关键帧分析 SOP.md', content: keyframeZh }, + en: { filename: 'Video Keyframe Analysis SOP.md', content: keyframeEn }, + }, +}; + +export function builtinSopFor(mode: ClipMode, language: Language): string | undefined { + return BUILTIN[mode]?.[language]?.content; +} export interface SopInstallOps { fileExists(path: string): boolean; @@ -13,29 +36,18 @@ export interface SopInstallOps { create(path: string, content: string): Promise; } -export const BUNDLED_SOPS: BundledSop[] = [ - { filename: '封面拆解学习 SOP.md', content: coverZh }, - { filename: '视频Hook分析 SOP.md', content: hookZh }, - { filename: '视频关键帧分析 SOP.md', content: keyframeZh }, - { filename: 'Cover Analysis SOP.md', content: coverEn }, - { filename: 'Video Hook Analysis SOP.md', content: hookEn }, - { filename: 'Video Keyframe Analysis SOP.md', content: keyframeEn }, -]; - -// Writes bundled SOPs into /SOPs. Existing files are never overwritten, -// and no sopPath setting is touched: the user picks which SOP applies where. -export async function installBundledSops( - ops: SopInstallOps, baseFolder: string, sops: BundledSop[] = BUNDLED_SOPS, -): Promise<{ written: string[]; skipped: string[] }> { +// One-click fork: copy this mode's built-in SOP into /SOPs (never +// overwriting) and return the vault-relative path so settings can point the +// mode's sopPath at it. Returns undefined for modes without a built-in SOP. +export async function exportBuiltinSop( + ops: SopInstallOps, baseFolder: string, mode: ClipMode, language: Language, +): Promise<{ path: string; existed: boolean } | undefined> { + const sop = BUILTIN[mode]?.[language]; + if (!sop) return undefined; const folder = `${(baseFolder || 'Clips').trim().replace(/\/+$/, '') || 'Clips'}/SOPs`; + const path = `${folder}/${sop.filename}`; + if (ops.fileExists(path)) return { path, existed: true }; await ops.ensureFolder(folder); - const written: string[] = []; - const skipped: string[] = []; - for (const s of sops) { - const path = `${folder}/${s.filename}`; - if (ops.fileExists(path)) { skipped.push(path); continue; } - await ops.create(path, s.content); - written.push(path); - } - return { written, skipped }; + await ops.create(path, sop.content); + return { path, existed: false }; } diff --git a/src/clip-router.ts b/src/clip-router.ts index 4490328..20b02ef 100644 --- a/src/clip-router.ts +++ b/src/clip-router.ts @@ -20,14 +20,15 @@ export async function routeClip( payload: ClipPayload, clipRules: PluginSettings['clipRules'], vaultOps: VaultOps, + builtinSops: Partial> = {}, ): Promise<{ notePath?: string; notice?: string }> { - if (payload.mode === 'thumbnail') return handleThumbnail(payload, clipRules.thumbnail, vaultOps); + if (payload.mode === 'thumbnail') return handleThumbnail(payload, clipRules.thumbnail, vaultOps, builtinSops.thumbnail); if (payload.mode === 'screenshot') { const normalized = normalizeScreenshot(payload); - return handleScreenshot(normalized, clipRules.screenshot, vaultOps, clipRules.thumbnail.outputFolder, clipRules.thumbnail.thumbnailFolder); + return handleScreenshot(normalized, clipRules.screenshot, vaultOps, clipRules.thumbnail.outputFolder, clipRules.thumbnail.thumbnailFolder, builtinSops.screenshot); } - if (payload.mode === 'hook') return handleMultiFrame(payload, clipRules.hook, vaultOps, clipRules.thumbnail.outputFolder, clipRules.thumbnail.thumbnailFolder); - if (payload.mode === 'keyframe') return handleMultiFrame(payload, clipRules.keyframe, vaultOps, clipRules.thumbnail.outputFolder, clipRules.thumbnail.thumbnailFolder); + if (payload.mode === 'hook') return handleMultiFrame(payload, clipRules.hook, vaultOps, clipRules.thumbnail.outputFolder, clipRules.thumbnail.thumbnailFolder, builtinSops.hook); + if (payload.mode === 'keyframe') return handleMultiFrame(payload, clipRules.keyframe, vaultOps, clipRules.thumbnail.outputFolder, clipRules.thumbnail.thumbnailFolder, builtinSops.keyframe); throw new Error('Unknown clip mode'); } @@ -114,6 +115,7 @@ async function handleScreenshot( vaultOps: VaultOps, searchFolder: string, assetFolder: string, + builtinSop?: string, ): Promise<{ notePath: string }> { if (!rule.outputFolder) { throw new Error(t('error.screenshotFolderNotConfigured')); @@ -140,7 +142,7 @@ async function handleScreenshot( const intoVideoNote = !!existing || extractVideoId(payload.url, undefined) != null; const meta: VideoNoteMeta = { platform: detectPlatform(payload.url), videoId: key, videoUrl: payload.url, title: payload.title }; - const sopContent = readSopSafely(rule.sopPath, vaultOps); + const sopContent = readSopSafely(rule.sopPath, vaultOps) ?? builtinSop; if (intoVideoNote) { const r = await upsertVideoNote(meta, screenshotSection(imageNames, sopContent), vaultOps, searchFolder); await ensureCover(meta.videoId, payload.cover_url, vaultOps, assetFolder); @@ -174,6 +176,7 @@ async function handleThumbnail( payload: ThumbnailPayload, rule: ThumbnailClipRule, vaultOps: VaultOps, + builtinSop?: string, ): Promise<{ notePath: string; notice?: string }> { if (!rule.outputFolder || !rule.thumbnailFolder) { throw new Error(t('error.videoFolderNotConfigured')); @@ -187,7 +190,7 @@ async function handleThumbnail( const imgData = await vaultOps.downloadUrl(payload.thumbnail_url); await vaultOps.createBinary(thumbnailPath, imgData); - const sopContent = readSopSafely(rule.sopPath, vaultOps); + const sopContent = readSopSafely(rule.sopPath, vaultOps) ?? builtinSop; const section = coverSection(thumbnailFile, sopContent); const meta: VideoNoteMeta = { platform: payload.platform, @@ -205,6 +208,7 @@ async function handleMultiFrame( vaultOps: VaultOps, searchFolder: string, assetFolder: string, + builtinSop?: string, ): Promise<{ notePath: string; notice?: string }> { // ── Pick which frames to keep from the candidates the extension sent ────────── // The extension already curates frames; save them as-is, or sample uniformly @@ -232,7 +236,7 @@ async function handleMultiFrame( await vaultOps.createBinary(`${framesDir}/${name}`, bytes.buffer as ArrayBuffer); frameNames.push(name); } - const sopContent = readSopSafely(rule.sopPath, vaultOps); + const sopContent = readSopSafely(rule.sopPath, vaultOps) ?? builtinSop; let section: NewSection; if (payload.mode === 'hook') { section = hookSection( diff --git a/src/locales/en.json b/src/locales/en.json index 7230c2a..9a8364a 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -21,15 +21,24 @@ "settings.port.desc": "Default 17183. Only change it if the port is taken; after changing, set the same value on the extension's welcome page (Advanced → Port), or the two sides will disconnect. Restart Obsidian afterwards.", "settings.maxFrames.name": "Max frames", "settings.maxFrames.desc": "Maximum frames kept per hook / keyframe clip (1 to 20). Default 5.", - "settings.installSops.name": "Bundled SOPs", - "settings.installSops.desc": "Three ready-made analysis SOPs (cover, hook, keyframe), each in Chinese and English. One click writes them into /SOPs. Existing files are never overwritten. Point the SOP path fields below at the ones you want to use.", - "settings.installSops.button": "Install", - "notice.sopsInstalled": "Installed {count} SOP file(s) into {folder}. Skipped {skipped} already there.", - "settings.sop.thumbnail": "Cover SOP path", - "settings.sop.screenshot": "Screenshot SOP path", - "settings.sop.hook": "Hook SOP path", - "settings.sop.keyframe": "Keyframe SOP path", - "settings.sop.desc": "Leave empty for material-only mode (no analysis prompt). Absolute path to a markdown file inside the vault.", + "settings.sopHeading": "Analysis SOPs", + "settings.useBuiltinSops.name": "Use built-in analysis SOPs", + "settings.useBuiltinSops.desc": "On: every saved clip carries its matching built-in SOP (cover, hook, keyframe), and plugin updates keep them current. Off: clips save material only, with no analysis prompt.", + "settings.sopRow.cover": "Cover SOP", + "settings.sopRow.screenshot": "Screenshot SOP", + "settings.sopRow.hook": "Hook SOP", + "settings.sopRow.keyframe": "Keyframe SOP", + "settings.sopRow.desc": "Leave empty to use the built-in SOP. Want to tweak it? Click Customize: the copy and the path are set up for you.", + "settings.sopRow.descNoBuiltin": "Screenshot mode has no built-in SOP. To attach one, enter the path of your own SOP file (inside the vault or absolute).", + "settings.sopRow.placeholder": "Empty = built-in", + "settings.sopRow.placeholderNoBuiltin": "Optional: path to your own SOP", + "settings.sopState.builtin": "In effect: built-in SOP (updates with the plugin)", + "settings.sopState.custom": "In effect: your custom file (clear the path to return to built-in)", + "settings.sopState.off": "In effect: no analysis (master switch is off)", + "settings.sopState.none": "In effect: no analysis (this mode has no built-in SOP)", + "settings.sopCustomize": "Customize", + "notice.sopExported": "Exported to {path}. Open it and edit; changes apply on the next save.", + "notice.sopExportExists": "{path} already exists. The path now points to it.", "notice.savedTo": "Saved to {folder}\nWant a different location? Settings → Vault Autopilot → Storage locations", "notice.portInUse": "Vault Autopilot: port {port} is already in use. Quit the program using it, or set the same new port in both the plugin settings and the extension settings.", "notice.sectionExists": "\"{section}\" already exists — not overwritten. To redo it, delete that section first, then clip again.", diff --git a/src/locales/zh.json b/src/locales/zh.json index c992299..09416b5 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -21,15 +21,24 @@ "settings.port.desc": "默认 17183。仅当端口被占用时才需要改;改完必须在扩展的引导页(高级 → 端口)改成同一个值,否则两边会断开。改后重启 Obsidian。", "settings.maxFrames.name": "抽帧数量上限", "settings.maxFrames.desc": "Hook / 关键帧模式最多保存几帧(1–20)。默认 5。", - "settings.installSops.name": "内置 SOP", - "settings.installSops.desc": "三份现成的分析 SOP(封面、Hook、关键帧),中英各一份。点一下写入 <根文件夹>/SOPs,已存在的文件不会被覆盖。想启用哪份,把下面对应的 SOP 路径指向它。", - "settings.installSops.button": "安装", - "notice.sopsInstalled": "已安装 {count} 份 SOP 到 {folder},跳过 {skipped} 份已存在的文件。", - "settings.sop.thumbnail": "封面 SOP 路径", - "settings.sop.screenshot": "截图 SOP 路径", - "settings.sop.hook": "Hook SOP 路径", - "settings.sop.keyframe": "关键帧 SOP 路径", - "settings.sop.desc": "留空 = 纯素材模式(不附带分析提示)。填 vault 内 markdown 文件的绝对路径。", + "settings.sopHeading": "分析 SOP", + "settings.useBuiltinSops.name": "使用内置分析 SOP", + "settings.useBuiltinSops.desc": "开着:保存 clip 时自动附带对应的内置分析 SOP(封面、Hook、关键帧),插件更新时 SOP 自动更新。关掉:只存素材,不带分析提示。", + "settings.sopRow.cover": "封面 SOP", + "settings.sopRow.screenshot": "网页截图 SOP", + "settings.sopRow.hook": "Hook SOP", + "settings.sopRow.keyframe": "关键帧 SOP", + "settings.sopRow.desc": "留空 = 用内置的。想在内置基础上改?点「自定义」,副本和路径都会帮你弄好。", + "settings.sopRow.descNoBuiltin": "截图模式没有内置 SOP。想给截图挂分析提示,填一个你自己的 SOP 文件路径(库内路径或绝对路径都行)。", + "settings.sopRow.placeholder": "留空即用内置", + "settings.sopRow.placeholderNoBuiltin": "可选:你自己的 SOP 路径", + "settings.sopState.builtin": "当前生效:内置 SOP(跟随插件自动更新)", + "settings.sopState.custom": "当前生效:你的自定义文件(清空路径可回到内置)", + "settings.sopState.off": "当前生效:不带分析(总开关已关)", + "settings.sopState.none": "当前生效:不带分析(此模式无内置 SOP)", + "settings.sopCustomize": "自定义", + "notice.sopExported": "已导出到 {path},打开编辑即可,改动下次保存时生效。", + "notice.sopExportExists": "{path} 已存在,路径已指向它。", "notice.savedTo": "已存到 {folder}\n想换位置?设置 → Vault Autopilot → 存储位置", "notice.portInUse": "Vault Autopilot:端口 {port} 被占用。请关闭占用它的程序;或在插件设置和扩展设置两处改成同一个新端口。", "notice.sectionExists": "「{section}」已存在,未覆盖。想重做请先删掉该小节再点。", diff --git a/src/main.ts b/src/main.ts index 92ebc83..a66fc42 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,11 +1,12 @@ import * as http from 'http'; import * as fs from 'fs'; -import { Notice, Plugin, TFile, requestUrl } from 'obsidian'; +import * as nodePath from 'path'; +import { FileSystemAdapter, Notice, Plugin, TFile, requestUrl } from 'obsidian'; import { DEFAULT_SETTINGS, VaultAutopilotSettingTab, normalizePort, emptyToDefault } from './settings'; import { PluginSettings, ClipMode } from './types'; import { createServer, ClipPayload } from './server'; import { routeClip, VaultOps } from './clip-router'; -import { SopInstallOps } from './bundled-sops'; +import { SopInstallOps, builtinSopFor } from './bundled-sops'; import { t, setLanguage } from './i18n'; export default class VaultAutopilotPlugin extends Plugin { @@ -65,6 +66,18 @@ export default class VaultAutopilotPlugin extends Plugin { } } + // Built-in SOP contents for modes whose sopPath is empty, respecting the + // master switch and the plugin language. + builtinSops(): Partial> { + if (!this.settings.useBuiltinSops) return {}; + const lang = this.settings.language; + return { + thumbnail: builtinSopFor('thumbnail', lang), + hook: builtinSopFor('hook', lang), + keyframe: builtinSopFor('keyframe', lang), + }; + } + // Narrow vault access for the settings tab's bundled-SOP installer. sopInstallOps(): SopInstallOps { return { @@ -100,7 +113,14 @@ export default class VaultAutopilotPlugin extends Plugin { else await this.app.vault.createBinary(p, data); }, create: async (p, content) => { await this.app.vault.create(p, content); }, - readFileSync: (p) => fs.readFileSync(p, 'utf8'), + readFileSync: (p) => { + // sopPath accepts both absolute paths and vault-relative ones (the + // Customize button fills vault-relative paths). + const abs = nodePath.isAbsolute(p) + ? p + : nodePath.join((this.app.vault.adapter as FileSystemAdapter).getBasePath(), p); + return fs.readFileSync(abs, 'utf8'); + }, downloadUrl: async (url) => { const resp = await requestUrl({ url, method: 'GET' }); return resp.arrayBuffer; @@ -125,7 +145,7 @@ export default class VaultAutopilotPlugin extends Plugin { this.server = createServer( port, async (payload) => { - const { notePath, notice } = await routeClip(payload, this.settings.clipRules, vaultOps); + const { notePath, notice } = await routeClip(payload, this.settings.clipRules, vaultOps, this.builtinSops()); if (notePath) await this.maybeFirstSaveNotice(payload.mode, notePath); const obsidianUrl = notePath ? `obsidian://open?vault=${encodeURIComponent(this.app.vault.getName())}&file=${encodeURIComponent(notePath)}` diff --git a/src/settings.ts b/src/settings.ts index e93ed19..bcbf60e 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -1,12 +1,13 @@ import { App, Notice, PluginSettingTab, Setting, TextComponent } from 'obsidian'; import type VaultAutopilotPlugin from './main'; -import { PluginSettings } from './types'; -import { t, setLanguage, Language } from './i18n'; -import { installBundledSops } from './bundled-sops'; +import { PluginSettings, ClipMode } from './types'; +import { t, setLanguage, Language, LocaleKey } from './i18n'; +import { exportBuiltinSop } from './bundled-sops'; export const DEFAULT_SETTINGS: PluginSettings = { language: 'en', baseFolder: 'Clips', + useBuiltinSops: true, httpServer: { enabled: true, port: 17183, @@ -209,37 +210,77 @@ export class VaultAutopilotSettingTab extends PluginSettingTab { } })); + // ── Analysis SOPs ───────────────────────────────────────────────────────────── + // Empty sopPath = the mode's built-in SOP (when the master switch is on). + // The Customize button forks the built-in into /SOPs and points the + // path at the copy; clearing the path returns the mode to built-in. + new Setting(containerEl).setName(t('settings.sopHeading')).setHeading(); + + const sopRows = [ + { mode: 'thumbnail' as const, name: t('settings.sopRow.cover'), hasBuiltin: true }, + { mode: 'screenshot' as const, name: t('settings.sopRow.screenshot'), hasBuiltin: false }, + { mode: 'hook' as const, name: t('settings.sopRow.hook'), hasBuiltin: true }, + { mode: 'keyframe' as const, name: t('settings.sopRow.keyframe'), hasBuiltin: true }, + ]; + const sopStateEls: Partial> = {}; + const refreshSopStates = () => { + for (const row of sopRows) { + const el = sopStateEls[row.mode]; + if (!el) continue; + const custom = this.plugin.settings.clipRules[row.mode].sopPath.trim().length > 0; + let key: LocaleKey; + let color: string; + if (custom) { key = 'settings.sopState.custom'; color = '#b08c2e'; } + else if (!row.hasBuiltin) { key = 'settings.sopState.none'; color = 'var(--text-muted)'; } + else if (this.plugin.settings.useBuiltinSops) { key = 'settings.sopState.builtin'; color = 'var(--color-green, #3d8a5f)'; } + else { key = 'settings.sopState.off'; color = 'var(--text-muted)'; } + el.textContent = t(key); + el.style.color = color; + } + }; + new Setting(containerEl) - .setName(t('settings.installSops.name')) - .setDesc(t('settings.installSops.desc')) - .addButton(b => b - .setButtonText(t('settings.installSops.button')) - .onClick(async () => { - const { written, skipped } = await installBundledSops( - this.plugin.sopInstallOps(), this.plugin.settings.baseFolder); - new Notice(t('notice.sopsInstalled', { - count: written.length, - folder: `${this.plugin.settings.baseFolder}/SOPs`, - skipped: skipped.length, - }), 8000); + .setName(t('settings.useBuiltinSops.name')) + .setDesc(t('settings.useBuiltinSops.desc')) + .addToggle(tg => tg + .setValue(this.plugin.settings.useBuiltinSops) + .onChange(async v => { + this.plugin.settings.useBuiltinSops = v; + await this.plugin.saveSettings(); + refreshSopStates(); })); - const sopModes = [ - ['thumbnail', t('settings.sop.thumbnail')], - ['screenshot', t('settings.sop.screenshot')], - ['hook', t('settings.sop.hook')], - ['keyframe', t('settings.sop.keyframe')], - ] as const; - for (const [mode, label] of sopModes) { - new Setting(containerEl) - .setName(label) - .setDesc(t('settings.sop.desc')) - .addText(t => t - .setValue(this.plugin.settings.clipRules[mode].sopPath) + for (const row of sopRows) { + const setting = new Setting(containerEl) + .setName(row.name) + .setDesc(t(row.hasBuiltin ? 'settings.sopRow.desc' : 'settings.sopRow.descNoBuiltin')) + .addText(txt => txt + .setPlaceholder(t(row.hasBuiltin ? 'settings.sopRow.placeholder' : 'settings.sopRow.placeholderNoBuiltin')) + .setValue(this.plugin.settings.clipRules[row.mode].sopPath) .onChange(async v => { - this.plugin.settings.clipRules[mode].sopPath = v.trim(); + this.plugin.settings.clipRules[row.mode].sopPath = v.trim(); await this.plugin.saveSettings(); + refreshSopStates(); })); + if (row.hasBuiltin) { + setting.addButton(b => b + .setButtonText(t('settings.sopCustomize')) + .onClick(async () => { + const r = await exportBuiltinSop( + this.plugin.sopInstallOps(), this.plugin.settings.baseFolder, + row.mode, this.plugin.settings.language); + if (!r) return; + this.plugin.settings.clipRules[row.mode].sopPath = r.path; + await this.plugin.saveSettings(); + new Notice(t(r.existed ? 'notice.sopExportExists' : 'notice.sopExported', { path: r.path }), 8000); + this.display(); + })); + } + const stateEl = setting.infoEl.createDiv(); + stateEl.style.fontSize = '12px'; + stateEl.style.marginTop = '4px'; + sopStateEls[row.mode] = stateEl; } + refreshSopStates(); } } diff --git a/src/sops/en/Cover Analysis SOP.md b/src/sops/en/Cover Analysis SOP.md index 7e7a9db..71f80cc 100644 --- a/src/sops/en/Cover Analysis SOP.md +++ b/src/sops/en/Cover Analysis SOP.md @@ -1,9 +1,3 @@ ---- -title: Cover Analysis SOP2 -type: note -permalink: obsidian-sop/02-writing-and-content/cover-analysis-sop2 ---- - # Cover and Title Analysis Prompt _After reading this document, output your analysis directly using the framework below._ diff --git a/src/sops/en/Video Hook Analysis SOP.md b/src/sops/en/Video Hook Analysis SOP.md index 39fbf8a..819fbae 100644 --- a/src/sops/en/Video Hook Analysis SOP.md +++ b/src/sops/en/Video Hook Analysis SOP.md @@ -1,9 +1,3 @@ ---- -title: Video Hook Analysis SOP -type: note -permalink: obsidian-sop/05-aesthetic-collection/video-hook-analysis-sop ---- - # Video Hook Analysis Prompt _After reading this document, output your Hook analysis directly using the framework below._ diff --git a/src/sops/en/Video Keyframe Analysis SOP.md b/src/sops/en/Video Keyframe Analysis SOP.md index 84ddb92..523b92b 100644 --- a/src/sops/en/Video Keyframe Analysis SOP.md +++ b/src/sops/en/Video Keyframe Analysis SOP.md @@ -1,9 +1,3 @@ ---- -title: Video Keyframe Analysis SOP -type: note -permalink: obsidian-sop/05-aesthetic-collection/video-keyframe-analysis-sop ---- - # Video Keyframe Analysis SOP > Input: multiple keyframe screenshots from a segment of the video (filename prefix `keyframe-`). diff --git a/src/sops/zh/封面拆解学习 SOP.md b/src/sops/zh/封面拆解学习 SOP.md index c8570cc..9f48a28 100644 --- a/src/sops/zh/封面拆解学习 SOP.md +++ b/src/sops/zh/封面拆解学习 SOP.md @@ -1,9 +1,3 @@ ---- -title: 封面拆解学习 SOP2 -type: note -permalink: obsidian-sop/02-写作与内容/封面拆解学习-sop2 ---- - # 封面与标题分析 Prompt _读完这份文档后,直接按以下框架输出分析。_ diff --git a/src/sops/zh/视频Hook分析 SOP.md b/src/sops/zh/视频Hook分析 SOP.md index 6baf135..04abbeb 100644 --- a/src/sops/zh/视频Hook分析 SOP.md +++ b/src/sops/zh/视频Hook分析 SOP.md @@ -1,9 +1,3 @@ ---- -title: 视频Hook分析 SOP -type: note -permalink: obsidian-sop/05-审美积累/视频-hook-分析-sop ---- - # 视频 Hook 分析 Prompt _读完这份文档后,直接按以下框架输出 Hook 分析。_ diff --git a/src/sops/zh/视频关键帧分析 SOP.md b/src/sops/zh/视频关键帧分析 SOP.md index d3bcf95..db4204a 100644 --- a/src/sops/zh/视频关键帧分析 SOP.md +++ b/src/sops/zh/视频关键帧分析 SOP.md @@ -1,9 +1,3 @@ ---- -title: 视频关键帧分析 SOP -type: note -permalink: obsidian-sop/05-审美积累/视频关键帧分析-sop ---- - # 视频关键帧分析 SOP > 输入:视频某段时间内的多张关键帧截图(文件名前缀 `keyframe-`)。 diff --git a/src/types.ts b/src/types.ts index cf3e1e6..289b0c3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -35,6 +35,9 @@ export interface PluginSettings { // Root folder for everything the plugin writes; the four folder fields // below derive from it when the user edits it in settings. baseFolder: string; + // When true, a mode with an empty sopPath falls back to its bundled SOP + // (cover, hook, keyframe); false means empty sopPath = material-only. + useBuiltinSops: boolean; httpServer: HttpServerSettings; clipRules: { thumbnail: ThumbnailClipRule; diff --git a/tests/bundled-sops.test.ts b/tests/bundled-sops.test.ts index 2a4aaaa..3198170 100644 --- a/tests/bundled-sops.test.ts +++ b/tests/bundled-sops.test.ts @@ -1,4 +1,6 @@ -import { installBundledSops, BUNDLED_SOPS, BundledSop, SopInstallOps } from '../src/bundled-sops'; +import * as fs from 'fs'; +import * as path from 'path'; +import { builtinSopFor, exportBuiltinSop, SopInstallOps } from '../src/bundled-sops'; function makeOps(existing: string[] = []) { const created: Record = {}; @@ -10,34 +12,67 @@ function makeOps(existing: string[] = []) { return { ops, created }; } -const sops: BundledSop[] = [ - { filename: 'A.md', content: 'aaa' }, - { filename: 'B.md', content: 'bbb' }, -]; - -describe('installBundledSops', () => { - test('writes every bundled file under /SOPs', async () => { - const { ops, created } = makeOps(); - const r = await installBundledSops(ops, 'Clips', sops); - expect(r.written).toEqual(['Clips/SOPs/A.md', 'Clips/SOPs/B.md']); - expect(r.skipped).toEqual([]); - expect(created['Clips/SOPs/A.md']).toBe('aaa'); - expect(ops.ensureFolder).toHaveBeenCalledWith('Clips/SOPs'); +describe('builtinSopFor', () => { + test('cover, hook, and keyframe have built-ins in both languages', () => { + for (const mode of ['thumbnail', 'hook', 'keyframe'] as const) + for (const lang of ['zh', 'en'] as const) + expect(builtinSopFor(mode, lang)).toBeTruthy(); }); - test('never overwrites an existing file', async () => { - const { ops, created } = makeOps(['Clips/SOPs/A.md']); - const r = await installBundledSops(ops, 'Clips', sops); - expect(r.written).toEqual(['Clips/SOPs/B.md']); - expect(r.skipped).toEqual(['Clips/SOPs/A.md']); - expect(created['Clips/SOPs/A.md']).toBeUndefined(); - }); - test('empty base falls back to Clips', async () => { - const { ops } = makeOps(); - const r = await installBundledSops(ops, '', sops); - expect(r.written[0]).toBe('Clips/SOPs/A.md'); - }); - test('ships six bundled SOPs, three per language', () => { - expect(BUNDLED_SOPS).toHaveLength(6); - for (const s of BUNDLED_SOPS) expect(s.content.length).toBeGreaterThan(0); + test('screenshot has no built-in SOP', () => { + expect(builtinSopFor('screenshot', 'zh')).toBeUndefined(); + expect(builtinSopFor('screenshot', 'en')).toBeUndefined(); + }); +}); + +describe('exportBuiltinSop', () => { + test('writes the copy under /SOPs and reports its path', async () => { + const { ops, created } = makeOps(); + const r = await exportBuiltinSop(ops, 'Clips', 'hook', 'zh'); + expect(r).toEqual({ path: 'Clips/SOPs/视频Hook分析 SOP.md', existed: false }); + expect(created['Clips/SOPs/视频Hook分析 SOP.md']).toBeTruthy(); + expect(ops.ensureFolder).toHaveBeenCalledWith('Clips/SOPs'); + }); + test('never overwrites: existing file reports existed and keeps content', async () => { + const { ops, created } = makeOps(['Clips/SOPs/视频Hook分析 SOP.md']); + const r = await exportBuiltinSop(ops, 'Clips', 'hook', 'zh'); + expect(r).toEqual({ path: 'Clips/SOPs/视频Hook分析 SOP.md', existed: true }); + expect(created['Clips/SOPs/视频Hook分析 SOP.md']).toBeUndefined(); + expect(ops.create).not.toHaveBeenCalled(); + }); + test('empty base falls back to Clips; language picks the filename', async () => { + const { ops } = makeOps(); + const r = await exportBuiltinSop(ops, '', 'thumbnail', 'en'); + expect(r?.path).toBe('Clips/SOPs/Cover Analysis SOP.md'); + }); + test('screenshot mode returns undefined and writes nothing', async () => { + const { ops } = makeOps(); + expect(await exportBuiltinSop(ops, 'Clips', 'screenshot', 'zh')).toBeUndefined(); + expect(ops.create).not.toHaveBeenCalled(); + }); +}); + +describe('bundled SOP source files', () => { + // jest maps .md imports to a stub, so inspect the real files on disk. + const sopDir = path.join(__dirname, '..', 'src', 'sops'); + const files = (['zh', 'en'] as const).flatMap(lang => + fs.readdirSync(path.join(sopDir, lang)).map(f => path.join(sopDir, lang, f))); + + test('six files ship, three per language', () => { + expect(files).toHaveLength(6); + }); + test('no personal vault frontmatter remains', () => { + for (const f of files) { + const content = fs.readFileSync(f, 'utf8'); + expect({ file: path.basename(f), startsWithFrontmatter: content.startsWith('---') }) + .toEqual({ file: path.basename(f), startsWithFrontmatter: false }); + expect(content).not.toContain('permalink:'); + } + }); + test('english files carry no em or en dashes', () => { + for (const f of files.filter(f => f.includes('/en/'))) { + const content = fs.readFileSync(f, 'utf8'); + expect({ file: path.basename(f), hasDash: /[—–]/.test(content) }) + .toEqual({ file: path.basename(f), hasDash: false }); + } }); }); diff --git a/tests/clip-router.test.ts b/tests/clip-router.test.ts index ed5ea9e..68af64a 100644 --- a/tests/clip-router.test.ts +++ b/tests/clip-router.test.ts @@ -453,3 +453,55 @@ describe('routeClip — unified video note (manual)', () => { }); }); }); + +// ── built-in SOP fallback ───────────────────────────────────────────────────── + +describe('routeClip — built-in SOP fallback', () => { + const payload: ClipPayload = { + mode: 'thumbnail', + platform: 'youtube', + video_id: 'abc123', + video_url: 'https://www.youtube.com/watch?v=abc123', + thumbnail_url: 'https://img.youtube.com/vi/abc123/maxresdefault.jpg', + title: 'How to Get Rich on Easy Mode', + channel: 'Ali Abdaal', + channel_handle: '@aliabdaal', + views: '27.8万', + captured_at: '2026-05-31T00:00:00Z', + }; + + test('empty sopPath uses the provided built-in SOP content', async () => { + const vaultOps = makeVaultOps(); + const rules = { ...clipRules, thumbnail: { ...thumbnailClipRule, sopPath: '' } }; + await routeClip(payload, rules, vaultOps, { thumbnail: '# Built-in cover SOP\ncheck contrast' }); + const [, noteContent] = (vaultOps.create as jest.Mock).mock.calls[0]; + expect(noteContent).toContain('# Built-in cover SOP'); + expect(vaultOps.readFileSync).not.toHaveBeenCalled(); + }); + + test('a configured sopPath wins over the built-in', async () => { + const vaultOps = makeVaultOps(); + await routeClip(payload, clipRules, vaultOps, { thumbnail: '# Built-in cover SOP' }); + const [, noteContent] = (vaultOps.create as jest.Mock).mock.calls[0]; + expect(noteContent).toContain('# SOP'); + expect(noteContent).toContain('Analyze this.'); + expect(noteContent).not.toContain('# Built-in cover SOP'); + }); + + test('empty sopPath and no built-in stays material-only', async () => { + const vaultOps = makeVaultOps(); + const rules = { ...clipRules, thumbnail: { ...thumbnailClipRule, sopPath: '' } }; + await routeClip(payload, rules, vaultOps, {}); + const [, noteContent] = (vaultOps.create as jest.Mock).mock.calls[0]; + expect(noteContent).not.toContain('Built-in'); + expect(vaultOps.readFileSync).not.toHaveBeenCalled(); + }); + + test('a broken custom path falls back to the built-in instead of failing silent', async () => { + const vaultOps = makeVaultOps(); + (vaultOps.readFileSync as jest.Mock).mockImplementation(() => { throw new Error('missing'); }); + await routeClip(payload, clipRules, vaultOps, { thumbnail: '# Built-in cover SOP' }); + const [, noteContent] = (vaultOps.create as jest.Mock).mock.calls[0]; + expect(noteContent).toContain('# Built-in cover SOP'); + }); +}); diff --git a/tests/settings.test.ts b/tests/settings.test.ts index 195b978..dbf455c 100644 --- a/tests/settings.test.ts +++ b/tests/settings.test.ts @@ -59,6 +59,9 @@ describe('base folder derivation', () => { test('default settings carry baseFolder Clips', () => { expect(DEFAULT_SETTINGS.baseFolder).toBe('Clips'); }); + test('built-in SOPs are on by default', () => { + expect(DEFAULT_SETTINGS.useBuiltinSops).toBe(true); + }); test('derives all five paths from a base name', () => { expect(deriveFolders('Great Videos')).toEqual({ videoNotes: 'Great Videos/Videos',