|
|
@@ -0,0 +1,464 @@
|
|
|
+import fs from 'node:fs';
|
|
|
+import path from 'node:path';
|
|
|
+import { fileURLToPath } from 'node:url';
|
|
|
+
|
|
|
+const FN_URL = process.env.SMOKE_FN_URL || 'https://server.fmode.cn/api/functions';
|
|
|
+const APP_ID = process.env.SMOKE_PARSE_APP_ID || 'ncloudmaster';
|
|
|
+const SESSION_TOKEN = process.env.SMOKE_SESSION_TOKEN || '';
|
|
|
+const ACCOUNT_FILTER = String(process.env.IP_CLEANUP_ACCOUNT_ID || '').trim();
|
|
|
+const INCLUDE_ALL_ACTIVE = /^(1|true|yes)$/i.test(String(process.env.IP_CLEANUP_INCLUDE_ALL_ACTIVE || ''));
|
|
|
+const PRINT_CANDIDATES = /^(1|true|yes)$/i.test(String(process.env.IP_CLEANUP_PRINT_CANDIDATES || ''));
|
|
|
+const OUTPUT_DIR = process.env.IP_CLEANUP_OUTPUT_DIR
|
|
|
+ ? path.resolve(process.env.IP_CLEANUP_OUTPUT_DIR)
|
|
|
+ : path.join(process.cwd(), 'tmp');
|
|
|
+
|
|
|
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
|
+const rootDir = path.resolve(__dirname, '..', '..');
|
|
|
+const cloudFunctionsPath = path.join(rootDir, 'src', 'app', 'services', 'cloud-functions.ts');
|
|
|
+const fn = readCloudFunctionIds(cloudFunctionsPath);
|
|
|
+
|
|
|
+const entityTypes = [
|
|
|
+ 'ipOperator.account',
|
|
|
+ 'ipOperator.profile',
|
|
|
+ 'ipOperator.plan',
|
|
|
+ 'ipOperator.accountSnapshot',
|
|
|
+ 'ipOperator.accountSnapshotWork',
|
|
|
+ 'ipOperator.accountSnapshotComment',
|
|
|
+ 'ipOperator.accountDiagnosis',
|
|
|
+ 'ipOperator.positioningProposal',
|
|
|
+ 'ipOperator.positioningVersion',
|
|
|
+ 'ipOperator.contentDirection',
|
|
|
+ 'ipOperator.operationTask',
|
|
|
+ 'ipOperator.publishBinding',
|
|
|
+ 'ipOperator.planEvidenceItem',
|
|
|
+ 'ipOperator.planCommentPainInsight',
|
|
|
+ 'ipOperator.planContentCalendarItem',
|
|
|
+ 'ipOperator.planPublishPackage',
|
|
|
+ 'ipOperator.planScriptBody',
|
|
|
+ 'ipOperator.planGenerationPreview',
|
|
|
+];
|
|
|
+
|
|
|
+if (!SESSION_TOKEN) {
|
|
|
+ console.error('Missing SMOKE_SESSION_TOKEN. This dry-run only reads the current Parse user namespace.');
|
|
|
+ process.exit(1);
|
|
|
+}
|
|
|
+if (!fn.systemStorage) {
|
|
|
+ console.error('CLOUD_FN.systemStorage is empty. Check src/app/services/cloud-functions.ts.');
|
|
|
+ process.exit(1);
|
|
|
+}
|
|
|
+
|
|
|
+const startedAt = new Date().toISOString();
|
|
|
+const rowsByType = new Map();
|
|
|
+const allRows = [];
|
|
|
+
|
|
|
+for (const entityType of entityTypes) {
|
|
|
+ const rows = await listRows(entityType);
|
|
|
+ rowsByType.set(entityType, rows);
|
|
|
+ allRows.push(...rows);
|
|
|
+}
|
|
|
+
|
|
|
+const stats = await call(fn.systemStorage, { action: 'stats' }).catch(() => null);
|
|
|
+const accounts = rowsByType.get('ipOperator.account') || [];
|
|
|
+const plans = rowsByType.get('ipOperator.plan') || [];
|
|
|
+const snapshots = rowsByType.get('ipOperator.accountSnapshot') || [];
|
|
|
+const works = rowsByType.get('ipOperator.accountSnapshotWork') || [];
|
|
|
+const comments = rowsByType.get('ipOperator.accountSnapshotComment') || [];
|
|
|
+const evidenceRows = rowsByType.get('ipOperator.planEvidenceItem') || [];
|
|
|
+const painRows = rowsByType.get('ipOperator.planCommentPainInsight') || [];
|
|
|
+
|
|
|
+const accountIds = new Set();
|
|
|
+for (const account of accounts) accountIds.add(account.entityId);
|
|
|
+for (const row of [...snapshots, ...works, ...comments, ...evidenceRows, ...painRows]) {
|
|
|
+ const accountId = row.data?.accountId || parseAccountIdFromSnapshotId(row.data?.snapshotId || row.entityId);
|
|
|
+ if (accountId) accountIds.add(accountId);
|
|
|
+}
|
|
|
+const targetAccountIds = [...accountIds].filter((id) => !ACCOUNT_FILTER || id === ACCOUNT_FILTER);
|
|
|
+
|
|
|
+const candidates = [];
|
|
|
+for (const accountId of targetAccountIds) {
|
|
|
+ collectAccountCandidates(accountId, candidates);
|
|
|
+}
|
|
|
+
|
|
|
+const typeStatus = summarizeByTypeAndStatus(allRows);
|
|
|
+const candidateSummary = summarizeByTypeAndStatus(candidates);
|
|
|
+const reportBase = `ip-operator-cleanup-dry-run-${safeTimestamp(startedAt)}`;
|
|
|
+const jsonPath = path.join(OUTPUT_DIR, `${reportBase}.json`);
|
|
|
+const mdPath = path.join(OUTPUT_DIR, `${reportBase}.md`);
|
|
|
+const report = buildReport({
|
|
|
+ generatedAt: startedAt,
|
|
|
+ accountFilter: ACCOUNT_FILTER || '',
|
|
|
+ stats: stats?.success ? stats.data : null,
|
|
|
+ fetchedSummary: summaryToObject(typeStatus),
|
|
|
+ candidateSummary: summaryToObject(candidateSummary),
|
|
|
+ fetchedRowCount: allRows.length,
|
|
|
+ candidateCount: candidates.length,
|
|
|
+ candidates,
|
|
|
+});
|
|
|
+fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
|
|
+fs.writeFileSync(jsonPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
|
|
|
+fs.writeFileSync(mdPath, renderMarkdownReport(report), 'utf8');
|
|
|
+
|
|
|
+console.log(`IP operator cleanup dry-run generatedAt=${startedAt}`);
|
|
|
+console.log(`scope=current Parse user; accountFilter=${ACCOUNT_FILTER || '(all detected accounts)'}`);
|
|
|
+console.log('');
|
|
|
+console.log('Current fetched rows by entityType/status:');
|
|
|
+printSummary(typeStatus);
|
|
|
+if (stats?.success && stats.data?.totalEntities !== undefined) {
|
|
|
+ console.log('');
|
|
|
+ console.log(`systemStorage.stats totalEntities=${stats.data.totalEntities}`);
|
|
|
+}
|
|
|
+console.log('');
|
|
|
+console.log('Cleanup candidates by entityType/status:');
|
|
|
+printSummary(candidateSummary);
|
|
|
+console.log('');
|
|
|
+console.log(`Cleanup candidate rows: ${candidates.length}`);
|
|
|
+console.log('');
|
|
|
+console.log(`Full JSON report: ${jsonPath}`);
|
|
|
+console.log(`Full Markdown report: ${mdPath}`);
|
|
|
+if (PRINT_CANDIDATES) {
|
|
|
+ console.log('');
|
|
|
+ printCandidates(candidates);
|
|
|
+}
|
|
|
+console.log('');
|
|
|
+console.log('DRY-RUN ONLY: no delete/purge/physical removal was executed.');
|
|
|
+console.log('Next step: review candidate reasons. Only after confirmation should a separate execution script be used.');
|
|
|
+
|
|
|
+async function listRows(entityType) {
|
|
|
+ const statuses = INCLUDE_ALL_ACTIVE
|
|
|
+ ? ['active', 'deleted', 'purged', 'archived']
|
|
|
+ : ['active', 'deleted', 'purged'];
|
|
|
+ const merged = [];
|
|
|
+ const seen = new Set();
|
|
|
+ for (const status of statuses) {
|
|
|
+ const result = await call(fn.systemStorage, { action: 'list', entityType, status, limit: 1000 });
|
|
|
+ if (!result?.success) {
|
|
|
+ console.warn(`WARN list failed entityType=${entityType} status=${status}: ${result?.error || JSON.stringify(result)}`);
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ for (const row of result.data || []) {
|
|
|
+ const key = row.objectId || `${row.entityType}:${row.entityId}:${row.status}`;
|
|
|
+ if (seen.has(key)) continue;
|
|
|
+ seen.add(key);
|
|
|
+ merged.push(normalizeRow(row));
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return merged;
|
|
|
+}
|
|
|
+
|
|
|
+function collectAccountCandidates(accountId, output) {
|
|
|
+ const currentSnapshotId = `snapshot_${accountId}_current`;
|
|
|
+ const currentPlanId = `ip_account_plan_${accountId}`;
|
|
|
+ const plan = plans.find((row) => row.entityId === currentPlanId || row.data?.id === currentPlanId);
|
|
|
+ const currentRefs = new Set([
|
|
|
+ ...toArray(plan?.data?.externalizedCollections?.evidenceItems),
|
|
|
+ ...toArray(plan?.data?.evidenceItems).map((item) => item?.id).filter(Boolean),
|
|
|
+ ]);
|
|
|
+ const currentPainRefs = new Set([
|
|
|
+ ...toArray(plan?.data?.externalizedCollections?.commentPainInsights),
|
|
|
+ ...toArray(plan?.data?.commentPainInsights).map((item) => item?.id).filter(Boolean),
|
|
|
+ ]);
|
|
|
+
|
|
|
+ const staleSnapshotIds = new Set();
|
|
|
+ for (const row of snapshots) {
|
|
|
+ const rowAccountId = row.data?.accountId || parseAccountIdFromSnapshotId(row.entityId);
|
|
|
+ if (rowAccountId !== accountId) continue;
|
|
|
+ if (row.entityId === currentSnapshotId) continue;
|
|
|
+ if (/^snapshot_.+_(current)$/.test(row.entityId)) continue;
|
|
|
+ staleSnapshotIds.add(row.entityId);
|
|
|
+ pushCandidate(output, row, accountId, `old account snapshot; keep ${currentSnapshotId}`);
|
|
|
+ }
|
|
|
+
|
|
|
+ for (const row of works) {
|
|
|
+ const snapshotId = row.data?.snapshotId || parseSnapshotIdFromChildId(row.entityId);
|
|
|
+ const rowAccountId = row.data?.accountId || parseAccountIdFromSnapshotId(snapshotId);
|
|
|
+ if (rowAccountId !== accountId) continue;
|
|
|
+ if (staleSnapshotIds.has(snapshotId)) {
|
|
|
+ pushCandidate(output, row, accountId, `work belongs to old snapshot ${snapshotId}`);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ for (const row of comments) {
|
|
|
+ const snapshotId = row.data?.snapshotId || parseSnapshotIdFromChildId(row.entityId);
|
|
|
+ const rowAccountId = row.data?.accountId || parseAccountIdFromSnapshotId(snapshotId);
|
|
|
+ if (rowAccountId !== accountId) continue;
|
|
|
+ if (staleSnapshotIds.has(snapshotId)) {
|
|
|
+ pushCandidate(output, row, accountId, `comment belongs to old snapshot ${snapshotId}`);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ for (const row of evidenceRows) {
|
|
|
+ const data = row.data || {};
|
|
|
+ if (data.accountId !== accountId) continue;
|
|
|
+ const evidenceId = data.id || row.entityId.split('__').pop();
|
|
|
+ const snapshotId = data.snapshotId || '';
|
|
|
+ if (staleSnapshotIds.has(snapshotId)) {
|
|
|
+ pushCandidate(output, row, accountId, `evidence belongs to old snapshot ${snapshotId}`);
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ if (plan && currentRefs.size && !currentRefs.has(evidenceId)) {
|
|
|
+ pushCandidate(output, row, accountId, `evidence is not referenced by current plan ${currentPlanId}`);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ for (const row of painRows) {
|
|
|
+ const data = row.data || {};
|
|
|
+ const planId = data.planId || row.entityId.split('__')[0];
|
|
|
+ if (planId !== currentPlanId) continue;
|
|
|
+ const painId = data.id || row.entityId.split('__').pop();
|
|
|
+ const evidenceIds = toArray(data.evidenceItemIds);
|
|
|
+ const referencesStaleEvidence = evidenceIds.some((id) => candidatesEvidenceIdsForAccount(output, accountId).has(id));
|
|
|
+ if (referencesStaleEvidence) {
|
|
|
+ pushCandidate(output, row, accountId, 'pain insight references stale evidence');
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ if (plan && currentPainRefs.size && !currentPainRefs.has(painId)) {
|
|
|
+ pushCandidate(output, row, accountId, `pain insight is not referenced by current plan ${currentPlanId}`);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ for (const row of allRows) {
|
|
|
+ if ((row.status === 'deleted' || row.status === 'purged') && row.entityType.startsWith('ipOperator.')) {
|
|
|
+ const rowAccountId = row.data?.accountId || parseAccountIdFromSnapshotId(row.data?.snapshotId || row.entityId);
|
|
|
+ if (rowAccountId === accountId) pushCandidate(output, row, accountId, `already ${row.status}; physical cleanup candidate only after review`);
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function candidatesEvidenceIdsForAccount(output, accountId) {
|
|
|
+ return new Set(output
|
|
|
+ .filter((row) => row.accountId === accountId && row.entityType === 'ipOperator.planEvidenceItem')
|
|
|
+ .map((row) => row.data?.id || row.entityId.split('__').pop())
|
|
|
+ .filter(Boolean));
|
|
|
+}
|
|
|
+
|
|
|
+function pushCandidate(output, row, accountId, reason) {
|
|
|
+ const key = row.objectId || `${row.entityType}:${row.entityId}`;
|
|
|
+ if (output.some((item) => (item.objectId || `${item.entityType}:${item.entityId}`) === key)) return;
|
|
|
+ output.push({ ...row, accountId, reason });
|
|
|
+}
|
|
|
+
|
|
|
+async function call(id, body) {
|
|
|
+ let lastResult = null;
|
|
|
+ let lastError = null;
|
|
|
+ for (let attempt = 1; attempt <= 4; attempt += 1) {
|
|
|
+ try {
|
|
|
+ const response = await fetch(FN_URL, {
|
|
|
+ method: 'POST',
|
|
|
+ headers: {
|
|
|
+ 'Content-Type': 'application/json',
|
|
|
+ 'X-Parse-Application-Id': APP_ID,
|
|
|
+ },
|
|
|
+ body: JSON.stringify({ id, _ApplicationId: APP_ID, sessionToken: SESSION_TOKEN, ...body }),
|
|
|
+ });
|
|
|
+ const text = await response.text();
|
|
|
+ let result;
|
|
|
+ try {
|
|
|
+ result = JSON.parse(text);
|
|
|
+ } catch {
|
|
|
+ result = { code: response.status, success: false, error: text };
|
|
|
+ }
|
|
|
+ lastResult = result;
|
|
|
+ if (attempt < 4 && isRetryableResult(result)) {
|
|
|
+ await sleep(600 * attempt);
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ return result;
|
|
|
+ } catch (error) {
|
|
|
+ lastError = error;
|
|
|
+ const message = `${error?.message || ''} ${error?.cause?.code || ''}`;
|
|
|
+ if (attempt < 4 && isRetryableMessage(message)) {
|
|
|
+ await sleep(600 * attempt);
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ return { code: 500, success: false, error: error?.message || 'fetch failed' };
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (lastResult) return lastResult;
|
|
|
+ return { code: 500, success: false, error: lastError?.message || 'fetch failed' };
|
|
|
+}
|
|
|
+
|
|
|
+function isRetryableResult(result) {
|
|
|
+ const message = `${result?.error || ''} ${result?.message || ''}`;
|
|
|
+ return Number(result?.code || 0) >= 500 || isRetryableMessage(message);
|
|
|
+}
|
|
|
+
|
|
|
+function isRetryableMessage(message) {
|
|
|
+ return /fetch failed|Failed to fetch|NetworkError|Load failed|ECONNRESET|ETIMEDOUT|EAI_AGAIN/i.test(message || '');
|
|
|
+}
|
|
|
+
|
|
|
+function sleep(ms) {
|
|
|
+ return new Promise((resolve) => setTimeout(resolve, ms));
|
|
|
+}
|
|
|
+
|
|
|
+function readCloudFunctionIds(filePath) {
|
|
|
+ const text = fs.readFileSync(filePath, 'utf8');
|
|
|
+ const ids = {};
|
|
|
+ const re = /(\w+):\s*'([^']*)'/g;
|
|
|
+ let match;
|
|
|
+ while ((match = re.exec(text))) ids[match[1]] = match[2];
|
|
|
+ return ids;
|
|
|
+}
|
|
|
+
|
|
|
+function normalizeRow(row) {
|
|
|
+ return {
|
|
|
+ objectId: row.objectId || '',
|
|
|
+ entityType: row.entityType || row.type || '',
|
|
|
+ entityId: row.entityId || row.id || '',
|
|
|
+ status: row.status || 'active',
|
|
|
+ createdAt: row.createdAt || '',
|
|
|
+ updatedAt: row.updatedAt || '',
|
|
|
+ data: row.data && typeof row.data === 'object' ? row.data : {},
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+function summarizeByTypeAndStatus(rows) {
|
|
|
+ const summary = new Map();
|
|
|
+ for (const row of rows) {
|
|
|
+ const type = row.entityType || '(unknown)';
|
|
|
+ const status = row.status || '(unknown)';
|
|
|
+ if (!summary.has(type)) summary.set(type, {});
|
|
|
+ summary.get(type)[status] = (summary.get(type)[status] || 0) + 1;
|
|
|
+ }
|
|
|
+ return summary;
|
|
|
+}
|
|
|
+
|
|
|
+function printSummary(summary) {
|
|
|
+ const types = [...summary.keys()].sort();
|
|
|
+ if (!types.length) {
|
|
|
+ console.log(' (none)');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ for (const type of types) {
|
|
|
+ const statuses = Object.entries(summary.get(type))
|
|
|
+ .sort(([a], [b]) => a.localeCompare(b))
|
|
|
+ .map(([status, count]) => `${status}:${count}`)
|
|
|
+ .join(', ');
|
|
|
+ console.log(` ${type} -> ${statuses}`);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function printCandidates(rows) {
|
|
|
+ if (!rows.length) {
|
|
|
+ console.log(' (none)');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const sorted = [...rows].sort((a, b) =>
|
|
|
+ a.accountId.localeCompare(b.accountId)
|
|
|
+ || a.entityType.localeCompare(b.entityType)
|
|
|
+ || a.entityId.localeCompare(b.entityId));
|
|
|
+ for (const row of sorted) {
|
|
|
+ console.log([
|
|
|
+ ` account=${row.accountId}`,
|
|
|
+ `type=${row.entityType}`,
|
|
|
+ `status=${row.status}`,
|
|
|
+ `entityId=${row.entityId}`,
|
|
|
+ row.objectId ? `objectId=${row.objectId}` : '',
|
|
|
+ `reason=${row.reason}`,
|
|
|
+ ].filter(Boolean).join(' | '));
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function buildReport(input) {
|
|
|
+ return {
|
|
|
+ generatedAt: input.generatedAt,
|
|
|
+ scope: {
|
|
|
+ parseUser: 'current sessionToken user',
|
|
|
+ accountFilter: input.accountFilter,
|
|
|
+ includeArchived: INCLUDE_ALL_ACTIVE,
|
|
|
+ },
|
|
|
+ stats: input.stats,
|
|
|
+ fetchedRowCount: input.fetchedRowCount,
|
|
|
+ fetchedSummary: input.fetchedSummary,
|
|
|
+ candidateCount: input.candidateCount,
|
|
|
+ candidateSummary: input.candidateSummary,
|
|
|
+ candidates: input.candidates.map((row) => ({
|
|
|
+ accountId: row.accountId,
|
|
|
+ objectId: row.objectId,
|
|
|
+ entityType: row.entityType,
|
|
|
+ entityId: row.entityId,
|
|
|
+ status: row.status,
|
|
|
+ createdAt: row.createdAt,
|
|
|
+ updatedAt: row.updatedAt,
|
|
|
+ reason: row.reason,
|
|
|
+ dataId: row.data?.id || '',
|
|
|
+ snapshotId: row.data?.snapshotId || '',
|
|
|
+ planId: row.data?.planId || '',
|
|
|
+ workId: row.data?.workId || '',
|
|
|
+ })),
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+function renderMarkdownReport(report) {
|
|
|
+ const lines = [
|
|
|
+ '# IP Operator Cleanup Dry Run',
|
|
|
+ '',
|
|
|
+ `- generatedAt: ${report.generatedAt}`,
|
|
|
+ `- scope: ${report.scope.parseUser}`,
|
|
|
+ `- accountFilter: ${report.scope.accountFilter || '(all detected accounts)'}`,
|
|
|
+ `- fetchedRowCount: ${report.fetchedRowCount}`,
|
|
|
+ `- candidateCount: ${report.candidateCount}`,
|
|
|
+ ];
|
|
|
+ if (report.stats?.totalEntities !== undefined) {
|
|
|
+ lines.push(`- systemStorage.stats totalEntities: ${report.stats.totalEntities}`);
|
|
|
+ }
|
|
|
+ lines.push('', '## Current fetched rows by entityType/status', '');
|
|
|
+ lines.push(...summaryMarkdownLines(report.fetchedSummary));
|
|
|
+ lines.push('', '## Cleanup candidates by entityType/status', '');
|
|
|
+ lines.push(...summaryMarkdownLines(report.candidateSummary));
|
|
|
+ lines.push('', '## Candidate rows', '');
|
|
|
+ if (!report.candidates.length) {
|
|
|
+ lines.push('(none)');
|
|
|
+ } else {
|
|
|
+ lines.push('| accountId | entityType | status | entityId | objectId | reason |');
|
|
|
+ lines.push('|---|---|---|---|---|---|');
|
|
|
+ for (const row of report.candidates) {
|
|
|
+ lines.push(`| ${escapeMd(row.accountId)} | ${escapeMd(row.entityType)} | ${escapeMd(row.status)} | ${escapeMd(row.entityId)} | ${escapeMd(row.objectId)} | ${escapeMd(row.reason)} |`);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ lines.push('', '> DRY-RUN ONLY: no delete/purge/physical removal was executed.', '');
|
|
|
+ return `${lines.join('\n')}\n`;
|
|
|
+}
|
|
|
+
|
|
|
+function summaryMarkdownLines(summary) {
|
|
|
+ const types = Object.keys(summary).sort();
|
|
|
+ if (!types.length) return ['(none)'];
|
|
|
+ return types.map((type) => {
|
|
|
+ const statuses = Object.entries(summary[type])
|
|
|
+ .sort(([a], [b]) => a.localeCompare(b))
|
|
|
+ .map(([status, count]) => `${status}:${count}`)
|
|
|
+ .join(', ');
|
|
|
+ return `- ${type}: ${statuses}`;
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+function summaryToObject(summary) {
|
|
|
+ const result = {};
|
|
|
+ for (const [type, statuses] of summary.entries()) {
|
|
|
+ result[type] = { ...statuses };
|
|
|
+ }
|
|
|
+ return result;
|
|
|
+}
|
|
|
+
|
|
|
+function safeTimestamp(value) {
|
|
|
+ return String(value).replace(/[:.]/g, '-');
|
|
|
+}
|
|
|
+
|
|
|
+function escapeMd(value) {
|
|
|
+ return String(value ?? '').replace(/\|/g, '\\|').replace(/\r?\n/g, '<br>');
|
|
|
+}
|
|
|
+
|
|
|
+function parseSnapshotIdFromChildId(value) {
|
|
|
+ const text = String(value || '');
|
|
|
+ const index = text.indexOf('__');
|
|
|
+ return index >= 0 ? text.slice(0, index) : '';
|
|
|
+}
|
|
|
+
|
|
|
+function parseAccountIdFromSnapshotId(value) {
|
|
|
+ const text = String(value || '');
|
|
|
+ const match = text.match(/^snapshot_(.+?)_(?:current|\d{10,}.*)$/);
|
|
|
+ return match?.[1] || '';
|
|
|
+}
|
|
|
+
|
|
|
+function toArray(value) {
|
|
|
+ return Array.isArray(value) ? value : [];
|
|
|
+}
|