mirror of
https://github.com/echore/vault-autopilot.git
synced 2026-07-22 08:34:00 +00:00
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 <base>/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).
This commit is contained in:
parent
2843a5114a
commit
e34c2ada38
16 changed files with 298 additions and 146 deletions
|
|
@ -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<Record<ClipMode, Record<Language, BuiltinSop>>> = {
|
||||
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<void>;
|
||||
}
|
||||
|
||||
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 <base>/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 <base>/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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,14 +20,15 @@ export async function routeClip(
|
|||
payload: ClipPayload,
|
||||
clipRules: PluginSettings['clipRules'],
|
||||
vaultOps: VaultOps,
|
||||
builtinSops: Partial<Record<ClipPayload['mode'], string>> = {},
|
||||
): 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(
|
||||
|
|
|
|||
|
|
@ -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 <Base folder>/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.",
|
||||
|
|
|
|||
|
|
@ -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}」已存在,未覆盖。想重做请先删掉该小节再点。",
|
||||
|
|
|
|||
28
src/main.ts
28
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<Record<ClipMode, string>> {
|
||||
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)}`
|
||||
|
|
|
|||
|
|
@ -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 <base>/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<Record<ClipMode, HTMLElement>> = {};
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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._
|
||||
|
|
|
|||
|
|
@ -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._
|
||||
|
|
|
|||
|
|
@ -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-`).
|
||||
|
|
|
|||
|
|
@ -1,9 +1,3 @@
|
|||
---
|
||||
title: 封面拆解学习 SOP2
|
||||
type: note
|
||||
permalink: obsidian-sop/02-写作与内容/封面拆解学习-sop2
|
||||
---
|
||||
|
||||
# 封面与标题分析 Prompt
|
||||
|
||||
_读完这份文档后,直接按以下框架输出分析。_
|
||||
|
|
|
|||
|
|
@ -1,9 +1,3 @@
|
|||
---
|
||||
title: 视频Hook分析 SOP
|
||||
type: note
|
||||
permalink: obsidian-sop/05-审美积累/视频-hook-分析-sop
|
||||
---
|
||||
|
||||
# 视频 Hook 分析 Prompt
|
||||
|
||||
_读完这份文档后,直接按以下框架输出 Hook 分析。_
|
||||
|
|
|
|||
|
|
@ -1,9 +1,3 @@
|
|||
---
|
||||
title: 视频关键帧分析 SOP
|
||||
type: note
|
||||
permalink: obsidian-sop/05-审美积累/视频关键帧分析-sop
|
||||
---
|
||||
|
||||
# 视频关键帧分析 SOP
|
||||
|
||||
> 输入:视频某段时间内的多张关键帧截图(文件名前缀 `keyframe-`)。
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<string, string> = {};
|
||||
|
|
@ -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 <base>/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 <base>/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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Reference in a new issue