2026-05-03 06:35:13 +00:00
|
|
|
import { logger } from '../utils/logger';
|
|
|
|
|
import type { App, TFile } from 'obsidian';
|
|
|
|
|
import { Modal, Setting, Notice, FileSystemAdapter } from 'obsidian';
|
feat(drop-gate): confirm modal for every dragged or pasted image — v0.2.0
Image Gin now intercepts every image dropped or pasted into a markdown
note and asks where it should go: vault attachments, ImageKit, or
Imgur. Built for writers who handle private client imagery and don't
want a default-on third-party uploader silently shipping screenshots
to a public CDN. Default is the vault. ImageKit is the recommended
destination for less-public information that still wants a CDN. Imgur
is there when something is genuinely shareable.
Off by default. Enable in Settings → Image Gin → "Drag-Drop / Paste
Confirmation Gate". Two policy modes:
- always-confirm — modal on every image drop or paste
- external-only — vault drops fall through silently; modal only
appears when at least one external destination is enabled
Per-session "remember choice" checkbox in the modal skips the gate for
subsequent drops in the same note, resets on active-leaf-change, and
never persists across Obsidian restarts. A reset command is registered
under "Drop Gate: Reset session-remembered destination."
Three destinations behind a small DropGateDestination interface:
- VaultDestination — re-dispatches a synthetic event copy into
Obsidian's internal view.currentMode.clipboardManager so the default
attachment-handling code runs cleanly. Falls back to
Vault.createBinary + FileManager.generateMarkdownLink if the
internal surface ever disappears.
- ImageKitDestination — thin adapter over the existing
ImageKitService.uploadFile pipeline image-gin already uses for
"Convert Local Images to Remote." Honors a drop-gate-specific folder
setting (settings.dropGate.imageKitFolder) that overrides the main
upload folder when set, so ad-hoc dropped images can be routed
somewhere different from generated images. Inherits the main folder
when blank.
- ImgurDestination — anonymous client-ID upload to
api.imgur.com/3/image. Hand-rolled multipart body because Obsidian's
requestUrl doesn't accept FormData.
The modal is widened correctly (modalEl, not contentEl, per the
perplexed widening reminder), shows all three destinations every time
— configured ones selectable, un-configured ones greyed-out with a
hint pointing at the right setting — and captures cursor position
synchronously at drop-time, threading it through DropGateContext as
insertPos so editor.replaceRange lands the markdown link where the
user dropped, not wherever the cursor drifted while the modal was
open.
Conflict detection: if the third-party obsidian-imgur-plugin is also
enabled, both plugins handle every drop and the user gets two modals
back-to-back — preventDefault() blocks the browser default, not other
plugins. We surface a persistent Notice on plugin load telling the
user to disable the other one and configure Imgur from inside Image
Gin.
Bumps version to 0.2.0 (manifest.json, package.json, versions.json).
Plan + design rationale:
content-farm/context-v/plans/Image-Drop-Confirmation-Gate.md
Ship note:
plugin-modules/image-gin/changelog/2026-05-09_01.md
Interception pattern adapted from gavvvr/obsidian-imgur-plugin (MIT).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 06:20:36 +00:00
|
|
|
import type { ToggleComponent } from 'obsidian';
|
2026-05-03 06:35:13 +00:00
|
|
|
import type ImageGinPlugin from '../../main';
|
2025-07-20 22:39:47 +00:00
|
|
|
import { ImageKitService } from '../services/imagekitService';
|
|
|
|
|
import { readFileSync } from 'fs';
|
|
|
|
|
import { join } from 'path';
|
|
|
|
|
|
|
|
|
|
interface ImageProperty {
|
|
|
|
|
key: string;
|
|
|
|
|
value: string;
|
|
|
|
|
isLocalFile: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export class ConvertLocalImagesForCurrentFile extends Modal {
|
|
|
|
|
private plugin: ImageGinPlugin;
|
|
|
|
|
private currentFile: TFile | null = null;
|
|
|
|
|
private imageProperties: ImageProperty[] = [];
|
2025-07-29 23:27:28 +00:00
|
|
|
private markdownImagePaths: { path: string; match: string }[] = [];
|
2025-07-20 22:39:47 +00:00
|
|
|
private isConverting: boolean = false;
|
|
|
|
|
private progressEl: HTMLElement | null = null;
|
|
|
|
|
private selectedProperties: Set<string> = new Set();
|
2025-07-29 23:27:28 +00:00
|
|
|
private selectedMarkdownImages: Set<string> = new Set();
|
2025-07-20 22:39:47 +00:00
|
|
|
|
feat(drop-gate): confirm modal for every dragged or pasted image — v0.2.0
Image Gin now intercepts every image dropped or pasted into a markdown
note and asks where it should go: vault attachments, ImageKit, or
Imgur. Built for writers who handle private client imagery and don't
want a default-on third-party uploader silently shipping screenshots
to a public CDN. Default is the vault. ImageKit is the recommended
destination for less-public information that still wants a CDN. Imgur
is there when something is genuinely shareable.
Off by default. Enable in Settings → Image Gin → "Drag-Drop / Paste
Confirmation Gate". Two policy modes:
- always-confirm — modal on every image drop or paste
- external-only — vault drops fall through silently; modal only
appears when at least one external destination is enabled
Per-session "remember choice" checkbox in the modal skips the gate for
subsequent drops in the same note, resets on active-leaf-change, and
never persists across Obsidian restarts. A reset command is registered
under "Drop Gate: Reset session-remembered destination."
Three destinations behind a small DropGateDestination interface:
- VaultDestination — re-dispatches a synthetic event copy into
Obsidian's internal view.currentMode.clipboardManager so the default
attachment-handling code runs cleanly. Falls back to
Vault.createBinary + FileManager.generateMarkdownLink if the
internal surface ever disappears.
- ImageKitDestination — thin adapter over the existing
ImageKitService.uploadFile pipeline image-gin already uses for
"Convert Local Images to Remote." Honors a drop-gate-specific folder
setting (settings.dropGate.imageKitFolder) that overrides the main
upload folder when set, so ad-hoc dropped images can be routed
somewhere different from generated images. Inherits the main folder
when blank.
- ImgurDestination — anonymous client-ID upload to
api.imgur.com/3/image. Hand-rolled multipart body because Obsidian's
requestUrl doesn't accept FormData.
The modal is widened correctly (modalEl, not contentEl, per the
perplexed widening reminder), shows all three destinations every time
— configured ones selectable, un-configured ones greyed-out with a
hint pointing at the right setting — and captures cursor position
synchronously at drop-time, threading it through DropGateContext as
insertPos so editor.replaceRange lands the markdown link where the
user dropped, not wherever the cursor drifted while the modal was
open.
Conflict detection: if the third-party obsidian-imgur-plugin is also
enabled, both plugins handle every drop and the user gets two modals
back-to-back — preventDefault() blocks the browser default, not other
plugins. We surface a persistent Notice on plugin load telling the
user to disable the other one and configure Imgur from inside Image
Gin.
Bumps version to 0.2.0 (manifest.json, package.json, versions.json).
Plan + design rationale:
content-farm/context-v/plans/Image-Drop-Confirmation-Gate.md
Ship note:
plugin-modules/image-gin/changelog/2026-05-09_01.md
Interception pattern adapted from gavvvr/obsidian-imgur-plugin (MIT).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 06:20:36 +00:00
|
|
|
// Refs for header "All" master toggles and the per-row toggles they
|
|
|
|
|
// command, mirroring the IdeogramModal pattern so master and rows
|
|
|
|
|
// stay in sync when either is clicked.
|
|
|
|
|
private fmMasterToggle: ToggleComponent | null = null;
|
|
|
|
|
private fmRowToggles: Map<string, ToggleComponent> = new Map();
|
|
|
|
|
private mdMasterToggle: ToggleComponent | null = null;
|
|
|
|
|
private mdRowToggles: Map<string, ToggleComponent> = new Map();
|
|
|
|
|
|
2025-07-20 22:39:47 +00:00
|
|
|
// Common image properties to check
|
|
|
|
|
private readonly IMAGE_PROPERTIES = [
|
|
|
|
|
'banner_image',
|
|
|
|
|
'portrait_image',
|
|
|
|
|
'square_image',
|
|
|
|
|
'og_image',
|
|
|
|
|
'featured_image',
|
|
|
|
|
'thumbnail',
|
|
|
|
|
'hero_image',
|
|
|
|
|
'cover_image'
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
constructor(app: App, plugin: ImageGinPlugin) {
|
|
|
|
|
super(app);
|
|
|
|
|
this.plugin = plugin;
|
|
|
|
|
this.currentFile = this.app.workspace.getActiveFile();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async onOpen(): Promise<void> {
|
2026-05-03 05:50:14 +00:00
|
|
|
const { contentEl, modalEl } = this;
|
2025-07-20 22:39:47 +00:00
|
|
|
contentEl.empty();
|
2026-05-03 05:50:14 +00:00
|
|
|
modalEl.addClass('image-gin-modal');
|
2025-07-20 22:39:47 +00:00
|
|
|
|
|
|
|
|
if (!this.currentFile) {
|
|
|
|
|
contentEl.createEl('p', { text: 'No active file found.' });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check if ImageKit is enabled
|
|
|
|
|
if (!this.plugin.settings.imageKit.enabled) {
|
|
|
|
|
contentEl.createEl('h2', { text: 'ImageKit Not Enabled' });
|
|
|
|
|
contentEl.createEl('p', { text: 'Please enable ImageKit CDN in the plugin settings first.' });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Load and analyze current file
|
|
|
|
|
await this.analyzeCurrentFile();
|
|
|
|
|
|
|
|
|
|
// Render modal content
|
|
|
|
|
this.renderModalContent();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async analyzeCurrentFile(): Promise<void> {
|
|
|
|
|
if (!this.currentFile) return;
|
|
|
|
|
|
|
|
|
|
try {
|
align(api): align frontmatter parsing with Obsidian recent API changes
Replace the in-tree hand-rolled YAML parser with Obsidian's first-party
frontmatter APIs (`app.metadataCache.getFileCache(file)?.frontmatter`
for reads, `app.fileManager.processFrontMatter(file, fn)` for writes).
Closes Phase 4.3-B of the Obsidian community-plugin publishing-prep plan
at context-v/plans/2026-05-03_Assuring-Obsidian-Community-Plugin-Requirements.md.
Why
---
The earlier publishing-prep pass typed away the parser's `any` to
satisfy ObsidianReviewBot, which kept lint green but left the underlying
parser intact. The hand-rolled parser had real edge-case correctness
bugs the type system could not catch: URL values containing colons
were mis-split, multi-line strings were truncated, YAML anchors and
list-of-maps were not understood, and the in-tree YAML emitter would
produce `[object Object]` output for plain-object values. Obsidian's
own implementation handles all of those correctly and is the path the
Obsidian community-plugin guidelines explicitly point at. Carrying our
own parser meant carrying the bug surface forever; deleting it
externalizes that work.
Files
-----
Deleted:
- src/utils/yamlFrontmatter.ts (176 lines: parser + emitter +
updateFileFrontmatter helper). No replacement file in our codebase
— the responsibility moves to Obsidian's APIs.
Modified:
- src/modals/CurrentFileModal.ts — three usages refactored
- src/modals/ConvertLocalImagesForCurrentFile.ts — three usages
refactored, including the largest restructure (see below)
- context-v/changelogs/2026-05-03_01.md — appended a §6 covering this
phase, updated the front-matter title and summary intro to claim
completion of every blocking publishing-prep phase, added the parser
to the deleted-files inventory at the bottom
Call-site categorization (5 sites total)
----------------------------------------
Pure reads → `metadataCache.getFileCache(file)?.frontmatter`:
- CurrentFileModal.loadExistingPrompt — was reading `image_prompt`
to seed the modal textarea. Bonus: removes a `vault.read` (the cache
is in-memory and synchronous) so the modal opens marginally faster
on files without an existing prompt.
- ConvertLocalImagesForCurrentFile.analyzeCurrentFile — discovers
image properties on file open.
- ConvertLocalImagesForCurrentFile.handleConvert — snapshots
frontmatter once at the top of the loop for tag extraction. Tags
are only used as upload metadata, not as a source of truth, so a
one-time snapshot is fine and avoids re-fetching from cache on
every iteration.
Single-key writes → `app.fileManager.processFrontMatter(file, fn)`:
- CurrentFileModal.updateFrontmatter — writes `image_prompt`.
- CurrentFileModal.updateImagePathInFrontmatter — writes
`<size>_image` keys with the URL of a generated image.
Both single-key writes now follow the uniform pattern:
await this.app.fileManager.processFrontMatter(file, (fm) => {
fm[key] = value;
});
processFrontMatter reads, mutates, and writes atomically via
Obsidian's own YAML emitter — no hand-rolled vault.read /
formatFrontmatter / vault.modify cycle, no race window between read
and re-write.
The harder case: handleConvert's mixed-mutation loop
----------------------------------------------------
The original handleConvert read the file once, parsed frontmatter into
an object, peeled off the body, then ran two long async loops:
1. For each frontmatter image: upload to ImageKit, then
`frontmatter[key] = uploadResult.url` in the in-memory object.
2. For each markdown body image: upload, then
`markdownContent = markdownContent.replace(...)` in the in-memory
string.
After both loops, it reassembled the file as
`---\n${formatFrontmatter(frontmatter)}---\n\n${markdownContent}` and
wrote the whole thing with vault.modify. The formatFrontmatter step
was where the hand-rolled YAML emitter was doing its damage.
Restructured so frontmatter and body live on independent code paths:
- Frontmatter mutations: each upload's frontmatter assignment now
calls processFrontMatter directly — atomic per upload, written by
Obsidian's emitter.
- Body mutations: accumulated into a `bodyMutations: Array<{from, to}>`
list during the markdown-image loop. After both loops complete, a
single vault.read(file) + N string replacements + one
vault.modify(file, updated) flushes them. The body re-read happens
*after* all processFrontMatter calls so it picks up the latest
version (including the frontmatter changes those calls just made);
no stale-content / lost-update race.
Also pulled `const file = this.currentFile;` to a local once at the
top of handleConvert so TypeScript's narrowing survives across
awaits — without the alias, the `if (!this.currentFile) return;`
guard is invalidated at every await boundary because modal lifecycle
could in principle null out currentFile.
Verification
------------
- pnpm build green: ESLint (the bot-mirror flat config from the
prior commit) + tsc strict + esbuild production.
- git grep -nE ": any\\b|as any\\b|<any>|any\\[\\]" -- "*.ts" empty.
- git grep -n "innerHTML" -- "*.ts" empty.
- main.js bundle 54 KB → 52 KB (the deleted parser was being bundled
in; net -2 KB shipped to users).
- All four version markers still in sync at 0.1.0
(manifest.json, package.json, versions.json key + value).
Risk worth knowing about
------------------------
processFrontMatter's callback signature is `(frontmatter: any) => void`
per Obsidian's type definitions. We mutate `fm[key] = value` directly
without a cast — that's the path of least friction, and the bot's
no-explicit-any rule does not fire because we are not *writing* `any`
in our source (we are receiving it from a third-party API). If a
future Obsidian update tightens the type to `Record<string, unknown>`,
our callsites will keep compiling. If it tightens to a stricter shape,
narrowing at the callsite is a small change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 07:03:06 +00:00
|
|
|
// Frontmatter via Obsidian's metadata cache — correct on URL
|
|
|
|
|
// values, multi-line strings, etc.; doesn't require us to also
|
|
|
|
|
// parse YAML by hand.
|
|
|
|
|
const frontmatter = this.app.metadataCache.getFileCache(this.currentFile)?.frontmatter;
|
|
|
|
|
|
|
|
|
|
// Body content is read separately because we still need to scan
|
|
|
|
|
// it for `![[...]]` image links.
|
2025-07-20 22:39:47 +00:00
|
|
|
const content = await this.app.vault.read(this.currentFile);
|
align(api): align frontmatter parsing with Obsidian recent API changes
Replace the in-tree hand-rolled YAML parser with Obsidian's first-party
frontmatter APIs (`app.metadataCache.getFileCache(file)?.frontmatter`
for reads, `app.fileManager.processFrontMatter(file, fn)` for writes).
Closes Phase 4.3-B of the Obsidian community-plugin publishing-prep plan
at context-v/plans/2026-05-03_Assuring-Obsidian-Community-Plugin-Requirements.md.
Why
---
The earlier publishing-prep pass typed away the parser's `any` to
satisfy ObsidianReviewBot, which kept lint green but left the underlying
parser intact. The hand-rolled parser had real edge-case correctness
bugs the type system could not catch: URL values containing colons
were mis-split, multi-line strings were truncated, YAML anchors and
list-of-maps were not understood, and the in-tree YAML emitter would
produce `[object Object]` output for plain-object values. Obsidian's
own implementation handles all of those correctly and is the path the
Obsidian community-plugin guidelines explicitly point at. Carrying our
own parser meant carrying the bug surface forever; deleting it
externalizes that work.
Files
-----
Deleted:
- src/utils/yamlFrontmatter.ts (176 lines: parser + emitter +
updateFileFrontmatter helper). No replacement file in our codebase
— the responsibility moves to Obsidian's APIs.
Modified:
- src/modals/CurrentFileModal.ts — three usages refactored
- src/modals/ConvertLocalImagesForCurrentFile.ts — three usages
refactored, including the largest restructure (see below)
- context-v/changelogs/2026-05-03_01.md — appended a §6 covering this
phase, updated the front-matter title and summary intro to claim
completion of every blocking publishing-prep phase, added the parser
to the deleted-files inventory at the bottom
Call-site categorization (5 sites total)
----------------------------------------
Pure reads → `metadataCache.getFileCache(file)?.frontmatter`:
- CurrentFileModal.loadExistingPrompt — was reading `image_prompt`
to seed the modal textarea. Bonus: removes a `vault.read` (the cache
is in-memory and synchronous) so the modal opens marginally faster
on files without an existing prompt.
- ConvertLocalImagesForCurrentFile.analyzeCurrentFile — discovers
image properties on file open.
- ConvertLocalImagesForCurrentFile.handleConvert — snapshots
frontmatter once at the top of the loop for tag extraction. Tags
are only used as upload metadata, not as a source of truth, so a
one-time snapshot is fine and avoids re-fetching from cache on
every iteration.
Single-key writes → `app.fileManager.processFrontMatter(file, fn)`:
- CurrentFileModal.updateFrontmatter — writes `image_prompt`.
- CurrentFileModal.updateImagePathInFrontmatter — writes
`<size>_image` keys with the URL of a generated image.
Both single-key writes now follow the uniform pattern:
await this.app.fileManager.processFrontMatter(file, (fm) => {
fm[key] = value;
});
processFrontMatter reads, mutates, and writes atomically via
Obsidian's own YAML emitter — no hand-rolled vault.read /
formatFrontmatter / vault.modify cycle, no race window between read
and re-write.
The harder case: handleConvert's mixed-mutation loop
----------------------------------------------------
The original handleConvert read the file once, parsed frontmatter into
an object, peeled off the body, then ran two long async loops:
1. For each frontmatter image: upload to ImageKit, then
`frontmatter[key] = uploadResult.url` in the in-memory object.
2. For each markdown body image: upload, then
`markdownContent = markdownContent.replace(...)` in the in-memory
string.
After both loops, it reassembled the file as
`---\n${formatFrontmatter(frontmatter)}---\n\n${markdownContent}` and
wrote the whole thing with vault.modify. The formatFrontmatter step
was where the hand-rolled YAML emitter was doing its damage.
Restructured so frontmatter and body live on independent code paths:
- Frontmatter mutations: each upload's frontmatter assignment now
calls processFrontMatter directly — atomic per upload, written by
Obsidian's emitter.
- Body mutations: accumulated into a `bodyMutations: Array<{from, to}>`
list during the markdown-image loop. After both loops complete, a
single vault.read(file) + N string replacements + one
vault.modify(file, updated) flushes them. The body re-read happens
*after* all processFrontMatter calls so it picks up the latest
version (including the frontmatter changes those calls just made);
no stale-content / lost-update race.
Also pulled `const file = this.currentFile;` to a local once at the
top of handleConvert so TypeScript's narrowing survives across
awaits — without the alias, the `if (!this.currentFile) return;`
guard is invalidated at every await boundary because modal lifecycle
could in principle null out currentFile.
Verification
------------
- pnpm build green: ESLint (the bot-mirror flat config from the
prior commit) + tsc strict + esbuild production.
- git grep -nE ": any\\b|as any\\b|<any>|any\\[\\]" -- "*.ts" empty.
- git grep -n "innerHTML" -- "*.ts" empty.
- main.js bundle 54 KB → 52 KB (the deleted parser was being bundled
in; net -2 KB shipped to users).
- All four version markers still in sync at 0.1.0
(manifest.json, package.json, versions.json key + value).
Risk worth knowing about
------------------------
processFrontMatter's callback signature is `(frontmatter: any) => void`
per Obsidian's type definitions. We mutate `fm[key] = value` directly
without a cast — that's the path of least friction, and the bot's
no-explicit-any rule does not fire because we are not *writing* `any`
in our source (we are receiving it from a third-party API). If a
future Obsidian update tightens the type to `Record<string, unknown>`,
our callsites will keep compiling. If it tightens to a stricter shape,
narrowing at the callsite is a small change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 07:03:06 +00:00
|
|
|
|
2025-07-29 23:27:28 +00:00
|
|
|
// Reset properties
|
2025-07-20 22:39:47 +00:00
|
|
|
this.imageProperties = [];
|
2025-07-29 23:27:28 +00:00
|
|
|
this.markdownImagePaths = [];
|
align(api): align frontmatter parsing with Obsidian recent API changes
Replace the in-tree hand-rolled YAML parser with Obsidian's first-party
frontmatter APIs (`app.metadataCache.getFileCache(file)?.frontmatter`
for reads, `app.fileManager.processFrontMatter(file, fn)` for writes).
Closes Phase 4.3-B of the Obsidian community-plugin publishing-prep plan
at context-v/plans/2026-05-03_Assuring-Obsidian-Community-Plugin-Requirements.md.
Why
---
The earlier publishing-prep pass typed away the parser's `any` to
satisfy ObsidianReviewBot, which kept lint green but left the underlying
parser intact. The hand-rolled parser had real edge-case correctness
bugs the type system could not catch: URL values containing colons
were mis-split, multi-line strings were truncated, YAML anchors and
list-of-maps were not understood, and the in-tree YAML emitter would
produce `[object Object]` output for plain-object values. Obsidian's
own implementation handles all of those correctly and is the path the
Obsidian community-plugin guidelines explicitly point at. Carrying our
own parser meant carrying the bug surface forever; deleting it
externalizes that work.
Files
-----
Deleted:
- src/utils/yamlFrontmatter.ts (176 lines: parser + emitter +
updateFileFrontmatter helper). No replacement file in our codebase
— the responsibility moves to Obsidian's APIs.
Modified:
- src/modals/CurrentFileModal.ts — three usages refactored
- src/modals/ConvertLocalImagesForCurrentFile.ts — three usages
refactored, including the largest restructure (see below)
- context-v/changelogs/2026-05-03_01.md — appended a §6 covering this
phase, updated the front-matter title and summary intro to claim
completion of every blocking publishing-prep phase, added the parser
to the deleted-files inventory at the bottom
Call-site categorization (5 sites total)
----------------------------------------
Pure reads → `metadataCache.getFileCache(file)?.frontmatter`:
- CurrentFileModal.loadExistingPrompt — was reading `image_prompt`
to seed the modal textarea. Bonus: removes a `vault.read` (the cache
is in-memory and synchronous) so the modal opens marginally faster
on files without an existing prompt.
- ConvertLocalImagesForCurrentFile.analyzeCurrentFile — discovers
image properties on file open.
- ConvertLocalImagesForCurrentFile.handleConvert — snapshots
frontmatter once at the top of the loop for tag extraction. Tags
are only used as upload metadata, not as a source of truth, so a
one-time snapshot is fine and avoids re-fetching from cache on
every iteration.
Single-key writes → `app.fileManager.processFrontMatter(file, fn)`:
- CurrentFileModal.updateFrontmatter — writes `image_prompt`.
- CurrentFileModal.updateImagePathInFrontmatter — writes
`<size>_image` keys with the URL of a generated image.
Both single-key writes now follow the uniform pattern:
await this.app.fileManager.processFrontMatter(file, (fm) => {
fm[key] = value;
});
processFrontMatter reads, mutates, and writes atomically via
Obsidian's own YAML emitter — no hand-rolled vault.read /
formatFrontmatter / vault.modify cycle, no race window between read
and re-write.
The harder case: handleConvert's mixed-mutation loop
----------------------------------------------------
The original handleConvert read the file once, parsed frontmatter into
an object, peeled off the body, then ran two long async loops:
1. For each frontmatter image: upload to ImageKit, then
`frontmatter[key] = uploadResult.url` in the in-memory object.
2. For each markdown body image: upload, then
`markdownContent = markdownContent.replace(...)` in the in-memory
string.
After both loops, it reassembled the file as
`---\n${formatFrontmatter(frontmatter)}---\n\n${markdownContent}` and
wrote the whole thing with vault.modify. The formatFrontmatter step
was where the hand-rolled YAML emitter was doing its damage.
Restructured so frontmatter and body live on independent code paths:
- Frontmatter mutations: each upload's frontmatter assignment now
calls processFrontMatter directly — atomic per upload, written by
Obsidian's emitter.
- Body mutations: accumulated into a `bodyMutations: Array<{from, to}>`
list during the markdown-image loop. After both loops complete, a
single vault.read(file) + N string replacements + one
vault.modify(file, updated) flushes them. The body re-read happens
*after* all processFrontMatter calls so it picks up the latest
version (including the frontmatter changes those calls just made);
no stale-content / lost-update race.
Also pulled `const file = this.currentFile;` to a local once at the
top of handleConvert so TypeScript's narrowing survives across
awaits — without the alias, the `if (!this.currentFile) return;`
guard is invalidated at every await boundary because modal lifecycle
could in principle null out currentFile.
Verification
------------
- pnpm build green: ESLint (the bot-mirror flat config from the
prior commit) + tsc strict + esbuild production.
- git grep -nE ": any\\b|as any\\b|<any>|any\\[\\]" -- "*.ts" empty.
- git grep -n "innerHTML" -- "*.ts" empty.
- main.js bundle 54 KB → 52 KB (the deleted parser was being bundled
in; net -2 KB shipped to users).
- All four version markers still in sync at 0.1.0
(manifest.json, package.json, versions.json key + value).
Risk worth knowing about
------------------------
processFrontMatter's callback signature is `(frontmatter: any) => void`
per Obsidian's type definitions. We mutate `fm[key] = value` directly
without a cast — that's the path of least friction, and the bot's
no-explicit-any rule does not fire because we are not *writing* `any`
in our source (we are receiving it from a third-party API). If a
future Obsidian update tightens the type to `Record<string, unknown>`,
our callsites will keep compiling. If it tightens to a stricter shape,
narrowing at the callsite is a small change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 07:03:06 +00:00
|
|
|
|
2025-07-29 23:27:28 +00:00
|
|
|
// 1. Analyze frontmatter
|
|
|
|
|
if (frontmatter) {
|
|
|
|
|
for (const property of this.IMAGE_PROPERTIES) {
|
|
|
|
|
const value = frontmatter[property];
|
|
|
|
|
if (value && typeof value === 'string') {
|
|
|
|
|
const isLocalFile = this.isLocalImagePath(value);
|
|
|
|
|
this.imageProperties.push({
|
|
|
|
|
key: property,
|
|
|
|
|
value: value,
|
|
|
|
|
isLocalFile: isLocalFile
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
align(api): align frontmatter parsing with Obsidian recent API changes
Replace the in-tree hand-rolled YAML parser with Obsidian's first-party
frontmatter APIs (`app.metadataCache.getFileCache(file)?.frontmatter`
for reads, `app.fileManager.processFrontMatter(file, fn)` for writes).
Closes Phase 4.3-B of the Obsidian community-plugin publishing-prep plan
at context-v/plans/2026-05-03_Assuring-Obsidian-Community-Plugin-Requirements.md.
Why
---
The earlier publishing-prep pass typed away the parser's `any` to
satisfy ObsidianReviewBot, which kept lint green but left the underlying
parser intact. The hand-rolled parser had real edge-case correctness
bugs the type system could not catch: URL values containing colons
were mis-split, multi-line strings were truncated, YAML anchors and
list-of-maps were not understood, and the in-tree YAML emitter would
produce `[object Object]` output for plain-object values. Obsidian's
own implementation handles all of those correctly and is the path the
Obsidian community-plugin guidelines explicitly point at. Carrying our
own parser meant carrying the bug surface forever; deleting it
externalizes that work.
Files
-----
Deleted:
- src/utils/yamlFrontmatter.ts (176 lines: parser + emitter +
updateFileFrontmatter helper). No replacement file in our codebase
— the responsibility moves to Obsidian's APIs.
Modified:
- src/modals/CurrentFileModal.ts — three usages refactored
- src/modals/ConvertLocalImagesForCurrentFile.ts — three usages
refactored, including the largest restructure (see below)
- context-v/changelogs/2026-05-03_01.md — appended a §6 covering this
phase, updated the front-matter title and summary intro to claim
completion of every blocking publishing-prep phase, added the parser
to the deleted-files inventory at the bottom
Call-site categorization (5 sites total)
----------------------------------------
Pure reads → `metadataCache.getFileCache(file)?.frontmatter`:
- CurrentFileModal.loadExistingPrompt — was reading `image_prompt`
to seed the modal textarea. Bonus: removes a `vault.read` (the cache
is in-memory and synchronous) so the modal opens marginally faster
on files without an existing prompt.
- ConvertLocalImagesForCurrentFile.analyzeCurrentFile — discovers
image properties on file open.
- ConvertLocalImagesForCurrentFile.handleConvert — snapshots
frontmatter once at the top of the loop for tag extraction. Tags
are only used as upload metadata, not as a source of truth, so a
one-time snapshot is fine and avoids re-fetching from cache on
every iteration.
Single-key writes → `app.fileManager.processFrontMatter(file, fn)`:
- CurrentFileModal.updateFrontmatter — writes `image_prompt`.
- CurrentFileModal.updateImagePathInFrontmatter — writes
`<size>_image` keys with the URL of a generated image.
Both single-key writes now follow the uniform pattern:
await this.app.fileManager.processFrontMatter(file, (fm) => {
fm[key] = value;
});
processFrontMatter reads, mutates, and writes atomically via
Obsidian's own YAML emitter — no hand-rolled vault.read /
formatFrontmatter / vault.modify cycle, no race window between read
and re-write.
The harder case: handleConvert's mixed-mutation loop
----------------------------------------------------
The original handleConvert read the file once, parsed frontmatter into
an object, peeled off the body, then ran two long async loops:
1. For each frontmatter image: upload to ImageKit, then
`frontmatter[key] = uploadResult.url` in the in-memory object.
2. For each markdown body image: upload, then
`markdownContent = markdownContent.replace(...)` in the in-memory
string.
After both loops, it reassembled the file as
`---\n${formatFrontmatter(frontmatter)}---\n\n${markdownContent}` and
wrote the whole thing with vault.modify. The formatFrontmatter step
was where the hand-rolled YAML emitter was doing its damage.
Restructured so frontmatter and body live on independent code paths:
- Frontmatter mutations: each upload's frontmatter assignment now
calls processFrontMatter directly — atomic per upload, written by
Obsidian's emitter.
- Body mutations: accumulated into a `bodyMutations: Array<{from, to}>`
list during the markdown-image loop. After both loops complete, a
single vault.read(file) + N string replacements + one
vault.modify(file, updated) flushes them. The body re-read happens
*after* all processFrontMatter calls so it picks up the latest
version (including the frontmatter changes those calls just made);
no stale-content / lost-update race.
Also pulled `const file = this.currentFile;` to a local once at the
top of handleConvert so TypeScript's narrowing survives across
awaits — without the alias, the `if (!this.currentFile) return;`
guard is invalidated at every await boundary because modal lifecycle
could in principle null out currentFile.
Verification
------------
- pnpm build green: ESLint (the bot-mirror flat config from the
prior commit) + tsc strict + esbuild production.
- git grep -nE ": any\\b|as any\\b|<any>|any\\[\\]" -- "*.ts" empty.
- git grep -n "innerHTML" -- "*.ts" empty.
- main.js bundle 54 KB → 52 KB (the deleted parser was being bundled
in; net -2 KB shipped to users).
- All four version markers still in sync at 0.1.0
(manifest.json, package.json, versions.json key + value).
Risk worth knowing about
------------------------
processFrontMatter's callback signature is `(frontmatter: any) => void`
per Obsidian's type definitions. We mutate `fm[key] = value` directly
without a cast — that's the path of least friction, and the bot's
no-explicit-any rule does not fire because we are not *writing* `any`
in our source (we are receiving it from a third-party API). If a
future Obsidian update tightens the type to `Record<string, unknown>`,
our callsites will keep compiling. If it tightens to a stricter shape,
narrowing at the callsite is a small change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 07:03:06 +00:00
|
|
|
|
2025-07-29 23:27:28 +00:00
|
|
|
// 2. Find all markdown image links in content
|
|
|
|
|
const markdownContent = content.replace(/^---[\s\S]*?---/g, ''); // Remove frontmatter
|
|
|
|
|
const imageRegex = /!\[\[([^\]]+)\]\]/g;
|
|
|
|
|
const matches = [...markdownContent.matchAll(imageRegex)];
|
|
|
|
|
|
|
|
|
|
for (const match of matches) {
|
|
|
|
|
const fullMatch = match[0];
|
|
|
|
|
const imagePath = match[1];
|
|
|
|
|
if (imagePath && this.isLocalImagePath(imagePath)) {
|
|
|
|
|
this.markdownImagePaths.push({
|
|
|
|
|
path: imagePath || '',
|
|
|
|
|
match: fullMatch
|
2025-07-20 22:39:47 +00:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
feat(drop-gate): confirm modal for every dragged or pasted image — v0.2.0
Image Gin now intercepts every image dropped or pasted into a markdown
note and asks where it should go: vault attachments, ImageKit, or
Imgur. Built for writers who handle private client imagery and don't
want a default-on third-party uploader silently shipping screenshots
to a public CDN. Default is the vault. ImageKit is the recommended
destination for less-public information that still wants a CDN. Imgur
is there when something is genuinely shareable.
Off by default. Enable in Settings → Image Gin → "Drag-Drop / Paste
Confirmation Gate". Two policy modes:
- always-confirm — modal on every image drop or paste
- external-only — vault drops fall through silently; modal only
appears when at least one external destination is enabled
Per-session "remember choice" checkbox in the modal skips the gate for
subsequent drops in the same note, resets on active-leaf-change, and
never persists across Obsidian restarts. A reset command is registered
under "Drop Gate: Reset session-remembered destination."
Three destinations behind a small DropGateDestination interface:
- VaultDestination — re-dispatches a synthetic event copy into
Obsidian's internal view.currentMode.clipboardManager so the default
attachment-handling code runs cleanly. Falls back to
Vault.createBinary + FileManager.generateMarkdownLink if the
internal surface ever disappears.
- ImageKitDestination — thin adapter over the existing
ImageKitService.uploadFile pipeline image-gin already uses for
"Convert Local Images to Remote." Honors a drop-gate-specific folder
setting (settings.dropGate.imageKitFolder) that overrides the main
upload folder when set, so ad-hoc dropped images can be routed
somewhere different from generated images. Inherits the main folder
when blank.
- ImgurDestination — anonymous client-ID upload to
api.imgur.com/3/image. Hand-rolled multipart body because Obsidian's
requestUrl doesn't accept FormData.
The modal is widened correctly (modalEl, not contentEl, per the
perplexed widening reminder), shows all three destinations every time
— configured ones selectable, un-configured ones greyed-out with a
hint pointing at the right setting — and captures cursor position
synchronously at drop-time, threading it through DropGateContext as
insertPos so editor.replaceRange lands the markdown link where the
user dropped, not wherever the cursor drifted while the modal was
open.
Conflict detection: if the third-party obsidian-imgur-plugin is also
enabled, both plugins handle every drop and the user gets two modals
back-to-back — preventDefault() blocks the browser default, not other
plugins. We surface a persistent Notice on plugin load telling the
user to disable the other one and configure Imgur from inside Image
Gin.
Bumps version to 0.2.0 (manifest.json, package.json, versions.json).
Plan + design rationale:
content-farm/context-v/plans/Image-Drop-Confirmation-Gate.md
Ship note:
plugin-modules/image-gin/changelog/2026-05-09_01.md
Interception pattern adapted from gavvvr/obsidian-imgur-plugin (MIT).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 06:20:36 +00:00
|
|
|
// Pre-select every eligible row. The common flow is "convert
|
|
|
|
|
// everything in this file" (banner + portrait + square, plus
|
|
|
|
|
// any inline markdown images), so default-on removes the
|
|
|
|
|
// three-clicks-every-time friction the user hit. Already-remote
|
|
|
|
|
// frontmatter URLs stay deselected because their toggle is
|
|
|
|
|
// disabled regardless.
|
|
|
|
|
for (const property of this.imageProperties) {
|
|
|
|
|
if (property.isLocalFile) this.selectedProperties.add(property.key);
|
|
|
|
|
}
|
|
|
|
|
for (const md of this.markdownImagePaths) {
|
|
|
|
|
this.selectedMarkdownImages.add(md.path);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-03 06:35:13 +00:00
|
|
|
logger.info('Found image properties:', this.imageProperties);
|
|
|
|
|
logger.info('Found markdown images:', this.markdownImagePaths);
|
2025-07-20 22:39:47 +00:00
|
|
|
} catch (error) {
|
2026-05-03 06:35:13 +00:00
|
|
|
logger.error('Error analyzing current file:', error);
|
2025-07-20 22:39:47 +00:00
|
|
|
this.imageProperties = [];
|
2025-07-29 23:27:28 +00:00
|
|
|
this.markdownImagePaths = [];
|
2025-07-20 22:39:47 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private isLocalImagePath(path: string): boolean {
|
|
|
|
|
// Check if it's already an ImageKit URL
|
|
|
|
|
const imagekitService = new ImageKitService(this.plugin.settings);
|
|
|
|
|
if (imagekitService.isImageKitUrl(path)) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check if it's a local file path
|
|
|
|
|
return !path.startsWith('http://') &&
|
|
|
|
|
!path.startsWith('https://') &&
|
|
|
|
|
(path.includes('.png') || path.includes('.jpg') || path.includes('.jpeg') ||
|
|
|
|
|
path.includes('.webp') || path.includes('.gif') || path.includes('.svg'));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private renderModalContent(): void {
|
|
|
|
|
const { contentEl } = this;
|
|
|
|
|
|
|
|
|
|
// Header
|
|
|
|
|
const headerEl = contentEl.createDiv('image-gin-header');
|
|
|
|
|
headerEl.createEl('h2', { text: 'Convert Local Images to ImageKit CDN', cls: 'image-gin-title' });
|
|
|
|
|
|
2025-07-29 23:27:28 +00:00
|
|
|
if (this.imageProperties.length === 0 && this.markdownImagePaths.length === 0) {
|
|
|
|
|
contentEl.createEl('p', { text: 'No local images found in this file.' });
|
2025-07-20 22:39:47 +00:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Instructions
|
|
|
|
|
const instructionsEl = contentEl.createDiv('image-gin-instructions');
|
|
|
|
|
instructionsEl.createEl('p', {
|
2025-07-29 23:27:28 +00:00
|
|
|
text: 'Select the local images you want to upload to ImageKit CDN:'
|
2025-07-20 22:39:47 +00:00
|
|
|
});
|
|
|
|
|
|
2025-07-29 23:27:28 +00:00
|
|
|
// Frontmatter image properties list
|
|
|
|
|
if (this.imageProperties.length > 0) {
|
|
|
|
|
this.renderImagePropertiesList(contentEl);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Markdown content images list
|
|
|
|
|
if (this.markdownImagePaths.length > 0) {
|
|
|
|
|
this.renderMarkdownImagesList(contentEl);
|
|
|
|
|
}
|
2025-07-20 22:39:47 +00:00
|
|
|
|
|
|
|
|
// Progress section (initially hidden)
|
|
|
|
|
this.renderProgressSection(contentEl);
|
|
|
|
|
|
|
|
|
|
// Convert button
|
|
|
|
|
this.renderConvertButton(contentEl);
|
|
|
|
|
}
|
|
|
|
|
|
feat(drop-gate): confirm modal for every dragged or pasted image — v0.2.0
Image Gin now intercepts every image dropped or pasted into a markdown
note and asks where it should go: vault attachments, ImageKit, or
Imgur. Built for writers who handle private client imagery and don't
want a default-on third-party uploader silently shipping screenshots
to a public CDN. Default is the vault. ImageKit is the recommended
destination for less-public information that still wants a CDN. Imgur
is there when something is genuinely shareable.
Off by default. Enable in Settings → Image Gin → "Drag-Drop / Paste
Confirmation Gate". Two policy modes:
- always-confirm — modal on every image drop or paste
- external-only — vault drops fall through silently; modal only
appears when at least one external destination is enabled
Per-session "remember choice" checkbox in the modal skips the gate for
subsequent drops in the same note, resets on active-leaf-change, and
never persists across Obsidian restarts. A reset command is registered
under "Drop Gate: Reset session-remembered destination."
Three destinations behind a small DropGateDestination interface:
- VaultDestination — re-dispatches a synthetic event copy into
Obsidian's internal view.currentMode.clipboardManager so the default
attachment-handling code runs cleanly. Falls back to
Vault.createBinary + FileManager.generateMarkdownLink if the
internal surface ever disappears.
- ImageKitDestination — thin adapter over the existing
ImageKitService.uploadFile pipeline image-gin already uses for
"Convert Local Images to Remote." Honors a drop-gate-specific folder
setting (settings.dropGate.imageKitFolder) that overrides the main
upload folder when set, so ad-hoc dropped images can be routed
somewhere different from generated images. Inherits the main folder
when blank.
- ImgurDestination — anonymous client-ID upload to
api.imgur.com/3/image. Hand-rolled multipart body because Obsidian's
requestUrl doesn't accept FormData.
The modal is widened correctly (modalEl, not contentEl, per the
perplexed widening reminder), shows all three destinations every time
— configured ones selectable, un-configured ones greyed-out with a
hint pointing at the right setting — and captures cursor position
synchronously at drop-time, threading it through DropGateContext as
insertPos so editor.replaceRange lands the markdown link where the
user dropped, not wherever the cursor drifted while the modal was
open.
Conflict detection: if the third-party obsidian-imgur-plugin is also
enabled, both plugins handle every drop and the user gets two modals
back-to-back — preventDefault() blocks the browser default, not other
plugins. We surface a persistent Notice on plugin load telling the
user to disable the other one and configure Imgur from inside Image
Gin.
Bumps version to 0.2.0 (manifest.json, package.json, versions.json).
Plan + design rationale:
content-farm/context-v/plans/Image-Drop-Confirmation-Gate.md
Ship note:
plugin-modules/image-gin/changelog/2026-05-09_01.md
Interception pattern adapted from gavvvr/obsidian-imgur-plugin (MIT).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 06:20:36 +00:00
|
|
|
/**
|
|
|
|
|
* Build a section header (label + right-aligned "All" master toggle)
|
|
|
|
|
* matching the IdeogramModal Image Sizes section. Returns the toggle
|
|
|
|
|
* component so the caller can wire it up to its row toggles.
|
|
|
|
|
*/
|
|
|
|
|
private renderSectionHeaderWithMasterToggle(
|
|
|
|
|
section: HTMLElement,
|
|
|
|
|
title: string,
|
|
|
|
|
initialAllSelected: boolean,
|
|
|
|
|
onMasterChange: (value: boolean) => void
|
|
|
|
|
): ToggleComponent | null {
|
|
|
|
|
const header = section.createDiv('image-gin-section-header');
|
|
|
|
|
header.style.display = 'flex';
|
|
|
|
|
header.style.justifyContent = 'space-between';
|
|
|
|
|
header.style.alignItems = 'center';
|
|
|
|
|
header.createEl('span', { text: title });
|
|
|
|
|
|
|
|
|
|
const masterWrap = header.createDiv();
|
|
|
|
|
masterWrap.style.display = 'flex';
|
|
|
|
|
masterWrap.style.alignItems = 'center';
|
|
|
|
|
masterWrap.style.gap = '0.5rem';
|
|
|
|
|
masterWrap.createEl('span', {
|
|
|
|
|
text: 'All',
|
|
|
|
|
attr: { style: 'font-size: 0.85em; opacity: 0.75;' },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
let captured: ToggleComponent | null = null;
|
|
|
|
|
new Setting(masterWrap)
|
|
|
|
|
.setClass('image-gin-master-toggle')
|
|
|
|
|
.addToggle(toggle => {
|
|
|
|
|
captured = toggle;
|
|
|
|
|
toggle.setValue(initialAllSelected);
|
|
|
|
|
toggle.setTooltip(`Toggle all ${title.toLowerCase()} on/off`);
|
|
|
|
|
toggle.onChange(onMasterChange);
|
|
|
|
|
});
|
|
|
|
|
// Strip the Setting component's left-info column so the toggle
|
|
|
|
|
// hugs the header's right edge (matches IdeogramModal).
|
|
|
|
|
masterWrap.querySelectorAll('.setting-item-info').forEach(el => el.remove());
|
|
|
|
|
|
|
|
|
|
return captured;
|
|
|
|
|
}
|
|
|
|
|
|
2025-07-20 22:39:47 +00:00
|
|
|
private renderImagePropertiesList(containerEl: HTMLElement): void {
|
feat(drop-gate): confirm modal for every dragged or pasted image — v0.2.0
Image Gin now intercepts every image dropped or pasted into a markdown
note and asks where it should go: vault attachments, ImageKit, or
Imgur. Built for writers who handle private client imagery and don't
want a default-on third-party uploader silently shipping screenshots
to a public CDN. Default is the vault. ImageKit is the recommended
destination for less-public information that still wants a CDN. Imgur
is there when something is genuinely shareable.
Off by default. Enable in Settings → Image Gin → "Drag-Drop / Paste
Confirmation Gate". Two policy modes:
- always-confirm — modal on every image drop or paste
- external-only — vault drops fall through silently; modal only
appears when at least one external destination is enabled
Per-session "remember choice" checkbox in the modal skips the gate for
subsequent drops in the same note, resets on active-leaf-change, and
never persists across Obsidian restarts. A reset command is registered
under "Drop Gate: Reset session-remembered destination."
Three destinations behind a small DropGateDestination interface:
- VaultDestination — re-dispatches a synthetic event copy into
Obsidian's internal view.currentMode.clipboardManager so the default
attachment-handling code runs cleanly. Falls back to
Vault.createBinary + FileManager.generateMarkdownLink if the
internal surface ever disappears.
- ImageKitDestination — thin adapter over the existing
ImageKitService.uploadFile pipeline image-gin already uses for
"Convert Local Images to Remote." Honors a drop-gate-specific folder
setting (settings.dropGate.imageKitFolder) that overrides the main
upload folder when set, so ad-hoc dropped images can be routed
somewhere different from generated images. Inherits the main folder
when blank.
- ImgurDestination — anonymous client-ID upload to
api.imgur.com/3/image. Hand-rolled multipart body because Obsidian's
requestUrl doesn't accept FormData.
The modal is widened correctly (modalEl, not contentEl, per the
perplexed widening reminder), shows all three destinations every time
— configured ones selectable, un-configured ones greyed-out with a
hint pointing at the right setting — and captures cursor position
synchronously at drop-time, threading it through DropGateContext as
insertPos so editor.replaceRange lands the markdown link where the
user dropped, not wherever the cursor drifted while the modal was
open.
Conflict detection: if the third-party obsidian-imgur-plugin is also
enabled, both plugins handle every drop and the user gets two modals
back-to-back — preventDefault() blocks the browser default, not other
plugins. We surface a persistent Notice on plugin load telling the
user to disable the other one and configure Imgur from inside Image
Gin.
Bumps version to 0.2.0 (manifest.json, package.json, versions.json).
Plan + design rationale:
content-farm/context-v/plans/Image-Drop-Confirmation-Gate.md
Ship note:
plugin-modules/image-gin/changelog/2026-05-09_01.md
Interception pattern adapted from gavvvr/obsidian-imgur-plugin (MIT).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 06:20:36 +00:00
|
|
|
const section = containerEl.createDiv('image-gin-section');
|
|
|
|
|
|
|
|
|
|
// Eligible = local files; already-ImageKit URLs are excluded from
|
|
|
|
|
// the master toggle's universe so flipping master-on never tries
|
|
|
|
|
// to "select" an already-converted entry.
|
|
|
|
|
const eligible = this.imageProperties.filter(p => p.isLocalFile);
|
|
|
|
|
|
|
|
|
|
this.fmMasterToggle = this.renderSectionHeaderWithMasterToggle(
|
|
|
|
|
section,
|
|
|
|
|
'Frontmatter Images',
|
|
|
|
|
this.areAllFmEligibleSelected(),
|
|
|
|
|
(value) => {
|
|
|
|
|
if (value) {
|
|
|
|
|
for (const p of eligible) this.selectedProperties.add(p.key);
|
|
|
|
|
} else {
|
|
|
|
|
for (const p of eligible) this.selectedProperties.delete(p.key);
|
|
|
|
|
}
|
|
|
|
|
for (const p of eligible) {
|
|
|
|
|
const t = this.fmRowToggles.get(p.key);
|
|
|
|
|
if (t) t.setValue(this.selectedProperties.has(p.key));
|
|
|
|
|
}
|
|
|
|
|
this.updateConvertButtonState();
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const content = section.createDiv('image-gin-section-content');
|
|
|
|
|
const toggleGroup = content.createDiv('image-gin-toggle-group');
|
2025-07-20 22:39:47 +00:00
|
|
|
|
|
|
|
|
this.imageProperties.forEach((prop) => {
|
feat(drop-gate): confirm modal for every dragged or pasted image — v0.2.0
Image Gin now intercepts every image dropped or pasted into a markdown
note and asks where it should go: vault attachments, ImageKit, or
Imgur. Built for writers who handle private client imagery and don't
want a default-on third-party uploader silently shipping screenshots
to a public CDN. Default is the vault. ImageKit is the recommended
destination for less-public information that still wants a CDN. Imgur
is there when something is genuinely shareable.
Off by default. Enable in Settings → Image Gin → "Drag-Drop / Paste
Confirmation Gate". Two policy modes:
- always-confirm — modal on every image drop or paste
- external-only — vault drops fall through silently; modal only
appears when at least one external destination is enabled
Per-session "remember choice" checkbox in the modal skips the gate for
subsequent drops in the same note, resets on active-leaf-change, and
never persists across Obsidian restarts. A reset command is registered
under "Drop Gate: Reset session-remembered destination."
Three destinations behind a small DropGateDestination interface:
- VaultDestination — re-dispatches a synthetic event copy into
Obsidian's internal view.currentMode.clipboardManager so the default
attachment-handling code runs cleanly. Falls back to
Vault.createBinary + FileManager.generateMarkdownLink if the
internal surface ever disappears.
- ImageKitDestination — thin adapter over the existing
ImageKitService.uploadFile pipeline image-gin already uses for
"Convert Local Images to Remote." Honors a drop-gate-specific folder
setting (settings.dropGate.imageKitFolder) that overrides the main
upload folder when set, so ad-hoc dropped images can be routed
somewhere different from generated images. Inherits the main folder
when blank.
- ImgurDestination — anonymous client-ID upload to
api.imgur.com/3/image. Hand-rolled multipart body because Obsidian's
requestUrl doesn't accept FormData.
The modal is widened correctly (modalEl, not contentEl, per the
perplexed widening reminder), shows all three destinations every time
— configured ones selectable, un-configured ones greyed-out with a
hint pointing at the right setting — and captures cursor position
synchronously at drop-time, threading it through DropGateContext as
insertPos so editor.replaceRange lands the markdown link where the
user dropped, not wherever the cursor drifted while the modal was
open.
Conflict detection: if the third-party obsidian-imgur-plugin is also
enabled, both plugins handle every drop and the user gets two modals
back-to-back — preventDefault() blocks the browser default, not other
plugins. We surface a persistent Notice on plugin load telling the
user to disable the other one and configure Imgur from inside Image
Gin.
Bumps version to 0.2.0 (manifest.json, package.json, versions.json).
Plan + design rationale:
content-farm/context-v/plans/Image-Drop-Confirmation-Gate.md
Ship note:
plugin-modules/image-gin/changelog/2026-05-09_01.md
Interception pattern adapted from gavvvr/obsidian-imgur-plugin (MIT).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 06:20:36 +00:00
|
|
|
const itemEl = toggleGroup.createDiv('image-gin-toggle-item');
|
|
|
|
|
|
2025-07-20 22:39:47 +00:00
|
|
|
const isAlreadyImageKit = !prop.isLocalFile;
|
|
|
|
|
const statusClass = isAlreadyImageKit ? 'already-imagekit' : 'local-file';
|
|
|
|
|
itemEl.addClass(statusClass);
|
|
|
|
|
|
|
|
|
|
new Setting(itemEl)
|
|
|
|
|
.setName(prop.key)
|
|
|
|
|
.setDesc(`${prop.value} ${isAlreadyImageKit ? '(Already ImageKit URL)' : '(Local file)'}`)
|
|
|
|
|
.addToggle(toggle => {
|
feat(drop-gate): confirm modal for every dragged or pasted image — v0.2.0
Image Gin now intercepts every image dropped or pasted into a markdown
note and asks where it should go: vault attachments, ImageKit, or
Imgur. Built for writers who handle private client imagery and don't
want a default-on third-party uploader silently shipping screenshots
to a public CDN. Default is the vault. ImageKit is the recommended
destination for less-public information that still wants a CDN. Imgur
is there when something is genuinely shareable.
Off by default. Enable in Settings → Image Gin → "Drag-Drop / Paste
Confirmation Gate". Two policy modes:
- always-confirm — modal on every image drop or paste
- external-only — vault drops fall through silently; modal only
appears when at least one external destination is enabled
Per-session "remember choice" checkbox in the modal skips the gate for
subsequent drops in the same note, resets on active-leaf-change, and
never persists across Obsidian restarts. A reset command is registered
under "Drop Gate: Reset session-remembered destination."
Three destinations behind a small DropGateDestination interface:
- VaultDestination — re-dispatches a synthetic event copy into
Obsidian's internal view.currentMode.clipboardManager so the default
attachment-handling code runs cleanly. Falls back to
Vault.createBinary + FileManager.generateMarkdownLink if the
internal surface ever disappears.
- ImageKitDestination — thin adapter over the existing
ImageKitService.uploadFile pipeline image-gin already uses for
"Convert Local Images to Remote." Honors a drop-gate-specific folder
setting (settings.dropGate.imageKitFolder) that overrides the main
upload folder when set, so ad-hoc dropped images can be routed
somewhere different from generated images. Inherits the main folder
when blank.
- ImgurDestination — anonymous client-ID upload to
api.imgur.com/3/image. Hand-rolled multipart body because Obsidian's
requestUrl doesn't accept FormData.
The modal is widened correctly (modalEl, not contentEl, per the
perplexed widening reminder), shows all three destinations every time
— configured ones selectable, un-configured ones greyed-out with a
hint pointing at the right setting — and captures cursor position
synchronously at drop-time, threading it through DropGateContext as
insertPos so editor.replaceRange lands the markdown link where the
user dropped, not wherever the cursor drifted while the modal was
open.
Conflict detection: if the third-party obsidian-imgur-plugin is also
enabled, both plugins handle every drop and the user gets two modals
back-to-back — preventDefault() blocks the browser default, not other
plugins. We surface a persistent Notice on plugin load telling the
user to disable the other one and configure Imgur from inside Image
Gin.
Bumps version to 0.2.0 (manifest.json, package.json, versions.json).
Plan + design rationale:
content-farm/context-v/plans/Image-Drop-Confirmation-Gate.md
Ship note:
plugin-modules/image-gin/changelog/2026-05-09_01.md
Interception pattern adapted from gavvvr/obsidian-imgur-plugin (MIT).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 06:20:36 +00:00
|
|
|
if (!isAlreadyImageKit) this.fmRowToggles.set(prop.key, toggle);
|
2025-07-20 22:39:47 +00:00
|
|
|
toggle.setValue(prop.isLocalFile && this.selectedProperties.has(prop.key));
|
|
|
|
|
toggle.setDisabled(isAlreadyImageKit);
|
|
|
|
|
toggle.onChange((value) => {
|
|
|
|
|
if (value) {
|
|
|
|
|
this.selectedProperties.add(prop.key);
|
|
|
|
|
} else {
|
|
|
|
|
this.selectedProperties.delete(prop.key);
|
|
|
|
|
}
|
feat(drop-gate): confirm modal for every dragged or pasted image — v0.2.0
Image Gin now intercepts every image dropped or pasted into a markdown
note and asks where it should go: vault attachments, ImageKit, or
Imgur. Built for writers who handle private client imagery and don't
want a default-on third-party uploader silently shipping screenshots
to a public CDN. Default is the vault. ImageKit is the recommended
destination for less-public information that still wants a CDN. Imgur
is there when something is genuinely shareable.
Off by default. Enable in Settings → Image Gin → "Drag-Drop / Paste
Confirmation Gate". Two policy modes:
- always-confirm — modal on every image drop or paste
- external-only — vault drops fall through silently; modal only
appears when at least one external destination is enabled
Per-session "remember choice" checkbox in the modal skips the gate for
subsequent drops in the same note, resets on active-leaf-change, and
never persists across Obsidian restarts. A reset command is registered
under "Drop Gate: Reset session-remembered destination."
Three destinations behind a small DropGateDestination interface:
- VaultDestination — re-dispatches a synthetic event copy into
Obsidian's internal view.currentMode.clipboardManager so the default
attachment-handling code runs cleanly. Falls back to
Vault.createBinary + FileManager.generateMarkdownLink if the
internal surface ever disappears.
- ImageKitDestination — thin adapter over the existing
ImageKitService.uploadFile pipeline image-gin already uses for
"Convert Local Images to Remote." Honors a drop-gate-specific folder
setting (settings.dropGate.imageKitFolder) that overrides the main
upload folder when set, so ad-hoc dropped images can be routed
somewhere different from generated images. Inherits the main folder
when blank.
- ImgurDestination — anonymous client-ID upload to
api.imgur.com/3/image. Hand-rolled multipart body because Obsidian's
requestUrl doesn't accept FormData.
The modal is widened correctly (modalEl, not contentEl, per the
perplexed widening reminder), shows all three destinations every time
— configured ones selectable, un-configured ones greyed-out with a
hint pointing at the right setting — and captures cursor position
synchronously at drop-time, threading it through DropGateContext as
insertPos so editor.replaceRange lands the markdown link where the
user dropped, not wherever the cursor drifted while the modal was
open.
Conflict detection: if the third-party obsidian-imgur-plugin is also
enabled, both plugins handle every drop and the user gets two modals
back-to-back — preventDefault() blocks the browser default, not other
plugins. We surface a persistent Notice on plugin load telling the
user to disable the other one and configure Imgur from inside Image
Gin.
Bumps version to 0.2.0 (manifest.json, package.json, versions.json).
Plan + design rationale:
content-farm/context-v/plans/Image-Drop-Confirmation-Gate.md
Ship note:
plugin-modules/image-gin/changelog/2026-05-09_01.md
Interception pattern adapted from gavvvr/obsidian-imgur-plugin (MIT).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 06:20:36 +00:00
|
|
|
if (this.fmMasterToggle) {
|
|
|
|
|
this.fmMasterToggle.setValue(this.areAllFmEligibleSelected());
|
|
|
|
|
}
|
2025-07-29 23:27:28 +00:00
|
|
|
this.updateConvertButtonState();
|
2025-07-20 22:39:47 +00:00
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
feat(drop-gate): confirm modal for every dragged or pasted image — v0.2.0
Image Gin now intercepts every image dropped or pasted into a markdown
note and asks where it should go: vault attachments, ImageKit, or
Imgur. Built for writers who handle private client imagery and don't
want a default-on third-party uploader silently shipping screenshots
to a public CDN. Default is the vault. ImageKit is the recommended
destination for less-public information that still wants a CDN. Imgur
is there when something is genuinely shareable.
Off by default. Enable in Settings → Image Gin → "Drag-Drop / Paste
Confirmation Gate". Two policy modes:
- always-confirm — modal on every image drop or paste
- external-only — vault drops fall through silently; modal only
appears when at least one external destination is enabled
Per-session "remember choice" checkbox in the modal skips the gate for
subsequent drops in the same note, resets on active-leaf-change, and
never persists across Obsidian restarts. A reset command is registered
under "Drop Gate: Reset session-remembered destination."
Three destinations behind a small DropGateDestination interface:
- VaultDestination — re-dispatches a synthetic event copy into
Obsidian's internal view.currentMode.clipboardManager so the default
attachment-handling code runs cleanly. Falls back to
Vault.createBinary + FileManager.generateMarkdownLink if the
internal surface ever disappears.
- ImageKitDestination — thin adapter over the existing
ImageKitService.uploadFile pipeline image-gin already uses for
"Convert Local Images to Remote." Honors a drop-gate-specific folder
setting (settings.dropGate.imageKitFolder) that overrides the main
upload folder when set, so ad-hoc dropped images can be routed
somewhere different from generated images. Inherits the main folder
when blank.
- ImgurDestination — anonymous client-ID upload to
api.imgur.com/3/image. Hand-rolled multipart body because Obsidian's
requestUrl doesn't accept FormData.
The modal is widened correctly (modalEl, not contentEl, per the
perplexed widening reminder), shows all three destinations every time
— configured ones selectable, un-configured ones greyed-out with a
hint pointing at the right setting — and captures cursor position
synchronously at drop-time, threading it through DropGateContext as
insertPos so editor.replaceRange lands the markdown link where the
user dropped, not wherever the cursor drifted while the modal was
open.
Conflict detection: if the third-party obsidian-imgur-plugin is also
enabled, both plugins handle every drop and the user gets two modals
back-to-back — preventDefault() blocks the browser default, not other
plugins. We surface a persistent Notice on plugin load telling the
user to disable the other one and configure Imgur from inside Image
Gin.
Bumps version to 0.2.0 (manifest.json, package.json, versions.json).
Plan + design rationale:
content-farm/context-v/plans/Image-Drop-Confirmation-Gate.md
Ship note:
plugin-modules/image-gin/changelog/2026-05-09_01.md
Interception pattern adapted from gavvvr/obsidian-imgur-plugin (MIT).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 06:20:36 +00:00
|
|
|
private areAllFmEligibleSelected(): boolean {
|
|
|
|
|
const eligible = this.imageProperties.filter(p => p.isLocalFile);
|
|
|
|
|
if (eligible.length === 0) return false;
|
|
|
|
|
return eligible.every(p => this.selectedProperties.has(p.key));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private areAllMdSelected(): boolean {
|
|
|
|
|
if (this.markdownImagePaths.length === 0) return false;
|
|
|
|
|
return this.markdownImagePaths.every(m => this.selectedMarkdownImages.has(m.path));
|
|
|
|
|
}
|
|
|
|
|
|
2025-07-20 22:39:47 +00:00
|
|
|
private renderProgressSection(containerEl: HTMLElement): void {
|
|
|
|
|
this.progressEl = containerEl.createDiv('image-gin-progress');
|
|
|
|
|
this.progressEl.style.display = 'none';
|
|
|
|
|
|
|
|
|
|
this.progressEl.createEl('p', {
|
|
|
|
|
text: 'Converting images...',
|
|
|
|
|
cls: 'image-gin-progress-text'
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2025-07-29 23:27:28 +00:00
|
|
|
private renderMarkdownImagesList(containerEl: HTMLElement): void {
|
feat(drop-gate): confirm modal for every dragged or pasted image — v0.2.0
Image Gin now intercepts every image dropped or pasted into a markdown
note and asks where it should go: vault attachments, ImageKit, or
Imgur. Built for writers who handle private client imagery and don't
want a default-on third-party uploader silently shipping screenshots
to a public CDN. Default is the vault. ImageKit is the recommended
destination for less-public information that still wants a CDN. Imgur
is there when something is genuinely shareable.
Off by default. Enable in Settings → Image Gin → "Drag-Drop / Paste
Confirmation Gate". Two policy modes:
- always-confirm — modal on every image drop or paste
- external-only — vault drops fall through silently; modal only
appears when at least one external destination is enabled
Per-session "remember choice" checkbox in the modal skips the gate for
subsequent drops in the same note, resets on active-leaf-change, and
never persists across Obsidian restarts. A reset command is registered
under "Drop Gate: Reset session-remembered destination."
Three destinations behind a small DropGateDestination interface:
- VaultDestination — re-dispatches a synthetic event copy into
Obsidian's internal view.currentMode.clipboardManager so the default
attachment-handling code runs cleanly. Falls back to
Vault.createBinary + FileManager.generateMarkdownLink if the
internal surface ever disappears.
- ImageKitDestination — thin adapter over the existing
ImageKitService.uploadFile pipeline image-gin already uses for
"Convert Local Images to Remote." Honors a drop-gate-specific folder
setting (settings.dropGate.imageKitFolder) that overrides the main
upload folder when set, so ad-hoc dropped images can be routed
somewhere different from generated images. Inherits the main folder
when blank.
- ImgurDestination — anonymous client-ID upload to
api.imgur.com/3/image. Hand-rolled multipart body because Obsidian's
requestUrl doesn't accept FormData.
The modal is widened correctly (modalEl, not contentEl, per the
perplexed widening reminder), shows all three destinations every time
— configured ones selectable, un-configured ones greyed-out with a
hint pointing at the right setting — and captures cursor position
synchronously at drop-time, threading it through DropGateContext as
insertPos so editor.replaceRange lands the markdown link where the
user dropped, not wherever the cursor drifted while the modal was
open.
Conflict detection: if the third-party obsidian-imgur-plugin is also
enabled, both plugins handle every drop and the user gets two modals
back-to-back — preventDefault() blocks the browser default, not other
plugins. We surface a persistent Notice on plugin load telling the
user to disable the other one and configure Imgur from inside Image
Gin.
Bumps version to 0.2.0 (manifest.json, package.json, versions.json).
Plan + design rationale:
content-farm/context-v/plans/Image-Drop-Confirmation-Gate.md
Ship note:
plugin-modules/image-gin/changelog/2026-05-09_01.md
Interception pattern adapted from gavvvr/obsidian-imgur-plugin (MIT).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 06:20:36 +00:00
|
|
|
const section = containerEl.createDiv('image-gin-section');
|
|
|
|
|
|
|
|
|
|
this.mdMasterToggle = this.renderSectionHeaderWithMasterToggle(
|
|
|
|
|
section,
|
|
|
|
|
'Markdown Content Images',
|
|
|
|
|
this.areAllMdSelected(),
|
|
|
|
|
(value) => {
|
|
|
|
|
if (value) {
|
|
|
|
|
for (const m of this.markdownImagePaths) this.selectedMarkdownImages.add(m.path);
|
|
|
|
|
} else {
|
|
|
|
|
this.selectedMarkdownImages.clear();
|
|
|
|
|
}
|
|
|
|
|
for (const [path, t] of this.mdRowToggles) {
|
|
|
|
|
t.setValue(this.selectedMarkdownImages.has(path));
|
|
|
|
|
}
|
|
|
|
|
this.updateConvertButtonState();
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const content = section.createDiv('image-gin-section-content');
|
|
|
|
|
const toggleGroup = content.createDiv('image-gin-toggle-group');
|
2025-07-29 23:27:28 +00:00
|
|
|
|
|
|
|
|
this.markdownImagePaths.forEach((image, index) => {
|
feat(drop-gate): confirm modal for every dragged or pasted image — v0.2.0
Image Gin now intercepts every image dropped or pasted into a markdown
note and asks where it should go: vault attachments, ImageKit, or
Imgur. Built for writers who handle private client imagery and don't
want a default-on third-party uploader silently shipping screenshots
to a public CDN. Default is the vault. ImageKit is the recommended
destination for less-public information that still wants a CDN. Imgur
is there when something is genuinely shareable.
Off by default. Enable in Settings → Image Gin → "Drag-Drop / Paste
Confirmation Gate". Two policy modes:
- always-confirm — modal on every image drop or paste
- external-only — vault drops fall through silently; modal only
appears when at least one external destination is enabled
Per-session "remember choice" checkbox in the modal skips the gate for
subsequent drops in the same note, resets on active-leaf-change, and
never persists across Obsidian restarts. A reset command is registered
under "Drop Gate: Reset session-remembered destination."
Three destinations behind a small DropGateDestination interface:
- VaultDestination — re-dispatches a synthetic event copy into
Obsidian's internal view.currentMode.clipboardManager so the default
attachment-handling code runs cleanly. Falls back to
Vault.createBinary + FileManager.generateMarkdownLink if the
internal surface ever disappears.
- ImageKitDestination — thin adapter over the existing
ImageKitService.uploadFile pipeline image-gin already uses for
"Convert Local Images to Remote." Honors a drop-gate-specific folder
setting (settings.dropGate.imageKitFolder) that overrides the main
upload folder when set, so ad-hoc dropped images can be routed
somewhere different from generated images. Inherits the main folder
when blank.
- ImgurDestination — anonymous client-ID upload to
api.imgur.com/3/image. Hand-rolled multipart body because Obsidian's
requestUrl doesn't accept FormData.
The modal is widened correctly (modalEl, not contentEl, per the
perplexed widening reminder), shows all three destinations every time
— configured ones selectable, un-configured ones greyed-out with a
hint pointing at the right setting — and captures cursor position
synchronously at drop-time, threading it through DropGateContext as
insertPos so editor.replaceRange lands the markdown link where the
user dropped, not wherever the cursor drifted while the modal was
open.
Conflict detection: if the third-party obsidian-imgur-plugin is also
enabled, both plugins handle every drop and the user gets two modals
back-to-back — preventDefault() blocks the browser default, not other
plugins. We surface a persistent Notice on plugin load telling the
user to disable the other one and configure Imgur from inside Image
Gin.
Bumps version to 0.2.0 (manifest.json, package.json, versions.json).
Plan + design rationale:
content-farm/context-v/plans/Image-Drop-Confirmation-Gate.md
Ship note:
plugin-modules/image-gin/changelog/2026-05-09_01.md
Interception pattern adapted from gavvvr/obsidian-imgur-plugin (MIT).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 06:20:36 +00:00
|
|
|
const itemEl = toggleGroup.createDiv('image-gin-toggle-item');
|
|
|
|
|
|
2025-07-29 23:27:28 +00:00
|
|
|
new Setting(itemEl)
|
|
|
|
|
.setName(`Image ${index + 1}: ${image.path}`)
|
|
|
|
|
.setDesc(`Found in markdown content`)
|
|
|
|
|
.addToggle(toggle => {
|
feat(drop-gate): confirm modal for every dragged or pasted image — v0.2.0
Image Gin now intercepts every image dropped or pasted into a markdown
note and asks where it should go: vault attachments, ImageKit, or
Imgur. Built for writers who handle private client imagery and don't
want a default-on third-party uploader silently shipping screenshots
to a public CDN. Default is the vault. ImageKit is the recommended
destination for less-public information that still wants a CDN. Imgur
is there when something is genuinely shareable.
Off by default. Enable in Settings → Image Gin → "Drag-Drop / Paste
Confirmation Gate". Two policy modes:
- always-confirm — modal on every image drop or paste
- external-only — vault drops fall through silently; modal only
appears when at least one external destination is enabled
Per-session "remember choice" checkbox in the modal skips the gate for
subsequent drops in the same note, resets on active-leaf-change, and
never persists across Obsidian restarts. A reset command is registered
under "Drop Gate: Reset session-remembered destination."
Three destinations behind a small DropGateDestination interface:
- VaultDestination — re-dispatches a synthetic event copy into
Obsidian's internal view.currentMode.clipboardManager so the default
attachment-handling code runs cleanly. Falls back to
Vault.createBinary + FileManager.generateMarkdownLink if the
internal surface ever disappears.
- ImageKitDestination — thin adapter over the existing
ImageKitService.uploadFile pipeline image-gin already uses for
"Convert Local Images to Remote." Honors a drop-gate-specific folder
setting (settings.dropGate.imageKitFolder) that overrides the main
upload folder when set, so ad-hoc dropped images can be routed
somewhere different from generated images. Inherits the main folder
when blank.
- ImgurDestination — anonymous client-ID upload to
api.imgur.com/3/image. Hand-rolled multipart body because Obsidian's
requestUrl doesn't accept FormData.
The modal is widened correctly (modalEl, not contentEl, per the
perplexed widening reminder), shows all three destinations every time
— configured ones selectable, un-configured ones greyed-out with a
hint pointing at the right setting — and captures cursor position
synchronously at drop-time, threading it through DropGateContext as
insertPos so editor.replaceRange lands the markdown link where the
user dropped, not wherever the cursor drifted while the modal was
open.
Conflict detection: if the third-party obsidian-imgur-plugin is also
enabled, both plugins handle every drop and the user gets two modals
back-to-back — preventDefault() blocks the browser default, not other
plugins. We surface a persistent Notice on plugin load telling the
user to disable the other one and configure Imgur from inside Image
Gin.
Bumps version to 0.2.0 (manifest.json, package.json, versions.json).
Plan + design rationale:
content-farm/context-v/plans/Image-Drop-Confirmation-Gate.md
Ship note:
plugin-modules/image-gin/changelog/2026-05-09_01.md
Interception pattern adapted from gavvvr/obsidian-imgur-plugin (MIT).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 06:20:36 +00:00
|
|
|
this.mdRowToggles.set(image.path, toggle);
|
2025-07-29 23:27:28 +00:00
|
|
|
toggle.setValue(this.selectedMarkdownImages.has(image.path));
|
|
|
|
|
toggle.onChange((value) => {
|
|
|
|
|
if (value) {
|
|
|
|
|
this.selectedMarkdownImages.add(image.path);
|
|
|
|
|
} else {
|
|
|
|
|
this.selectedMarkdownImages.delete(image.path);
|
|
|
|
|
}
|
feat(drop-gate): confirm modal for every dragged or pasted image — v0.2.0
Image Gin now intercepts every image dropped or pasted into a markdown
note and asks where it should go: vault attachments, ImageKit, or
Imgur. Built for writers who handle private client imagery and don't
want a default-on third-party uploader silently shipping screenshots
to a public CDN. Default is the vault. ImageKit is the recommended
destination for less-public information that still wants a CDN. Imgur
is there when something is genuinely shareable.
Off by default. Enable in Settings → Image Gin → "Drag-Drop / Paste
Confirmation Gate". Two policy modes:
- always-confirm — modal on every image drop or paste
- external-only — vault drops fall through silently; modal only
appears when at least one external destination is enabled
Per-session "remember choice" checkbox in the modal skips the gate for
subsequent drops in the same note, resets on active-leaf-change, and
never persists across Obsidian restarts. A reset command is registered
under "Drop Gate: Reset session-remembered destination."
Three destinations behind a small DropGateDestination interface:
- VaultDestination — re-dispatches a synthetic event copy into
Obsidian's internal view.currentMode.clipboardManager so the default
attachment-handling code runs cleanly. Falls back to
Vault.createBinary + FileManager.generateMarkdownLink if the
internal surface ever disappears.
- ImageKitDestination — thin adapter over the existing
ImageKitService.uploadFile pipeline image-gin already uses for
"Convert Local Images to Remote." Honors a drop-gate-specific folder
setting (settings.dropGate.imageKitFolder) that overrides the main
upload folder when set, so ad-hoc dropped images can be routed
somewhere different from generated images. Inherits the main folder
when blank.
- ImgurDestination — anonymous client-ID upload to
api.imgur.com/3/image. Hand-rolled multipart body because Obsidian's
requestUrl doesn't accept FormData.
The modal is widened correctly (modalEl, not contentEl, per the
perplexed widening reminder), shows all three destinations every time
— configured ones selectable, un-configured ones greyed-out with a
hint pointing at the right setting — and captures cursor position
synchronously at drop-time, threading it through DropGateContext as
insertPos so editor.replaceRange lands the markdown link where the
user dropped, not wherever the cursor drifted while the modal was
open.
Conflict detection: if the third-party obsidian-imgur-plugin is also
enabled, both plugins handle every drop and the user gets two modals
back-to-back — preventDefault() blocks the browser default, not other
plugins. We surface a persistent Notice on plugin load telling the
user to disable the other one and configure Imgur from inside Image
Gin.
Bumps version to 0.2.0 (manifest.json, package.json, versions.json).
Plan + design rationale:
content-farm/context-v/plans/Image-Drop-Confirmation-Gate.md
Ship note:
plugin-modules/image-gin/changelog/2026-05-09_01.md
Interception pattern adapted from gavvvr/obsidian-imgur-plugin (MIT).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 06:20:36 +00:00
|
|
|
if (this.mdMasterToggle) {
|
|
|
|
|
this.mdMasterToggle.setValue(this.areAllMdSelected());
|
|
|
|
|
}
|
2025-07-29 23:27:28 +00:00
|
|
|
this.updateConvertButtonState();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private convertButton: HTMLButtonElement | null = null;
|
|
|
|
|
|
|
|
|
|
private updateConvertButtonState(): void {
|
|
|
|
|
if (!this.convertButton) return;
|
|
|
|
|
|
|
|
|
|
const hasSelections = this.selectedProperties.size > 0 || this.selectedMarkdownImages.size > 0;
|
|
|
|
|
if (hasSelections) {
|
|
|
|
|
this.convertButton.removeAttribute('disabled');
|
|
|
|
|
} else {
|
|
|
|
|
this.convertButton.setAttribute('disabled', 'true');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-07-20 22:39:47 +00:00
|
|
|
private renderConvertButton(containerEl: HTMLElement): void {
|
2025-07-29 23:27:28 +00:00
|
|
|
const buttonContainer = containerEl.createDiv('image-gin-button-container');
|
2025-07-20 22:39:47 +00:00
|
|
|
|
2025-07-29 23:27:28 +00:00
|
|
|
this.convertButton = buttonContainer.createEl('button', {
|
2025-07-20 22:39:47 +00:00
|
|
|
text: 'Convert Selected Images',
|
|
|
|
|
cls: 'image-gin-button'
|
|
|
|
|
});
|
|
|
|
|
|
2025-07-29 23:27:28 +00:00
|
|
|
// Set initial button state
|
|
|
|
|
this.updateConvertButtonState();
|
|
|
|
|
|
|
|
|
|
this.convertButton.addEventListener('click', () => {
|
2026-05-03 06:35:13 +00:00
|
|
|
void this.handleConvert();
|
2025-07-20 22:39:47 +00:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async handleConvert(): Promise<void> {
|
|
|
|
|
if (this.isConverting) return;
|
|
|
|
|
|
2025-07-29 23:27:28 +00:00
|
|
|
if (this.selectedProperties.size === 0 && this.selectedMarkdownImages.size === 0) {
|
|
|
|
|
new Notice('Please select at least one image to convert');
|
2025-07-20 22:39:47 +00:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!this.currentFile) {
|
|
|
|
|
new Notice('No active file found');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.isConverting = true;
|
|
|
|
|
this.showProgress();
|
|
|
|
|
|
align(api): align frontmatter parsing with Obsidian recent API changes
Replace the in-tree hand-rolled YAML parser with Obsidian's first-party
frontmatter APIs (`app.metadataCache.getFileCache(file)?.frontmatter`
for reads, `app.fileManager.processFrontMatter(file, fn)` for writes).
Closes Phase 4.3-B of the Obsidian community-plugin publishing-prep plan
at context-v/plans/2026-05-03_Assuring-Obsidian-Community-Plugin-Requirements.md.
Why
---
The earlier publishing-prep pass typed away the parser's `any` to
satisfy ObsidianReviewBot, which kept lint green but left the underlying
parser intact. The hand-rolled parser had real edge-case correctness
bugs the type system could not catch: URL values containing colons
were mis-split, multi-line strings were truncated, YAML anchors and
list-of-maps were not understood, and the in-tree YAML emitter would
produce `[object Object]` output for plain-object values. Obsidian's
own implementation handles all of those correctly and is the path the
Obsidian community-plugin guidelines explicitly point at. Carrying our
own parser meant carrying the bug surface forever; deleting it
externalizes that work.
Files
-----
Deleted:
- src/utils/yamlFrontmatter.ts (176 lines: parser + emitter +
updateFileFrontmatter helper). No replacement file in our codebase
— the responsibility moves to Obsidian's APIs.
Modified:
- src/modals/CurrentFileModal.ts — three usages refactored
- src/modals/ConvertLocalImagesForCurrentFile.ts — three usages
refactored, including the largest restructure (see below)
- context-v/changelogs/2026-05-03_01.md — appended a §6 covering this
phase, updated the front-matter title and summary intro to claim
completion of every blocking publishing-prep phase, added the parser
to the deleted-files inventory at the bottom
Call-site categorization (5 sites total)
----------------------------------------
Pure reads → `metadataCache.getFileCache(file)?.frontmatter`:
- CurrentFileModal.loadExistingPrompt — was reading `image_prompt`
to seed the modal textarea. Bonus: removes a `vault.read` (the cache
is in-memory and synchronous) so the modal opens marginally faster
on files without an existing prompt.
- ConvertLocalImagesForCurrentFile.analyzeCurrentFile — discovers
image properties on file open.
- ConvertLocalImagesForCurrentFile.handleConvert — snapshots
frontmatter once at the top of the loop for tag extraction. Tags
are only used as upload metadata, not as a source of truth, so a
one-time snapshot is fine and avoids re-fetching from cache on
every iteration.
Single-key writes → `app.fileManager.processFrontMatter(file, fn)`:
- CurrentFileModal.updateFrontmatter — writes `image_prompt`.
- CurrentFileModal.updateImagePathInFrontmatter — writes
`<size>_image` keys with the URL of a generated image.
Both single-key writes now follow the uniform pattern:
await this.app.fileManager.processFrontMatter(file, (fm) => {
fm[key] = value;
});
processFrontMatter reads, mutates, and writes atomically via
Obsidian's own YAML emitter — no hand-rolled vault.read /
formatFrontmatter / vault.modify cycle, no race window between read
and re-write.
The harder case: handleConvert's mixed-mutation loop
----------------------------------------------------
The original handleConvert read the file once, parsed frontmatter into
an object, peeled off the body, then ran two long async loops:
1. For each frontmatter image: upload to ImageKit, then
`frontmatter[key] = uploadResult.url` in the in-memory object.
2. For each markdown body image: upload, then
`markdownContent = markdownContent.replace(...)` in the in-memory
string.
After both loops, it reassembled the file as
`---\n${formatFrontmatter(frontmatter)}---\n\n${markdownContent}` and
wrote the whole thing with vault.modify. The formatFrontmatter step
was where the hand-rolled YAML emitter was doing its damage.
Restructured so frontmatter and body live on independent code paths:
- Frontmatter mutations: each upload's frontmatter assignment now
calls processFrontMatter directly — atomic per upload, written by
Obsidian's emitter.
- Body mutations: accumulated into a `bodyMutations: Array<{from, to}>`
list during the markdown-image loop. After both loops complete, a
single vault.read(file) + N string replacements + one
vault.modify(file, updated) flushes them. The body re-read happens
*after* all processFrontMatter calls so it picks up the latest
version (including the frontmatter changes those calls just made);
no stale-content / lost-update race.
Also pulled `const file = this.currentFile;` to a local once at the
top of handleConvert so TypeScript's narrowing survives across
awaits — without the alias, the `if (!this.currentFile) return;`
guard is invalidated at every await boundary because modal lifecycle
could in principle null out currentFile.
Verification
------------
- pnpm build green: ESLint (the bot-mirror flat config from the
prior commit) + tsc strict + esbuild production.
- git grep -nE ": any\\b|as any\\b|<any>|any\\[\\]" -- "*.ts" empty.
- git grep -n "innerHTML" -- "*.ts" empty.
- main.js bundle 54 KB → 52 KB (the deleted parser was being bundled
in; net -2 KB shipped to users).
- All four version markers still in sync at 0.1.0
(manifest.json, package.json, versions.json key + value).
Risk worth knowing about
------------------------
processFrontMatter's callback signature is `(frontmatter: any) => void`
per Obsidian's type definitions. We mutate `fm[key] = value` directly
without a cast — that's the path of least friction, and the bot's
no-explicit-any rule does not fire because we are not *writing* `any`
in our source (we are receiving it from a third-party API). If a
future Obsidian update tightens the type to `Record<string, unknown>`,
our callsites will keep compiling. If it tightens to a stricter shape,
narrowing at the callsite is a small change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 07:03:06 +00:00
|
|
|
const file = this.currentFile;
|
|
|
|
|
|
2025-07-20 22:39:47 +00:00
|
|
|
try {
|
|
|
|
|
const imagekitService = new ImageKitService(this.plugin.settings);
|
align(api): align frontmatter parsing with Obsidian recent API changes
Replace the in-tree hand-rolled YAML parser with Obsidian's first-party
frontmatter APIs (`app.metadataCache.getFileCache(file)?.frontmatter`
for reads, `app.fileManager.processFrontMatter(file, fn)` for writes).
Closes Phase 4.3-B of the Obsidian community-plugin publishing-prep plan
at context-v/plans/2026-05-03_Assuring-Obsidian-Community-Plugin-Requirements.md.
Why
---
The earlier publishing-prep pass typed away the parser's `any` to
satisfy ObsidianReviewBot, which kept lint green but left the underlying
parser intact. The hand-rolled parser had real edge-case correctness
bugs the type system could not catch: URL values containing colons
were mis-split, multi-line strings were truncated, YAML anchors and
list-of-maps were not understood, and the in-tree YAML emitter would
produce `[object Object]` output for plain-object values. Obsidian's
own implementation handles all of those correctly and is the path the
Obsidian community-plugin guidelines explicitly point at. Carrying our
own parser meant carrying the bug surface forever; deleting it
externalizes that work.
Files
-----
Deleted:
- src/utils/yamlFrontmatter.ts (176 lines: parser + emitter +
updateFileFrontmatter helper). No replacement file in our codebase
— the responsibility moves to Obsidian's APIs.
Modified:
- src/modals/CurrentFileModal.ts — three usages refactored
- src/modals/ConvertLocalImagesForCurrentFile.ts — three usages
refactored, including the largest restructure (see below)
- context-v/changelogs/2026-05-03_01.md — appended a §6 covering this
phase, updated the front-matter title and summary intro to claim
completion of every blocking publishing-prep phase, added the parser
to the deleted-files inventory at the bottom
Call-site categorization (5 sites total)
----------------------------------------
Pure reads → `metadataCache.getFileCache(file)?.frontmatter`:
- CurrentFileModal.loadExistingPrompt — was reading `image_prompt`
to seed the modal textarea. Bonus: removes a `vault.read` (the cache
is in-memory and synchronous) so the modal opens marginally faster
on files without an existing prompt.
- ConvertLocalImagesForCurrentFile.analyzeCurrentFile — discovers
image properties on file open.
- ConvertLocalImagesForCurrentFile.handleConvert — snapshots
frontmatter once at the top of the loop for tag extraction. Tags
are only used as upload metadata, not as a source of truth, so a
one-time snapshot is fine and avoids re-fetching from cache on
every iteration.
Single-key writes → `app.fileManager.processFrontMatter(file, fn)`:
- CurrentFileModal.updateFrontmatter — writes `image_prompt`.
- CurrentFileModal.updateImagePathInFrontmatter — writes
`<size>_image` keys with the URL of a generated image.
Both single-key writes now follow the uniform pattern:
await this.app.fileManager.processFrontMatter(file, (fm) => {
fm[key] = value;
});
processFrontMatter reads, mutates, and writes atomically via
Obsidian's own YAML emitter — no hand-rolled vault.read /
formatFrontmatter / vault.modify cycle, no race window between read
and re-write.
The harder case: handleConvert's mixed-mutation loop
----------------------------------------------------
The original handleConvert read the file once, parsed frontmatter into
an object, peeled off the body, then ran two long async loops:
1. For each frontmatter image: upload to ImageKit, then
`frontmatter[key] = uploadResult.url` in the in-memory object.
2. For each markdown body image: upload, then
`markdownContent = markdownContent.replace(...)` in the in-memory
string.
After both loops, it reassembled the file as
`---\n${formatFrontmatter(frontmatter)}---\n\n${markdownContent}` and
wrote the whole thing with vault.modify. The formatFrontmatter step
was where the hand-rolled YAML emitter was doing its damage.
Restructured so frontmatter and body live on independent code paths:
- Frontmatter mutations: each upload's frontmatter assignment now
calls processFrontMatter directly — atomic per upload, written by
Obsidian's emitter.
- Body mutations: accumulated into a `bodyMutations: Array<{from, to}>`
list during the markdown-image loop. After both loops complete, a
single vault.read(file) + N string replacements + one
vault.modify(file, updated) flushes them. The body re-read happens
*after* all processFrontMatter calls so it picks up the latest
version (including the frontmatter changes those calls just made);
no stale-content / lost-update race.
Also pulled `const file = this.currentFile;` to a local once at the
top of handleConvert so TypeScript's narrowing survives across
awaits — without the alias, the `if (!this.currentFile) return;`
guard is invalidated at every await boundary because modal lifecycle
could in principle null out currentFile.
Verification
------------
- pnpm build green: ESLint (the bot-mirror flat config from the
prior commit) + tsc strict + esbuild production.
- git grep -nE ": any\\b|as any\\b|<any>|any\\[\\]" -- "*.ts" empty.
- git grep -n "innerHTML" -- "*.ts" empty.
- main.js bundle 54 KB → 52 KB (the deleted parser was being bundled
in; net -2 KB shipped to users).
- All four version markers still in sync at 0.1.0
(manifest.json, package.json, versions.json key + value).
Risk worth knowing about
------------------------
processFrontMatter's callback signature is `(frontmatter: any) => void`
per Obsidian's type definitions. We mutate `fm[key] = value` directly
without a cast — that's the path of least friction, and the bot's
no-explicit-any rule does not fire because we are not *writing* `any`
in our source (we are receiving it from a third-party API). If a
future Obsidian update tightens the type to `Record<string, unknown>`,
our callsites will keep compiling. If it tightens to a stricter shape,
narrowing at the callsite is a small change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 07:03:06 +00:00
|
|
|
|
|
|
|
|
// Read frontmatter via the metadata cache for tag extraction.
|
|
|
|
|
// Frontmatter mutations below go through processFrontMatter
|
|
|
|
|
// (atomic per-call), so we never serialize YAML by hand.
|
|
|
|
|
const cachedFrontmatter = this.app.metadataCache.getFileCache(file)?.frontmatter;
|
|
|
|
|
|
|
|
|
|
// Body mutations are accumulated and flushed in a single
|
|
|
|
|
// vault.read + vault.modify cycle after all uploads are done.
|
|
|
|
|
// This keeps frontmatter writes (processFrontMatter) and body
|
|
|
|
|
// writes (vault.modify) on separate code paths so neither
|
|
|
|
|
// serializes the other's domain.
|
|
|
|
|
const bodyMutations: Array<{ from: string; to: string }> = [];
|
2025-07-20 22:39:47 +00:00
|
|
|
|
|
|
|
|
let successCount = 0;
|
|
|
|
|
let errorCount = 0;
|
|
|
|
|
|
2025-07-29 23:27:28 +00:00
|
|
|
// 1. Process frontmatter images
|
2025-07-20 22:39:47 +00:00
|
|
|
for (const propertyKey of this.selectedProperties) {
|
|
|
|
|
const property = this.imageProperties.find(p => p.key === propertyKey);
|
|
|
|
|
if (!property || !property.isLocalFile) continue;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
this.updateProgress(`Converting ${property.key}...`);
|
align(api): align frontmatter parsing with Obsidian recent API changes
Replace the in-tree hand-rolled YAML parser with Obsidian's first-party
frontmatter APIs (`app.metadataCache.getFileCache(file)?.frontmatter`
for reads, `app.fileManager.processFrontMatter(file, fn)` for writes).
Closes Phase 4.3-B of the Obsidian community-plugin publishing-prep plan
at context-v/plans/2026-05-03_Assuring-Obsidian-Community-Plugin-Requirements.md.
Why
---
The earlier publishing-prep pass typed away the parser's `any` to
satisfy ObsidianReviewBot, which kept lint green but left the underlying
parser intact. The hand-rolled parser had real edge-case correctness
bugs the type system could not catch: URL values containing colons
were mis-split, multi-line strings were truncated, YAML anchors and
list-of-maps were not understood, and the in-tree YAML emitter would
produce `[object Object]` output for plain-object values. Obsidian's
own implementation handles all of those correctly and is the path the
Obsidian community-plugin guidelines explicitly point at. Carrying our
own parser meant carrying the bug surface forever; deleting it
externalizes that work.
Files
-----
Deleted:
- src/utils/yamlFrontmatter.ts (176 lines: parser + emitter +
updateFileFrontmatter helper). No replacement file in our codebase
— the responsibility moves to Obsidian's APIs.
Modified:
- src/modals/CurrentFileModal.ts — three usages refactored
- src/modals/ConvertLocalImagesForCurrentFile.ts — three usages
refactored, including the largest restructure (see below)
- context-v/changelogs/2026-05-03_01.md — appended a §6 covering this
phase, updated the front-matter title and summary intro to claim
completion of every blocking publishing-prep phase, added the parser
to the deleted-files inventory at the bottom
Call-site categorization (5 sites total)
----------------------------------------
Pure reads → `metadataCache.getFileCache(file)?.frontmatter`:
- CurrentFileModal.loadExistingPrompt — was reading `image_prompt`
to seed the modal textarea. Bonus: removes a `vault.read` (the cache
is in-memory and synchronous) so the modal opens marginally faster
on files without an existing prompt.
- ConvertLocalImagesForCurrentFile.analyzeCurrentFile — discovers
image properties on file open.
- ConvertLocalImagesForCurrentFile.handleConvert — snapshots
frontmatter once at the top of the loop for tag extraction. Tags
are only used as upload metadata, not as a source of truth, so a
one-time snapshot is fine and avoids re-fetching from cache on
every iteration.
Single-key writes → `app.fileManager.processFrontMatter(file, fn)`:
- CurrentFileModal.updateFrontmatter — writes `image_prompt`.
- CurrentFileModal.updateImagePathInFrontmatter — writes
`<size>_image` keys with the URL of a generated image.
Both single-key writes now follow the uniform pattern:
await this.app.fileManager.processFrontMatter(file, (fm) => {
fm[key] = value;
});
processFrontMatter reads, mutates, and writes atomically via
Obsidian's own YAML emitter — no hand-rolled vault.read /
formatFrontmatter / vault.modify cycle, no race window between read
and re-write.
The harder case: handleConvert's mixed-mutation loop
----------------------------------------------------
The original handleConvert read the file once, parsed frontmatter into
an object, peeled off the body, then ran two long async loops:
1. For each frontmatter image: upload to ImageKit, then
`frontmatter[key] = uploadResult.url` in the in-memory object.
2. For each markdown body image: upload, then
`markdownContent = markdownContent.replace(...)` in the in-memory
string.
After both loops, it reassembled the file as
`---\n${formatFrontmatter(frontmatter)}---\n\n${markdownContent}` and
wrote the whole thing with vault.modify. The formatFrontmatter step
was where the hand-rolled YAML emitter was doing its damage.
Restructured so frontmatter and body live on independent code paths:
- Frontmatter mutations: each upload's frontmatter assignment now
calls processFrontMatter directly — atomic per upload, written by
Obsidian's emitter.
- Body mutations: accumulated into a `bodyMutations: Array<{from, to}>`
list during the markdown-image loop. After both loops complete, a
single vault.read(file) + N string replacements + one
vault.modify(file, updated) flushes them. The body re-read happens
*after* all processFrontMatter calls so it picks up the latest
version (including the frontmatter changes those calls just made);
no stale-content / lost-update race.
Also pulled `const file = this.currentFile;` to a local once at the
top of handleConvert so TypeScript's narrowing survives across
awaits — without the alias, the `if (!this.currentFile) return;`
guard is invalidated at every await boundary because modal lifecycle
could in principle null out currentFile.
Verification
------------
- pnpm build green: ESLint (the bot-mirror flat config from the
prior commit) + tsc strict + esbuild production.
- git grep -nE ": any\\b|as any\\b|<any>|any\\[\\]" -- "*.ts" empty.
- git grep -n "innerHTML" -- "*.ts" empty.
- main.js bundle 54 KB → 52 KB (the deleted parser was being bundled
in; net -2 KB shipped to users).
- All four version markers still in sync at 0.1.0
(manifest.json, package.json, versions.json key + value).
Risk worth knowing about
------------------------
processFrontMatter's callback signature is `(frontmatter: any) => void`
per Obsidian's type definitions. We mutate `fm[key] = value` directly
without a cast — that's the path of least friction, and the bot's
no-explicit-any rule does not fire because we are not *writing* `any`
in our source (we are receiving it from a third-party API). If a
future Obsidian update tightens the type to `Record<string, unknown>`,
our callsites will keep compiling. If it tightens to a stricter shape,
narrowing at the callsite is a small change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 07:03:06 +00:00
|
|
|
|
2025-07-20 22:39:47 +00:00
|
|
|
// Read the local file
|
|
|
|
|
const localPath = this.resolveLocalPath(property.value);
|
|
|
|
|
const fileBuffer = readFileSync(localPath);
|
align(api): align frontmatter parsing with Obsidian recent API changes
Replace the in-tree hand-rolled YAML parser with Obsidian's first-party
frontmatter APIs (`app.metadataCache.getFileCache(file)?.frontmatter`
for reads, `app.fileManager.processFrontMatter(file, fn)` for writes).
Closes Phase 4.3-B of the Obsidian community-plugin publishing-prep plan
at context-v/plans/2026-05-03_Assuring-Obsidian-Community-Plugin-Requirements.md.
Why
---
The earlier publishing-prep pass typed away the parser's `any` to
satisfy ObsidianReviewBot, which kept lint green but left the underlying
parser intact. The hand-rolled parser had real edge-case correctness
bugs the type system could not catch: URL values containing colons
were mis-split, multi-line strings were truncated, YAML anchors and
list-of-maps were not understood, and the in-tree YAML emitter would
produce `[object Object]` output for plain-object values. Obsidian's
own implementation handles all of those correctly and is the path the
Obsidian community-plugin guidelines explicitly point at. Carrying our
own parser meant carrying the bug surface forever; deleting it
externalizes that work.
Files
-----
Deleted:
- src/utils/yamlFrontmatter.ts (176 lines: parser + emitter +
updateFileFrontmatter helper). No replacement file in our codebase
— the responsibility moves to Obsidian's APIs.
Modified:
- src/modals/CurrentFileModal.ts — three usages refactored
- src/modals/ConvertLocalImagesForCurrentFile.ts — three usages
refactored, including the largest restructure (see below)
- context-v/changelogs/2026-05-03_01.md — appended a §6 covering this
phase, updated the front-matter title and summary intro to claim
completion of every blocking publishing-prep phase, added the parser
to the deleted-files inventory at the bottom
Call-site categorization (5 sites total)
----------------------------------------
Pure reads → `metadataCache.getFileCache(file)?.frontmatter`:
- CurrentFileModal.loadExistingPrompt — was reading `image_prompt`
to seed the modal textarea. Bonus: removes a `vault.read` (the cache
is in-memory and synchronous) so the modal opens marginally faster
on files without an existing prompt.
- ConvertLocalImagesForCurrentFile.analyzeCurrentFile — discovers
image properties on file open.
- ConvertLocalImagesForCurrentFile.handleConvert — snapshots
frontmatter once at the top of the loop for tag extraction. Tags
are only used as upload metadata, not as a source of truth, so a
one-time snapshot is fine and avoids re-fetching from cache on
every iteration.
Single-key writes → `app.fileManager.processFrontMatter(file, fn)`:
- CurrentFileModal.updateFrontmatter — writes `image_prompt`.
- CurrentFileModal.updateImagePathInFrontmatter — writes
`<size>_image` keys with the URL of a generated image.
Both single-key writes now follow the uniform pattern:
await this.app.fileManager.processFrontMatter(file, (fm) => {
fm[key] = value;
});
processFrontMatter reads, mutates, and writes atomically via
Obsidian's own YAML emitter — no hand-rolled vault.read /
formatFrontmatter / vault.modify cycle, no race window between read
and re-write.
The harder case: handleConvert's mixed-mutation loop
----------------------------------------------------
The original handleConvert read the file once, parsed frontmatter into
an object, peeled off the body, then ran two long async loops:
1. For each frontmatter image: upload to ImageKit, then
`frontmatter[key] = uploadResult.url` in the in-memory object.
2. For each markdown body image: upload, then
`markdownContent = markdownContent.replace(...)` in the in-memory
string.
After both loops, it reassembled the file as
`---\n${formatFrontmatter(frontmatter)}---\n\n${markdownContent}` and
wrote the whole thing with vault.modify. The formatFrontmatter step
was where the hand-rolled YAML emitter was doing its damage.
Restructured so frontmatter and body live on independent code paths:
- Frontmatter mutations: each upload's frontmatter assignment now
calls processFrontMatter directly — atomic per upload, written by
Obsidian's emitter.
- Body mutations: accumulated into a `bodyMutations: Array<{from, to}>`
list during the markdown-image loop. After both loops complete, a
single vault.read(file) + N string replacements + one
vault.modify(file, updated) flushes them. The body re-read happens
*after* all processFrontMatter calls so it picks up the latest
version (including the frontmatter changes those calls just made);
no stale-content / lost-update race.
Also pulled `const file = this.currentFile;` to a local once at the
top of handleConvert so TypeScript's narrowing survives across
awaits — without the alias, the `if (!this.currentFile) return;`
guard is invalidated at every await boundary because modal lifecycle
could in principle null out currentFile.
Verification
------------
- pnpm build green: ESLint (the bot-mirror flat config from the
prior commit) + tsc strict + esbuild production.
- git grep -nE ": any\\b|as any\\b|<any>|any\\[\\]" -- "*.ts" empty.
- git grep -n "innerHTML" -- "*.ts" empty.
- main.js bundle 54 KB → 52 KB (the deleted parser was being bundled
in; net -2 KB shipped to users).
- All four version markers still in sync at 0.1.0
(manifest.json, package.json, versions.json key + value).
Risk worth knowing about
------------------------
processFrontMatter's callback signature is `(frontmatter: any) => void`
per Obsidian's type definitions. We mutate `fm[key] = value` directly
without a cast — that's the path of least friction, and the bot's
no-explicit-any rule does not fire because we are not *writing* `any`
in our source (we are receiving it from a third-party API). If a
future Obsidian update tightens the type to `Record<string, unknown>`,
our callsites will keep compiling. If it tightens to a stricter shape,
narrowing at the callsite is a small change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 07:03:06 +00:00
|
|
|
|
2025-07-20 22:39:47 +00:00
|
|
|
// Extract tags from frontmatter for ImageKit
|
align(api): align frontmatter parsing with Obsidian recent API changes
Replace the in-tree hand-rolled YAML parser with Obsidian's first-party
frontmatter APIs (`app.metadataCache.getFileCache(file)?.frontmatter`
for reads, `app.fileManager.processFrontMatter(file, fn)` for writes).
Closes Phase 4.3-B of the Obsidian community-plugin publishing-prep plan
at context-v/plans/2026-05-03_Assuring-Obsidian-Community-Plugin-Requirements.md.
Why
---
The earlier publishing-prep pass typed away the parser's `any` to
satisfy ObsidianReviewBot, which kept lint green but left the underlying
parser intact. The hand-rolled parser had real edge-case correctness
bugs the type system could not catch: URL values containing colons
were mis-split, multi-line strings were truncated, YAML anchors and
list-of-maps were not understood, and the in-tree YAML emitter would
produce `[object Object]` output for plain-object values. Obsidian's
own implementation handles all of those correctly and is the path the
Obsidian community-plugin guidelines explicitly point at. Carrying our
own parser meant carrying the bug surface forever; deleting it
externalizes that work.
Files
-----
Deleted:
- src/utils/yamlFrontmatter.ts (176 lines: parser + emitter +
updateFileFrontmatter helper). No replacement file in our codebase
— the responsibility moves to Obsidian's APIs.
Modified:
- src/modals/CurrentFileModal.ts — three usages refactored
- src/modals/ConvertLocalImagesForCurrentFile.ts — three usages
refactored, including the largest restructure (see below)
- context-v/changelogs/2026-05-03_01.md — appended a §6 covering this
phase, updated the front-matter title and summary intro to claim
completion of every blocking publishing-prep phase, added the parser
to the deleted-files inventory at the bottom
Call-site categorization (5 sites total)
----------------------------------------
Pure reads → `metadataCache.getFileCache(file)?.frontmatter`:
- CurrentFileModal.loadExistingPrompt — was reading `image_prompt`
to seed the modal textarea. Bonus: removes a `vault.read` (the cache
is in-memory and synchronous) so the modal opens marginally faster
on files without an existing prompt.
- ConvertLocalImagesForCurrentFile.analyzeCurrentFile — discovers
image properties on file open.
- ConvertLocalImagesForCurrentFile.handleConvert — snapshots
frontmatter once at the top of the loop for tag extraction. Tags
are only used as upload metadata, not as a source of truth, so a
one-time snapshot is fine and avoids re-fetching from cache on
every iteration.
Single-key writes → `app.fileManager.processFrontMatter(file, fn)`:
- CurrentFileModal.updateFrontmatter — writes `image_prompt`.
- CurrentFileModal.updateImagePathInFrontmatter — writes
`<size>_image` keys with the URL of a generated image.
Both single-key writes now follow the uniform pattern:
await this.app.fileManager.processFrontMatter(file, (fm) => {
fm[key] = value;
});
processFrontMatter reads, mutates, and writes atomically via
Obsidian's own YAML emitter — no hand-rolled vault.read /
formatFrontmatter / vault.modify cycle, no race window between read
and re-write.
The harder case: handleConvert's mixed-mutation loop
----------------------------------------------------
The original handleConvert read the file once, parsed frontmatter into
an object, peeled off the body, then ran two long async loops:
1. For each frontmatter image: upload to ImageKit, then
`frontmatter[key] = uploadResult.url` in the in-memory object.
2. For each markdown body image: upload, then
`markdownContent = markdownContent.replace(...)` in the in-memory
string.
After both loops, it reassembled the file as
`---\n${formatFrontmatter(frontmatter)}---\n\n${markdownContent}` and
wrote the whole thing with vault.modify. The formatFrontmatter step
was where the hand-rolled YAML emitter was doing its damage.
Restructured so frontmatter and body live on independent code paths:
- Frontmatter mutations: each upload's frontmatter assignment now
calls processFrontMatter directly — atomic per upload, written by
Obsidian's emitter.
- Body mutations: accumulated into a `bodyMutations: Array<{from, to}>`
list during the markdown-image loop. After both loops complete, a
single vault.read(file) + N string replacements + one
vault.modify(file, updated) flushes them. The body re-read happens
*after* all processFrontMatter calls so it picks up the latest
version (including the frontmatter changes those calls just made);
no stale-content / lost-update race.
Also pulled `const file = this.currentFile;` to a local once at the
top of handleConvert so TypeScript's narrowing survives across
awaits — without the alias, the `if (!this.currentFile) return;`
guard is invalidated at every await boundary because modal lifecycle
could in principle null out currentFile.
Verification
------------
- pnpm build green: ESLint (the bot-mirror flat config from the
prior commit) + tsc strict + esbuild production.
- git grep -nE ": any\\b|as any\\b|<any>|any\\[\\]" -- "*.ts" empty.
- git grep -n "innerHTML" -- "*.ts" empty.
- main.js bundle 54 KB → 52 KB (the deleted parser was being bundled
in; net -2 KB shipped to users).
- All four version markers still in sync at 0.1.0
(manifest.json, package.json, versions.json key + value).
Risk worth knowing about
------------------------
processFrontMatter's callback signature is `(frontmatter: any) => void`
per Obsidian's type definitions. We mutate `fm[key] = value` directly
without a cast — that's the path of least friction, and the bot's
no-explicit-any rule does not fire because we are not *writing* `any`
in our source (we are receiving it from a third-party API). If a
future Obsidian update tightens the type to `Record<string, unknown>`,
our callsites will keep compiling. If it tightens to a stricter shape,
narrowing at the callsite is a small change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 07:03:06 +00:00
|
|
|
const tags = imagekitService.extractTagsFromFrontmatter(cachedFrontmatter);
|
|
|
|
|
|
2025-07-20 22:39:47 +00:00
|
|
|
// Generate filename
|
|
|
|
|
const fileName = this.generateFileName(property.key, localPath);
|
align(api): align frontmatter parsing with Obsidian recent API changes
Replace the in-tree hand-rolled YAML parser with Obsidian's first-party
frontmatter APIs (`app.metadataCache.getFileCache(file)?.frontmatter`
for reads, `app.fileManager.processFrontMatter(file, fn)` for writes).
Closes Phase 4.3-B of the Obsidian community-plugin publishing-prep plan
at context-v/plans/2026-05-03_Assuring-Obsidian-Community-Plugin-Requirements.md.
Why
---
The earlier publishing-prep pass typed away the parser's `any` to
satisfy ObsidianReviewBot, which kept lint green but left the underlying
parser intact. The hand-rolled parser had real edge-case correctness
bugs the type system could not catch: URL values containing colons
were mis-split, multi-line strings were truncated, YAML anchors and
list-of-maps were not understood, and the in-tree YAML emitter would
produce `[object Object]` output for plain-object values. Obsidian's
own implementation handles all of those correctly and is the path the
Obsidian community-plugin guidelines explicitly point at. Carrying our
own parser meant carrying the bug surface forever; deleting it
externalizes that work.
Files
-----
Deleted:
- src/utils/yamlFrontmatter.ts (176 lines: parser + emitter +
updateFileFrontmatter helper). No replacement file in our codebase
— the responsibility moves to Obsidian's APIs.
Modified:
- src/modals/CurrentFileModal.ts — three usages refactored
- src/modals/ConvertLocalImagesForCurrentFile.ts — three usages
refactored, including the largest restructure (see below)
- context-v/changelogs/2026-05-03_01.md — appended a §6 covering this
phase, updated the front-matter title and summary intro to claim
completion of every blocking publishing-prep phase, added the parser
to the deleted-files inventory at the bottom
Call-site categorization (5 sites total)
----------------------------------------
Pure reads → `metadataCache.getFileCache(file)?.frontmatter`:
- CurrentFileModal.loadExistingPrompt — was reading `image_prompt`
to seed the modal textarea. Bonus: removes a `vault.read` (the cache
is in-memory and synchronous) so the modal opens marginally faster
on files without an existing prompt.
- ConvertLocalImagesForCurrentFile.analyzeCurrentFile — discovers
image properties on file open.
- ConvertLocalImagesForCurrentFile.handleConvert — snapshots
frontmatter once at the top of the loop for tag extraction. Tags
are only used as upload metadata, not as a source of truth, so a
one-time snapshot is fine and avoids re-fetching from cache on
every iteration.
Single-key writes → `app.fileManager.processFrontMatter(file, fn)`:
- CurrentFileModal.updateFrontmatter — writes `image_prompt`.
- CurrentFileModal.updateImagePathInFrontmatter — writes
`<size>_image` keys with the URL of a generated image.
Both single-key writes now follow the uniform pattern:
await this.app.fileManager.processFrontMatter(file, (fm) => {
fm[key] = value;
});
processFrontMatter reads, mutates, and writes atomically via
Obsidian's own YAML emitter — no hand-rolled vault.read /
formatFrontmatter / vault.modify cycle, no race window between read
and re-write.
The harder case: handleConvert's mixed-mutation loop
----------------------------------------------------
The original handleConvert read the file once, parsed frontmatter into
an object, peeled off the body, then ran two long async loops:
1. For each frontmatter image: upload to ImageKit, then
`frontmatter[key] = uploadResult.url` in the in-memory object.
2. For each markdown body image: upload, then
`markdownContent = markdownContent.replace(...)` in the in-memory
string.
After both loops, it reassembled the file as
`---\n${formatFrontmatter(frontmatter)}---\n\n${markdownContent}` and
wrote the whole thing with vault.modify. The formatFrontmatter step
was where the hand-rolled YAML emitter was doing its damage.
Restructured so frontmatter and body live on independent code paths:
- Frontmatter mutations: each upload's frontmatter assignment now
calls processFrontMatter directly — atomic per upload, written by
Obsidian's emitter.
- Body mutations: accumulated into a `bodyMutations: Array<{from, to}>`
list during the markdown-image loop. After both loops complete, a
single vault.read(file) + N string replacements + one
vault.modify(file, updated) flushes them. The body re-read happens
*after* all processFrontMatter calls so it picks up the latest
version (including the frontmatter changes those calls just made);
no stale-content / lost-update race.
Also pulled `const file = this.currentFile;` to a local once at the
top of handleConvert so TypeScript's narrowing survives across
awaits — without the alias, the `if (!this.currentFile) return;`
guard is invalidated at every await boundary because modal lifecycle
could in principle null out currentFile.
Verification
------------
- pnpm build green: ESLint (the bot-mirror flat config from the
prior commit) + tsc strict + esbuild production.
- git grep -nE ": any\\b|as any\\b|<any>|any\\[\\]" -- "*.ts" empty.
- git grep -n "innerHTML" -- "*.ts" empty.
- main.js bundle 54 KB → 52 KB (the deleted parser was being bundled
in; net -2 KB shipped to users).
- All four version markers still in sync at 0.1.0
(manifest.json, package.json, versions.json key + value).
Risk worth knowing about
------------------------
processFrontMatter's callback signature is `(frontmatter: any) => void`
per Obsidian's type definitions. We mutate `fm[key] = value` directly
without a cast — that's the path of least friction, and the bot's
no-explicit-any rule does not fire because we are not *writing* `any`
in our source (we are receiving it from a third-party API). If a
future Obsidian update tightens the type to `Record<string, unknown>`,
our callsites will keep compiling. If it tightens to a stricter shape,
narrowing at the callsite is a small change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 07:03:06 +00:00
|
|
|
|
2025-07-20 22:39:47 +00:00
|
|
|
// Upload to ImageKit
|
|
|
|
|
const uploadResult = await imagekitService.uploadFile(
|
|
|
|
|
fileBuffer.buffer,
|
|
|
|
|
fileName,
|
|
|
|
|
undefined, // Use default folder from settings
|
|
|
|
|
tags
|
|
|
|
|
);
|
|
|
|
|
|
align(api): align frontmatter parsing with Obsidian recent API changes
Replace the in-tree hand-rolled YAML parser with Obsidian's first-party
frontmatter APIs (`app.metadataCache.getFileCache(file)?.frontmatter`
for reads, `app.fileManager.processFrontMatter(file, fn)` for writes).
Closes Phase 4.3-B of the Obsidian community-plugin publishing-prep plan
at context-v/plans/2026-05-03_Assuring-Obsidian-Community-Plugin-Requirements.md.
Why
---
The earlier publishing-prep pass typed away the parser's `any` to
satisfy ObsidianReviewBot, which kept lint green but left the underlying
parser intact. The hand-rolled parser had real edge-case correctness
bugs the type system could not catch: URL values containing colons
were mis-split, multi-line strings were truncated, YAML anchors and
list-of-maps were not understood, and the in-tree YAML emitter would
produce `[object Object]` output for plain-object values. Obsidian's
own implementation handles all of those correctly and is the path the
Obsidian community-plugin guidelines explicitly point at. Carrying our
own parser meant carrying the bug surface forever; deleting it
externalizes that work.
Files
-----
Deleted:
- src/utils/yamlFrontmatter.ts (176 lines: parser + emitter +
updateFileFrontmatter helper). No replacement file in our codebase
— the responsibility moves to Obsidian's APIs.
Modified:
- src/modals/CurrentFileModal.ts — three usages refactored
- src/modals/ConvertLocalImagesForCurrentFile.ts — three usages
refactored, including the largest restructure (see below)
- context-v/changelogs/2026-05-03_01.md — appended a §6 covering this
phase, updated the front-matter title and summary intro to claim
completion of every blocking publishing-prep phase, added the parser
to the deleted-files inventory at the bottom
Call-site categorization (5 sites total)
----------------------------------------
Pure reads → `metadataCache.getFileCache(file)?.frontmatter`:
- CurrentFileModal.loadExistingPrompt — was reading `image_prompt`
to seed the modal textarea. Bonus: removes a `vault.read` (the cache
is in-memory and synchronous) so the modal opens marginally faster
on files without an existing prompt.
- ConvertLocalImagesForCurrentFile.analyzeCurrentFile — discovers
image properties on file open.
- ConvertLocalImagesForCurrentFile.handleConvert — snapshots
frontmatter once at the top of the loop for tag extraction. Tags
are only used as upload metadata, not as a source of truth, so a
one-time snapshot is fine and avoids re-fetching from cache on
every iteration.
Single-key writes → `app.fileManager.processFrontMatter(file, fn)`:
- CurrentFileModal.updateFrontmatter — writes `image_prompt`.
- CurrentFileModal.updateImagePathInFrontmatter — writes
`<size>_image` keys with the URL of a generated image.
Both single-key writes now follow the uniform pattern:
await this.app.fileManager.processFrontMatter(file, (fm) => {
fm[key] = value;
});
processFrontMatter reads, mutates, and writes atomically via
Obsidian's own YAML emitter — no hand-rolled vault.read /
formatFrontmatter / vault.modify cycle, no race window between read
and re-write.
The harder case: handleConvert's mixed-mutation loop
----------------------------------------------------
The original handleConvert read the file once, parsed frontmatter into
an object, peeled off the body, then ran two long async loops:
1. For each frontmatter image: upload to ImageKit, then
`frontmatter[key] = uploadResult.url` in the in-memory object.
2. For each markdown body image: upload, then
`markdownContent = markdownContent.replace(...)` in the in-memory
string.
After both loops, it reassembled the file as
`---\n${formatFrontmatter(frontmatter)}---\n\n${markdownContent}` and
wrote the whole thing with vault.modify. The formatFrontmatter step
was where the hand-rolled YAML emitter was doing its damage.
Restructured so frontmatter and body live on independent code paths:
- Frontmatter mutations: each upload's frontmatter assignment now
calls processFrontMatter directly — atomic per upload, written by
Obsidian's emitter.
- Body mutations: accumulated into a `bodyMutations: Array<{from, to}>`
list during the markdown-image loop. After both loops complete, a
single vault.read(file) + N string replacements + one
vault.modify(file, updated) flushes them. The body re-read happens
*after* all processFrontMatter calls so it picks up the latest
version (including the frontmatter changes those calls just made);
no stale-content / lost-update race.
Also pulled `const file = this.currentFile;` to a local once at the
top of handleConvert so TypeScript's narrowing survives across
awaits — without the alias, the `if (!this.currentFile) return;`
guard is invalidated at every await boundary because modal lifecycle
could in principle null out currentFile.
Verification
------------
- pnpm build green: ESLint (the bot-mirror flat config from the
prior commit) + tsc strict + esbuild production.
- git grep -nE ": any\\b|as any\\b|<any>|any\\[\\]" -- "*.ts" empty.
- git grep -n "innerHTML" -- "*.ts" empty.
- main.js bundle 54 KB → 52 KB (the deleted parser was being bundled
in; net -2 KB shipped to users).
- All four version markers still in sync at 0.1.0
(manifest.json, package.json, versions.json key + value).
Risk worth knowing about
------------------------
processFrontMatter's callback signature is `(frontmatter: any) => void`
per Obsidian's type definitions. We mutate `fm[key] = value` directly
without a cast — that's the path of least friction, and the bot's
no-explicit-any rule does not fire because we are not *writing* `any`
in our source (we are receiving it from a third-party API). If a
future Obsidian update tightens the type to `Record<string, unknown>`,
our callsites will keep compiling. If it tightens to a stricter shape,
narrowing at the callsite is a small change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 07:03:06 +00:00
|
|
|
// Update frontmatter with ImageKit URL via Obsidian's API
|
|
|
|
|
await this.app.fileManager.processFrontMatter(file, (fm) => {
|
|
|
|
|
fm[property.key] = uploadResult.url;
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-03 06:35:13 +00:00
|
|
|
logger.info(`Successfully converted ${property.key}: ${uploadResult.url}`);
|
2025-07-20 22:39:47 +00:00
|
|
|
successCount++;
|
|
|
|
|
|
|
|
|
|
// Optionally remove local file if setting is enabled
|
|
|
|
|
if (this.plugin.settings.imageKit.removeLocalFiles) {
|
|
|
|
|
try {
|
|
|
|
|
const fs = require('fs');
|
|
|
|
|
fs.unlinkSync(localPath);
|
2026-05-03 06:35:13 +00:00
|
|
|
logger.info(`Removed local file: ${localPath}`);
|
2025-07-20 22:39:47 +00:00
|
|
|
} catch (removeError) {
|
2026-05-03 06:35:13 +00:00
|
|
|
logger.warn(`Failed to remove local file ${localPath}:`, removeError);
|
2025-07-20 22:39:47 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
} catch (error) {
|
2026-05-03 06:35:13 +00:00
|
|
|
logger.error(`Error converting ${property.key}:`, error);
|
2025-07-20 22:39:47 +00:00
|
|
|
errorCount++;
|
|
|
|
|
new Notice(`Failed to convert ${property.key}: ${this.getErrorMessage(error)}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-07-29 23:27:28 +00:00
|
|
|
// 2. Process markdown content images
|
|
|
|
|
for (const imagePath of this.selectedMarkdownImages) {
|
|
|
|
|
try {
|
|
|
|
|
this.updateProgress(`Converting markdown image: ${imagePath}...`);
|
align(api): align frontmatter parsing with Obsidian recent API changes
Replace the in-tree hand-rolled YAML parser with Obsidian's first-party
frontmatter APIs (`app.metadataCache.getFileCache(file)?.frontmatter`
for reads, `app.fileManager.processFrontMatter(file, fn)` for writes).
Closes Phase 4.3-B of the Obsidian community-plugin publishing-prep plan
at context-v/plans/2026-05-03_Assuring-Obsidian-Community-Plugin-Requirements.md.
Why
---
The earlier publishing-prep pass typed away the parser's `any` to
satisfy ObsidianReviewBot, which kept lint green but left the underlying
parser intact. The hand-rolled parser had real edge-case correctness
bugs the type system could not catch: URL values containing colons
were mis-split, multi-line strings were truncated, YAML anchors and
list-of-maps were not understood, and the in-tree YAML emitter would
produce `[object Object]` output for plain-object values. Obsidian's
own implementation handles all of those correctly and is the path the
Obsidian community-plugin guidelines explicitly point at. Carrying our
own parser meant carrying the bug surface forever; deleting it
externalizes that work.
Files
-----
Deleted:
- src/utils/yamlFrontmatter.ts (176 lines: parser + emitter +
updateFileFrontmatter helper). No replacement file in our codebase
— the responsibility moves to Obsidian's APIs.
Modified:
- src/modals/CurrentFileModal.ts — three usages refactored
- src/modals/ConvertLocalImagesForCurrentFile.ts — three usages
refactored, including the largest restructure (see below)
- context-v/changelogs/2026-05-03_01.md — appended a §6 covering this
phase, updated the front-matter title and summary intro to claim
completion of every blocking publishing-prep phase, added the parser
to the deleted-files inventory at the bottom
Call-site categorization (5 sites total)
----------------------------------------
Pure reads → `metadataCache.getFileCache(file)?.frontmatter`:
- CurrentFileModal.loadExistingPrompt — was reading `image_prompt`
to seed the modal textarea. Bonus: removes a `vault.read` (the cache
is in-memory and synchronous) so the modal opens marginally faster
on files without an existing prompt.
- ConvertLocalImagesForCurrentFile.analyzeCurrentFile — discovers
image properties on file open.
- ConvertLocalImagesForCurrentFile.handleConvert — snapshots
frontmatter once at the top of the loop for tag extraction. Tags
are only used as upload metadata, not as a source of truth, so a
one-time snapshot is fine and avoids re-fetching from cache on
every iteration.
Single-key writes → `app.fileManager.processFrontMatter(file, fn)`:
- CurrentFileModal.updateFrontmatter — writes `image_prompt`.
- CurrentFileModal.updateImagePathInFrontmatter — writes
`<size>_image` keys with the URL of a generated image.
Both single-key writes now follow the uniform pattern:
await this.app.fileManager.processFrontMatter(file, (fm) => {
fm[key] = value;
});
processFrontMatter reads, mutates, and writes atomically via
Obsidian's own YAML emitter — no hand-rolled vault.read /
formatFrontmatter / vault.modify cycle, no race window between read
and re-write.
The harder case: handleConvert's mixed-mutation loop
----------------------------------------------------
The original handleConvert read the file once, parsed frontmatter into
an object, peeled off the body, then ran two long async loops:
1. For each frontmatter image: upload to ImageKit, then
`frontmatter[key] = uploadResult.url` in the in-memory object.
2. For each markdown body image: upload, then
`markdownContent = markdownContent.replace(...)` in the in-memory
string.
After both loops, it reassembled the file as
`---\n${formatFrontmatter(frontmatter)}---\n\n${markdownContent}` and
wrote the whole thing with vault.modify. The formatFrontmatter step
was where the hand-rolled YAML emitter was doing its damage.
Restructured so frontmatter and body live on independent code paths:
- Frontmatter mutations: each upload's frontmatter assignment now
calls processFrontMatter directly — atomic per upload, written by
Obsidian's emitter.
- Body mutations: accumulated into a `bodyMutations: Array<{from, to}>`
list during the markdown-image loop. After both loops complete, a
single vault.read(file) + N string replacements + one
vault.modify(file, updated) flushes them. The body re-read happens
*after* all processFrontMatter calls so it picks up the latest
version (including the frontmatter changes those calls just made);
no stale-content / lost-update race.
Also pulled `const file = this.currentFile;` to a local once at the
top of handleConvert so TypeScript's narrowing survives across
awaits — without the alias, the `if (!this.currentFile) return;`
guard is invalidated at every await boundary because modal lifecycle
could in principle null out currentFile.
Verification
------------
- pnpm build green: ESLint (the bot-mirror flat config from the
prior commit) + tsc strict + esbuild production.
- git grep -nE ": any\\b|as any\\b|<any>|any\\[\\]" -- "*.ts" empty.
- git grep -n "innerHTML" -- "*.ts" empty.
- main.js bundle 54 KB → 52 KB (the deleted parser was being bundled
in; net -2 KB shipped to users).
- All four version markers still in sync at 0.1.0
(manifest.json, package.json, versions.json key + value).
Risk worth knowing about
------------------------
processFrontMatter's callback signature is `(frontmatter: any) => void`
per Obsidian's type definitions. We mutate `fm[key] = value` directly
without a cast — that's the path of least friction, and the bot's
no-explicit-any rule does not fire because we are not *writing* `any`
in our source (we are receiving it from a third-party API). If a
future Obsidian update tightens the type to `Record<string, unknown>`,
our callsites will keep compiling. If it tightens to a stricter shape,
narrowing at the callsite is a small change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 07:03:06 +00:00
|
|
|
|
2025-07-29 23:27:28 +00:00
|
|
|
// Find the full match for this path
|
|
|
|
|
const imageInfo = this.markdownImagePaths.find(img => img.path === imagePath);
|
|
|
|
|
if (!imageInfo) continue;
|
align(api): align frontmatter parsing with Obsidian recent API changes
Replace the in-tree hand-rolled YAML parser with Obsidian's first-party
frontmatter APIs (`app.metadataCache.getFileCache(file)?.frontmatter`
for reads, `app.fileManager.processFrontMatter(file, fn)` for writes).
Closes Phase 4.3-B of the Obsidian community-plugin publishing-prep plan
at context-v/plans/2026-05-03_Assuring-Obsidian-Community-Plugin-Requirements.md.
Why
---
The earlier publishing-prep pass typed away the parser's `any` to
satisfy ObsidianReviewBot, which kept lint green but left the underlying
parser intact. The hand-rolled parser had real edge-case correctness
bugs the type system could not catch: URL values containing colons
were mis-split, multi-line strings were truncated, YAML anchors and
list-of-maps were not understood, and the in-tree YAML emitter would
produce `[object Object]` output for plain-object values. Obsidian's
own implementation handles all of those correctly and is the path the
Obsidian community-plugin guidelines explicitly point at. Carrying our
own parser meant carrying the bug surface forever; deleting it
externalizes that work.
Files
-----
Deleted:
- src/utils/yamlFrontmatter.ts (176 lines: parser + emitter +
updateFileFrontmatter helper). No replacement file in our codebase
— the responsibility moves to Obsidian's APIs.
Modified:
- src/modals/CurrentFileModal.ts — three usages refactored
- src/modals/ConvertLocalImagesForCurrentFile.ts — three usages
refactored, including the largest restructure (see below)
- context-v/changelogs/2026-05-03_01.md — appended a §6 covering this
phase, updated the front-matter title and summary intro to claim
completion of every blocking publishing-prep phase, added the parser
to the deleted-files inventory at the bottom
Call-site categorization (5 sites total)
----------------------------------------
Pure reads → `metadataCache.getFileCache(file)?.frontmatter`:
- CurrentFileModal.loadExistingPrompt — was reading `image_prompt`
to seed the modal textarea. Bonus: removes a `vault.read` (the cache
is in-memory and synchronous) so the modal opens marginally faster
on files without an existing prompt.
- ConvertLocalImagesForCurrentFile.analyzeCurrentFile — discovers
image properties on file open.
- ConvertLocalImagesForCurrentFile.handleConvert — snapshots
frontmatter once at the top of the loop for tag extraction. Tags
are only used as upload metadata, not as a source of truth, so a
one-time snapshot is fine and avoids re-fetching from cache on
every iteration.
Single-key writes → `app.fileManager.processFrontMatter(file, fn)`:
- CurrentFileModal.updateFrontmatter — writes `image_prompt`.
- CurrentFileModal.updateImagePathInFrontmatter — writes
`<size>_image` keys with the URL of a generated image.
Both single-key writes now follow the uniform pattern:
await this.app.fileManager.processFrontMatter(file, (fm) => {
fm[key] = value;
});
processFrontMatter reads, mutates, and writes atomically via
Obsidian's own YAML emitter — no hand-rolled vault.read /
formatFrontmatter / vault.modify cycle, no race window between read
and re-write.
The harder case: handleConvert's mixed-mutation loop
----------------------------------------------------
The original handleConvert read the file once, parsed frontmatter into
an object, peeled off the body, then ran two long async loops:
1. For each frontmatter image: upload to ImageKit, then
`frontmatter[key] = uploadResult.url` in the in-memory object.
2. For each markdown body image: upload, then
`markdownContent = markdownContent.replace(...)` in the in-memory
string.
After both loops, it reassembled the file as
`---\n${formatFrontmatter(frontmatter)}---\n\n${markdownContent}` and
wrote the whole thing with vault.modify. The formatFrontmatter step
was where the hand-rolled YAML emitter was doing its damage.
Restructured so frontmatter and body live on independent code paths:
- Frontmatter mutations: each upload's frontmatter assignment now
calls processFrontMatter directly — atomic per upload, written by
Obsidian's emitter.
- Body mutations: accumulated into a `bodyMutations: Array<{from, to}>`
list during the markdown-image loop. After both loops complete, a
single vault.read(file) + N string replacements + one
vault.modify(file, updated) flushes them. The body re-read happens
*after* all processFrontMatter calls so it picks up the latest
version (including the frontmatter changes those calls just made);
no stale-content / lost-update race.
Also pulled `const file = this.currentFile;` to a local once at the
top of handleConvert so TypeScript's narrowing survives across
awaits — without the alias, the `if (!this.currentFile) return;`
guard is invalidated at every await boundary because modal lifecycle
could in principle null out currentFile.
Verification
------------
- pnpm build green: ESLint (the bot-mirror flat config from the
prior commit) + tsc strict + esbuild production.
- git grep -nE ": any\\b|as any\\b|<any>|any\\[\\]" -- "*.ts" empty.
- git grep -n "innerHTML" -- "*.ts" empty.
- main.js bundle 54 KB → 52 KB (the deleted parser was being bundled
in; net -2 KB shipped to users).
- All four version markers still in sync at 0.1.0
(manifest.json, package.json, versions.json key + value).
Risk worth knowing about
------------------------
processFrontMatter's callback signature is `(frontmatter: any) => void`
per Obsidian's type definitions. We mutate `fm[key] = value` directly
without a cast — that's the path of least friction, and the bot's
no-explicit-any rule does not fire because we are not *writing* `any`
in our source (we are receiving it from a third-party API). If a
future Obsidian update tightens the type to `Record<string, unknown>`,
our callsites will keep compiling. If it tightens to a stricter shape,
narrowing at the callsite is a small change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 07:03:06 +00:00
|
|
|
|
2025-07-29 23:27:28 +00:00
|
|
|
// Read the local file
|
|
|
|
|
const localPath = this.resolveLocalPath(imagePath);
|
|
|
|
|
const fileBuffer = readFileSync(localPath);
|
align(api): align frontmatter parsing with Obsidian recent API changes
Replace the in-tree hand-rolled YAML parser with Obsidian's first-party
frontmatter APIs (`app.metadataCache.getFileCache(file)?.frontmatter`
for reads, `app.fileManager.processFrontMatter(file, fn)` for writes).
Closes Phase 4.3-B of the Obsidian community-plugin publishing-prep plan
at context-v/plans/2026-05-03_Assuring-Obsidian-Community-Plugin-Requirements.md.
Why
---
The earlier publishing-prep pass typed away the parser's `any` to
satisfy ObsidianReviewBot, which kept lint green but left the underlying
parser intact. The hand-rolled parser had real edge-case correctness
bugs the type system could not catch: URL values containing colons
were mis-split, multi-line strings were truncated, YAML anchors and
list-of-maps were not understood, and the in-tree YAML emitter would
produce `[object Object]` output for plain-object values. Obsidian's
own implementation handles all of those correctly and is the path the
Obsidian community-plugin guidelines explicitly point at. Carrying our
own parser meant carrying the bug surface forever; deleting it
externalizes that work.
Files
-----
Deleted:
- src/utils/yamlFrontmatter.ts (176 lines: parser + emitter +
updateFileFrontmatter helper). No replacement file in our codebase
— the responsibility moves to Obsidian's APIs.
Modified:
- src/modals/CurrentFileModal.ts — three usages refactored
- src/modals/ConvertLocalImagesForCurrentFile.ts — three usages
refactored, including the largest restructure (see below)
- context-v/changelogs/2026-05-03_01.md — appended a §6 covering this
phase, updated the front-matter title and summary intro to claim
completion of every blocking publishing-prep phase, added the parser
to the deleted-files inventory at the bottom
Call-site categorization (5 sites total)
----------------------------------------
Pure reads → `metadataCache.getFileCache(file)?.frontmatter`:
- CurrentFileModal.loadExistingPrompt — was reading `image_prompt`
to seed the modal textarea. Bonus: removes a `vault.read` (the cache
is in-memory and synchronous) so the modal opens marginally faster
on files without an existing prompt.
- ConvertLocalImagesForCurrentFile.analyzeCurrentFile — discovers
image properties on file open.
- ConvertLocalImagesForCurrentFile.handleConvert — snapshots
frontmatter once at the top of the loop for tag extraction. Tags
are only used as upload metadata, not as a source of truth, so a
one-time snapshot is fine and avoids re-fetching from cache on
every iteration.
Single-key writes → `app.fileManager.processFrontMatter(file, fn)`:
- CurrentFileModal.updateFrontmatter — writes `image_prompt`.
- CurrentFileModal.updateImagePathInFrontmatter — writes
`<size>_image` keys with the URL of a generated image.
Both single-key writes now follow the uniform pattern:
await this.app.fileManager.processFrontMatter(file, (fm) => {
fm[key] = value;
});
processFrontMatter reads, mutates, and writes atomically via
Obsidian's own YAML emitter — no hand-rolled vault.read /
formatFrontmatter / vault.modify cycle, no race window between read
and re-write.
The harder case: handleConvert's mixed-mutation loop
----------------------------------------------------
The original handleConvert read the file once, parsed frontmatter into
an object, peeled off the body, then ran two long async loops:
1. For each frontmatter image: upload to ImageKit, then
`frontmatter[key] = uploadResult.url` in the in-memory object.
2. For each markdown body image: upload, then
`markdownContent = markdownContent.replace(...)` in the in-memory
string.
After both loops, it reassembled the file as
`---\n${formatFrontmatter(frontmatter)}---\n\n${markdownContent}` and
wrote the whole thing with vault.modify. The formatFrontmatter step
was where the hand-rolled YAML emitter was doing its damage.
Restructured so frontmatter and body live on independent code paths:
- Frontmatter mutations: each upload's frontmatter assignment now
calls processFrontMatter directly — atomic per upload, written by
Obsidian's emitter.
- Body mutations: accumulated into a `bodyMutations: Array<{from, to}>`
list during the markdown-image loop. After both loops complete, a
single vault.read(file) + N string replacements + one
vault.modify(file, updated) flushes them. The body re-read happens
*after* all processFrontMatter calls so it picks up the latest
version (including the frontmatter changes those calls just made);
no stale-content / lost-update race.
Also pulled `const file = this.currentFile;` to a local once at the
top of handleConvert so TypeScript's narrowing survives across
awaits — without the alias, the `if (!this.currentFile) return;`
guard is invalidated at every await boundary because modal lifecycle
could in principle null out currentFile.
Verification
------------
- pnpm build green: ESLint (the bot-mirror flat config from the
prior commit) + tsc strict + esbuild production.
- git grep -nE ": any\\b|as any\\b|<any>|any\\[\\]" -- "*.ts" empty.
- git grep -n "innerHTML" -- "*.ts" empty.
- main.js bundle 54 KB → 52 KB (the deleted parser was being bundled
in; net -2 KB shipped to users).
- All four version markers still in sync at 0.1.0
(manifest.json, package.json, versions.json key + value).
Risk worth knowing about
------------------------
processFrontMatter's callback signature is `(frontmatter: any) => void`
per Obsidian's type definitions. We mutate `fm[key] = value` directly
without a cast — that's the path of least friction, and the bot's
no-explicit-any rule does not fire because we are not *writing* `any`
in our source (we are receiving it from a third-party API). If a
future Obsidian update tightens the type to `Record<string, unknown>`,
our callsites will keep compiling. If it tightens to a stricter shape,
narrowing at the callsite is a small change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 07:03:06 +00:00
|
|
|
|
2025-07-29 23:27:28 +00:00
|
|
|
// Generate a unique filename
|
|
|
|
|
const fileName = this.generateFileName('content', localPath);
|
align(api): align frontmatter parsing with Obsidian recent API changes
Replace the in-tree hand-rolled YAML parser with Obsidian's first-party
frontmatter APIs (`app.metadataCache.getFileCache(file)?.frontmatter`
for reads, `app.fileManager.processFrontMatter(file, fn)` for writes).
Closes Phase 4.3-B of the Obsidian community-plugin publishing-prep plan
at context-v/plans/2026-05-03_Assuring-Obsidian-Community-Plugin-Requirements.md.
Why
---
The earlier publishing-prep pass typed away the parser's `any` to
satisfy ObsidianReviewBot, which kept lint green but left the underlying
parser intact. The hand-rolled parser had real edge-case correctness
bugs the type system could not catch: URL values containing colons
were mis-split, multi-line strings were truncated, YAML anchors and
list-of-maps were not understood, and the in-tree YAML emitter would
produce `[object Object]` output for plain-object values. Obsidian's
own implementation handles all of those correctly and is the path the
Obsidian community-plugin guidelines explicitly point at. Carrying our
own parser meant carrying the bug surface forever; deleting it
externalizes that work.
Files
-----
Deleted:
- src/utils/yamlFrontmatter.ts (176 lines: parser + emitter +
updateFileFrontmatter helper). No replacement file in our codebase
— the responsibility moves to Obsidian's APIs.
Modified:
- src/modals/CurrentFileModal.ts — three usages refactored
- src/modals/ConvertLocalImagesForCurrentFile.ts — three usages
refactored, including the largest restructure (see below)
- context-v/changelogs/2026-05-03_01.md — appended a §6 covering this
phase, updated the front-matter title and summary intro to claim
completion of every blocking publishing-prep phase, added the parser
to the deleted-files inventory at the bottom
Call-site categorization (5 sites total)
----------------------------------------
Pure reads → `metadataCache.getFileCache(file)?.frontmatter`:
- CurrentFileModal.loadExistingPrompt — was reading `image_prompt`
to seed the modal textarea. Bonus: removes a `vault.read` (the cache
is in-memory and synchronous) so the modal opens marginally faster
on files without an existing prompt.
- ConvertLocalImagesForCurrentFile.analyzeCurrentFile — discovers
image properties on file open.
- ConvertLocalImagesForCurrentFile.handleConvert — snapshots
frontmatter once at the top of the loop for tag extraction. Tags
are only used as upload metadata, not as a source of truth, so a
one-time snapshot is fine and avoids re-fetching from cache on
every iteration.
Single-key writes → `app.fileManager.processFrontMatter(file, fn)`:
- CurrentFileModal.updateFrontmatter — writes `image_prompt`.
- CurrentFileModal.updateImagePathInFrontmatter — writes
`<size>_image` keys with the URL of a generated image.
Both single-key writes now follow the uniform pattern:
await this.app.fileManager.processFrontMatter(file, (fm) => {
fm[key] = value;
});
processFrontMatter reads, mutates, and writes atomically via
Obsidian's own YAML emitter — no hand-rolled vault.read /
formatFrontmatter / vault.modify cycle, no race window between read
and re-write.
The harder case: handleConvert's mixed-mutation loop
----------------------------------------------------
The original handleConvert read the file once, parsed frontmatter into
an object, peeled off the body, then ran two long async loops:
1. For each frontmatter image: upload to ImageKit, then
`frontmatter[key] = uploadResult.url` in the in-memory object.
2. For each markdown body image: upload, then
`markdownContent = markdownContent.replace(...)` in the in-memory
string.
After both loops, it reassembled the file as
`---\n${formatFrontmatter(frontmatter)}---\n\n${markdownContent}` and
wrote the whole thing with vault.modify. The formatFrontmatter step
was where the hand-rolled YAML emitter was doing its damage.
Restructured so frontmatter and body live on independent code paths:
- Frontmatter mutations: each upload's frontmatter assignment now
calls processFrontMatter directly — atomic per upload, written by
Obsidian's emitter.
- Body mutations: accumulated into a `bodyMutations: Array<{from, to}>`
list during the markdown-image loop. After both loops complete, a
single vault.read(file) + N string replacements + one
vault.modify(file, updated) flushes them. The body re-read happens
*after* all processFrontMatter calls so it picks up the latest
version (including the frontmatter changes those calls just made);
no stale-content / lost-update race.
Also pulled `const file = this.currentFile;` to a local once at the
top of handleConvert so TypeScript's narrowing survives across
awaits — without the alias, the `if (!this.currentFile) return;`
guard is invalidated at every await boundary because modal lifecycle
could in principle null out currentFile.
Verification
------------
- pnpm build green: ESLint (the bot-mirror flat config from the
prior commit) + tsc strict + esbuild production.
- git grep -nE ": any\\b|as any\\b|<any>|any\\[\\]" -- "*.ts" empty.
- git grep -n "innerHTML" -- "*.ts" empty.
- main.js bundle 54 KB → 52 KB (the deleted parser was being bundled
in; net -2 KB shipped to users).
- All four version markers still in sync at 0.1.0
(manifest.json, package.json, versions.json key + value).
Risk worth knowing about
------------------------
processFrontMatter's callback signature is `(frontmatter: any) => void`
per Obsidian's type definitions. We mutate `fm[key] = value` directly
without a cast — that's the path of least friction, and the bot's
no-explicit-any rule does not fire because we are not *writing* `any`
in our source (we are receiving it from a third-party API). If a
future Obsidian update tightens the type to `Record<string, unknown>`,
our callsites will keep compiling. If it tightens to a stricter shape,
narrowing at the callsite is a small change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 07:03:06 +00:00
|
|
|
|
2025-07-29 23:27:28 +00:00
|
|
|
// Upload to ImageKit
|
|
|
|
|
const uploadResult = await imagekitService.uploadFile(
|
|
|
|
|
fileBuffer.buffer,
|
|
|
|
|
fileName,
|
|
|
|
|
undefined, // Use default folder from settings
|
align(api): align frontmatter parsing with Obsidian recent API changes
Replace the in-tree hand-rolled YAML parser with Obsidian's first-party
frontmatter APIs (`app.metadataCache.getFileCache(file)?.frontmatter`
for reads, `app.fileManager.processFrontMatter(file, fn)` for writes).
Closes Phase 4.3-B of the Obsidian community-plugin publishing-prep plan
at context-v/plans/2026-05-03_Assuring-Obsidian-Community-Plugin-Requirements.md.
Why
---
The earlier publishing-prep pass typed away the parser's `any` to
satisfy ObsidianReviewBot, which kept lint green but left the underlying
parser intact. The hand-rolled parser had real edge-case correctness
bugs the type system could not catch: URL values containing colons
were mis-split, multi-line strings were truncated, YAML anchors and
list-of-maps were not understood, and the in-tree YAML emitter would
produce `[object Object]` output for plain-object values. Obsidian's
own implementation handles all of those correctly and is the path the
Obsidian community-plugin guidelines explicitly point at. Carrying our
own parser meant carrying the bug surface forever; deleting it
externalizes that work.
Files
-----
Deleted:
- src/utils/yamlFrontmatter.ts (176 lines: parser + emitter +
updateFileFrontmatter helper). No replacement file in our codebase
— the responsibility moves to Obsidian's APIs.
Modified:
- src/modals/CurrentFileModal.ts — three usages refactored
- src/modals/ConvertLocalImagesForCurrentFile.ts — three usages
refactored, including the largest restructure (see below)
- context-v/changelogs/2026-05-03_01.md — appended a §6 covering this
phase, updated the front-matter title and summary intro to claim
completion of every blocking publishing-prep phase, added the parser
to the deleted-files inventory at the bottom
Call-site categorization (5 sites total)
----------------------------------------
Pure reads → `metadataCache.getFileCache(file)?.frontmatter`:
- CurrentFileModal.loadExistingPrompt — was reading `image_prompt`
to seed the modal textarea. Bonus: removes a `vault.read` (the cache
is in-memory and synchronous) so the modal opens marginally faster
on files without an existing prompt.
- ConvertLocalImagesForCurrentFile.analyzeCurrentFile — discovers
image properties on file open.
- ConvertLocalImagesForCurrentFile.handleConvert — snapshots
frontmatter once at the top of the loop for tag extraction. Tags
are only used as upload metadata, not as a source of truth, so a
one-time snapshot is fine and avoids re-fetching from cache on
every iteration.
Single-key writes → `app.fileManager.processFrontMatter(file, fn)`:
- CurrentFileModal.updateFrontmatter — writes `image_prompt`.
- CurrentFileModal.updateImagePathInFrontmatter — writes
`<size>_image` keys with the URL of a generated image.
Both single-key writes now follow the uniform pattern:
await this.app.fileManager.processFrontMatter(file, (fm) => {
fm[key] = value;
});
processFrontMatter reads, mutates, and writes atomically via
Obsidian's own YAML emitter — no hand-rolled vault.read /
formatFrontmatter / vault.modify cycle, no race window between read
and re-write.
The harder case: handleConvert's mixed-mutation loop
----------------------------------------------------
The original handleConvert read the file once, parsed frontmatter into
an object, peeled off the body, then ran two long async loops:
1. For each frontmatter image: upload to ImageKit, then
`frontmatter[key] = uploadResult.url` in the in-memory object.
2. For each markdown body image: upload, then
`markdownContent = markdownContent.replace(...)` in the in-memory
string.
After both loops, it reassembled the file as
`---\n${formatFrontmatter(frontmatter)}---\n\n${markdownContent}` and
wrote the whole thing with vault.modify. The formatFrontmatter step
was where the hand-rolled YAML emitter was doing its damage.
Restructured so frontmatter and body live on independent code paths:
- Frontmatter mutations: each upload's frontmatter assignment now
calls processFrontMatter directly — atomic per upload, written by
Obsidian's emitter.
- Body mutations: accumulated into a `bodyMutations: Array<{from, to}>`
list during the markdown-image loop. After both loops complete, a
single vault.read(file) + N string replacements + one
vault.modify(file, updated) flushes them. The body re-read happens
*after* all processFrontMatter calls so it picks up the latest
version (including the frontmatter changes those calls just made);
no stale-content / lost-update race.
Also pulled `const file = this.currentFile;` to a local once at the
top of handleConvert so TypeScript's narrowing survives across
awaits — without the alias, the `if (!this.currentFile) return;`
guard is invalidated at every await boundary because modal lifecycle
could in principle null out currentFile.
Verification
------------
- pnpm build green: ESLint (the bot-mirror flat config from the
prior commit) + tsc strict + esbuild production.
- git grep -nE ": any\\b|as any\\b|<any>|any\\[\\]" -- "*.ts" empty.
- git grep -n "innerHTML" -- "*.ts" empty.
- main.js bundle 54 KB → 52 KB (the deleted parser was being bundled
in; net -2 KB shipped to users).
- All four version markers still in sync at 0.1.0
(manifest.json, package.json, versions.json key + value).
Risk worth knowing about
------------------------
processFrontMatter's callback signature is `(frontmatter: any) => void`
per Obsidian's type definitions. We mutate `fm[key] = value` directly
without a cast — that's the path of least friction, and the bot's
no-explicit-any rule does not fire because we are not *writing* `any`
in our source (we are receiving it from a third-party API). If a
future Obsidian update tightens the type to `Record<string, unknown>`,
our callsites will keep compiling. If it tightens to a stricter shape,
narrowing at the callsite is a small change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 07:03:06 +00:00
|
|
|
[file.basename, 'markdown'] // Basic tags
|
2025-07-29 23:27:28 +00:00
|
|
|
);
|
align(api): align frontmatter parsing with Obsidian recent API changes
Replace the in-tree hand-rolled YAML parser with Obsidian's first-party
frontmatter APIs (`app.metadataCache.getFileCache(file)?.frontmatter`
for reads, `app.fileManager.processFrontMatter(file, fn)` for writes).
Closes Phase 4.3-B of the Obsidian community-plugin publishing-prep plan
at context-v/plans/2026-05-03_Assuring-Obsidian-Community-Plugin-Requirements.md.
Why
---
The earlier publishing-prep pass typed away the parser's `any` to
satisfy ObsidianReviewBot, which kept lint green but left the underlying
parser intact. The hand-rolled parser had real edge-case correctness
bugs the type system could not catch: URL values containing colons
were mis-split, multi-line strings were truncated, YAML anchors and
list-of-maps were not understood, and the in-tree YAML emitter would
produce `[object Object]` output for plain-object values. Obsidian's
own implementation handles all of those correctly and is the path the
Obsidian community-plugin guidelines explicitly point at. Carrying our
own parser meant carrying the bug surface forever; deleting it
externalizes that work.
Files
-----
Deleted:
- src/utils/yamlFrontmatter.ts (176 lines: parser + emitter +
updateFileFrontmatter helper). No replacement file in our codebase
— the responsibility moves to Obsidian's APIs.
Modified:
- src/modals/CurrentFileModal.ts — three usages refactored
- src/modals/ConvertLocalImagesForCurrentFile.ts — three usages
refactored, including the largest restructure (see below)
- context-v/changelogs/2026-05-03_01.md — appended a §6 covering this
phase, updated the front-matter title and summary intro to claim
completion of every blocking publishing-prep phase, added the parser
to the deleted-files inventory at the bottom
Call-site categorization (5 sites total)
----------------------------------------
Pure reads → `metadataCache.getFileCache(file)?.frontmatter`:
- CurrentFileModal.loadExistingPrompt — was reading `image_prompt`
to seed the modal textarea. Bonus: removes a `vault.read` (the cache
is in-memory and synchronous) so the modal opens marginally faster
on files without an existing prompt.
- ConvertLocalImagesForCurrentFile.analyzeCurrentFile — discovers
image properties on file open.
- ConvertLocalImagesForCurrentFile.handleConvert — snapshots
frontmatter once at the top of the loop for tag extraction. Tags
are only used as upload metadata, not as a source of truth, so a
one-time snapshot is fine and avoids re-fetching from cache on
every iteration.
Single-key writes → `app.fileManager.processFrontMatter(file, fn)`:
- CurrentFileModal.updateFrontmatter — writes `image_prompt`.
- CurrentFileModal.updateImagePathInFrontmatter — writes
`<size>_image` keys with the URL of a generated image.
Both single-key writes now follow the uniform pattern:
await this.app.fileManager.processFrontMatter(file, (fm) => {
fm[key] = value;
});
processFrontMatter reads, mutates, and writes atomically via
Obsidian's own YAML emitter — no hand-rolled vault.read /
formatFrontmatter / vault.modify cycle, no race window between read
and re-write.
The harder case: handleConvert's mixed-mutation loop
----------------------------------------------------
The original handleConvert read the file once, parsed frontmatter into
an object, peeled off the body, then ran two long async loops:
1. For each frontmatter image: upload to ImageKit, then
`frontmatter[key] = uploadResult.url` in the in-memory object.
2. For each markdown body image: upload, then
`markdownContent = markdownContent.replace(...)` in the in-memory
string.
After both loops, it reassembled the file as
`---\n${formatFrontmatter(frontmatter)}---\n\n${markdownContent}` and
wrote the whole thing with vault.modify. The formatFrontmatter step
was where the hand-rolled YAML emitter was doing its damage.
Restructured so frontmatter and body live on independent code paths:
- Frontmatter mutations: each upload's frontmatter assignment now
calls processFrontMatter directly — atomic per upload, written by
Obsidian's emitter.
- Body mutations: accumulated into a `bodyMutations: Array<{from, to}>`
list during the markdown-image loop. After both loops complete, a
single vault.read(file) + N string replacements + one
vault.modify(file, updated) flushes them. The body re-read happens
*after* all processFrontMatter calls so it picks up the latest
version (including the frontmatter changes those calls just made);
no stale-content / lost-update race.
Also pulled `const file = this.currentFile;` to a local once at the
top of handleConvert so TypeScript's narrowing survives across
awaits — without the alias, the `if (!this.currentFile) return;`
guard is invalidated at every await boundary because modal lifecycle
could in principle null out currentFile.
Verification
------------
- pnpm build green: ESLint (the bot-mirror flat config from the
prior commit) + tsc strict + esbuild production.
- git grep -nE ": any\\b|as any\\b|<any>|any\\[\\]" -- "*.ts" empty.
- git grep -n "innerHTML" -- "*.ts" empty.
- main.js bundle 54 KB → 52 KB (the deleted parser was being bundled
in; net -2 KB shipped to users).
- All four version markers still in sync at 0.1.0
(manifest.json, package.json, versions.json key + value).
Risk worth knowing about
------------------------
processFrontMatter's callback signature is `(frontmatter: any) => void`
per Obsidian's type definitions. We mutate `fm[key] = value` directly
without a cast — that's the path of least friction, and the bot's
no-explicit-any rule does not fire because we are not *writing* `any`
in our source (we are receiving it from a third-party API). If a
future Obsidian update tightens the type to `Record<string, unknown>`,
our callsites will keep compiling. If it tightens to a stricter shape,
narrowing at the callsite is a small change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 07:03:06 +00:00
|
|
|
|
|
|
|
|
// Queue the body replacement for the post-loop flush.
|
|
|
|
|
bodyMutations.push({
|
|
|
|
|
from: imageInfo.match,
|
|
|
|
|
to: ``,
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-03 06:35:13 +00:00
|
|
|
logger.info(`Successfully converted markdown image: ${uploadResult.url}`);
|
2025-07-29 23:27:28 +00:00
|
|
|
successCount++;
|
align(api): align frontmatter parsing with Obsidian recent API changes
Replace the in-tree hand-rolled YAML parser with Obsidian's first-party
frontmatter APIs (`app.metadataCache.getFileCache(file)?.frontmatter`
for reads, `app.fileManager.processFrontMatter(file, fn)` for writes).
Closes Phase 4.3-B of the Obsidian community-plugin publishing-prep plan
at context-v/plans/2026-05-03_Assuring-Obsidian-Community-Plugin-Requirements.md.
Why
---
The earlier publishing-prep pass typed away the parser's `any` to
satisfy ObsidianReviewBot, which kept lint green but left the underlying
parser intact. The hand-rolled parser had real edge-case correctness
bugs the type system could not catch: URL values containing colons
were mis-split, multi-line strings were truncated, YAML anchors and
list-of-maps were not understood, and the in-tree YAML emitter would
produce `[object Object]` output for plain-object values. Obsidian's
own implementation handles all of those correctly and is the path the
Obsidian community-plugin guidelines explicitly point at. Carrying our
own parser meant carrying the bug surface forever; deleting it
externalizes that work.
Files
-----
Deleted:
- src/utils/yamlFrontmatter.ts (176 lines: parser + emitter +
updateFileFrontmatter helper). No replacement file in our codebase
— the responsibility moves to Obsidian's APIs.
Modified:
- src/modals/CurrentFileModal.ts — three usages refactored
- src/modals/ConvertLocalImagesForCurrentFile.ts — three usages
refactored, including the largest restructure (see below)
- context-v/changelogs/2026-05-03_01.md — appended a §6 covering this
phase, updated the front-matter title and summary intro to claim
completion of every blocking publishing-prep phase, added the parser
to the deleted-files inventory at the bottom
Call-site categorization (5 sites total)
----------------------------------------
Pure reads → `metadataCache.getFileCache(file)?.frontmatter`:
- CurrentFileModal.loadExistingPrompt — was reading `image_prompt`
to seed the modal textarea. Bonus: removes a `vault.read` (the cache
is in-memory and synchronous) so the modal opens marginally faster
on files without an existing prompt.
- ConvertLocalImagesForCurrentFile.analyzeCurrentFile — discovers
image properties on file open.
- ConvertLocalImagesForCurrentFile.handleConvert — snapshots
frontmatter once at the top of the loop for tag extraction. Tags
are only used as upload metadata, not as a source of truth, so a
one-time snapshot is fine and avoids re-fetching from cache on
every iteration.
Single-key writes → `app.fileManager.processFrontMatter(file, fn)`:
- CurrentFileModal.updateFrontmatter — writes `image_prompt`.
- CurrentFileModal.updateImagePathInFrontmatter — writes
`<size>_image` keys with the URL of a generated image.
Both single-key writes now follow the uniform pattern:
await this.app.fileManager.processFrontMatter(file, (fm) => {
fm[key] = value;
});
processFrontMatter reads, mutates, and writes atomically via
Obsidian's own YAML emitter — no hand-rolled vault.read /
formatFrontmatter / vault.modify cycle, no race window between read
and re-write.
The harder case: handleConvert's mixed-mutation loop
----------------------------------------------------
The original handleConvert read the file once, parsed frontmatter into
an object, peeled off the body, then ran two long async loops:
1. For each frontmatter image: upload to ImageKit, then
`frontmatter[key] = uploadResult.url` in the in-memory object.
2. For each markdown body image: upload, then
`markdownContent = markdownContent.replace(...)` in the in-memory
string.
After both loops, it reassembled the file as
`---\n${formatFrontmatter(frontmatter)}---\n\n${markdownContent}` and
wrote the whole thing with vault.modify. The formatFrontmatter step
was where the hand-rolled YAML emitter was doing its damage.
Restructured so frontmatter and body live on independent code paths:
- Frontmatter mutations: each upload's frontmatter assignment now
calls processFrontMatter directly — atomic per upload, written by
Obsidian's emitter.
- Body mutations: accumulated into a `bodyMutations: Array<{from, to}>`
list during the markdown-image loop. After both loops complete, a
single vault.read(file) + N string replacements + one
vault.modify(file, updated) flushes them. The body re-read happens
*after* all processFrontMatter calls so it picks up the latest
version (including the frontmatter changes those calls just made);
no stale-content / lost-update race.
Also pulled `const file = this.currentFile;` to a local once at the
top of handleConvert so TypeScript's narrowing survives across
awaits — without the alias, the `if (!this.currentFile) return;`
guard is invalidated at every await boundary because modal lifecycle
could in principle null out currentFile.
Verification
------------
- pnpm build green: ESLint (the bot-mirror flat config from the
prior commit) + tsc strict + esbuild production.
- git grep -nE ": any\\b|as any\\b|<any>|any\\[\\]" -- "*.ts" empty.
- git grep -n "innerHTML" -- "*.ts" empty.
- main.js bundle 54 KB → 52 KB (the deleted parser was being bundled
in; net -2 KB shipped to users).
- All four version markers still in sync at 0.1.0
(manifest.json, package.json, versions.json key + value).
Risk worth knowing about
------------------------
processFrontMatter's callback signature is `(frontmatter: any) => void`
per Obsidian's type definitions. We mutate `fm[key] = value` directly
without a cast — that's the path of least friction, and the bot's
no-explicit-any rule does not fire because we are not *writing* `any`
in our source (we are receiving it from a third-party API). If a
future Obsidian update tightens the type to `Record<string, unknown>`,
our callsites will keep compiling. If it tightens to a stricter shape,
narrowing at the callsite is a small change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 07:03:06 +00:00
|
|
|
|
2025-07-29 23:27:28 +00:00
|
|
|
// Optionally remove local file if setting is enabled
|
|
|
|
|
if (this.plugin.settings.imageKit.removeLocalFiles) {
|
|
|
|
|
try {
|
|
|
|
|
const fs = require('fs');
|
|
|
|
|
fs.unlinkSync(localPath);
|
2026-05-03 06:35:13 +00:00
|
|
|
logger.info(`Removed local file: ${localPath}`);
|
2025-07-29 23:27:28 +00:00
|
|
|
} catch (removeError) {
|
2026-05-03 06:35:13 +00:00
|
|
|
logger.warn(`Failed to remove local file ${localPath}:`, removeError);
|
2025-07-29 23:27:28 +00:00
|
|
|
}
|
|
|
|
|
}
|
align(api): align frontmatter parsing with Obsidian recent API changes
Replace the in-tree hand-rolled YAML parser with Obsidian's first-party
frontmatter APIs (`app.metadataCache.getFileCache(file)?.frontmatter`
for reads, `app.fileManager.processFrontMatter(file, fn)` for writes).
Closes Phase 4.3-B of the Obsidian community-plugin publishing-prep plan
at context-v/plans/2026-05-03_Assuring-Obsidian-Community-Plugin-Requirements.md.
Why
---
The earlier publishing-prep pass typed away the parser's `any` to
satisfy ObsidianReviewBot, which kept lint green but left the underlying
parser intact. The hand-rolled parser had real edge-case correctness
bugs the type system could not catch: URL values containing colons
were mis-split, multi-line strings were truncated, YAML anchors and
list-of-maps were not understood, and the in-tree YAML emitter would
produce `[object Object]` output for plain-object values. Obsidian's
own implementation handles all of those correctly and is the path the
Obsidian community-plugin guidelines explicitly point at. Carrying our
own parser meant carrying the bug surface forever; deleting it
externalizes that work.
Files
-----
Deleted:
- src/utils/yamlFrontmatter.ts (176 lines: parser + emitter +
updateFileFrontmatter helper). No replacement file in our codebase
— the responsibility moves to Obsidian's APIs.
Modified:
- src/modals/CurrentFileModal.ts — three usages refactored
- src/modals/ConvertLocalImagesForCurrentFile.ts — three usages
refactored, including the largest restructure (see below)
- context-v/changelogs/2026-05-03_01.md — appended a §6 covering this
phase, updated the front-matter title and summary intro to claim
completion of every blocking publishing-prep phase, added the parser
to the deleted-files inventory at the bottom
Call-site categorization (5 sites total)
----------------------------------------
Pure reads → `metadataCache.getFileCache(file)?.frontmatter`:
- CurrentFileModal.loadExistingPrompt — was reading `image_prompt`
to seed the modal textarea. Bonus: removes a `vault.read` (the cache
is in-memory and synchronous) so the modal opens marginally faster
on files without an existing prompt.
- ConvertLocalImagesForCurrentFile.analyzeCurrentFile — discovers
image properties on file open.
- ConvertLocalImagesForCurrentFile.handleConvert — snapshots
frontmatter once at the top of the loop for tag extraction. Tags
are only used as upload metadata, not as a source of truth, so a
one-time snapshot is fine and avoids re-fetching from cache on
every iteration.
Single-key writes → `app.fileManager.processFrontMatter(file, fn)`:
- CurrentFileModal.updateFrontmatter — writes `image_prompt`.
- CurrentFileModal.updateImagePathInFrontmatter — writes
`<size>_image` keys with the URL of a generated image.
Both single-key writes now follow the uniform pattern:
await this.app.fileManager.processFrontMatter(file, (fm) => {
fm[key] = value;
});
processFrontMatter reads, mutates, and writes atomically via
Obsidian's own YAML emitter — no hand-rolled vault.read /
formatFrontmatter / vault.modify cycle, no race window between read
and re-write.
The harder case: handleConvert's mixed-mutation loop
----------------------------------------------------
The original handleConvert read the file once, parsed frontmatter into
an object, peeled off the body, then ran two long async loops:
1. For each frontmatter image: upload to ImageKit, then
`frontmatter[key] = uploadResult.url` in the in-memory object.
2. For each markdown body image: upload, then
`markdownContent = markdownContent.replace(...)` in the in-memory
string.
After both loops, it reassembled the file as
`---\n${formatFrontmatter(frontmatter)}---\n\n${markdownContent}` and
wrote the whole thing with vault.modify. The formatFrontmatter step
was where the hand-rolled YAML emitter was doing its damage.
Restructured so frontmatter and body live on independent code paths:
- Frontmatter mutations: each upload's frontmatter assignment now
calls processFrontMatter directly — atomic per upload, written by
Obsidian's emitter.
- Body mutations: accumulated into a `bodyMutations: Array<{from, to}>`
list during the markdown-image loop. After both loops complete, a
single vault.read(file) + N string replacements + one
vault.modify(file, updated) flushes them. The body re-read happens
*after* all processFrontMatter calls so it picks up the latest
version (including the frontmatter changes those calls just made);
no stale-content / lost-update race.
Also pulled `const file = this.currentFile;` to a local once at the
top of handleConvert so TypeScript's narrowing survives across
awaits — without the alias, the `if (!this.currentFile) return;`
guard is invalidated at every await boundary because modal lifecycle
could in principle null out currentFile.
Verification
------------
- pnpm build green: ESLint (the bot-mirror flat config from the
prior commit) + tsc strict + esbuild production.
- git grep -nE ": any\\b|as any\\b|<any>|any\\[\\]" -- "*.ts" empty.
- git grep -n "innerHTML" -- "*.ts" empty.
- main.js bundle 54 KB → 52 KB (the deleted parser was being bundled
in; net -2 KB shipped to users).
- All four version markers still in sync at 0.1.0
(manifest.json, package.json, versions.json key + value).
Risk worth knowing about
------------------------
processFrontMatter's callback signature is `(frontmatter: any) => void`
per Obsidian's type definitions. We mutate `fm[key] = value` directly
without a cast — that's the path of least friction, and the bot's
no-explicit-any rule does not fire because we are not *writing* `any`
in our source (we are receiving it from a third-party API). If a
future Obsidian update tightens the type to `Record<string, unknown>`,
our callsites will keep compiling. If it tightens to a stricter shape,
narrowing at the callsite is a small change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 07:03:06 +00:00
|
|
|
|
2025-07-29 23:27:28 +00:00
|
|
|
} catch (error) {
|
2026-05-03 06:35:13 +00:00
|
|
|
logger.error(`Error converting markdown image ${imagePath}:`, error);
|
2025-07-29 23:27:28 +00:00
|
|
|
errorCount++;
|
|
|
|
|
new Notice(`Failed to convert image ${imagePath}: ${this.getErrorMessage(error)}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
align(api): align frontmatter parsing with Obsidian recent API changes
Replace the in-tree hand-rolled YAML parser with Obsidian's first-party
frontmatter APIs (`app.metadataCache.getFileCache(file)?.frontmatter`
for reads, `app.fileManager.processFrontMatter(file, fn)` for writes).
Closes Phase 4.3-B of the Obsidian community-plugin publishing-prep plan
at context-v/plans/2026-05-03_Assuring-Obsidian-Community-Plugin-Requirements.md.
Why
---
The earlier publishing-prep pass typed away the parser's `any` to
satisfy ObsidianReviewBot, which kept lint green but left the underlying
parser intact. The hand-rolled parser had real edge-case correctness
bugs the type system could not catch: URL values containing colons
were mis-split, multi-line strings were truncated, YAML anchors and
list-of-maps were not understood, and the in-tree YAML emitter would
produce `[object Object]` output for plain-object values. Obsidian's
own implementation handles all of those correctly and is the path the
Obsidian community-plugin guidelines explicitly point at. Carrying our
own parser meant carrying the bug surface forever; deleting it
externalizes that work.
Files
-----
Deleted:
- src/utils/yamlFrontmatter.ts (176 lines: parser + emitter +
updateFileFrontmatter helper). No replacement file in our codebase
— the responsibility moves to Obsidian's APIs.
Modified:
- src/modals/CurrentFileModal.ts — three usages refactored
- src/modals/ConvertLocalImagesForCurrentFile.ts — three usages
refactored, including the largest restructure (see below)
- context-v/changelogs/2026-05-03_01.md — appended a §6 covering this
phase, updated the front-matter title and summary intro to claim
completion of every blocking publishing-prep phase, added the parser
to the deleted-files inventory at the bottom
Call-site categorization (5 sites total)
----------------------------------------
Pure reads → `metadataCache.getFileCache(file)?.frontmatter`:
- CurrentFileModal.loadExistingPrompt — was reading `image_prompt`
to seed the modal textarea. Bonus: removes a `vault.read` (the cache
is in-memory and synchronous) so the modal opens marginally faster
on files without an existing prompt.
- ConvertLocalImagesForCurrentFile.analyzeCurrentFile — discovers
image properties on file open.
- ConvertLocalImagesForCurrentFile.handleConvert — snapshots
frontmatter once at the top of the loop for tag extraction. Tags
are only used as upload metadata, not as a source of truth, so a
one-time snapshot is fine and avoids re-fetching from cache on
every iteration.
Single-key writes → `app.fileManager.processFrontMatter(file, fn)`:
- CurrentFileModal.updateFrontmatter — writes `image_prompt`.
- CurrentFileModal.updateImagePathInFrontmatter — writes
`<size>_image` keys with the URL of a generated image.
Both single-key writes now follow the uniform pattern:
await this.app.fileManager.processFrontMatter(file, (fm) => {
fm[key] = value;
});
processFrontMatter reads, mutates, and writes atomically via
Obsidian's own YAML emitter — no hand-rolled vault.read /
formatFrontmatter / vault.modify cycle, no race window between read
and re-write.
The harder case: handleConvert's mixed-mutation loop
----------------------------------------------------
The original handleConvert read the file once, parsed frontmatter into
an object, peeled off the body, then ran two long async loops:
1. For each frontmatter image: upload to ImageKit, then
`frontmatter[key] = uploadResult.url` in the in-memory object.
2. For each markdown body image: upload, then
`markdownContent = markdownContent.replace(...)` in the in-memory
string.
After both loops, it reassembled the file as
`---\n${formatFrontmatter(frontmatter)}---\n\n${markdownContent}` and
wrote the whole thing with vault.modify. The formatFrontmatter step
was where the hand-rolled YAML emitter was doing its damage.
Restructured so frontmatter and body live on independent code paths:
- Frontmatter mutations: each upload's frontmatter assignment now
calls processFrontMatter directly — atomic per upload, written by
Obsidian's emitter.
- Body mutations: accumulated into a `bodyMutations: Array<{from, to}>`
list during the markdown-image loop. After both loops complete, a
single vault.read(file) + N string replacements + one
vault.modify(file, updated) flushes them. The body re-read happens
*after* all processFrontMatter calls so it picks up the latest
version (including the frontmatter changes those calls just made);
no stale-content / lost-update race.
Also pulled `const file = this.currentFile;` to a local once at the
top of handleConvert so TypeScript's narrowing survives across
awaits — without the alias, the `if (!this.currentFile) return;`
guard is invalidated at every await boundary because modal lifecycle
could in principle null out currentFile.
Verification
------------
- pnpm build green: ESLint (the bot-mirror flat config from the
prior commit) + tsc strict + esbuild production.
- git grep -nE ": any\\b|as any\\b|<any>|any\\[\\]" -- "*.ts" empty.
- git grep -n "innerHTML" -- "*.ts" empty.
- main.js bundle 54 KB → 52 KB (the deleted parser was being bundled
in; net -2 KB shipped to users).
- All four version markers still in sync at 0.1.0
(manifest.json, package.json, versions.json key + value).
Risk worth knowing about
------------------------
processFrontMatter's callback signature is `(frontmatter: any) => void`
per Obsidian's type definitions. We mutate `fm[key] = value` directly
without a cast — that's the path of least friction, and the bot's
no-explicit-any rule does not fire because we are not *writing* `any`
in our source (we are receiving it from a third-party API). If a
future Obsidian update tightens the type to `Record<string, unknown>`,
our callsites will keep compiling. If it tightens to a stricter shape,
narrowing at the callsite is a small change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 07:03:06 +00:00
|
|
|
// Flush body mutations in a single read/modify cycle. Frontmatter
|
|
|
|
|
// is already updated above via processFrontMatter, so we re-read
|
|
|
|
|
// here to pick up the latest version (including those updates).
|
|
|
|
|
if (bodyMutations.length > 0) {
|
|
|
|
|
const currentContent = await this.app.vault.read(file);
|
|
|
|
|
let updated = currentContent;
|
|
|
|
|
for (const { from, to } of bodyMutations) {
|
|
|
|
|
updated = updated.replace(new RegExp(this.escapeRegExp(from), 'g'), to);
|
|
|
|
|
}
|
|
|
|
|
if (updated !== currentContent) {
|
|
|
|
|
await this.app.vault.modify(file, updated);
|
2025-07-29 23:27:28 +00:00
|
|
|
}
|
2025-07-20 22:39:47 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Show results
|
|
|
|
|
const message = `Conversion complete: ${successCount} successful, ${errorCount} failed`;
|
|
|
|
|
new Notice(message);
|
|
|
|
|
|
|
|
|
|
if (successCount > 0) {
|
|
|
|
|
this.close();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
} catch (error) {
|
2026-05-03 06:35:13 +00:00
|
|
|
logger.error('Error in conversion process:', error);
|
2025-07-20 22:39:47 +00:00
|
|
|
new Notice(`Conversion failed: ${this.getErrorMessage(error)}`);
|
|
|
|
|
} finally {
|
|
|
|
|
this.isConverting = false;
|
|
|
|
|
this.hideProgress();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-07-29 23:27:28 +00:00
|
|
|
private escapeRegExp(string: string): string {
|
|
|
|
|
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
|
|
|
|
|
}
|
|
|
|
|
|
2025-07-20 22:39:47 +00:00
|
|
|
private resolveLocalPath(imagePath: string): string {
|
2025-07-29 23:27:28 +00:00
|
|
|
// Clean up the path (remove any markdown link syntax)
|
|
|
|
|
let cleanPath = imagePath.trim();
|
|
|
|
|
|
2025-07-20 22:39:47 +00:00
|
|
|
// If it's already an absolute path, use it
|
2025-07-29 23:27:28 +00:00
|
|
|
if (cleanPath.startsWith('/')) {
|
|
|
|
|
return cleanPath;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-03 06:35:13 +00:00
|
|
|
// Get the vault's filesystem base path. On desktop the adapter is
|
|
|
|
|
// FileSystemAdapter (with getBasePath); on mobile it isn't, and we
|
|
|
|
|
// simply have no filesystem path to anchor against.
|
|
|
|
|
const adapter = this.app.vault.adapter;
|
|
|
|
|
const basePath = adapter instanceof FileSystemAdapter ? adapter.getBasePath() : '';
|
|
|
|
|
|
2025-07-29 23:27:28 +00:00
|
|
|
// Handle Obsidian-style paths (relative to vault)
|
|
|
|
|
if (cleanPath.startsWith('./') || cleanPath.startsWith('../')) {
|
|
|
|
|
// Resolve relative to current file
|
|
|
|
|
const currentDir = this.currentFile?.parent?.path || '';
|
2026-05-03 06:35:13 +00:00
|
|
|
return join(basePath, currentDir, cleanPath);
|
2025-07-20 22:39:47 +00:00
|
|
|
}
|
|
|
|
|
|
2025-07-29 23:27:28 +00:00
|
|
|
// Default: resolve relative to vault root
|
2026-05-03 06:35:13 +00:00
|
|
|
return join(basePath, cleanPath);
|
2025-07-20 22:39:47 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private generateFileName(propertyKey: string, localPath: string): string {
|
|
|
|
|
const timestamp = Date.now();
|
|
|
|
|
const extension = localPath.split('.').pop() || 'png';
|
|
|
|
|
const baseName = this.currentFile?.basename || 'image';
|
|
|
|
|
|
|
|
|
|
return `${baseName}_${propertyKey}_${timestamp}.${extension}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private showProgress(): void {
|
|
|
|
|
if (this.progressEl) {
|
|
|
|
|
this.progressEl.style.display = 'block';
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private hideProgress(): void {
|
|
|
|
|
if (this.progressEl) {
|
|
|
|
|
this.progressEl.style.display = 'none';
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private updateProgress(message: string): void {
|
|
|
|
|
if (this.progressEl) {
|
|
|
|
|
const textEl = this.progressEl.querySelector('.image-gin-progress-text');
|
|
|
|
|
if (textEl) {
|
|
|
|
|
textEl.textContent = message;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private getErrorMessage(error: unknown): string {
|
|
|
|
|
if (error instanceof Error) {
|
|
|
|
|
return error.message;
|
|
|
|
|
}
|
|
|
|
|
if (typeof error === 'string') {
|
|
|
|
|
return error;
|
|
|
|
|
}
|
|
|
|
|
return 'An unknown error occurred';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
onClose(): void {
|
|
|
|
|
const { contentEl } = this;
|
|
|
|
|
contentEl.empty();
|
|
|
|
|
}
|
2026-05-03 06:35:13 +00:00
|
|
|
}
|