diff --git a/esbuild.config.mjs b/esbuild.config.mjs index 65d6276..b4e2702 100644 --- a/esbuild.config.mjs +++ b/esbuild.config.mjs @@ -54,7 +54,7 @@ const context = await esbuild.context({ }, logLevel: 'info', outfile: 'main.js', - loader: { '.css': 'text' }, + loader: { '.css': 'text', '.md': 'text' }, }); if (isProduction) { diff --git a/main.ts b/main.ts index 1389bd2..a576e04 100644 --- a/main.ts +++ b/main.ts @@ -33,6 +33,7 @@ import type { DirectoryTemplateSettings, ParsedTemplate } from './src/services/d import type { TFile } from 'obsidian'; import { findImagesForSelection } from './src/services/findImagesService'; import type { FindImagesSettings } from './src/services/findImagesService'; +import { reSeedMissingFiles, seedTemplatesIfMissing } from './src/services/templateSeederService'; interface PerplexedPluginSettings { @@ -329,7 +330,20 @@ export default class PerplexedPlugin extends Plugin { await this.loadSettings(); console.debug('Perplexed Plugin: Settings loaded successfully'); - + + // First-run seeding: if the configured templates root is missing + // or empty, drop in the four shipped templates plus a README so a + // freshly-installed perplexed has working defaults out of the box. + // Idempotent — never overwrites existing files. + try { + const result = await seedTemplatesIfMissing(this.app, this.settings.directoryTemplatesRoot); + if (result.seeded > 0) { + console.debug(`Perplexed Plugin: seeded ${result.seeded.toString()} template(s) (${result.reason})`); + } + } catch (error) { + console.error('Perplexed Plugin: template seeding failed:', error); + } + // Initialize prompts service first try { this.promptsService = new PromptsService(this.settings.prompts); @@ -1796,6 +1810,21 @@ class PerplexedSettingTab extends PluginSettingTab { }) ); + new Setting(containerEl) + .setName('Re-seed templates') + .setDesc('Write any shipped template files that are missing from the templates root. Existing files are never overwritten — to reset a template to its shipped default, delete the file first then re-seed.') + .addButton(btn => btn + .setButtonText('Re-seed') + .onClick(async () => { + try { + await reSeedMissingFiles(this.plugin.app, this.plugin.settings.directoryTemplatesRoot); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + new Notice(`Re-seed failed: ${msg}`); + } + }) + ); + // Find images for selection new Setting(containerEl).setName('Find images for selection').setHeading(); containerEl.createEl('p', { diff --git a/src/docs/templates/README.md b/src/docs/templates/README.md new file mode 100644 index 0000000..fc599d5 --- /dev/null +++ b/src/docs/templates/README.md @@ -0,0 +1,131 @@ +--- +title: Perplexed — directory templates +description: How the per-directory template system works in the perplexed plugin. +--- + +# Perplexed — directory templates + +This folder is the home of **directory templates** used by the perplexed plugin's `Apply directory template…` commands. Each template is one markdown file with three zones — frontmatter, a fenced configuration block (`cft`), and a heading skeleton — and it tells perplexed how to fill out a category of vault files (concepts, vocabulary, sources, etc.) using Perplexity research. + +The plugin seeds this folder on first run with the four templates below. You can edit them, delete them, add your own — perplexed only re-seeds when the folder is missing or empty (or when you click **Re-seed templates** in settings, which only fills in templates whose filenames don't already exist). + +## Shipped templates + +| File | Targets paths | Use for | +|---|---|---| +| `concept-profile.md` | `concepts/**` | Encyclopedia-style entries on ideas, patterns, mental models. Anti-incumbent editorial stance baked in. | +| `vocabulary-profile.md` | `Vocabulary/**` | Definitions of terms with disambiguation through an innovation-consulting lens. | +| `source-profile.md` | `Sources/**` | Profiles of trusted sources — books, people, channels, publications, journals, reports, events. Adapts emphasis to the source's type. | +| `toolkit-profile.md` | `Tooling/**` | Profiles of tools, products, platforms, frameworks. | + +## How a template works + +A template file has three zones: + +```markdown +--- +title: My Template +applies-to-paths: + - "MyDir/**" +description: One-line description. +--- + +# Free-form intro +Anything before the cft block is ignored — it's documentation for you. + +` ` `cft +provider: perplexity +model: sonar-pro +return-citations: true +return-images: true +system: | + System prompt for the model. Can use {{basename}}, {{frontmatter}}, + {{title}}, {{today}}, or {{frontmatter.}}. +` ` ` + +# Heading 1 — first section +- Bullet instructions tell the model what to write here. + +# Heading 2 — next section +- More bullet instructions. + +*** + +# User Notes + +Anything below the `***` line is excluded from the request. +``` + +### The three zones + +1. **Frontmatter** (top, between `---` lines) — carries `title`, `applies-to-paths` (array of glob patterns), and an optional `description`. The plugin uses `applies-to-paths` to match a template to a target file. +2. **`cft` block** (a code fence with language `cft`) — YAML config: `provider`, `model`, `search-recency`, `return-citations`, `return-images`, plus a multi-line `system:` prompt. Anything above the `cft` block is treated as documentation and dropped from the request. +3. **Heading skeleton** (everything between the `cft` block's closing fence and the first `***`) — the user prompt. This is the markdown structure the model fills in. Bullets under each heading are *instructions to the model*, not literal output. + +The `***` divider terminates the user prompt. Anything below it (the User Notes zone) is for your own scratch work and never reaches the model. + +### Interpolation tokens + +These tokens get replaced before the request is sent: + +| Token | Resolves to | +|---|---| +| `{{basename}}` | The target file's filename without extension. **Use this for the H1**, never the frontmatter `title`. | +| `{{title}}` | The frontmatter `title` field, or the basename if missing. | +| `{{today}}` | Today's date in `YYYY-MM-DD` form. | +| `{{frontmatter}}` | The whole frontmatter (whitelisted keys only) as YAML. Useful to give the model existing context. | +| `{{frontmatter.}}` or `{{}}` | A specific frontmatter value (string, number, or array joined by commas). | + +The frontmatter whitelist is configurable in plugin settings (default: `title, og_description, tags, og_image`). Only whitelisted keys are interpolated into prompts. + +## Invoking a template + +Three commands in the Obsidian command palette: + +- **Apply directory template to current file** — picks the template that matches the active file's path via `applies-to-paths`, fills the body, stamps `cf_last_run` and `cf_last_run_model` to frontmatter. +- **Apply directory template to folder** — runs the matched template across every markdown file under a chosen folder. Stops on Cancel. +- **Stop directory template batch** — stops a running folder batch. + +Modes: + +- **Fill** — runs when the target file's body is empty or whitespace-only. The template fills the empty body. +- **Append** — runs when the target file already has content. The template's output is appended after the existing body and before a new sources footer. + +## Image markers and fallback + +When a template sets `return-images: true`, the runtime auto-appends an instruction telling the model to insert `[IMAGE N: ]` markers in its prose where images would help. After streaming completes: + +1. The runtime swaps each `[IMAGE N: …]` for `![desc](image_url)` using Perplexity's returned `images` array. +2. If the model didn't emit any markers (or if the regex misses) but Perplexity *did* return images, they're appended as a `# Images` section before the sources footer. +3. If Perplexity returned no images at all, you'll see a console warning explaining that the model/query combination didn't yield images. The image-placeholder bullet in the template body remains a manual fallback (run **Find images for selection** on the section). + +Image quality is best with `sonar-pro`. `sonar-deep-research` does not reliably return images. + +## Citation behavior + +Templates auto-prepend a system directive that tells the model to inline numeric `[N]` citation markers tied 1:1 to Perplexity's returned search results. After streaming, the runtime appends a `# Sources` footer using the canonical Lossless `[N]: [Title](URL)` format. cite-wide's `Convert All Citations to Hex Format` command will swap the numeric markers for hex IDs. + +## Frontmatter stamps + +Every successful run stamps: + +- `cf_last_run` — ISO timestamp of the run. +- `cf_last_run_model` — `Provider model` label (e.g., `Perplexity sonar-pro`). + +Books additionally get `google_books_url` stamped if the model surfaces a Google Books URL in body content and the field isn't already in frontmatter. + +## Writing your own template + +Copy any shipped template, change: + +- The frontmatter `title`, `description`, and `applies-to-paths`. +- The `cft` block's `system:` prompt to your domain. +- The heading skeleton to the structure you want the model to fill. + +Save it as `-profile.md` in this folder. The plugin picks it up on the next palette invocation — no reload needed. + +`applies-to-paths` accepts an array of glob patterns. `**` matches any depth. The first template whose patterns match the target file's path wins; specificity isn't ranked, so don't write overlapping patterns across templates. + +## Re-seeding + +The plugin only auto-seeds when this folder is missing or empty. To pull in a fresh shipped template (e.g., after a plugin update added one), open plugin settings → **Directory templates** → click **Re-seed templates**. That command only writes files whose filenames don't already exist; your edits to existing templates are never overwritten. To reset a template to its shipped default, delete the file first, then click Re-seed. diff --git a/src/services/templateSeederService.ts b/src/services/templateSeederService.ts new file mode 100644 index 0000000..69fc080 --- /dev/null +++ b/src/services/templateSeederService.ts @@ -0,0 +1,111 @@ +import type { App } from 'obsidian'; +import { Notice, normalizePath, TFolder } from 'obsidian'; + +import readmeContent from '../docs/templates/README.md'; +import conceptProfile from '../docs/templates/concept-profile.md'; +import vocabularyProfile from '../docs/templates/vocabulary-profile.md'; +import sourceProfile from '../docs/templates/source-profile.md'; +import toolkitProfile from '../docs/templates/toolkit-profile.md'; + +interface SeedFile { + name: string; + content: string; +} + +const SEED_FILES: SeedFile[] = [ + { name: 'README.md', content: readmeContent }, + { name: 'concept-profile.md', content: conceptProfile }, + { name: 'vocabulary-profile.md', content: vocabularyProfile }, + { name: 'source-profile.md', content: sourceProfile }, + { name: 'toolkit-profile.md', content: toolkitProfile }, +]; + +async function ensureFolder(app: App, folderPath: string): Promise { + const normalized = normalizePath(folderPath); + const existing = app.vault.getAbstractFileByPath(normalized); + if (existing) return; + // Walk parents and create each missing segment so a nested templatesRoot + // like `zz-cf-lib/templates` works on a fresh vault. + const segments = normalized.split('/').filter(s => s.length > 0); + let cursor = ''; + for (const seg of segments) { + cursor = cursor ? `${cursor}/${seg}` : seg; + if (!app.vault.getAbstractFileByPath(cursor)) { + await app.vault.createFolder(cursor); + } + } +} + +function folderHasMarkdown(app: App, folderPath: string): boolean { + const normalized = normalizePath(folderPath); + const folder = app.vault.getAbstractFileByPath(normalized); + if (!(folder instanceof TFolder)) return false; + return folder.children.some(child => child.path.endsWith('.md')); +} + +/** + * Seed shipped templates into the configured templates root if the folder is + * missing or contains no markdown. Idempotent — never overwrites an existing + * file. Designed for first-run experience: a fresh perplexed install creates + * `zz-cf-lib/templates/` populated with the four shipped templates and a + * README so the user has working templates without copy/paste. + */ +export async function seedTemplatesIfMissing( + app: App, + templatesRoot: string, + options: { quiet?: boolean } = {}, +): Promise<{ seeded: number; reason: 'missing' | 'empty' | 'skipped' }> { + const quiet = options.quiet === true; + const normalized = normalizePath(templatesRoot); + const existing = app.vault.getAbstractFileByPath(normalized); + + if (existing && folderHasMarkdown(app, normalized)) { + return { seeded: 0, reason: 'skipped' }; + } + + const reason: 'missing' | 'empty' = existing ? 'empty' : 'missing'; + await ensureFolder(app, normalized); + + let seeded = 0; + for (const file of SEED_FILES) { + const path = `${normalized}/${file.name}`; + if (app.vault.getAbstractFileByPath(path)) continue; + await app.vault.create(path, file.content); + seeded++; + } + + if (!quiet && seeded > 0) { + new Notice(`Perplexed: seeded ${seeded.toString()} template${seeded === 1 ? '' : 's'} at ${normalized}`); + } + return { seeded, reason }; +} + +/** + * Force-seed: write every shipped file whose filename doesn't already exist. + * For users who want to pull in a newly-added shipped template after a plugin + * update without losing edits to existing templates. Bound to the "Re-seed + * templates" settings button. + */ +export async function reSeedMissingFiles( + app: App, + templatesRoot: string, +): Promise<{ seeded: number; skipped: number }> { + const normalized = normalizePath(templatesRoot); + await ensureFolder(app, normalized); + + let seeded = 0; + let skipped = 0; + for (const file of SEED_FILES) { + const path = `${normalized}/${file.name}`; + if (app.vault.getAbstractFileByPath(path)) { + skipped++; + continue; + } + await app.vault.create(path, file.content); + seeded++; + } + new Notice( + `Perplexed: seeded ${seeded.toString()}, skipped ${skipped.toString()} (already present) at ${normalized}`, + ); + return { seeded, skipped }; +} diff --git a/src/types/markdown.d.ts b/src/types/markdown.d.ts new file mode 100644 index 0000000..827adb7 --- /dev/null +++ b/src/types/markdown.d.ts @@ -0,0 +1,4 @@ +declare module '*.md' { + const content: string; + export default content; +}