@pixlcore/xycli 1.0.7 → 1.0.9

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.
@@ -0,0 +1,315 @@
1
+ // Filesystem sync for storage bucket data and binary files.
2
+ // Bucket definitions themselves remain in the ordinary XYPDF sync engine.
3
+
4
+ const fs = require('fs');
5
+ const Path = require('path');
6
+ const Crypto = require('crypto');
7
+ const Diff = require('diff');
8
+ const cli = require('pixl-cli');
9
+ const Tools = cli.Tools;
10
+
11
+ module.exports = {
12
+
13
+ requireBucketSyncVersion() {
14
+ // The replace flag and separate data modification time arrived in 1.1.2.
15
+ // Keep the older minimum version for every other sync resource.
16
+ if (!this.xyopsVersion || this.compareVersions(this.xyopsVersion, '1.1.2') < 0) {
17
+ this.die("Bucket sync requires xyOps v1.1.2 or later (server: " + (this.xyopsVersion || 'unknown') + ").");
18
+ }
19
+ },
20
+
21
+ getSyncBucketPaths(item) {
22
+ // The local XYPDF source owns its sibling content directory. The bucket ID
23
+ // identifies the remote object, so title edits never move existing files.
24
+ var dir = item.file.replace(/\.json$/i, '');
25
+ return { dir, data: Path.join(dir, 'data.json'), files: Path.join(dir, 'files') };
26
+ },
27
+
28
+ cleanSyncBucketFilename(filename) {
29
+ // Match xyOps cleanFilename(), which is used for manifest identity. The
30
+ // server's cleanURLFilename() is only for the storage URL component.
31
+ return filename.replace(/[^\w\-\+\.\,\s\(\)\[\]\{\}\'\"\!\&\^\%\$\#\@\*\?\~]+/g, '_');
32
+ },
33
+
34
+ validateSyncBucketFilename(filename) {
35
+ // A server manifest must never be allowed to escape the bucket files dir.
36
+ if (!filename || (typeof(filename) != 'string') || (filename == '.') || (filename == '..') ||
37
+ filename.includes('/') || filename.includes('\\') || filename.includes('\0') || (Path.basename(filename) != filename)) {
38
+ throw new Error("Unsafe bucket filename: " + filename);
39
+ }
40
+ },
41
+
42
+ async getSyncBucket(id) {
43
+ // Read both independently stored content records and their timestamps.
44
+ var { err, data } = await this.api.getBucket({ id });
45
+ if (err) throw err;
46
+ if (!data || !Tools.isaHash(data.data) || !Array.isArray(data.files) ||
47
+ !data.meta || (typeof(data.meta.mod) != 'number') || !Number.isFinite(data.meta.mod)) {
48
+ throw new Error("Incomplete bucket data or file manifest: " + id);
49
+ }
50
+
51
+ var names = new Set();
52
+ data.files.forEach( file => {
53
+ this.validateSyncBucketFilename(file.filename);
54
+ if ((typeof(file.date) != 'number') || !Number.isFinite(file.date) ||
55
+ (typeof(file.size) != 'number') || !Number.isSafeInteger(file.size) || (file.size < 0) ||
56
+ (typeof(file.path) != 'string') || !file.path.startsWith('files/bucket/' + id + '/') ||
57
+ Path.posix.normalize(file.path) != file.path) {
58
+ throw new Error("Invalid file manifest entry in bucket: " + id);
59
+ }
60
+ if (names.has(file.filename)) throw new Error("Duplicate bucket filename in manifest: " + file.filename);
61
+ names.add(file.filename);
62
+ });
63
+ return data;
64
+ },
65
+
66
+ readSyncBucketLocal(item) {
67
+ // A missing data file or files directory is an incomplete inventory. This
68
+ // is an error even when the remote bucket currently happens to be empty.
69
+ var paths = this.getSyncBucketPaths(item);
70
+ var stat = fs.lstatSync(paths.data);
71
+ if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("Invalid bucket data file: " + paths.data);
72
+ var data = JSON.parse(fs.readFileSync(paths.data, 'utf8'));
73
+ if (!Tools.isaHash(data)) throw new Error("Bucket data must be a JSON object: " + paths.data);
74
+
75
+ var dir_stat = fs.lstatSync(paths.files);
76
+ if (!dir_stat.isDirectory() || dir_stat.isSymbolicLink()) throw new Error("Invalid bucket files directory: " + paths.files);
77
+
78
+ var files = new Map();
79
+ fs.readdirSync(paths.files, { withFileTypes: true }).forEach( entry => {
80
+ var path = Path.join(paths.files, entry.name);
81
+ if (!entry.isFile()) throw new Error("Bucket files directory must contain only regular files: " + path);
82
+ var name = this.cleanSyncBucketFilename(entry.name);
83
+ this.validateSyncBucketFilename(name);
84
+ if (files.has(name)) throw new Error("Bucket filenames collide after normalization: " + path);
85
+ files.set(name, { path, stat: fs.statSync(path) });
86
+ });
87
+ return { paths, data, data_stat: stat, files };
88
+ },
89
+
90
+ stampSyncBucketFile(path, epoch) {
91
+ // Keep the access time intact. Server times are Unix seconds; Node accepts
92
+ // the same unit for utimes(), including fractional seconds if provided.
93
+ var stat = fs.statSync(path);
94
+ fs.utimesSync(path, stat.atime, Number(epoch));
95
+ },
96
+
97
+ printSyncBucketHeading(icon, label, title, id, deleting) {
98
+ // Match the standard sync heading: direction, action, display title and
99
+ // a quiet internal ID. Give destructive operations the existing red style.
100
+ var action = deleting ? cli.chalk.red.bold : cli.chalk.green.bold;
101
+ var name = deleting ? cli.chalk.red.bold : cli.chalk.cyan.bold;
102
+ println( "\n " + cli.emoji(icon) + " " + action(label) + ": " + name(title) + cli.chalk.gray(" (" + id + ")") );
103
+ },
104
+
105
+ async downloadSyncBucketFile(id, file, destination) {
106
+ // Download to a private sibling first. A failed or partial response must
107
+ // never replace the previous local copy of a bucket file.
108
+ var temp = destination + '.xy-sync-' + Crypto.randomBytes(8).toString('hex') + '.tmp';
109
+ try {
110
+ var { err } = await this.api.file({
111
+ path: file.path.replace(/^files\//, ''),
112
+ download: file.filename
113
+ }, { download: temp });
114
+ if (err) throw err;
115
+ var stat = fs.statSync(temp);
116
+ if (stat.size !== Number(file.size)) throw new Error("Downloaded bucket file size differs from manifest: " + file.filename);
117
+ this.stampSyncBucketFile(temp, file.date);
118
+ fs.renameSync(temp, destination);
119
+ }
120
+ finally {
121
+ if (fs.existsSync(temp)) fs.unlinkSync(temp);
122
+ }
123
+ },
124
+
125
+ async setupBucketContents(plans, opts) {
126
+ // Fetch every selected bucket before writing any bucket extras. Honor the
127
+ // ordinary setup --force, --new and --dry rules for all content files.
128
+ var prepared = [];
129
+ var paths_seen = new Set();
130
+ for (const item of plans) {
131
+ var paths = this.getSyncBucketPaths(item);
132
+ if (paths_seen.has(paths.dir)) this.die("Bucket setup paths collide: " + paths.dir);
133
+ paths_seen.add(paths.dir);
134
+ for (const dir of [ paths.dir, paths.files ]) {
135
+ if (fs.existsSync(dir) && (!fs.lstatSync(dir).isDirectory() || fs.lstatSync(dir).isSymbolicLink())) {
136
+ this.die("Invalid bucket setup directory: " + dir);
137
+ }
138
+ }
139
+ var snapshot = await this.getSyncBucket(item.id);
140
+ var files = snapshot.files.map( file => ({ file, path: Path.join(paths.files, file.filename) }) );
141
+ if (!opts.force && !opts.dry) {
142
+ if (fs.existsSync(paths.data)) this.die("File exists, add '--force' to overwrite all: " + paths.data);
143
+ files.forEach( entry => {
144
+ if (fs.existsSync(entry.path)) this.die("File exists, add '--force' to overwrite all: " + entry.path);
145
+ });
146
+ }
147
+ prepared.push({ item, paths, snapshot, files });
148
+ }
149
+
150
+ for (const entry of prepared) {
151
+ // Mirror the setup tree above: a colored folder heading, then short
152
+ // names for the data file and nested uploaded-file directory.
153
+ println( "\n " + cli.emoji('🪣') + " " + cli.chalk.bold.cyan(Path.basename(entry.paths.dir) + '/') );
154
+ println( " " + cli.emoji('📄') + " " + cli.chalk.bold.green('data.json') );
155
+ println( " " + cli.emoji('📂') + " " + cli.chalk.bold.cyan('files/') );
156
+ entry.files.forEach( file => {
157
+ println( " " + cli.emoji('📎') + " " + cli.chalk.bold.yellow(file.file.filename) );
158
+ });
159
+ if (opts.dry) continue;
160
+ Tools.mkdirp.sync(entry.paths.files);
161
+ Tools.writeFileAtomicSync(entry.paths.data, JSON.stringify(entry.snapshot.data, null, '\t') + '\n');
162
+ this.stampSyncBucketFile(entry.paths.data, entry.snapshot.meta.mod);
163
+ for (const file of entry.files) {
164
+ await this.downloadSyncBucketFile(entry.item.id, file.file, file.path);
165
+ }
166
+ }
167
+ },
168
+
169
+ async syncBucketContents(items, sconfig) {
170
+ if (!items.length || this.errors.length || this.warnings.length) return;
171
+ var prepared = [];
172
+
173
+ // Preflight every selected local inventory and every remote manifest before
174
+ // applying changes. A missing data.json or unreadable files dir must never
175
+ // turn into a remote deletion candidate.
176
+ try {
177
+ for (const item of items) {
178
+ prepared.push({ item, local: this.readSyncBucketLocal(item), remote: await this.getSyncBucket(item.data.id) });
179
+ }
180
+ }
181
+ catch (err) {
182
+ this.logSyncError("Bucket sync preflight failed: " + err);
183
+ return;
184
+ }
185
+
186
+ var up = sconfig.up && sconfig.up.includes('buckets');
187
+ var down = sconfig.down && sconfig.down.includes('buckets');
188
+ var delete_files = sconfig.delete && sconfig.delete.includes('buckets');
189
+ var deletes = [];
190
+
191
+ for (const entry of prepared) {
192
+ var id = entry.item.data.id;
193
+ var local = entry.local;
194
+ var remote = entry.remote;
195
+ var title = (remote.bucket && remote.bucket.title) || entry.item.data.title || id;
196
+ var same_data = Tools.stableStringify(local.data) === Tools.stableStringify(remote.data);
197
+
198
+ try {
199
+ if (!same_data) {
200
+ var local_time = local.data_stat.mtimeMs / 1000;
201
+ var remote_time = Number(remote.meta.mod);
202
+ if (up && down && local_time == remote_time) throw new Error("Bucket data conflict with equal timestamps: " + id);
203
+ var data_direction = up && down ? (local_time > remote_time ? 'up' : 'down') : (up ? 'up' : 'down');
204
+ var data_changes = Diff.diffLines(
205
+ Tools.stablePrettyStringify(remote.data),
206
+ Tools.stablePrettyStringify(local.data)
207
+ );
208
+ this.printSyncBucketHeading(data_direction == 'up' ? '⬆️' : '⬇️', 'Updating bucket data', title, id);
209
+ println( "\n" + this.markdown(this.formatSyncDiff(data_changes)) );
210
+ if (!this.dry && data_direction == 'up') {
211
+ await this.callStandardAPI('writeBucketData', { id, data: local.data, replace: true }, { text: 'Writing bucket data...', throw: true });
212
+ var updated = await this.getSyncBucket(id);
213
+ if (Tools.stableStringify(updated.data) !== Tools.stableStringify(local.data)) {
214
+ throw new Error("Bucket data changed during upload: " + id);
215
+ }
216
+ if (Tools.stableStringify(JSON.parse(fs.readFileSync(local.paths.data, 'utf8'))) !== Tools.stableStringify(local.data)) {
217
+ throw new Error("Local bucket data changed during upload: " + local.paths.data);
218
+ }
219
+ this.stampSyncBucketFile(local.paths.data, updated.meta.mod);
220
+ this.upSynced = true;
221
+ }
222
+ else if (!this.dry) {
223
+ Tools.writeFileAtomicSync(local.paths.data, JSON.stringify(remote.data, null, '\t') + '\n');
224
+ this.stampSyncBucketFile(local.paths.data, remote.meta.mod);
225
+ this.downSynced = true;
226
+ }
227
+ }
228
+
229
+ var manifest = new Map(remote.files.map( file => [file.filename, file] ));
230
+ var uploads = [];
231
+ var downloads = [];
232
+ for (const [name, source] of local.files) {
233
+ var target = manifest.get(name);
234
+ if (!target) {
235
+ if (up) uploads.push({ name, source });
236
+ continue;
237
+ }
238
+ var local_time = source.stat.mtimeMs / 1000;
239
+ var remote_time = Number(target.date);
240
+ var size_differs = source.stat.size !== Number(target.size);
241
+ if (up && down) {
242
+ if (local_time > remote_time) uploads.push({ name, source });
243
+ else if (remote_time > local_time) downloads.push({ name, target, path: source.path });
244
+ else if (size_differs) throw new Error("Bucket file size conflict with equal timestamps: " + source.path);
245
+ }
246
+ else if (up && (size_differs || local_time > remote_time)) uploads.push({ name, source });
247
+ else if (down && (size_differs || remote_time > local_time)) downloads.push({ name, target, path: source.path });
248
+ }
249
+ for (const file of remote.files) {
250
+ if (!local.files.has(file.filename)) {
251
+ if (down) downloads.push({ name: file.filename, target: file, path: Path.join(local.paths.files, file.filename) });
252
+ else if (delete_files) deletes.push({ id, title, file });
253
+ }
254
+ }
255
+
256
+ if (uploads.length) {
257
+ var upload_label = "Uploading " + uploads.length + " bucket " + (uploads.length == 1 ? 'file' : 'files');
258
+ this.printSyncBucketHeading('⬆️', upload_label, title, id);
259
+ uploads.forEach( entry => {
260
+ println( " " + cli.emoji('📎') + " " + cli.chalk.bold.yellow(Path.basename(entry.source.path)) );
261
+ });
262
+ if (!this.dry) {
263
+ var result = await this.callStandardAPI('uploadBucketFiles', { id }, {
264
+ text: 'Uploading bucket files...', files: uploads.map( entry => entry.source.path ), throw: true
265
+ });
266
+ var updated = new Map((result.files || []).map( file => [file.filename, file] ));
267
+ for (const entry of uploads) {
268
+ var file = updated.get(entry.name);
269
+ if (!file || (typeof(file.date) != 'number') || !Number.isFinite(file.date)) {
270
+ throw new Error("Uploaded bucket file missing or invalid in response: " + entry.name);
271
+ }
272
+ var current = fs.statSync(entry.source.path);
273
+ if ((current.size !== entry.source.stat.size) || (current.mtimeMs !== entry.source.stat.mtimeMs)) {
274
+ throw new Error("Local bucket file changed during upload: " + entry.source.path);
275
+ }
276
+ this.stampSyncBucketFile(entry.source.path, file.date);
277
+ }
278
+ this.upSynced = true;
279
+ }
280
+ }
281
+ for (const entry of downloads) {
282
+ this.printSyncBucketHeading('⬇️', 'Downloading bucket file', title, id);
283
+ println( " " + cli.emoji('📎') + " " + cli.chalk.bold.yellow(entry.name) );
284
+ if (!this.dry) {
285
+ await this.downloadSyncBucketFile(id, entry.target, entry.path);
286
+ this.downSynced = true;
287
+ }
288
+ }
289
+ }
290
+ catch (err) {
291
+ this.logSyncError("Bucket sync failed for " + id + ": " + err);
292
+ }
293
+ }
294
+
295
+ // Deletions are the final content phase. Any earlier warning or error
296
+ // suppresses all bucket file deletes, just like whole-object deletion.
297
+ if (deletes.length && (this.errors.length || this.warnings.length)) {
298
+ this.logSyncWarning("Bucket file delete pass skipped because sync encountered warnings or errors.");
299
+ return;
300
+ }
301
+ for (const entry of deletes) {
302
+ this.printSyncBucketHeading('🗑️', 'Deleting bucket file', entry.title, entry.id, true);
303
+ println( " " + cli.emoji('📎') + " " + cli.chalk.bold.yellow(entry.file.filename) );
304
+ if (this.dry) continue;
305
+ try {
306
+ await this.callStandardAPI('deleteBucketFile', { id: entry.id, path: entry.file.path }, { text: 'Deleting bucket file...', throw: true });
307
+ this.upSynced = true;
308
+ }
309
+ catch (err) {
310
+ this.logSyncError("Bucket file deletion failed: " + err);
311
+ return;
312
+ }
313
+ }
314
+ }
315
+ };
@@ -0,0 +1,207 @@
1
+ // Local XYPDF deletion for xyOps System Hooks.
2
+ // This command never updates definitions or queries the remote server.
3
+
4
+ const fs = require('fs');
5
+ const Path = require('path');
6
+ const cli = require('pixl-cli');
7
+ const Tools = cli.Tools;
8
+
9
+ const MAX_HOOK_BYTES = 64 * 1024 * 1024;
10
+
11
+ // Map each supported xyOps deletion activity to both its STDIN payload key
12
+ // and its XYPDF item type. API Keys use "apikey" in the activity name but
13
+ // "api_key" in the payload; workflows are stored as Event definitions.
14
+ // Buckets are excluded because their data and uploaded files need separate
15
+ // deletion handling beyond the single XYPDF source and its property files.
16
+ const HOOK_TYPES = {
17
+ alert_delete: 'alert',
18
+ apikey_delete: 'api_key',
19
+ category_delete: 'category',
20
+ channel_delete: 'channel',
21
+ event_delete: 'event',
22
+ group_delete: 'group',
23
+ monitor_delete: 'monitor',
24
+ plugin_delete: 'plugin',
25
+ tag_delete: 'tag',
26
+ web_hook_delete: 'web_hook'
27
+ };
28
+
29
+ async function readHookPayload() {
30
+ // System Hooks send one JSON activity object followed by a newline. Read
31
+ // until EOF so a partial pipe write cannot be mistaken for a valid event.
32
+ if (process.stdin.isTTY) throw new Error("System Hook sync requires an activity payload on STDIN.");
33
+
34
+ var chunks = [];
35
+ var size = 0;
36
+ for await (const chunk of process.stdin) {
37
+ var buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
38
+ size += buffer.length;
39
+ if (size > MAX_HOOK_BYTES) throw new Error("System Hook activity payload is too large.");
40
+ chunks.push(buffer);
41
+ }
42
+
43
+ if (!size) throw new Error("System Hook activity payload is empty.");
44
+
45
+ try { return JSON.parse(Buffer.concat(chunks).toString('utf8')); }
46
+ catch (err) { throw new Error("Invalid System Hook activity JSON: " + err.message); }
47
+ }
48
+
49
+ function listLocalFiles(dir) {
50
+ // Match ordinary sync's visible-file scan, but never follow symbolic links.
51
+ // A local delete must not cross into an unrelated tree through a link.
52
+ var files = [];
53
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
54
+ if (entry.name.startsWith('.')) continue;
55
+ var file = Path.join(dir, entry.name);
56
+ if (entry.isDirectory()) files.push(...listLocalFiles(file));
57
+ else if (entry.isFile()) files.push(file);
58
+ }
59
+ return files;
60
+ }
61
+
62
+ function isInsideDir(file, dir) {
63
+ var relative = Path.relative(dir, file);
64
+ return !!relative && (relative != '..') && !relative.startsWith('..' + Path.sep) && !Path.isAbsolute(relative);
65
+ }
66
+
67
+ function scanSources(files) {
68
+ // Index every valid XYPDF stem, not only the target item. A source named
69
+ // "Foo" must not claim property files belonging to "Foo-V2".
70
+ var sources = [];
71
+ var bucket_dirs = [];
72
+ var parse_errors = [];
73
+ for (const file of files.filter( file => file.match(/\.json$/i) )) {
74
+ var raw = null;
75
+ try { raw = fs.readFileSync(file, 'utf8'); }
76
+ catch (err) { throw new Error("Failed to read JSON file: " + file + ": " + err.message); }
77
+
78
+ var xypdf = null;
79
+ try { xypdf = JSON.parse(raw); }
80
+ catch (err) {
81
+ parse_errors.push({ file, err });
82
+ continue;
83
+ }
84
+ if (!xypdf || (xypdf.type !== 'xypdf')) continue;
85
+
86
+ var stem = Path.basename(file).replace(/\.json$/i, '');
87
+ var item = Array.isArray(xypdf.items) && (xypdf.items.length === 1) ? xypdf.items[0] : null;
88
+ if (item && (item.type === 'bucket')) bucket_dirs.push(file.replace(/\.json$/i, ''));
89
+ sources.push({ file, stem, xypdf, item });
90
+ }
91
+
92
+ // Bucket uploaded files are opaque content even when named *.json. They
93
+ // must not participate in this definition source inventory.
94
+ for (const entry of parse_errors) {
95
+ if (bucket_dirs.some( dir => isInsideDir(entry.file, dir) )) continue;
96
+ throw new Error("Failed to parse JSON file: " + entry.file + ": " + entry.err.message);
97
+ }
98
+
99
+ return sources.filter( source => !bucket_dirs.some( dir => isInsideDir(source.file, dir) ) );
100
+ }
101
+
102
+ function findOwnedNeighbors(app, files, sources, source) {
103
+ var dir = Path.dirname(source.file);
104
+ var stem = source.stem;
105
+ var source_stems = sources.filter( item => Path.dirname(item.file) === dir )
106
+ .map( item => item.stem ).sort( (a, b) => b.length - a.length );
107
+ var neighbors = [];
108
+
109
+ for (const file of files) {
110
+ if (Path.dirname(file) !== dir || file === source.file) continue;
111
+ var name = Path.basename(file);
112
+ var name_stem = name.replace(/\.\w+$/, '');
113
+ if (!name_stem.startsWith(stem + '-')) continue;
114
+
115
+ // The longest XYPDF prefix owns a neighboring property file. Another
116
+ // XYPDF is always its own source, even if its name looks like a neighbor.
117
+ var owner = source_stems.find( other => name_stem.startsWith(other + '-') );
118
+ if (owner && (owner !== stem)) continue;
119
+ if (sources.some( other => other.file === file )) continue;
120
+
121
+ var prop_path = name_stem.substring(stem.length + 1);
122
+ if (!prop_path.match(/^[\w\.\-]+$/) ||
123
+ (typeof(app.getSyncPropByPath(source.item.data, prop_path)) !== 'string')) {
124
+ throw new Error("Neighbor file does not match a string property: " + file);
125
+ }
126
+ neighbors.push(file);
127
+ }
128
+ return neighbors;
129
+ }
130
+
131
+ module.exports = {
132
+
133
+ async runSyncSyshook() {
134
+ // The argument is an exact activity action, not a general sync direction.
135
+ // Ignore saved up/down/delete settings by never entering ordinary sync.
136
+ var action = this.args.syshook;
137
+ var type = HOOK_TYPES[action];
138
+ if (!type) this.die("Invalid --syshook action (use one of: " + Object.keys(HOOK_TYPES).join(', ') + ").");
139
+ if (['up', 'down', 'delete', 'setup', 'new'].some( key => key in this.args )) {
140
+ this.die("--syshook cannot be combined with ordinary sync modes.");
141
+ }
142
+ if (!this.args.other || (this.args.other.length !== 1)) {
143
+ this.die("--syshook requires exactly one sync directory.");
144
+ }
145
+ if (this.args.down_cmd && (typeof(this.args.down_cmd) !== 'string')) {
146
+ this.die("--down_cmd must be a shell command.");
147
+ }
148
+
149
+ var dir = Path.resolve(this.args.other[0]);
150
+ var stat = null;
151
+ try { stat = fs.statSync(dir); }
152
+ catch (err) { this.die("Sync directory is not accessible: " + dir + ": " + err.message); }
153
+ if (!stat.isDirectory()) this.die("Sync path is not a directory: " + dir);
154
+ this.args.other = [dir]; // completion commands run from this directory
155
+ this.sconfig = Tools.mergeHashes(this.config.sync || {}, this.args);
156
+ this.warnings = [];
157
+ this.errors = [];
158
+
159
+ var payload = null;
160
+ try { payload = await readHookPayload(); }
161
+ catch (err) { this.die(err.message); }
162
+ if (!payload || (typeof(payload) !== 'object') || Array.isArray(payload) ||
163
+ (payload.xy !== 1) || (payload.action !== action)) {
164
+ this.die("System Hook activity does not match --syshook " + action + ".");
165
+ }
166
+ var object = payload[type];
167
+ if (!object || (typeof(object) !== 'object') || Array.isArray(object) ||
168
+ (typeof(object.id) !== 'string') || !object.id.match(/^[a-z0-9_]+$/)) {
169
+ this.die("System Hook activity is missing a valid " + type + " ID.");
170
+ }
171
+
172
+ // Identify the single local source by its stored type and ID, never by
173
+ // title or filename. Read the entire local inventory before changing it.
174
+ var files = [];
175
+ var sources = [];
176
+ try {
177
+ files = listLocalFiles(dir);
178
+ sources = scanSources(files);
179
+ }
180
+ catch (err) { this.die("Failed to scan sync directory: " + err.message); }
181
+ var matches = sources.filter( source => source.item && (source.item.type === type) &&
182
+ source.item.data && (source.item.data.id === object.id) );
183
+ if (matches.length > 1) this.die("Duplicate XYPDF sources for " + type + " ID " + object.id + ".");
184
+ if (!matches.length) {
185
+ println("No local XYPDF source found for " + type + " ID " + object.id + ".");
186
+ return;
187
+ }
188
+ var source = matches[0];
189
+ if (source.xypdf.version !== '1.0') this.die("Cannot delete incompatible XYPDF source: " + source.file);
190
+
191
+ var neighbors = [];
192
+ try { neighbors = findOwnedNeighbors(this, files, sources, source); }
193
+ catch (err) { this.die(err.message); }
194
+ var targets = [...neighbors, source.file]; // keep JSON last for safe retries
195
+
196
+ for (const file of targets) {
197
+ println((this.dry ? "Would delete: " : "Deleting: ") + file);
198
+ if (this.dry) continue;
199
+ try { fs.unlinkSync(file); }
200
+ catch (err) { this.die("Failed to delete local file: " + file + ": " + err.message); }
201
+ }
202
+
203
+ // A dry run and a missing local source never trigger Git commands. Run
204
+ // only the explicitly supplied down_cmd after a real local deletion.
205
+ if (!this.dry && this.args.down_cmd) this.syncFinishCmd(this.args.down_cmd);
206
+ }
207
+ };