2026-08-06 09f01778d5a034e2af50ae05da8908f7b6a871c8
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
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 '../lib/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;
}