This commit is contained in:
parent
739a534ac2
commit
f237916291
165 changed files with 79237 additions and 0 deletions
152
.agents/skills/impeccable/scripts/live-manual-edits-buffer.mjs
Normal file
152
.agents/skills/impeccable/scripts/live-manual-edits-buffer.mjs
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
/**
|
||||
* Shared helpers for the pending-manual-edits buffer on disk.
|
||||
*
|
||||
* Location: .impeccable/live/pending-manual-edits.json (project-local).
|
||||
* Schema: { version: 1, entries: [{ id, pageUrl, element, ops, stagedAt }] }
|
||||
*
|
||||
* Each entry corresponds to one Save action from the browser. Ops merge by
|
||||
* (pageUrl, ref): if the user re-edits the same element before committing, the
|
||||
* existing entry's `newText` is replaced and `originalText` is kept (it holds
|
||||
* the real source state).
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { getLiveDir } from './impeccable-paths.mjs';
|
||||
|
||||
const BUFFER_VERSION = 1;
|
||||
const BUFFER_FILENAME = 'pending-manual-edits.json';
|
||||
|
||||
export function getBufferPath(cwd = process.cwd()) {
|
||||
return path.join(getLiveDir(cwd), BUFFER_FILENAME);
|
||||
}
|
||||
|
||||
export function readBuffer(cwd = process.cwd()) {
|
||||
return readBufferInternal(cwd, { strict: false });
|
||||
}
|
||||
|
||||
export function readBufferStrict(cwd = process.cwd()) {
|
||||
return readBufferInternal(cwd, { strict: true });
|
||||
}
|
||||
|
||||
function readBufferInternal(cwd, { strict }) {
|
||||
const filePath = getBufferPath(cwd);
|
||||
try {
|
||||
const raw = fs.readFileSync(filePath, 'utf-8');
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.entries)) {
|
||||
if (strict) throw new Error('manual_edit_buffer_invalid_schema');
|
||||
return { version: BUFFER_VERSION, entries: [] };
|
||||
}
|
||||
return { version: BUFFER_VERSION, entries: parsed.entries };
|
||||
} catch (err) {
|
||||
if (strict && err?.code !== 'ENOENT') {
|
||||
throw new Error('manual_edit_buffer_unreadable: ' + (err.message || String(err)));
|
||||
}
|
||||
return { version: BUFFER_VERSION, entries: [] };
|
||||
}
|
||||
}
|
||||
|
||||
export function writeBuffer(cwd, buffer) {
|
||||
const filePath = getBufferPath(cwd);
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, JSON.stringify({ version: BUFFER_VERSION, entries: buffer.entries }, null, 2));
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge a new entry into the buffer. For each op in the new entry, if there's
|
||||
* already a buffered op for the same (pageUrl, ref), update that op's newText
|
||||
* and keep its original originalText (the true source state). Otherwise add
|
||||
* the op (creating an entry if needed).
|
||||
*
|
||||
* Multiple ops in one Save are allowed; each is keyed by (pageUrl, ref).
|
||||
*/
|
||||
export function stageEntry(cwd, newEntry) {
|
||||
const buf = readBufferStrict(cwd);
|
||||
const pageUrl = newEntry.pageUrl;
|
||||
for (const newOp of newEntry.ops) {
|
||||
let mergedIntoExisting = false;
|
||||
for (const existing of buf.entries) {
|
||||
if (existing.pageUrl !== pageUrl) continue;
|
||||
const existingOpIdx = existing.ops.findIndex((op) => op.ref === newOp.ref);
|
||||
if (existingOpIdx >= 0) {
|
||||
// Keep the original source text but refresh the latest DOM/source evidence.
|
||||
existing.ops[existingOpIdx] = {
|
||||
...newOp,
|
||||
originalText: existing.ops[existingOpIdx].originalText,
|
||||
newText: newOp.newText,
|
||||
deleted: newOp.deleted || false,
|
||||
};
|
||||
if (newEntry.element) existing.element = newEntry.element;
|
||||
existing.stagedAt = new Date().toISOString();
|
||||
mergedIntoExisting = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (mergedIntoExisting) continue;
|
||||
// No existing op for this (pageUrl, ref). Find or create an entry to hold it.
|
||||
let entry = buf.entries.find((e) => e.pageUrl === pageUrl && e.id === newEntry.id);
|
||||
if (!entry) {
|
||||
entry = {
|
||||
id: newEntry.id,
|
||||
pageUrl,
|
||||
element: newEntry.element,
|
||||
ops: [],
|
||||
stagedAt: new Date().toISOString(),
|
||||
};
|
||||
buf.entries.push(entry);
|
||||
}
|
||||
entry.ops.push(newOp);
|
||||
entry.stagedAt = new Date().toISOString();
|
||||
}
|
||||
writeBuffer(cwd, buf);
|
||||
return buf;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove entries matching a predicate. Returns count of removed *ops* (not
|
||||
* entries) so callers report a unit consistent with truncateBuffer and the
|
||||
* pill's per-page op count. Empty entries (no ops left) are also pruned.
|
||||
*/
|
||||
export function removeEntries(cwd, predicate) {
|
||||
const buf = readBuffer(cwd);
|
||||
let removedOps = 0;
|
||||
const kept = [];
|
||||
for (const entry of buf.entries) {
|
||||
if (predicate(entry)) {
|
||||
removedOps += entry.ops?.length || 0;
|
||||
} else if (entry.ops && entry.ops.length > 0) {
|
||||
kept.push(entry);
|
||||
}
|
||||
}
|
||||
buf.entries = kept;
|
||||
writeBuffer(cwd, buf);
|
||||
return removedOps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count by page for the counter UI. Returns { totalCount, perPage: {[pageUrl]: count} }.
|
||||
*/
|
||||
export function countByPage(cwd = process.cwd()) {
|
||||
const buf = readBuffer(cwd);
|
||||
const perPage = {};
|
||||
let totalCount = 0;
|
||||
for (const entry of buf.entries) {
|
||||
const n = entry.ops.length;
|
||||
perPage[entry.pageUrl] = (perPage[entry.pageUrl] || 0) + n;
|
||||
totalCount += n;
|
||||
}
|
||||
return { totalCount, perPage };
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate the buffer to empty (used by discard-all). Returns the count of
|
||||
* removed ops.
|
||||
*/
|
||||
export function truncateBuffer(cwd) {
|
||||
const buf = readBuffer(cwd);
|
||||
let removed = 0;
|
||||
for (const entry of buf.entries) removed += entry.ops.length;
|
||||
writeBuffer(cwd, { version: BUFFER_VERSION, entries: [] });
|
||||
return removed;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue