Files

509 lines
14 KiB
JavaScript
Raw Permalink Normal View History

2026-06-20 19:32:05 +08:00
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 = "<!-- exif-prompt-extractor -->";
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;
}