返回 AiToEarn
live-manual-edits-buffer.mjs
根目录 / project / aitoearn-web / .agents / skills / impeccable / scripts / live-manual-edits-buffer.mjs
1 /**
2 * Shared helpers for the pending-manual-edits buffer on disk.
3 *
4 * Location: .impeccable/live/pending-manual-edits.json (project-local).
5 * Schema: { version: 1, entries: [{ id, pageUrl, element, ops, stagedAt }] }
6 *
7 * Each entry corresponds to one Save action from the browser. Ops merge by
8 * (pageUrl, ref): if the user re-edits the same element before committing, the
9 * existing entry's `newText` is replaced and `originalText` is kept (it holds
10 * the real source state).
11 */
12
13 import fs from 'node:fs';
14 import path from 'node:path';
15 import { getLiveDir } from './impeccable-paths.mjs';
16
17 const BUFFER_VERSION = 1;
18 const BUFFER_FILENAME = 'pending-manual-edits.json';
19
20 export function getBufferPath(cwd = process.cwd()) {
21 return path.join(getLiveDir(cwd), BUFFER_FILENAME);
22 }
23
24 export function readBuffer(cwd = process.cwd()) {
25 return readBufferInternal(cwd, { strict: false });
26 }
27
28 export function readBufferStrict(cwd = process.cwd()) {
29 return readBufferInternal(cwd, { strict: true });
30 }
31
32 function readBufferInternal(cwd, { strict }) {
33 const filePath = getBufferPath(cwd);
34 try {
35 const raw = fs.readFileSync(filePath, 'utf-8');
36 const parsed = JSON.parse(raw);
37 if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.entries)) {
38 if (strict) throw new Error('manual_edit_buffer_invalid_schema');
39 return { version: BUFFER_VERSION, entries: [] };
40 }
41 return { version: BUFFER_VERSION, entries: parsed.entries };
42 } catch (err) {
43 if (strict && err?.code !== 'ENOENT') {
44 throw new Error('manual_edit_buffer_unreadable: ' + (err.message || String(err)));
45 }
46 return { version: BUFFER_VERSION, entries: [] };
47 }
48 }
49
50 export function writeBuffer(cwd, buffer) {
51 const filePath = getBufferPath(cwd);
52 fs.mkdirSync(path.dirname(filePath), { recursive: true });
53 fs.writeFileSync(filePath, JSON.stringify({ version: BUFFER_VERSION, entries: buffer.entries }, null, 2));
54 }
55
56 /**
57 * Merge a new entry into the buffer. For each op in the new entry, if there's
58 * already a buffered op for the same (pageUrl, ref), update that op's newText
59 * and keep its original originalText (the true source state). Otherwise add
60 * the op (creating an entry if needed).
61 *
62 * Multiple ops in one Save are allowed; each is keyed by (pageUrl, ref).
63 */
64 export function stageEntry(cwd, newEntry) {
65 const buf = readBufferStrict(cwd);
66 const pageUrl = newEntry.pageUrl;
67 for (const newOp of newEntry.ops) {
68 let mergedIntoExisting = false;
69 for (const existing of buf.entries) {
70 if (existing.pageUrl !== pageUrl) continue;
71 const existingOpIdx = existing.ops.findIndex((op) => op.ref === newOp.ref);
72 if (existingOpIdx >= 0) {
73 // Keep the original source text but refresh the latest DOM/source evidence.
74 existing.ops[existingOpIdx] = {
75 ...newOp,
76 originalText: existing.ops[existingOpIdx].originalText,
77 newText: newOp.newText,
78 deleted: newOp.deleted || false,
79 };
80 if (newEntry.element) existing.element = newEntry.element;
81 existing.stagedAt = new Date().toISOString();
82 mergedIntoExisting = true;
83 break;
84 }
85 }
86 if (mergedIntoExisting) continue;
87 // No existing op for this (pageUrl, ref). Find or create an entry to hold it.
88 let entry = buf.entries.find((e) => e.pageUrl === pageUrl && e.id === newEntry.id);
89 if (!entry) {
90 entry = {
91 id: newEntry.id,
92 pageUrl,
93 element: newEntry.element,
94 ops: [],
95 stagedAt: new Date().toISOString(),
96 };
97 buf.entries.push(entry);
98 }
99 entry.ops.push(newOp);
100 entry.stagedAt = new Date().toISOString();
101 }
102 writeBuffer(cwd, buf);
103 return buf;
104 }
105
106 /**
107 * Remove entries matching a predicate. Returns count of removed *ops* (not
108 * entries) so callers report a unit consistent with truncateBuffer and the
109 * pill's per-page op count. Empty entries (no ops left) are also pruned.
110 */
111 export function removeEntries(cwd, predicate) {
112 const buf = readBuffer(cwd);
113 let removedOps = 0;
114 const kept = [];
115 for (const entry of buf.entries) {
116 if (predicate(entry)) {
117 removedOps += entry.ops?.length || 0;
118 } else if (entry.ops && entry.ops.length > 0) {
119 kept.push(entry);
120 }
121 }
122 buf.entries = kept;
123 writeBuffer(cwd, buf);
124 return removedOps;
125 }
126
127 /**
128 * Count by page for the counter UI. Returns { totalCount, perPage: {[pageUrl]: count} }.
129 */
130 export function countByPage(cwd = process.cwd()) {
131 const buf = readBuffer(cwd);
132 const perPage = {};
133 let totalCount = 0;
134 for (const entry of buf.entries) {
135 const n = entry.ops.length;
136 perPage[entry.pageUrl] = (perPage[entry.pageUrl] || 0) + n;
137 totalCount += n;
138 }
139 return { totalCount, perPage };
140 }
141
142 /**
143 * Truncate the buffer to empty (used by discard-all). Returns the count of
144 * removed ops.
145 */
146 export function truncateBuffer(cwd) {
147 const buf = readBuffer(cwd);
148 let removed = 0;
149 for (const entry of buf.entries) removed += entry.ops.length;
150 writeBuffer(cwd, { version: BUFFER_VERSION, entries: [] });
151 return removed;
152 }
153
153 lines Plain Text