projectionlab-mcp 1.0.1 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/HOW-IT-WORKS.md +2 -2
- package/MANUAL-SETUP.md +64 -0
- package/README.md +34 -271
- package/REFERENCE.md +221 -0
- package/SETUP-FOR-AGENTS.md +37 -0
- package/dist/bootstrap.js +20 -3
- package/dist/bootstrap.js.map +1 -1
- package/dist/bridge.d.ts +2 -0
- package/dist/clonePlan.d.ts +9 -0
- package/dist/clonePlan.js +15 -14
- package/dist/clonePlan.js.map +1 -1
- package/dist/config.d.ts +4 -0
- package/dist/config.js +41 -6
- package/dist/config.js.map +1 -1
- package/dist/currentFinances.d.ts +12 -1
- package/dist/currentFinances.js +60 -0
- package/dist/currentFinances.js.map +1 -1
- package/dist/dataModel.d.ts +58 -0
- package/dist/dataModel.js +1429 -0
- package/dist/dataModel.js.map +1 -0
- package/dist/deletePlan.d.ts +17 -0
- package/dist/deletePlan.js +72 -0
- package/dist/deletePlan.js.map +1 -0
- package/dist/diff.d.ts +288 -0
- package/dist/diff.js +744 -0
- package/dist/diff.js.map +1 -0
- package/dist/errors.d.ts +6 -0
- package/dist/errors.js +14 -1
- package/dist/errors.js.map +1 -1
- package/dist/extensionBridge.d.ts +16 -2
- package/dist/extensionBridge.js +104 -23
- package/dist/extensionBridge.js.map +1 -1
- package/dist/index.js +27 -1
- package/dist/index.js.map +1 -1
- package/dist/instructions.d.ts +1 -1
- package/dist/instructions.js +3 -2
- package/dist/instructions.js.map +1 -1
- package/dist/largeOutput.js +25 -0
- package/dist/largeOutput.js.map +1 -1
- package/dist/logger.js +11 -4
- package/dist/logger.js.map +1 -1
- package/dist/pathPatch.d.ts +5 -2
- package/dist/pathPatch.js +16 -2
- package/dist/pathPatch.js.map +1 -1
- package/dist/planSettings.d.ts +1 -1
- package/dist/planSettings.js +3 -3
- package/dist/planSettings.js.map +1 -1
- package/dist/plans.d.ts +73 -10
- package/dist/plans.js +206 -42
- package/dist/plans.js.map +1 -1
- package/dist/prompts/index.js +7 -4
- package/dist/prompts/index.js.map +1 -1
- package/dist/realEstate.d.ts +22 -0
- package/dist/realEstate.js +118 -0
- package/dist/realEstate.js.map +1 -0
- package/dist/redact.d.ts +26 -0
- package/dist/redact.js +93 -1
- package/dist/redact.js.map +1 -1
- package/dist/resources/dataModel.d.ts +3 -0
- package/dist/resources/dataModel.js +66 -0
- package/dist/resources/dataModel.js.map +1 -0
- package/dist/resources/knowledge.js +189 -47
- package/dist/resources/knowledge.js.map +1 -1
- package/dist/resources/planData.js +3 -1
- package/dist/resources/planData.js.map +1 -1
- package/dist/resources/summary.js +4 -1
- package/dist/resources/summary.js.map +1 -1
- package/dist/snapshot.d.ts +21 -0
- package/dist/snapshot.js +116 -12
- package/dist/snapshot.js.map +1 -1
- package/dist/summary.js +19 -6
- package/dist/summary.js.map +1 -1
- package/dist/tools/clonePlan.js +17 -8
- package/dist/tools/clonePlan.js.map +1 -1
- package/dist/tools/connection.js +29 -4
- package/dist/tools/connection.js.map +1 -1
- package/dist/tools/deletePlan.d.ts +4 -0
- package/dist/tools/deletePlan.js +103 -0
- package/dist/tools/deletePlan.js.map +1 -0
- package/dist/tools/diff.d.ts +4 -0
- package/dist/tools/diff.js +417 -0
- package/dist/tools/diff.js.map +1 -0
- package/dist/tools/getCurrentFinances.js +27 -4
- package/dist/tools/getCurrentFinances.js.map +1 -1
- package/dist/tools/getPlan.js +4 -4
- package/dist/tools/getPlan.js.map +1 -1
- package/dist/tools/getPlanSection.js +7 -6
- package/dist/tools/getPlanSection.js.map +1 -1
- package/dist/tools/getPlanSettings.js +10 -6
- package/dist/tools/getPlanSettings.js.map +1 -1
- package/dist/tools/restore.js +18 -1
- package/dist/tools/restore.js.map +1 -1
- package/dist/tools/schema.js +28 -4
- package/dist/tools/schema.js.map +1 -1
- package/dist/tools/snapshots.d.ts +1 -1
- package/dist/tools/snapshots.js +53 -6
- package/dist/tools/snapshots.js.map +1 -1
- package/dist/tools/summary.js +3 -2
- package/dist/tools/summary.js.map +1 -1
- package/dist/tools/updateAccount.js +9 -7
- package/dist/tools/updateAccount.js.map +1 -1
- package/dist/tools/updatePlanEvent.js +71 -33
- package/dist/tools/updatePlanEvent.js.map +1 -1
- package/dist/tools/updatePlanSection.js +17 -32
- package/dist/tools/updatePlanSection.js.map +1 -1
- package/dist/tools/updatePlanSettings.js +11 -7
- package/dist/tools/updatePlanSettings.js.map +1 -1
- package/dist/types/projectionlab.d.ts +3 -1
- package/dist/types/wire.d.ts +27 -2
- package/dist/types/wire.js +6 -2
- package/dist/types/wire.js.map +1 -1
- package/dist/updateCheck.d.ts +22 -0
- package/dist/updateCheck.js +58 -0
- package/dist/updateCheck.js.map +1 -0
- package/dist/versionCompare.d.ts +5 -0
- package/dist/versionCompare.js +33 -0
- package/dist/versionCompare.js.map +1 -0
- package/dist/versionNotices.d.ts +9 -0
- package/dist/versionNotices.js +42 -0
- package/dist/versionNotices.js.map +1 -0
- package/package.json +6 -3
- package/skills/projectionlab/SKILL.md +329 -29
package/dist/diff.js
ADDED
|
@@ -0,0 +1,744 @@
|
|
|
1
|
+
import { CURRENT_FINANCE_COLLECTIONS, } from "./currentFinances.js";
|
|
2
|
+
import { PLAN_SECTIONS, getEvents, keyEvents, } from "./plans.js";
|
|
3
|
+
import { REDACTED, isSecretFieldName } from "./redact.js";
|
|
4
|
+
/**
|
|
5
|
+
* Field-level diff of two readings of ProjectionLab data, shared by
|
|
6
|
+
* projectionlab_diff. It compares the same data at two points in time
|
|
7
|
+
* (`diffSectionData`, `diffPlan`) or two different plans field by field
|
|
8
|
+
* (`diffPlanPair`), where each event's own id is dropped and each reference to
|
|
9
|
+
* another event of the same plan is compared by the event it names. Values
|
|
10
|
+
* render as `before → after` the way `diffFields` renders them, and every path
|
|
11
|
+
* is a dot path in the form `projectionlab_get_plan_section` and the patch
|
|
12
|
+
* tools use (`events.2.amount`), so a reported path can be read back or patched
|
|
13
|
+
* with it.
|
|
14
|
+
*
|
|
15
|
+
* The engine is pure and bounds its own output: `DiffBudget` caps how many
|
|
16
|
+
* field changes and how many list entries one call may report, so the payload
|
|
17
|
+
* stays small enough for a local model to hold in context, and it can ask for
|
|
18
|
+
* the differing paths with the values withheld. Omitted items are counted
|
|
19
|
+
* rather than dropped silently, and whether something counts as changed is
|
|
20
|
+
* decided from the data, never from what was left to report.
|
|
21
|
+
*
|
|
22
|
+
* A change whose path names a credential (`credentials.apiKey`,
|
|
23
|
+
* `access.0.password`) is recorded as `[REDACTED]`, so neither value leaves
|
|
24
|
+
* the process, and a value longer than `MAX_VALUE_CHARS` is truncated with its
|
|
25
|
+
* original length so one long note cannot flood the diff.
|
|
26
|
+
*/
|
|
27
|
+
/** Field changes one diff reports before it starts omitting. */
|
|
28
|
+
export const FIELD_CHANGE_CAP = 200;
|
|
29
|
+
/** Added/removed names and ids one diff reports before it starts omitting. */
|
|
30
|
+
export const ENTRY_CAP = 200;
|
|
31
|
+
/** Serialized values longer than this are truncated in a change entry. */
|
|
32
|
+
export const MAX_VALUE_CHARS = 200;
|
|
33
|
+
/**
|
|
34
|
+
* Plan fields ProjectionLab computes or re-stamps on its own. Leaving them out
|
|
35
|
+
* is what makes a snapshot-to-live diff of an untouched plan read as clean:
|
|
36
|
+
* saving a plan moves `lastUpdated`, and re-running a simulation rewrites
|
|
37
|
+
* `simKey` and `meta` (same rule as `samePlanContent`).
|
|
38
|
+
*/
|
|
39
|
+
const COMPUTED_PLAN_FIELDS = ["lastUpdated", "simKey", "meta"];
|
|
40
|
+
/**
|
|
41
|
+
* Plan fields that say which plan an object is rather than what it plans. Two
|
|
42
|
+
* plans never share them, so a plan-to-plan comparison leaves them out of the
|
|
43
|
+
* field comparison and reports them as the identity of each side.
|
|
44
|
+
*/
|
|
45
|
+
const PLAN_IDENTITY_FIELDS = ["id", "name"];
|
|
46
|
+
export function createDiffBudget(options = {}) {
|
|
47
|
+
const { fieldChangeCap = FIELD_CHANGE_CAP, entryCap = ENTRY_CAP, pathsOnly = false, } = options;
|
|
48
|
+
return {
|
|
49
|
+
fieldChangeCap,
|
|
50
|
+
entryCap,
|
|
51
|
+
pathsOnly,
|
|
52
|
+
fieldChangesUsed: 0,
|
|
53
|
+
entriesUsed: 0,
|
|
54
|
+
omittedFieldChanges: 0,
|
|
55
|
+
omittedEntries: 0,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
function isContainer(value) {
|
|
59
|
+
return !!value && typeof value === "object";
|
|
60
|
+
}
|
|
61
|
+
function renderValue(value) {
|
|
62
|
+
const json = JSON.stringify(value);
|
|
63
|
+
if (json === undefined)
|
|
64
|
+
return "(absent)";
|
|
65
|
+
if (json.length <= MAX_VALUE_CHARS)
|
|
66
|
+
return json;
|
|
67
|
+
return `${json.slice(0, MAX_VALUE_CHARS)}...[truncated from ${json.length} characters]`;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* True when any element of a dot path names a credential, so a nested
|
|
71
|
+
* `credentials.apiKey` or `access.0.password` is caught even though the whole
|
|
72
|
+
* path is not a field name. Numeric elements are array positions, not names.
|
|
73
|
+
*/
|
|
74
|
+
function pathHoldsSecret(path) {
|
|
75
|
+
for (const element of path.split(".")) {
|
|
76
|
+
if (/^\d+$/.test(element))
|
|
77
|
+
continue;
|
|
78
|
+
if (isSecretFieldName(element))
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Record one added/removed entry, unless the entry cap is reached. The caller
|
|
85
|
+
* keeps its own count of what it saw, so a capped list still reports the true
|
|
86
|
+
* count and `omittedEntries` says how many names were left out.
|
|
87
|
+
*/
|
|
88
|
+
function addEntry(list, entry, budget) {
|
|
89
|
+
if (budget.entriesUsed >= budget.entryCap) {
|
|
90
|
+
budget.omittedEntries++;
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
budget.entriesUsed++;
|
|
94
|
+
list.push(entry);
|
|
95
|
+
}
|
|
96
|
+
function walkLeaves(value, prefix, out) {
|
|
97
|
+
if (Array.isArray(value)) {
|
|
98
|
+
if (value.length === 0) {
|
|
99
|
+
if (prefix)
|
|
100
|
+
out.set(prefix, []);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
for (let i = 0; i < value.length; i++) {
|
|
104
|
+
walkLeaves(value[i], prefix ? `${prefix}.${i}` : String(i), out);
|
|
105
|
+
}
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (isContainer(value)) {
|
|
109
|
+
const keys = Object.keys(value).sort();
|
|
110
|
+
// An empty container is a leaf, so `{}` versus a value still reads as a
|
|
111
|
+
// change instead of vanishing.
|
|
112
|
+
if (keys.length === 0) {
|
|
113
|
+
if (prefix)
|
|
114
|
+
out.set(prefix, value);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
for (const key of keys) {
|
|
118
|
+
walkLeaves(value[key], prefix ? `${prefix}.${key}` : key, out);
|
|
119
|
+
}
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
if (prefix)
|
|
123
|
+
out.set(prefix, value);
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Leaf-by-leaf diff of two values as `path: "before → after"`. Arrays carry
|
|
127
|
+
* their index in the path, so a reordered list reads as changed leaves rather
|
|
128
|
+
* than as nothing.
|
|
129
|
+
*/
|
|
130
|
+
export function diffLeafValues(before, after, budget) {
|
|
131
|
+
const changes = {};
|
|
132
|
+
const paths = budget.pathsOnly ? [] : undefined;
|
|
133
|
+
let detected = 0;
|
|
134
|
+
const record = (path, from, to) => {
|
|
135
|
+
if (JSON.stringify(from) === JSON.stringify(to))
|
|
136
|
+
return;
|
|
137
|
+
detected++;
|
|
138
|
+
if (budget.fieldChangesUsed >= budget.fieldChangeCap) {
|
|
139
|
+
budget.omittedFieldChanges++;
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
budget.fieldChangesUsed++;
|
|
143
|
+
if (paths) {
|
|
144
|
+
paths.push(path);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
changes[path] = pathHoldsSecret(path)
|
|
148
|
+
? REDACTED
|
|
149
|
+
: `${renderValue(from)} → ${renderValue(to)}`;
|
|
150
|
+
};
|
|
151
|
+
if (!isContainer(before) && !isContainer(after)) {
|
|
152
|
+
record("(value)", before, after);
|
|
153
|
+
return { changes, ...(paths ? { paths } : {}), detected };
|
|
154
|
+
}
|
|
155
|
+
const beforeLeaves = new Map();
|
|
156
|
+
const afterLeaves = new Map();
|
|
157
|
+
walkLeaves(before, "", beforeLeaves);
|
|
158
|
+
walkLeaves(after, "", afterLeaves);
|
|
159
|
+
const allPaths = [
|
|
160
|
+
...new Set([...beforeLeaves.keys(), ...afterLeaves.keys()]),
|
|
161
|
+
].sort();
|
|
162
|
+
for (const path of allPaths) {
|
|
163
|
+
record(path, beforeLeaves.get(path), afterLeaves.get(path));
|
|
164
|
+
}
|
|
165
|
+
return { changes, ...(paths ? { paths } : {}), detected };
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* The change entry for one event or row: its identity, the field changes the
|
|
169
|
+
* budget recorded (or, when values are withheld, just their paths), and how
|
|
170
|
+
* many more it found than it could record.
|
|
171
|
+
*/
|
|
172
|
+
function changeEntry(identity, leaf) {
|
|
173
|
+
const recorded = leaf.paths
|
|
174
|
+
? leaf.paths.length
|
|
175
|
+
: Object.keys(leaf.changes).length;
|
|
176
|
+
const reported = recorded === 0
|
|
177
|
+
? {}
|
|
178
|
+
: leaf.paths
|
|
179
|
+
? { paths: leaf.paths }
|
|
180
|
+
: { changes: leaf.changes };
|
|
181
|
+
return {
|
|
182
|
+
...identity,
|
|
183
|
+
...reported,
|
|
184
|
+
...(leaf.detected > recorded
|
|
185
|
+
? { omittedChanges: leaf.detected - recorded }
|
|
186
|
+
: {}),
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
function isEventish(section) {
|
|
190
|
+
if (Array.isArray(section))
|
|
191
|
+
return true;
|
|
192
|
+
return isContainer(section) && Array.isArray(section.events);
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Diff one plan section. Event-shaped sections (`{ events: [] }`, or a bare
|
|
196
|
+
* array such as `milestones`) compare events by name plus occurrence, the same
|
|
197
|
+
* keying the section dry run uses, so events sharing a name each keep their
|
|
198
|
+
* own entry. Anything else (`variables`, `montecarlo`,
|
|
199
|
+
* `withdrawalStrategy`) compares leaf by leaf.
|
|
200
|
+
*/
|
|
201
|
+
export function diffEventSection(before, after, budget) {
|
|
202
|
+
const beforeEvents = getEvents(before);
|
|
203
|
+
const afterEvents = getEvents(after);
|
|
204
|
+
const beforeKeyed = keyEvents(beforeEvents);
|
|
205
|
+
const afterKeyed = keyEvents(afterEvents);
|
|
206
|
+
const added = [];
|
|
207
|
+
const removed = [];
|
|
208
|
+
const modified = [];
|
|
209
|
+
let addedCount = 0;
|
|
210
|
+
let removedCount = 0;
|
|
211
|
+
let modifiedCount = 0;
|
|
212
|
+
let unchangedCount = 0;
|
|
213
|
+
for (const [key, { name, event }] of afterKeyed) {
|
|
214
|
+
const previous = beforeKeyed.get(key);
|
|
215
|
+
if (!previous) {
|
|
216
|
+
addedCount++;
|
|
217
|
+
addEntry(added, name, budget);
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
const leaf = diffLeafValues(previous.event, event, budget);
|
|
221
|
+
if (leaf.detected > 0) {
|
|
222
|
+
modifiedCount++;
|
|
223
|
+
addEntry(modified, changeEntry({ name }, leaf), budget);
|
|
224
|
+
}
|
|
225
|
+
else {
|
|
226
|
+
unchangedCount++;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
for (const [key, { name }] of beforeKeyed) {
|
|
230
|
+
if (!afterKeyed.has(key)) {
|
|
231
|
+
removedCount++;
|
|
232
|
+
addEntry(removed, name, budget);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
added.sort();
|
|
236
|
+
removed.sort();
|
|
237
|
+
return {
|
|
238
|
+
kind: "events",
|
|
239
|
+
count: `${beforeEvents.length} → ${afterEvents.length}`,
|
|
240
|
+
changed: addedCount > 0 || removedCount > 0 || modifiedCount > 0,
|
|
241
|
+
addedCount,
|
|
242
|
+
...(added.length > 0 ? { added } : {}),
|
|
243
|
+
removedCount,
|
|
244
|
+
...(removed.length > 0 ? { removed } : {}),
|
|
245
|
+
modifiedCount,
|
|
246
|
+
...(modified.length > 0 ? { modified } : {}),
|
|
247
|
+
unchangedCount,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Diff a data area that holds neither events nor rows: a plan's `variables`,
|
|
252
|
+
* `montecarlo` or `withdrawalStrategy`, and the whole `progress` or `settings`
|
|
253
|
+
* section.
|
|
254
|
+
*/
|
|
255
|
+
export function diffFieldSection(before, after, budget) {
|
|
256
|
+
const leaf = diffLeafValues(before, after, budget);
|
|
257
|
+
return {
|
|
258
|
+
kind: "fields",
|
|
259
|
+
changed: leaf.detected > 0,
|
|
260
|
+
...changeEntry({}, leaf),
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Choose how to compare one plan section. A section that is event-shaped on
|
|
265
|
+
* either side compares as events, including one that exists on only one side,
|
|
266
|
+
* so a section added or dropped reads as events appearing or disappearing
|
|
267
|
+
* rather than as a wall of leaf paths. `compareEvents` decides how two sets of
|
|
268
|
+
* events are reported: as added/removed/modified over time, or as
|
|
269
|
+
* onlyInA/onlyInB/different between two plans.
|
|
270
|
+
*/
|
|
271
|
+
function diffSectionShape(before, after, budget, compareEvents) {
|
|
272
|
+
const beforeIsEvent = isEventish(before);
|
|
273
|
+
const afterIsEvent = isEventish(after);
|
|
274
|
+
if (beforeIsEvent && afterIsEvent)
|
|
275
|
+
return compareEvents(before, after, budget);
|
|
276
|
+
if (beforeIsEvent && (after === undefined || after === null)) {
|
|
277
|
+
return compareEvents(before, after, budget);
|
|
278
|
+
}
|
|
279
|
+
if (afterIsEvent && (before === undefined || before === null)) {
|
|
280
|
+
return compareEvents(before, after, budget);
|
|
281
|
+
}
|
|
282
|
+
return diffFieldSection(before, after, budget);
|
|
283
|
+
}
|
|
284
|
+
function planNameOf(plan) {
|
|
285
|
+
return String(plan?.name ?? plan?.id ?? "unnamed");
|
|
286
|
+
}
|
|
287
|
+
/** Match plans across two reads: by id when they carry one, else by name. */
|
|
288
|
+
export function planKey(plan) {
|
|
289
|
+
const obj = (plan ?? {});
|
|
290
|
+
const id = obj.id;
|
|
291
|
+
if (id !== undefined && id !== null && id !== "")
|
|
292
|
+
return `id:${String(id)}`;
|
|
293
|
+
return `name:${planNameOf(plan)}`;
|
|
294
|
+
}
|
|
295
|
+
export function planIdentity(plan) {
|
|
296
|
+
const obj = (plan ?? {});
|
|
297
|
+
return {
|
|
298
|
+
id: String(obj.id ?? ""),
|
|
299
|
+
name: String(obj.name ?? "unnamed"),
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
function asArray(value) {
|
|
303
|
+
return Array.isArray(value) ? value : [];
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* Diff two copies of one plan. `section` narrows the comparison to a single
|
|
307
|
+
* plan section; the plan's own settings (every field that is not a known
|
|
308
|
+
* section, such as `retirementAge` or `endAge`) are always compared, minus the
|
|
309
|
+
* computed fields.
|
|
310
|
+
*/
|
|
311
|
+
export function diffPlan(before, after, budget, section) {
|
|
312
|
+
const sections = {};
|
|
313
|
+
const changedSections = [];
|
|
314
|
+
const sectionNames = section
|
|
315
|
+
? [section]
|
|
316
|
+
: PLAN_SECTIONS.filter((name) => name in before || name in after);
|
|
317
|
+
for (const name of sectionNames) {
|
|
318
|
+
const result = diffSectionShape(before[name], after[name], budget, diffEventSection);
|
|
319
|
+
sections[name] = result;
|
|
320
|
+
if (result.changed)
|
|
321
|
+
changedSections.push(name);
|
|
322
|
+
}
|
|
323
|
+
// Plan settings are every field that is not a known section and not one of
|
|
324
|
+
// the fields ProjectionLab computes, whatever the section filter is.
|
|
325
|
+
const skip = new Set([...PLAN_SECTIONS, ...COMPUTED_PLAN_FIELDS]);
|
|
326
|
+
const beforeRest = {};
|
|
327
|
+
const afterRest = {};
|
|
328
|
+
for (const key of new Set([...Object.keys(before), ...Object.keys(after)])) {
|
|
329
|
+
if (skip.has(key))
|
|
330
|
+
continue;
|
|
331
|
+
beforeRest[key] = before[key];
|
|
332
|
+
afterRest[key] = after[key];
|
|
333
|
+
}
|
|
334
|
+
const planFields = diffLeafValues(beforeRest, afterRest, budget);
|
|
335
|
+
return {
|
|
336
|
+
...planIdentity(after ?? before),
|
|
337
|
+
changed: changedSections.length > 0 || planFields.detected > 0,
|
|
338
|
+
...changeEntry({}, planFields),
|
|
339
|
+
...(sectionNames.length > 0 ? { sections } : {}),
|
|
340
|
+
...(changedSections.length > 0 ? { changedSections } : {}),
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* Tokens that name each event of `plan` by its section and display name, keyed
|
|
345
|
+
* by the id that plan's own events use to point at it.
|
|
346
|
+
*
|
|
347
|
+
* Events refer to other events of the same plan by id (`start: { type:
|
|
348
|
+
* "milestone", value: "<milestone id>" }`, a priority's `accountId`), and two
|
|
349
|
+
* plans built independently give the same milestone unrelated ids. Comparing
|
|
350
|
+
* the raw ids would report a difference at every reference of a hand-rebuilt
|
|
351
|
+
* plan; comparing tokens makes a reference to the same-named event compare
|
|
352
|
+
* equal and a reference to a differently named one read as a difference. An id
|
|
353
|
+
* that is not an event of this plan (a Current Finances `accountId`, which
|
|
354
|
+
* every plan shares) gets no token and is compared as it is.
|
|
355
|
+
*/
|
|
356
|
+
export function eventReferenceTokens(plan) {
|
|
357
|
+
const tokens = new Map();
|
|
358
|
+
if (!isContainer(plan) || Array.isArray(plan))
|
|
359
|
+
return tokens;
|
|
360
|
+
for (const section of Object.keys(plan)) {
|
|
361
|
+
const value = plan[section];
|
|
362
|
+
if (!isEventish(value))
|
|
363
|
+
continue;
|
|
364
|
+
for (const { name, event } of keyEvents(getEvents(value)).values()) {
|
|
365
|
+
const id = event?.id;
|
|
366
|
+
if (id === undefined || id === null || id === "")
|
|
367
|
+
continue;
|
|
368
|
+
const token = `@${section}:${name}`;
|
|
369
|
+
if (!tokens.has(String(id)))
|
|
370
|
+
tokens.set(String(id), token);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
return tokens;
|
|
374
|
+
}
|
|
375
|
+
/** A copy of `value` with each string naming an event of the plan replaced by its token. */
|
|
376
|
+
function substituteReferences(value, tokens) {
|
|
377
|
+
if (typeof value === "string")
|
|
378
|
+
return tokens.get(value) ?? value;
|
|
379
|
+
if (Array.isArray(value)) {
|
|
380
|
+
return value.map((element) => substituteReferences(element, tokens));
|
|
381
|
+
}
|
|
382
|
+
if (isContainer(value)) {
|
|
383
|
+
const copy = {};
|
|
384
|
+
for (const key of Object.keys(value)) {
|
|
385
|
+
copy[key] = substituteReferences(value[key], tokens);
|
|
386
|
+
}
|
|
387
|
+
return copy;
|
|
388
|
+
}
|
|
389
|
+
return value;
|
|
390
|
+
}
|
|
391
|
+
/**
|
|
392
|
+
* One event as plan-to-plan comparison sees it: its own `id` and `key` dropped,
|
|
393
|
+
* references tokened. ProjectionLab gives each event a random `key` when the
|
|
394
|
+
* event is created, so two independently built plans never share it.
|
|
395
|
+
*/
|
|
396
|
+
function eventForPair(event, tokens) {
|
|
397
|
+
if (Array.isArray(event) || !isContainer(event)) {
|
|
398
|
+
return substituteReferences(event, tokens);
|
|
399
|
+
}
|
|
400
|
+
const copy = {};
|
|
401
|
+
for (const key of Object.keys(event)) {
|
|
402
|
+
if (key === "id" || key === "key")
|
|
403
|
+
continue;
|
|
404
|
+
copy[key] = substituteReferences(event[key], tokens);
|
|
405
|
+
}
|
|
406
|
+
return copy;
|
|
407
|
+
}
|
|
408
|
+
/**
|
|
409
|
+
* One plan section with each event's own `id` and `key` dropped and each
|
|
410
|
+
* reference tokened.
|
|
411
|
+
*/
|
|
412
|
+
function sectionForPair(section, tokens) {
|
|
413
|
+
if (Array.isArray(section)) {
|
|
414
|
+
return section.map((event) => eventForPair(event, tokens));
|
|
415
|
+
}
|
|
416
|
+
const obj = section;
|
|
417
|
+
if (isContainer(section) && Array.isArray(obj.events)) {
|
|
418
|
+
const copy = {};
|
|
419
|
+
for (const key of Object.keys(obj)) {
|
|
420
|
+
copy[key] =
|
|
421
|
+
key === "events"
|
|
422
|
+
? obj.events.map((event) => eventForPair(event, tokens))
|
|
423
|
+
: substituteReferences(obj[key], tokens);
|
|
424
|
+
}
|
|
425
|
+
return copy;
|
|
426
|
+
}
|
|
427
|
+
return substituteReferences(section, tokens);
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* One plan reshaped so it can be compared against a different plan: each
|
|
431
|
+
* event's own `id` and `key` are dropped and each reference to another event of
|
|
432
|
+
* the same plan is replaced by that event's token. Those are the only fields
|
|
433
|
+
* two independently built plans of the same content cannot share.
|
|
434
|
+
*/
|
|
435
|
+
function planForPair(plan) {
|
|
436
|
+
const tokens = eventReferenceTokens(plan);
|
|
437
|
+
const prepared = {};
|
|
438
|
+
for (const key of Object.keys(plan)) {
|
|
439
|
+
prepared[key] = sectionForPair(plan[key], tokens);
|
|
440
|
+
}
|
|
441
|
+
return prepared;
|
|
442
|
+
}
|
|
443
|
+
/**
|
|
444
|
+
* Compare one event-shaped section of two different plans. `a` is plan A and
|
|
445
|
+
* `b` is plan B, so each change reads `A's value → B's value`. Both sides carry
|
|
446
|
+
* the ids and random keys dropped and the references tokened by `planForPair`.
|
|
447
|
+
*/
|
|
448
|
+
export function diffEventPair(a, b, budget) {
|
|
449
|
+
const aEvents = getEvents(a);
|
|
450
|
+
const bEvents = getEvents(b);
|
|
451
|
+
const aKeyed = keyEvents(aEvents);
|
|
452
|
+
const bKeyed = keyEvents(bEvents);
|
|
453
|
+
const onlyInA = [];
|
|
454
|
+
const onlyInB = [];
|
|
455
|
+
const different = [];
|
|
456
|
+
let onlyInACount = 0;
|
|
457
|
+
let onlyInBCount = 0;
|
|
458
|
+
let differentCount = 0;
|
|
459
|
+
let sameCount = 0;
|
|
460
|
+
for (const [key, { name, event }] of bKeyed) {
|
|
461
|
+
const counterpart = aKeyed.get(key);
|
|
462
|
+
if (!counterpart) {
|
|
463
|
+
onlyInBCount++;
|
|
464
|
+
addEntry(onlyInB, name, budget);
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
467
|
+
const leaf = diffLeafValues(counterpart.event, event, budget);
|
|
468
|
+
if (leaf.detected > 0) {
|
|
469
|
+
differentCount++;
|
|
470
|
+
addEntry(different, changeEntry({ name }, leaf), budget);
|
|
471
|
+
}
|
|
472
|
+
else {
|
|
473
|
+
sameCount++;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
for (const [key, { name }] of aKeyed) {
|
|
477
|
+
if (!bKeyed.has(key)) {
|
|
478
|
+
onlyInACount++;
|
|
479
|
+
addEntry(onlyInA, name, budget);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
onlyInA.sort();
|
|
483
|
+
onlyInB.sort();
|
|
484
|
+
return {
|
|
485
|
+
kind: "events",
|
|
486
|
+
count: `${aEvents.length} → ${bEvents.length}`,
|
|
487
|
+
changed: onlyInACount > 0 || onlyInBCount > 0 || differentCount > 0,
|
|
488
|
+
onlyInACount,
|
|
489
|
+
...(onlyInA.length > 0 ? { onlyInA } : {}),
|
|
490
|
+
onlyInBCount,
|
|
491
|
+
...(onlyInB.length > 0 ? { onlyInB } : {}),
|
|
492
|
+
differentCount,
|
|
493
|
+
...(different.length > 0 ? { different } : {}),
|
|
494
|
+
sameCount,
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
/**
|
|
498
|
+
* Compare two different plans field by field, so a plan rebuilt by hand can be
|
|
499
|
+
* checked against the original. Plan A's values are reported first in every
|
|
500
|
+
* `A → B` pair. Events match by name plus occurrence, the same keying the
|
|
501
|
+
* time diff uses; the fields that say which plan an object is (`id`, `name`),
|
|
502
|
+
* the fields ProjectionLab computes (`lastUpdated`, `simKey`, `meta`) and each
|
|
503
|
+
* event's own `id` and randomly assigned `key` are left out, and references
|
|
504
|
+
* between a plan's own events are compared by the event they name. `section`
|
|
505
|
+
* narrows the section comparison; plan settings outside the sections are always
|
|
506
|
+
* compared.
|
|
507
|
+
*/
|
|
508
|
+
export function diffPlanPair(planA, planB, budget, section) {
|
|
509
|
+
const a = planForPair(planA);
|
|
510
|
+
const b = planForPair(planB);
|
|
511
|
+
const sections = {};
|
|
512
|
+
const changedSections = [];
|
|
513
|
+
const sectionNames = section
|
|
514
|
+
? [section]
|
|
515
|
+
: PLAN_SECTIONS.filter((name) => name in planA || name in planB);
|
|
516
|
+
for (const name of sectionNames) {
|
|
517
|
+
const result = diffSectionShape(a[name], b[name], budget, diffEventPair);
|
|
518
|
+
sections[name] = result;
|
|
519
|
+
if (result.changed)
|
|
520
|
+
changedSections.push(name);
|
|
521
|
+
}
|
|
522
|
+
const skip = new Set([
|
|
523
|
+
...PLAN_SECTIONS,
|
|
524
|
+
...COMPUTED_PLAN_FIELDS,
|
|
525
|
+
...PLAN_IDENTITY_FIELDS,
|
|
526
|
+
]);
|
|
527
|
+
const aRest = {};
|
|
528
|
+
const bRest = {};
|
|
529
|
+
for (const key of new Set([...Object.keys(a), ...Object.keys(b)])) {
|
|
530
|
+
if (skip.has(key))
|
|
531
|
+
continue;
|
|
532
|
+
aRest[key] = a[key];
|
|
533
|
+
bRest[key] = b[key];
|
|
534
|
+
}
|
|
535
|
+
const settings = diffFieldSection(aRest, bRest, budget);
|
|
536
|
+
return {
|
|
537
|
+
a: planIdentity(planA),
|
|
538
|
+
b: planIdentity(planB),
|
|
539
|
+
changed: changedSections.length > 0 || settings.changed,
|
|
540
|
+
settings,
|
|
541
|
+
...(sectionNames.length > 0 ? { sections } : {}),
|
|
542
|
+
...(changedSections.length > 0 ? { changedSections } : {}),
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
/**
|
|
546
|
+
* Diff two plans arrays. `onlyKey` (from `planKey`) reports one plan alone, so
|
|
547
|
+
* a caller narrowed to a single plan gets just that plan's entry.
|
|
548
|
+
*/
|
|
549
|
+
export function diffPlanList(before, after, budget, options = {}) {
|
|
550
|
+
const beforePlans = asArray(before);
|
|
551
|
+
const afterPlans = asArray(after);
|
|
552
|
+
const beforeByKey = new Map();
|
|
553
|
+
for (const plan of beforePlans) {
|
|
554
|
+
if (isContainer(plan))
|
|
555
|
+
beforeByKey.set(planKey(plan), plan);
|
|
556
|
+
}
|
|
557
|
+
const afterByKey = new Map();
|
|
558
|
+
for (const plan of afterPlans) {
|
|
559
|
+
if (isContainer(plan))
|
|
560
|
+
afterByKey.set(planKey(plan), plan);
|
|
561
|
+
}
|
|
562
|
+
const added = [];
|
|
563
|
+
const removed = [];
|
|
564
|
+
const changedPlans = [];
|
|
565
|
+
const unchanged = [];
|
|
566
|
+
let addedCount = 0;
|
|
567
|
+
let removedCount = 0;
|
|
568
|
+
let changedCount = 0;
|
|
569
|
+
let unchangedCount = 0;
|
|
570
|
+
for (const [key, plan] of afterByKey) {
|
|
571
|
+
if (options.onlyKey && key !== options.onlyKey)
|
|
572
|
+
continue;
|
|
573
|
+
const previous = beforeByKey.get(key);
|
|
574
|
+
if (!previous) {
|
|
575
|
+
addedCount++;
|
|
576
|
+
addEntry(added, planIdentity(plan), budget);
|
|
577
|
+
continue;
|
|
578
|
+
}
|
|
579
|
+
const diff = diffPlan(previous, plan, budget, options.section);
|
|
580
|
+
if (diff.changed) {
|
|
581
|
+
changedCount++;
|
|
582
|
+
addEntry(changedPlans, diff, budget);
|
|
583
|
+
}
|
|
584
|
+
else {
|
|
585
|
+
unchangedCount++;
|
|
586
|
+
addEntry(unchanged, diff.name, budget);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
for (const [key, plan] of beforeByKey) {
|
|
590
|
+
if (options.onlyKey && key !== options.onlyKey)
|
|
591
|
+
continue;
|
|
592
|
+
if (!afterByKey.has(key)) {
|
|
593
|
+
removedCount++;
|
|
594
|
+
addEntry(removed, planIdentity(plan), budget);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
return {
|
|
598
|
+
changed: addedCount > 0 || removedCount > 0 || changedCount > 0,
|
|
599
|
+
planCount: `${beforePlans.length} → ${afterPlans.length}`,
|
|
600
|
+
addedCount,
|
|
601
|
+
...(added.length > 0 ? { added } : {}),
|
|
602
|
+
removedCount,
|
|
603
|
+
...(removed.length > 0 ? { removed } : {}),
|
|
604
|
+
changedCount,
|
|
605
|
+
...(changedPlans.length > 0 ? { changedPlans } : {}),
|
|
606
|
+
unchangedCount,
|
|
607
|
+
...(unchanged.length > 0 ? { unchanged } : {}),
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
/**
|
|
611
|
+
* Key Current Finances rows for matching: by id when present, since ProjectionLab
|
|
612
|
+
* ids are stable, else by name plus occurrence the way events are keyed.
|
|
613
|
+
*/
|
|
614
|
+
function keyRows(rows) {
|
|
615
|
+
// Rows without an id keep their own entry under the event keying, which
|
|
616
|
+
// numbers repeated names so two of them can't collapse into one.
|
|
617
|
+
const idlessKeys = [
|
|
618
|
+
...keyEvents(rows.filter((row) => {
|
|
619
|
+
const id = (row ?? {});
|
|
620
|
+
return id.id === undefined || id.id === null || id.id === "";
|
|
621
|
+
})).keys(),
|
|
622
|
+
];
|
|
623
|
+
const keyed = new Map();
|
|
624
|
+
let idlessAt = 0;
|
|
625
|
+
for (const row of rows) {
|
|
626
|
+
const obj = (row ?? {});
|
|
627
|
+
const id = obj.id;
|
|
628
|
+
if (id !== undefined && id !== null && id !== "") {
|
|
629
|
+
keyed.set(`id:${String(id)}`, {
|
|
630
|
+
id: String(id),
|
|
631
|
+
name: planNameOf(row),
|
|
632
|
+
row,
|
|
633
|
+
});
|
|
634
|
+
continue;
|
|
635
|
+
}
|
|
636
|
+
const key = idlessKeys[idlessAt++];
|
|
637
|
+
keyed.set(key, { id: "", name: planNameOf(row), row });
|
|
638
|
+
}
|
|
639
|
+
return keyed;
|
|
640
|
+
}
|
|
641
|
+
function diffCollection(before, after, budget) {
|
|
642
|
+
const beforeRows = asArray(before);
|
|
643
|
+
const afterRows = asArray(after);
|
|
644
|
+
const beforeKeyed = keyRows(beforeRows);
|
|
645
|
+
const afterKeyed = keyRows(afterRows);
|
|
646
|
+
const added = [];
|
|
647
|
+
const removed = [];
|
|
648
|
+
const modified = [];
|
|
649
|
+
let addedCount = 0;
|
|
650
|
+
let removedCount = 0;
|
|
651
|
+
let modifiedCount = 0;
|
|
652
|
+
let unchangedCount = 0;
|
|
653
|
+
for (const [key, { id, name, row }] of afterKeyed) {
|
|
654
|
+
const previous = beforeKeyed.get(key);
|
|
655
|
+
if (!previous) {
|
|
656
|
+
addedCount++;
|
|
657
|
+
addEntry(added, { id, name }, budget);
|
|
658
|
+
continue;
|
|
659
|
+
}
|
|
660
|
+
const leaf = diffLeafValues(previous.row, row, budget);
|
|
661
|
+
if (leaf.detected > 0) {
|
|
662
|
+
modifiedCount++;
|
|
663
|
+
addEntry(modified, changeEntry({ id, name }, leaf), budget);
|
|
664
|
+
}
|
|
665
|
+
else {
|
|
666
|
+
unchangedCount++;
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
for (const [key, { id, name }] of beforeKeyed) {
|
|
670
|
+
if (!afterKeyed.has(key)) {
|
|
671
|
+
removedCount++;
|
|
672
|
+
addEntry(removed, { id, name }, budget);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
return {
|
|
676
|
+
kind: "rows",
|
|
677
|
+
count: `${beforeRows.length} → ${afterRows.length}`,
|
|
678
|
+
changed: addedCount > 0 || removedCount > 0 || modifiedCount > 0,
|
|
679
|
+
addedCount,
|
|
680
|
+
...(added.length > 0 ? { added } : {}),
|
|
681
|
+
removedCount,
|
|
682
|
+
...(removed.length > 0 ? { removed } : {}),
|
|
683
|
+
modifiedCount,
|
|
684
|
+
...(modified.length > 0 ? { modified } : {}),
|
|
685
|
+
unchangedCount,
|
|
686
|
+
};
|
|
687
|
+
}
|
|
688
|
+
/**
|
|
689
|
+
* Diff Current Finances collection by collection, matching rows by id. Fields
|
|
690
|
+
* outside the account collections (a `today` key that is not one of the four
|
|
691
|
+
* collections) are compared leaf by leaf.
|
|
692
|
+
*/
|
|
693
|
+
export function diffCurrentFinances(before, after, budget) {
|
|
694
|
+
const beforeRoot = (isContainer(before) ? before : {});
|
|
695
|
+
const afterRoot = (isContainer(after) ? after : {});
|
|
696
|
+
const collections = {};
|
|
697
|
+
let changed = false;
|
|
698
|
+
for (const collection of CURRENT_FINANCE_COLLECTIONS) {
|
|
699
|
+
if (!Array.isArray(beforeRoot[collection]) &&
|
|
700
|
+
!Array.isArray(afterRoot[collection])) {
|
|
701
|
+
continue;
|
|
702
|
+
}
|
|
703
|
+
const result = diffCollection(beforeRoot[collection], afterRoot[collection], budget);
|
|
704
|
+
collections[collection] = result;
|
|
705
|
+
if (result.changed)
|
|
706
|
+
changed = true;
|
|
707
|
+
}
|
|
708
|
+
const skip = new Set(CURRENT_FINANCE_COLLECTIONS);
|
|
709
|
+
const beforeRest = {};
|
|
710
|
+
const afterRest = {};
|
|
711
|
+
for (const key of new Set([
|
|
712
|
+
...Object.keys(beforeRoot),
|
|
713
|
+
...Object.keys(afterRoot),
|
|
714
|
+
])) {
|
|
715
|
+
if (skip.has(key))
|
|
716
|
+
continue;
|
|
717
|
+
beforeRest[key] = beforeRoot[key];
|
|
718
|
+
afterRest[key] = afterRoot[key];
|
|
719
|
+
}
|
|
720
|
+
const rest = diffLeafValues(beforeRest, afterRest, budget);
|
|
721
|
+
return {
|
|
722
|
+
changed: changed || rest.detected > 0,
|
|
723
|
+
collections,
|
|
724
|
+
...changeEntry({}, rest),
|
|
725
|
+
};
|
|
726
|
+
}
|
|
727
|
+
/**
|
|
728
|
+
* Diff one snapshot data section (`plans`, `currentFinances`, `progress`,
|
|
729
|
+
* `settings`) by its stored key.
|
|
730
|
+
*/
|
|
731
|
+
export function diffSectionData(section, before, after, budget, options = {}) {
|
|
732
|
+
switch (section) {
|
|
733
|
+
case "plans":
|
|
734
|
+
return diffPlanList(before, after, budget, {
|
|
735
|
+
section: options.planSection,
|
|
736
|
+
onlyKey: options.planOnlyKey,
|
|
737
|
+
});
|
|
738
|
+
case "currentFinances":
|
|
739
|
+
return diffCurrentFinances(before, after, budget);
|
|
740
|
+
default:
|
|
741
|
+
return diffFieldSection(before, after, budget);
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
//# sourceMappingURL=diff.js.map
|