- syncDecorations.mjs tracks which slugs its regex actually removed and exits 1 without writing if that set doesn't match the missing assets, instead of silently no-op'ing on a reformatted catalog. - patch-folds.mjs matches a 4-line context block, reports the installed folds version and expected snippet when the target is missing, and distinguishes "already patched" (exit 0) from "pattern not found" (exit 1). Verified against all three states. Fixes #88 Fixes #55 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
153 lines
5.9 KiB
JavaScript
153 lines
5.9 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Syncs avatarDecorations.ts with what's actually available on the Nextcloud CDN.
|
|
*
|
|
* Usage:
|
|
* npm run sync:decorations
|
|
*
|
|
* Workflow after deleting files from Nextcloud:
|
|
* 1. Delete decoration files from your Nextcloud share.
|
|
* 2. Run: npm run sync:decorations
|
|
* 3. It probes each catalog slug via HTTP HEAD and removes entries
|
|
* whose files returned 404. Empty categories are dropped automatically.
|
|
* 4. Commit the updated avatarDecorations.ts.
|
|
*/
|
|
|
|
import { readFileSync, writeFileSync } from 'fs';
|
|
import { join, dirname } from 'path';
|
|
import { fileURLToPath } from 'url';
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const root = join(__dirname, '..');
|
|
const catalogPath = join(root, 'src', 'app', 'features', 'lotus', 'avatarDecorations.ts');
|
|
|
|
// Single source of truth: the CDN base URL lives in avatarDecorations.ts as
|
|
// `export const DECORATION_CDN`. We extract it from there at runtime rather than
|
|
// re-declaring it here, so the build script and the app can never drift. This
|
|
// .mjs script can't cleanly import the browser-side .ts module (it's outside the
|
|
// Vite/TS app graph), so we parse the constant out of the file text instead.
|
|
// If you migrate the CDN, change it ONLY in avatarDecorations.ts.
|
|
const catalog = readFileSync(catalogPath, 'utf8');
|
|
|
|
const cdnMatch = catalog.match(/export const DECORATION_CDN\s*=\s*['"]([^'"]+)['"]/);
|
|
if (!cdnMatch) {
|
|
console.error(
|
|
'Could not find `export const DECORATION_CDN` in avatarDecorations.ts — ' +
|
|
'the constant may have been renamed. Update scripts/syncDecorations.mjs.',
|
|
);
|
|
process.exit(1);
|
|
}
|
|
const CDN = cdnMatch[1];
|
|
|
|
// Extract all slugs from the catalog file
|
|
const slugMatches = [...catalog.matchAll(/slug: '([^']+)'/g)].map((m) => m[1]);
|
|
|
|
if (slugMatches.length === 0) {
|
|
console.error('No slugs found in catalog — check the file path.');
|
|
process.exit(1);
|
|
}
|
|
|
|
console.log(`Checking ${slugMatches.length} decorations against ${CDN} …`);
|
|
console.log('(This makes one HEAD request per decoration)\n');
|
|
|
|
// Probe all slugs in parallel batches of 16
|
|
async function headCheck(slug) {
|
|
try {
|
|
const res = await fetch(`${CDN}/${slug}.png`, { method: 'HEAD' });
|
|
return { slug, ok: res.ok, status: res.status };
|
|
} catch {
|
|
// Network/DNS/TLS failure — NOT a confirmation the file is gone.
|
|
return { slug, ok: false, status: 0, networkError: true };
|
|
}
|
|
}
|
|
|
|
const BATCH = 16;
|
|
const results = [];
|
|
for (let i = 0; i < slugMatches.length; i += BATCH) {
|
|
const batch = slugMatches.slice(i, i + BATCH);
|
|
const batchResults = await Promise.all(batch.map(headCheck));
|
|
results.push(...batchResults);
|
|
}
|
|
|
|
// Only a CONFIRMED HTTP 404 means the file is genuinely gone and safe to
|
|
// remove. A network error or any other non-ok status (5xx, 403, timeout) is
|
|
// ambiguous — the CDN may be unreachable — so refuse to remove anything and
|
|
// abort, otherwise a transient outage would wipe the whole catalog from source
|
|
// control (N119).
|
|
const transient = results.filter((r) => !r.ok && r.status !== 404);
|
|
if (transient.length > 0) {
|
|
console.error(
|
|
`Aborting: ${transient.length} decoration(s) returned a non-404 failure ` +
|
|
`(network error / server error). The CDN may be unreachable — refusing to ` +
|
|
`remove entries to avoid wiping the catalog.`,
|
|
);
|
|
transient
|
|
.slice(0, 8)
|
|
.forEach((r) =>
|
|
console.error(` ${r.slug}: ${r.networkError ? 'network error' : `HTTP ${r.status}`}`),
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
const missing = results.filter((r) => r.status === 404);
|
|
const found = results.filter((r) => r.ok);
|
|
|
|
if (missing.length === 0) {
|
|
console.log(`All ${found.length} decorations are available — catalog is up to date.`);
|
|
process.exit(0);
|
|
}
|
|
|
|
console.log(`Found: ${found.length} Missing: ${missing.length}\n`);
|
|
missing.forEach((r) => console.log(` Removing (HTTP ${r.status}): ${r.slug}`));
|
|
|
|
const missingSet = new Set(missing.map((r) => r.slug));
|
|
|
|
// Remove individual entries for missing slugs
|
|
const removedSlugs = new Set();
|
|
let updated = catalog.replace(/^[ \t]*\{ slug: '([^']+)', name: .+\},?\r?\n/gm, (match, slug) => {
|
|
if (!missingSet.has(slug)) return match;
|
|
removedSlugs.add(slug);
|
|
return '';
|
|
});
|
|
|
|
// Regex-based removal is brittle: if the catalog is reformatted (different
|
|
// indentation, line-wrapped entries, etc.) the pattern above can silently
|
|
// match zero entries while HTTP probing still reports slugs missing. Verify
|
|
// every slug we intended to remove actually got matched — otherwise abort
|
|
// without writing, so a formatting change fails loudly instead of leaving
|
|
// stale/dead entries in the catalog (see Gitea #88).
|
|
const unmatched = [...missingSet].filter((slug) => !removedSlugs.has(slug));
|
|
if (unmatched.length > 0) {
|
|
console.error(
|
|
`Aborting: expected to remove ${missingSet.size} entr${missingSet.size === 1 ? 'y' : 'ies'} ` +
|
|
`but only matched ${removedSlugs.size}. The catalog's formatting may have changed and the ` +
|
|
`parser in scripts/syncDecorations.mjs needs updating. Refusing to write a partial result.`,
|
|
);
|
|
console.error(` Unmatched slugs: ${unmatched.join(', ')}`);
|
|
process.exit(1);
|
|
}
|
|
if (removedSlugs.size === 0) {
|
|
// We already exited above when `missing.length === 0`, so reaching here
|
|
// with zero removals despite `missing.length > 0` means the diff between
|
|
// "expected" and "actual" itself is broken — fail rather than proceed.
|
|
console.error(
|
|
'Aborting: no entries were matched for removal despite missing slugs. Refusing to write.',
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
// Drop category blocks that now have an empty decorations array
|
|
updated = updated.replace(
|
|
/ \{\n id: '[^']+',\n label: '[^']+',\n decorations: \[\n?[ \t]*\],?\n \},?\n/g,
|
|
'',
|
|
);
|
|
|
|
// Clean up stray blank lines
|
|
updated = updated.replace(/\n{3,}/g, '\n\n');
|
|
|
|
writeFileSync(catalogPath, updated, 'utf8');
|
|
console.log(
|
|
`\nDone. Removed ${removedSlugs.size} entr${removedSlugs.size === 1 ? 'y' : 'ies'} from the catalog.`,
|
|
);
|
|
console.log('Review with: git diff src/app/features/lotus/avatarDecorations.ts');
|