Files
obsidian-exif-promt/obsidian-exif-prompt/main.js
T
2026-06-20 19:32:05 +08:00

509 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}