Forked from vadimfedenko/analyze-images
Forked from vadimfedenko/analyze-images
scripts / restore-expired-images.cjs
scripts / restore-expired-images.cjs
// Explicit repair utility. No conversation is rewritten; no file is overwritten.
// node scripts/restore-expired-images.cjs <conversation.json> <working-directory> <user-files> [--apply]
const fs = require('node:fs/promises');
const path = require('node:path');
const crypto = require('node:crypto');
const digest = bytes => crypto.createHash('sha256').update(bytes).digest('hex');
async function main() {
const [chatArg, sourceArg, targetArg, mode] = process.argv.slice(2);
if (!chatArg || !sourceArg || !targetArg || (mode && mode !== '--apply')) throw Error('Expected conversation, working directory, user-files, optional --apply');
const chatPath = await fs.realpath(chatArg);
const sourceRoot = await fs.realpath(sourceArg);
const targetRoot = await fs.realpath(targetArg);
if (path.basename(targetRoot) !== 'user-files') throw Error('Target must be the LM Studio user-files directory');
const original = await fs.readFile(chatPath, 'utf8');
const chat = JSON.parse(original);
if (chat.workingDirectoryName !== path.basename(sourceRoot)) throw Error('Working directory does not match this conversation');
const wanted = new Map();
function walk(value) {
if (!value || typeof value !== 'object') return;
if (value.type === 'file' && value.fileType === 'image') {
const hash = /^aia-([a-f0-9]{64})\.(?:png|jpe?g|webp|gif)$/.exec(value.name ?? '')?.[1];
const name = String(value.identifier ?? '').replace(/^local:/, '');
if (hash && /^\d+ - \d+\.(?:png|jpe?g|webp|gif)$/.test(name)
&& Number.isInteger(value.sizeBytes) && value.sizeBytes > 0 && value.sizeBytes <= 8 * 1024 * 1024) {
wanted.set(name, {hash, size: value.sizeBytes});
}
}
for (const child of Object.values(value)) walk(child);
}
walk(chat);
const sources = new Map();
const sizes = new Set([...wanted.values()].map(value => value.size));
for (const entry of await fs.readdir(sourceRoot, {withFileTypes: true})) {
if (!entry.isFile()) continue; // No symlinks or traversal into unrelated directories.
const source = path.join(sourceRoot, entry.name);
const info = await fs.stat(source);
if (!sizes.has(info.size)) continue;
const bytes = await fs.readFile(source);
sources.set(digest(bytes), bytes);
}
let restored = 0;
let matched = 0;
for (const [name, expected] of wanted) {
const target = path.resolve(targetRoot, name);
if (path.dirname(target) !== targetRoot) throw Error('Target escapes user-files');
try { await fs.lstat(target); continue; } catch (error) { if (error.code !== 'ENOENT') throw error; }
const bytes = sources.get(expected.hash);
if (!bytes || bytes.length !== expected.size) { console.log(`No verified source: ${name}`); continue; }
matched++;
if (mode === '--apply') {
if (await fs.readFile(chatPath, 'utf8') !== original) throw Error('Conversation changed; rerun repair');
await fs.writeFile(target, bytes, {flag: 'wx'});
if (digest(await fs.readFile(target)) !== expected.hash) throw Error('Restored hash mismatch');
restored++;
}
console.log(`${mode === '--apply' ? 'Restored' : 'Verified source for'}: ${name}`);
}
console.log(JSON.stringify({matched, restored, conversationUnchanged: await fs.readFile(chatPath, 'utf8') === original}));
}
main().catch(error => {console.error(error); process.exitCode = 1;});
// Explicit repair utility. No conversation is rewritten; no file is overwritten.
// node scripts/restore-expired-images.cjs <conversation.json> <working-directory> <user-files> [--apply]
const fs = require('node:fs/promises');
const path = require('node:path');
const crypto = require('node:crypto');
const digest = bytes => crypto.createHash('sha256').update(bytes).digest('hex');
async function main() {
const [chatArg, sourceArg, targetArg, mode] = process.argv.slice(2);
if (!chatArg || !sourceArg || !targetArg || (mode && mode !== '--apply')) throw Error('Expected conversation, working directory, user-files, optional --apply');
const chatPath = await fs.realpath(chatArg);
const sourceRoot = await fs.realpath(sourceArg);
const targetRoot = await fs.realpath(targetArg);
if (path.basename(targetRoot) !== 'user-files') throw Error('Target must be the LM Studio user-files directory');
const original = await fs.readFile(chatPath, 'utf8');
const chat = JSON.parse(original);
if (chat.workingDirectoryName !== path.basename(sourceRoot)) throw Error('Working directory does not match this conversation');
const wanted = new Map();
function walk(value) {
if (!value || typeof value !== 'object') return;
if (value.type === 'file' && value.fileType === 'image') {
const hash = /^aia-([a-f0-9]{64})\.(?:png|jpe?g|webp|gif)$/.exec(value.name ?? '')?.[1];
const name = String(value.identifier ?? '').replace(/^local:/, '');
if (hash && /^\d+ - \d+\.(?:png|jpe?g|webp|gif)$/.test(name)
&& Number.isInteger(value.sizeBytes) && value.sizeBytes > 0 && value.sizeBytes <= 8 * 1024 * 1024) {
wanted.set(name, {hash, size: value.sizeBytes});
}
}
for (const child of Object.values(value)) walk(child);
}
walk(chat);
const sources = new Map();
const sizes = new Set([...wanted.values()].map(value => value.size));
for (const entry of await fs.readdir(sourceRoot, {withFileTypes: true})) {
if (!entry.isFile()) continue; // No symlinks or traversal into unrelated directories.
const source = path.join(sourceRoot, entry.name);
const info = await fs.stat(source);
if (!sizes.has(info.size)) continue;
const bytes = await fs.readFile(source);
sources.set(digest(bytes), bytes);
}
let restored = 0;
let matched = 0;
for (const [name, expected] of wanted) {
const target = path.resolve(targetRoot, name);
if (path.dirname(target) !== targetRoot) throw Error('Target escapes user-files');
try { await fs.lstat(target); continue; } catch (error) { if (error.code !== 'ENOENT') throw error; }
const bytes = sources.get(expected.hash);
if (!bytes || bytes.length !== expected.size) { console.log(`No verified source: ${name}`); continue; }
matched++;
if (mode === '--apply') {
if (await fs.readFile(chatPath, 'utf8') !== original) throw Error('Conversation changed; rerun repair');
await fs.writeFile(target, bytes, {flag: 'wx'});
if (digest(await fs.readFile(target)) !== expected.hash) throw Error('Restored hash mismatch');
restored++;
}
console.log(`${mode === '--apply' ? 'Restored' : 'Verified source for'}: ${name}`);
}
console.log(JSON.stringify({matched, restored, conversationUnchanged: await fs.readFile(chatPath, 'utf8') === original}));
}
main().catch(error => {console.error(error); process.exitCode = 1;});