@@ -0,0 +1,508 @@
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 ;
}