const { Notice, Plugin, PluginSettingTab, Setting, TFile, } = require("obsidian"); const DEFAULT_SETTINGS = { codeBlockLanguage: "text", includeMetadataKey: true, skipExistingPromptBlocks: true, }; const IMAGE_EMBED_RE = /!\[[^\]]*]\(([^)]+)\)|!\[\[([^|\]\n]+)(?:\|[^\]\n]*)?]]/g; const PROMPT_BLOCK_MARKER = ""; module.exports = class ExifPromptPlugin extends Plugin { async onload() { this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); this.addRibbonIcon("image-plus", "Extract image prompt", async () => { await this.extractForActiveImage(); }); this.addCommand({ id: "extract-prompt-for-image-at-cursor", name: "Extract prompt for image at cursor", icon: "image-plus", hotkeys: [{ modifiers: ["Mod", "Alt"], key: "p" }], editorCallback: async (editor, view) => { await this.extractForImageAtCursor(editor, view); }, }); this.addCommand({ id: "extract-prompts-for-all-images-in-note", name: "Extract prompts for all images in note", icon: "images", editorCallback: async (editor, view) => { await this.extractAllInEditor(editor, view); }, }); this.addSettingTab(new ExifPromptSettingTab(this.app, this)); } async saveSettings() { await this.saveData(this.settings); } async extractForActiveImage() { const view = this.app.workspace.getActiveViewOfType(require("obsidian").MarkdownView); if (!view) { new Notice("Open a Markdown note first."); return; } await this.extractForImageAtCursor(view.editor, view); } async extractForImageAtCursor(editor, view) { const lineNumber = editor.getCursor().line; const match = findNearestImageEmbed(editor, lineNumber); if (!match) { new Notice("No image embed found near the cursor."); return; } const result = await this.extractPromptFromLink(match.link, view.file); if (!result.prompt) { new Notice(result.message || "No prompt metadata found."); return; } if (this.settings.skipExistingPromptBlocks && hasPromptBlockAfter(editor, match.line)) { new Notice("Prompt block already exists below this image."); return; } const block = this.formatPromptBlock(result.prompt, result.label); editor.replaceRange("\n" + block, { line: match.line + 1, ch: 0 }); new Notice("Prompt inserted below the image."); } async extractAllInEditor(editor, view) { const embeds = findAllImageEmbeds(editor.getValue()); if (!embeds.length) { new Notice("No image embeds found in this note."); return; } let inserted = 0; for (let index = embeds.length - 1; index >= 0; index--) { const embed = embeds[index]; if (this.settings.skipExistingPromptBlocks && hasPromptBlockAfter(editor, embed.line)) { continue; } const result = await this.extractPromptFromLink(embed.link, view.file); if (!result.prompt) { continue; } editor.replaceRange( "\n" + this.formatPromptBlock(result.prompt, result.label), { line: embed.line + 1, ch: 0 } ); inserted++; } new Notice(inserted ? `Inserted ${inserted} prompt block(s).` : "No new prompt metadata found."); } async extractPromptFromLink(link, sourceFile) { const cleanLink = normalizeImageLink(link); const file = this.app.metadataCache.getFirstLinkpathDest(cleanLink, sourceFile ? sourceFile.path : ""); if (!(file instanceof TFile)) { return { prompt: null, message: `Image not found: ${cleanLink}` }; } const extension = file.extension.toLowerCase(); if (!["png", "jpg", "jpeg", "webp"].includes(extension)) { return { prompt: null, message: `Unsupported image type: ${file.extension}` }; } const buffer = await this.app.vault.readBinary(file); const bytes = new Uint8Array(buffer); const metadata = extractImageMetadata(bytes, extension); const picked = pickPrompt(metadata); return { prompt: picked ? picked.value : null, label: picked ? picked.key : null, message: picked ? null : `No prompt metadata found in ${file.name}.`, }; } formatPromptBlock(prompt, label) { const language = this.settings.codeBlockLanguage.trim() || "text"; const header = this.settings.includeMetadataKey && label ? `Prompt metadata: ${label}\n\n` : ""; return `${PROMPT_BLOCK_MARKER}\n\`\`\`${language}\n${header}${prompt.trim()}\n\`\`\`\n`; } }; class ExifPromptSettingTab extends PluginSettingTab { constructor(app, plugin) { super(app, plugin); this.plugin = plugin; } display() { const { containerEl } = this; containerEl.empty(); new Setting(containerEl) .setName("Code block language") .setDesc("Language label for inserted fenced code blocks.") .addText((text) => text .setPlaceholder("text") .setValue(this.plugin.settings.codeBlockLanguage) .onChange(async (value) => { this.plugin.settings.codeBlockLanguage = value || "text"; await this.plugin.saveSettings(); })); new Setting(containerEl) .setName("Include metadata key") .setDesc("Add the metadata field name at the top of inserted prompt blocks.") .addToggle((toggle) => toggle .setValue(this.plugin.settings.includeMetadataKey) .onChange(async (value) => { this.plugin.settings.includeMetadataKey = value; await this.plugin.saveSettings(); })); new Setting(containerEl) .setName("Skip existing prompt blocks") .setDesc("Avoid inserting another block when this plugin already added one below the image.") .addToggle((toggle) => toggle .setValue(this.plugin.settings.skipExistingPromptBlocks) .onChange(async (value) => { this.plugin.settings.skipExistingPromptBlocks = value; await this.plugin.saveSettings(); })); } } function findNearestImageEmbed(editor, lineNumber) { const maxDistance = 8; for (let distance = 0; distance <= maxDistance; distance++) { for (const candidateLine of uniqueLineCandidates(lineNumber, distance, editor.lineCount())) { const line = editor.getLine(candidateLine); const matches = parseImageEmbedsFromLine(line, candidateLine); if (matches.length) { return matches[0]; } } } return null; } function uniqueLineCandidates(baseLine, distance, lineCount) { const candidates = distance === 0 ? [baseLine] : [baseLine - distance, baseLine + distance]; return [...new Set(candidates)].filter((line) => line >= 0 && line < lineCount); } function findAllImageEmbeds(markdown) { const lines = markdown.split("\n"); return lines.flatMap((line, index) => parseImageEmbedsFromLine(line, index)); } function parseImageEmbedsFromLine(line, lineNumber) { const matches = []; IMAGE_EMBED_RE.lastIndex = 0; let match; while ((match = IMAGE_EMBED_RE.exec(line)) !== null) { matches.push({ line: lineNumber, link: match[1] || match[2], from: match.index, to: match.index + match[0].length, }); } return matches; } function hasPromptBlockAfter(editor, imageLine) { const lookAhead = Math.min(editor.lineCount(), imageLine + 8); for (let line = imageLine + 1; line < lookAhead; line++) { const text = editor.getLine(line); if (text.includes(PROMPT_BLOCK_MARKER)) { return true; } if (parseImageEmbedsFromLine(text, line).length) { return false; } } return false; } function normalizeImageLink(link) { return decodeURIComponent(link) .split("#")[0] .split("?")[0] .trim(); } function extractImageMetadata(bytes, extension) { if (extension === "png") { return extractPngMetadata(bytes); } return extractStringMetadata(bytes); } function extractPngMetadata(bytes) { const metadata = {}; if (!hasPngSignature(bytes)) { return extractStringMetadata(bytes); } let offset = 8; while (offset + 12 <= bytes.length) { const length = readUInt32(bytes, offset); const type = ascii(bytes.subarray(offset + 4, offset + 8)); const dataStart = offset + 8; const dataEnd = dataStart + length; if (dataEnd + 4 > bytes.length) { break; } if (type === "tEXt") { readTextChunk(bytes.subarray(dataStart, dataEnd), metadata); } else if (type === "iTXt") { readInternationalTextChunk(bytes.subarray(dataStart, dataEnd), metadata); } else if (type === "zTXt") { readCompressedTextChunk(bytes.subarray(dataStart, dataEnd), metadata); } offset = dataEnd + 4; if (type === "IEND") { break; } } return Object.keys(metadata).length ? metadata : extractStringMetadata(bytes); } function readTextChunk(data, metadata) { const separator = data.indexOf(0); if (separator < 0) { return; } const key = latin1(data.subarray(0, separator)); const value = decodeText(data.subarray(separator + 1)); if (key && value) { metadata[key] = value; } } function readInternationalTextChunk(data, metadata) { let cursor = 0; const keyEnd = data.indexOf(0, cursor); if (keyEnd < 0) return; const key = latin1(data.subarray(cursor, keyEnd)); cursor = keyEnd + 1; const compressionFlag = data[cursor++]; cursor++; const languageEnd = data.indexOf(0, cursor); if (languageEnd < 0) return; cursor = languageEnd + 1; const translatedEnd = data.indexOf(0, cursor); if (translatedEnd < 0) return; cursor = translatedEnd + 1; let textBytes = data.subarray(cursor); if (compressionFlag === 1) { textBytes = inflateBytes(textBytes); } const value = decodeText(textBytes); if (key && value) { metadata[key] = value; } } function readCompressedTextChunk(data, metadata) { const separator = data.indexOf(0); if (separator < 0 || separator + 2 >= data.length) { return; } const key = latin1(data.subarray(0, separator)); const compressed = data.subarray(separator + 2); const value = decodeText(inflateBytes(compressed)); if (key && value) { metadata[key] = value; } } function extractStringMetadata(bytes) { const text = decodeText(bytes) .replace(/\u0000/g, "\n") .replace(/[^\S\r\n]+/g, " "); const metadata = {}; const patterns = [ ["parameters", /parameters[\s:=]+([\s\S]{20,8000}?)(?:\n[A-Z][A-Za-z ]{1,30}:|\n\n\n|$)/i], ["UserComment", /UserComment[\s:=]+([\s\S]{20,8000}?)(?:\n[A-Z][A-Za-z ]{1,30}:|\n\n\n|$)/i], ["ImageDescription", /ImageDescription[\s:=]+([\s\S]{20,8000}?)(?:\n[A-Z][A-Za-z ]{1,30}:|\n\n\n|$)/i], ["prompt", /prompt["'\s:=]+([\s\S]{20,8000}?)(?:negative_prompt|Negative prompt|Steps:|Sampler:|Seed:|$)/i], ]; for (const [key, pattern] of patterns) { const match = text.match(pattern); if (match && match[1]) { metadata[key] = cleanExtractedText(match[1]); } } return metadata; } function pickPrompt(metadata) { const preferredKeys = [ "parameters", "prompt", "Prompt", "UserComment", "ImageDescription", "Description", "Comment", "workflow", ]; for (const key of preferredKeys) { if (metadata[key]) { return { key, value: normalizePromptValue(metadata[key]) }; } } const fallbackKey = Object.keys(metadata).find((key) => looksLikePrompt(metadata[key])); return fallbackKey ? { key: fallbackKey, value: normalizePromptValue(metadata[fallbackKey]) } : null; } function normalizePromptValue(value) { const trimmed = cleanExtractedText(value); const jsonPrompt = extractPromptFromJson(trimmed); return jsonPrompt || trimmed; } function extractPromptFromJson(value) { if (!value.trim().startsWith("{") && !value.trim().startsWith("[")) { return null; } try { const parsed = JSON.parse(value); const collected = []; collectPromptStrings(parsed, collected); return collected.length ? collected.join("\n\n") : null; } catch (_) { return null; } } function collectPromptStrings(node, collected) { if (!node || collected.length >= 12) { return; } if (typeof node === "string") { if (looksLikePrompt(node)) { collected.push(node.trim()); } return; } if (Array.isArray(node)) { node.forEach((item) => collectPromptStrings(item, collected)); return; } if (typeof node === "object") { for (const [key, value] of Object.entries(node)) { const lower = key.toLowerCase(); if (typeof value === "string" && ["text", "prompt", "positive", "inputs"].some((part) => lower.includes(part))) { collectPromptStrings(value, collected); } else if (typeof value === "object") { collectPromptStrings(value, collected); } } } } function looksLikePrompt(value) { if (!value || value.length < 20) { return false; } return /(negative prompt|steps:|sampler:|cfg scale|seed:|masterpiece|cinematic|portrait|photo|prompt)/i.test(value); } function cleanExtractedText(value) { return value .replace(/\r\n/g, "\n") .replace(/\r/g, "\n") .replace(/[^\x09\x0A\x0D\x20-\x7E\u0400-\u04FF\u2010-\u2027]+/g, " ") .replace(/[ \t]+\n/g, "\n") .replace(/\n{4,}/g, "\n\n\n") .trim(); } function inflateBytes(bytes) { try { const zlib = require("zlib"); return new Uint8Array(zlib.inflateSync(Buffer.from(bytes))); } catch (_) { return new Uint8Array(); } } function hasPngSignature(bytes) { return bytes.length > 8 && bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47 && bytes[4] === 0x0d && bytes[5] === 0x0a && bytes[6] === 0x1a && bytes[7] === 0x0a; } function readUInt32(bytes, offset) { return ((bytes[offset] << 24) | (bytes[offset + 1] << 16) | (bytes[offset + 2] << 8) | bytes[offset + 3]) >>> 0; } function ascii(bytes) { return Array.from(bytes, (byte) => String.fromCharCode(byte)).join(""); } function latin1(bytes) { return new TextDecoder("latin1").decode(bytes).trim(); } function decodeText(bytes) { const utf8 = new TextDecoder("utf-8", { fatal: false }).decode(bytes); if (utf8.includes("\u0000")) { const utf16 = tryDecodeUtf16(bytes); if (utf16 && countPrintable(utf16) > countPrintable(utf8)) { return utf16; } } return utf8; } function tryDecodeUtf16(bytes) { try { return new TextDecoder("utf-16le", { fatal: false }).decode(bytes); } catch (_) { return ""; } } function countPrintable(text) { return (text.match(/[\wа-яА-Я.,:;!?()[\]{}'" -]/g) || []).length; }