diff --git a/core/services/image_metadata_service.py b/core/services/image_metadata_service.py index 338e2fa..3f6d23d 100644 --- a/core/services/image_metadata_service.py +++ b/core/services/image_metadata_service.py @@ -643,9 +643,29 @@ def inspect_image_bytes( def _parse_workflow_json_text(value: str) -> Any: if len(value.encode("utf-8")) > MAX_WORKFLOW_JSON_BYTES: raise ValueError("Workflow JSON is too large") + text = value.lstrip("\ufeff").strip() + if text.startswith("```"): + first_newline = text.find("\n") + last_fence = text.rfind("```") + if first_newline != -1 and last_fence > first_newline: + text = text[first_newline + 1 : last_fence].strip() try: - return json.loads(value) + return json.loads(text) except (TypeError, ValueError, json.JSONDecodeError) as exc: + first_brace = text.find("{") + last_brace = text.rfind("}") + if first_brace != -1 and last_brace > first_brace: + try: + return json.loads(text[first_brace : last_brace + 1]) + except (TypeError, ValueError, json.JSONDecodeError): + pass + first_bracket = text.find("[") + last_bracket = text.rfind("]") + if first_bracket != -1 and last_bracket > first_bracket: + try: + return json.loads(text[first_bracket : last_bracket + 1]) + except (TypeError, ValueError, json.JSONDecodeError): + pass raise ValueError("Workflow JSON is not valid JSON") from exc diff --git a/tests/test_image_metadata.py b/tests/test_image_metadata.py index ff0c5dd..7877abf 100644 --- a/tests/test_image_metadata.py +++ b/tests/test_image_metadata.py @@ -185,6 +185,19 @@ def test_json_inspection_accepts_ui_and_api_workflows(): assert api_result["workflow_node_count"] == 2 +def test_json_inspection_accepts_fenced_markdown_and_bom_workflow_text(): + raw_ui = '```json\n{"nodes": [{"id": 1, "type": "CheckpointLoaderSimple", "widgets_values": []}]}\n```' + result = inspect_workflow_json(raw_ui, filename="workflow.txt") + assert result["workflow_format"] == UI_WORKFLOW_FORMAT + assert result["workflow_node_count"] == 1 + assert result["filename"] == "workflow.txt" + + bom_api = "\ufeff" + json.dumps(api_workflow()) + bom_result = inspect_workflow_json(bom_api, filename="api_workflow.txt") + assert bom_result["workflow_format"] == API_WORKFLOW_FORMAT + assert bom_result["workflow_node_count"] == 2 + + def test_json_inspection_rejects_unrelated_json(): with pytest.raises(ValueError, match="Unsupported workflow JSON"): inspect_workflow_json({"settings": {"theme": "dark"}}) diff --git a/tests/test_metadata_collapse.mjs b/tests/test_metadata_collapse.mjs index e9dba11..7d8cdf2 100644 --- a/tests/test_metadata_collapse.mjs +++ b/tests/test_metadata_collapse.mjs @@ -308,6 +308,85 @@ test('handleImageMetadataPaste inspects workflow JSON file from clipboard', () = assert.strictEqual(inspectedWorkflowFile, jsonFile); }); +test('handleImageMetadataPaste inspects workflow TXT file from clipboard', () => { + const ctx = createMockContext(); + const { root } = createMockPasteTarget(ctx); + let inspectedWorkflowFile = null; + ctx.inspectWorkflowFile = (file) => { inspectedWorkflowFile = file; }; + const txtFile = { name: 'my_workflow.txt', type: 'text/plain', size: 512 }; + const event = { + target: root, + preventDefault: () => {}, + stopPropagation: () => {}, + clipboardData: { files: [txtFile], items: [] }, + }; + assert.strictEqual(ctx.handleImageMetadataPaste(event), true); + assert.strictEqual(inspectedWorkflowFile, txtFile); +}); + +test('inspectImageMetadataFile routes .txt file to inspectWorkflowFile', async () => { + const ctx = createMockContext(); + let routedWorkflowFile = null; + ctx.inspectWorkflowFile = (file) => { routedWorkflowFile = file; return 'routed'; }; + const txtFile = { name: 'workflow.txt', type: 'text/plain', size: 256 }; + const res = await ctx.inspectImageMetadataFile(txtFile); + assert.strictEqual(res, 'routed'); + assert.strictEqual(routedWorkflowFile, txtFile); +}); + +test('inspectWorkflowFile parses JSON workflow from .txt file', async () => { + const ctx = createMockContext(); + let inspectedPayload = null; + ctx.runImageMetadataInspection = (payload) => { inspectedPayload = payload; return payload; }; + const workflowObj = { nodes: [{ id: 1, type: 'KSampler' }] }; + const txtFile = { + name: 'workflow_export.txt', + type: 'text/plain', + size: 256, + text: async () => JSON.stringify(workflowObj), + }; + const res = await ctx.inspectWorkflowFile(txtFile); + assert.ok(res); + assert.strictEqual(inspectedPayload?.source_type, 'workflow'); + assert.strictEqual(inspectedPayload?.filename, 'workflow_export.txt'); + assert.deepStrictEqual(inspectedPayload?.workflow, workflowObj); +}); + +test('inspectWorkflowFile parses markdown fenced JSON workflow from .txt file', async () => { + const ctx = createMockContext(); + let inspectedPayload = null; + ctx.runImageMetadataInspection = (payload) => { inspectedPayload = payload; return payload; }; + const workflowObj = { nodes: [{ id: 2, type: 'VAELoader' }] }; + const fencedContent = '```json\n' + JSON.stringify(workflowObj) + '\n```'; + const txtFile = { + name: 'shared_workflow.txt', + type: 'text/plain', + size: 512, + text: async () => fencedContent, + }; + const res = await ctx.inspectWorkflowFile(txtFile); + assert.ok(res); + assert.deepStrictEqual(inspectedPayload?.workflow, workflowObj); +}); + +test('inspectWorkflowFile shows error notification when .txt file does not contain valid JSON', async () => { + const ctx = createMockContext(); + let notification = null; + ctx.showNotification = (msg, type) => { notification = { msg, type }; }; + const txtFile = { + name: 'notes.txt', + type: 'text/plain', + size: 100, + text: async () => 'This is just a simple text note, not a JSON workflow.', + }; + const res = await ctx.inspectWorkflowFile(txtFile); + assert.strictEqual(res, null); + assert.deepStrictEqual(notification, { + msg: 'The text file does not contain valid workflow JSON.', + type: 'error', + }); +}); + test('handleImageMetadataPaste inspects workflow JSON text from clipboard', () => { const ctx = createMockContext(); const { root, textarea } = createMockPasteTarget(ctx); diff --git a/web/css/resolver-main.css b/web/css/resolver-main.css index 3a04052..9c29420 100644 --- a/web/css/resolver-main.css +++ b/web/css/resolver-main.css @@ -5437,7 +5437,7 @@ button.mr-info-link:disabled { padding: var(--mr-spacing-4) var(--mr-spacing-8); border: 1px solid rgba(255, 255, 255, 0.08); border-radius: var(--mr-radius-md, var(--mr-radius-lg)); - background: rgba(0, 0, 0, 0.16); + background: rgba(255, 255, 255, 0.045); color: var(--mr-text); font-family: var(--mr-font-family-mono); font-size: var(--mr-font-xs); diff --git a/web/resolver/views/tabs_image_metadata_methods.js b/web/resolver/views/tabs_image_metadata_methods.js index 71d64ff..13e1b28 100644 --- a/web/resolver/views/tabs_image_metadata_methods.js +++ b/web/resolver/views/tabs_image_metadata_methods.js @@ -120,7 +120,61 @@ function isWorkflowFile(file) { const name = String(file?.name || '').toLowerCase(); return file?.type === 'application/json' || file?.type === 'text/json' - || name.endsWith('.json'); + || file?.type === 'text/plain' + || name.endsWith('.json') + || name.endsWith('.txt'); +} + +function readFileAsText(file) { + if (typeof file?.text === 'function') { + return file.text(); + } + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(String(reader.result || '')); + reader.onerror = () => reject(new Error('Could not read the workflow file.')); + reader.readAsText(file); + }); +} + +export function extractJsonFromText(rawText) { + if (!rawText || typeof rawText !== 'string') return null; + let text = rawText.replace(/^\uFEFF/, '').trim(); + if (!text) return null; + + if (text.startsWith('```')) { + const firstLineEnd = text.indexOf('\n'); + if (firstLineEnd !== -1) { + const lastFence = text.lastIndexOf('```'); + if (lastFence > firstLineEnd) { + text = text.slice(firstLineEnd + 1, lastFence).trim(); + } + } + } + + try { + return JSON.parse(text); + } catch { + const firstBrace = text.indexOf('{'); + const lastBrace = text.lastIndexOf('}'); + if (firstBrace !== -1 && lastBrace > firstBrace) { + try { + return JSON.parse(text.slice(firstBrace, lastBrace + 1)); + } catch { + // Ignore nested extraction failure. + } + } + const firstBracket = text.indexOf('['); + const lastBracket = text.lastIndexOf(']'); + if (firstBracket !== -1 && lastBracket > firstBracket) { + try { + return JSON.parse(text.slice(firstBracket, lastBracket + 1)); + } catch { + // Ignore nested extraction failure. + } + } + return null; + } } function readFileAsDataUrl(file) { @@ -385,12 +439,12 @@ export const imageMetadataMethods = { Image Metadata - ${this.getMetadataTooltipBadge('Drop or paste (Ctrl+V) an image, Civitai link, or workflow JSON here, or paste JSON into the box below.', 'Image metadata help')} + ${this.getMetadataTooltipBadge('Drop or paste (Ctrl+V) an image, Civitai link, or workflow JSON/text file here, or paste JSON into the box below.', 'Image metadata help')}
- +
@@ -736,30 +790,19 @@ export const imageMetadataMethods = { return true; } - if ( - (trimmed.startsWith('{') && trimmed.endsWith('}')) - || (trimmed.startsWith('[') && trimmed.endsWith(']')) - ) { - let parsed = null; - try { - parsed = JSON.parse(trimmed); - } catch { - // Ignore parse errors for non-JSON text. - } - - if (parsed && typeof parsed === 'object') { - event.preventDefault?.(); - event.stopPropagation?.(); - const textarea = root.querySelector('.mr-image-inspector-textarea'); - if (textarea) { - textarea.value = trimmed; - if (typeof Event === 'function') { - textarea.dispatchEvent(new Event('input', { bubbles: true })); - } + const parsedWorkflow = extractJsonFromText(trimmed); + if (parsedWorkflow && typeof parsedWorkflow === 'object') { + event.preventDefault?.(); + event.stopPropagation?.(); + const textarea = root.querySelector('.mr-image-inspector-textarea'); + if (textarea) { + textarea.value = trimmed; + if (typeof Event === 'function') { + textarea.dispatchEvent(new Event('input', { bubbles: true })); } - void this.inspectPastedWorkflow(trimmed); - return true; } + void this.inspectPastedWorkflow(trimmed); + return true; } if (targetIsTextarea) { @@ -779,7 +822,7 @@ export const imageMetadataMethods = { if (isWorkflowFile(file)) { return this.inspectWorkflowFile(file); } - this.showNotification('Select an image or a workflow JSON file.', 'warning'); + this.showNotification('Select an image, workflow JSON, or workflow text file.', 'warning'); return null; }, @@ -807,15 +850,28 @@ export const imageMetadataMethods = { return null; } try { - const text = await file.text(); - const workflow = JSON.parse(text); + const raw = await readFileAsText(file); + const text = (raw || '').replace(/^\uFEFF/, '').trim(); + const civitaiImageId = extractCivitaiImageId(text); + if (civitaiImageId) { + return this.inspectCivitaiImage(civitaiImageId, text); + } + const workflow = extractJsonFromText(text); + if (!workflow || typeof workflow !== 'object') { + throw new Error('Not valid JSON'); + } return this.runImageMetadataInspection({ source_type: 'workflow', filename: file.name || 'Pasted workflow.json', workflow, }); } catch (error) { - this.showNotification(error.message || 'The workflow file is not valid JSON.', 'error'); + const name = String(file?.name || '').toLowerCase(); + const isTxt = name.endsWith('.txt') || file?.type === 'text/plain'; + const msg = isTxt + ? 'The text file does not contain valid workflow JSON.' + : (error.message && error.message !== 'Not valid JSON' ? error.message : 'The workflow file is not valid JSON.'); + this.showNotification(msg, 'error'); return null; } },