152 lines
5 KiB
JavaScript
152 lines
5 KiB
JavaScript
/**
|
|
* 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;
|
|
}
|