emdash-emit 0.1.13 → 0.1.15
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/CHANGELOG.md +108 -1
- package/README.md +235 -187
- package/dist/cli/commands/delete.d.ts.map +1 -1
- package/dist/cli/commands/delete.js +2 -0
- package/dist/cli/commands/delete.js.map +1 -1
- package/dist/cli/commands/doctor.d.ts.map +1 -1
- package/dist/cli/commands/doctor.js +9 -0
- package/dist/cli/commands/doctor.js.map +1 -1
- package/dist/cli/commands/pull.d.ts +32 -0
- package/dist/cli/commands/pull.d.ts.map +1 -1
- package/dist/cli/commands/pull.js +108 -9
- package/dist/cli/commands/pull.js.map +1 -1
- package/dist/cli/commands/push.d.ts.map +1 -1
- package/dist/cli/commands/push.js +18 -0
- package/dist/cli/commands/push.js.map +1 -1
- package/dist/cli/commands/status.d.ts.map +1 -1
- package/dist/cli/commands/status.js +15 -5
- package/dist/cli/commands/status.js.map +1 -1
- package/dist/client/media.d.ts.map +1 -1
- package/dist/client/media.js +16 -5
- package/dist/client/media.js.map +1 -1
- package/dist/db/cache.d.ts +24 -5
- package/dist/db/cache.d.ts.map +1 -1
- package/dist/db/cache.js +308 -61
- package/dist/db/cache.js.map +1 -1
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/mcp/server.js +5 -3
- package/dist/mcp/server.js.map +1 -1
- package/dist/mcp/tools/sync-delete.d.ts.map +1 -1
- package/dist/mcp/tools/sync-delete.js +5 -0
- package/dist/mcp/tools/sync-delete.js.map +1 -1
- package/dist/mcp/tools/sync-doctor.d.ts.map +1 -1
- package/dist/mcp/tools/sync-doctor.js +12 -0
- package/dist/mcp/tools/sync-doctor.js.map +1 -1
- package/dist/mcp/tools/sync-pull.d.ts.map +1 -1
- package/dist/mcp/tools/sync-pull.js +49 -12
- package/dist/mcp/tools/sync-pull.js.map +1 -1
- package/dist/mcp/tools/sync-push.d.ts.map +1 -1
- package/dist/mcp/tools/sync-push.js +5 -0
- package/dist/mcp/tools/sync-push.js.map +1 -1
- package/dist/mcp/tools/sync-status.d.ts.map +1 -1
- package/dist/mcp/tools/sync-status.js +6 -2
- package/dist/mcp/tools/sync-status.js.map +1 -1
- package/dist/sync/conflicts.d.ts.map +1 -1
- package/dist/sync/conflicts.js +44 -0
- package/dist/sync/conflicts.js.map +1 -1
- package/dist/sync/delete.d.ts +3 -1
- package/dist/sync/delete.d.ts.map +1 -1
- package/dist/sync/delete.js +7 -20
- package/dist/sync/delete.js.map +1 -1
- package/dist/sync/diff.d.ts.map +1 -1
- package/dist/sync/diff.js +18 -2
- package/dist/sync/diff.js.map +1 -1
- package/dist/sync/doctor.d.ts +28 -1
- package/dist/sync/doctor.d.ts.map +1 -1
- package/dist/sync/doctor.js +393 -45
- package/dist/sync/doctor.js.map +1 -1
- package/dist/sync/guards.d.ts +45 -0
- package/dist/sync/guards.d.ts.map +1 -0
- package/dist/sync/guards.js +235 -0
- package/dist/sync/guards.js.map +1 -0
- package/dist/sync/media/body_scan.d.ts +15 -0
- package/dist/sync/media/body_scan.d.ts.map +1 -0
- package/dist/sync/media/body_scan.js +132 -0
- package/dist/sync/media/body_scan.js.map +1 -0
- package/dist/sync/media/companions.d.ts +20 -0
- package/dist/sync/media/companions.d.ts.map +1 -0
- package/dist/sync/media/companions.js +111 -0
- package/dist/sync/media/companions.js.map +1 -0
- package/dist/sync/media/index.d.ts +9 -0
- package/dist/sync/media/index.d.ts.map +1 -0
- package/dist/sync/media/index.js +9 -0
- package/dist/sync/media/index.js.map +1 -0
- package/dist/sync/media/mirror_item.d.ts +27 -0
- package/dist/sync/media/mirror_item.d.ts.map +1 -0
- package/dist/sync/media/mirror_item.js +207 -0
- package/dist/sync/media/mirror_item.js.map +1 -0
- package/dist/sync/media/naming_mirror.d.ts +36 -0
- package/dist/sync/media/naming_mirror.d.ts.map +1 -0
- package/dist/sync/media/naming_mirror.js +143 -0
- package/dist/sync/media/naming_mirror.js.map +1 -0
- package/dist/sync/media/pull_article.d.ts +37 -0
- package/dist/sync/media/pull_article.d.ts.map +1 -0
- package/dist/sync/media/pull_article.js +273 -0
- package/dist/sync/media/pull_article.js.map +1 -0
- package/dist/sync/media/pull_library.d.ts +12 -0
- package/dist/sync/media/pull_library.d.ts.map +1 -0
- package/dist/sync/media/pull_library.js +207 -0
- package/dist/sync/media/pull_library.js.map +1 -0
- package/dist/sync/media/remote_index.d.ts +18 -0
- package/dist/sync/media/remote_index.d.ts.map +1 -0
- package/dist/sync/media/remote_index.js +94 -0
- package/dist/sync/media/remote_index.js.map +1 -0
- package/dist/sync/media/types.d.ts +191 -0
- package/dist/sync/media/types.d.ts.map +1 -0
- package/dist/sync/media/types.js +11 -0
- package/dist/sync/media/types.js.map +1 -0
- package/dist/sync/media.d.ts +1 -74
- package/dist/sync/media.d.ts.map +1 -1
- package/dist/sync/media.js +1 -592
- package/dist/sync/media.js.map +1 -1
- package/dist/sync/merge.d.ts +11 -0
- package/dist/sync/merge.d.ts.map +1 -1
- package/dist/sync/merge.js +53 -6
- package/dist/sync/merge.js.map +1 -1
- package/dist/sync/orchestrator.d.ts +23 -0
- package/dist/sync/orchestrator.d.ts.map +1 -1
- package/dist/sync/orchestrator.js +85 -1
- package/dist/sync/orchestrator.js.map +1 -1
- package/dist/sync/preflight.d.ts +1 -1
- package/dist/sync/preflight.d.ts.map +1 -1
- package/dist/sync/preflight.js +127 -15
- package/dist/sync/preflight.js.map +1 -1
- package/dist/sync/pull.d.ts +21 -0
- package/dist/sync/pull.d.ts.map +1 -1
- package/dist/sync/pull.js +278 -17
- package/dist/sync/pull.js.map +1 -1
- package/dist/sync/push.d.ts +7 -0
- package/dist/sync/push.d.ts.map +1 -1
- package/dist/sync/push.js +135 -58
- package/dist/sync/push.js.map +1 -1
- package/dist/sync/status.d.ts +4 -0
- package/dist/sync/status.d.ts.map +1 -1
- package/dist/sync/status.js +68 -4
- package/dist/sync/status.js.map +1 -1
- package/dist/utils/config.d.ts +25 -0
- package/dist/utils/config.d.ts.map +1 -1
- package/dist/utils/config.js +28 -2
- package/dist/utils/config.js.map +1 -1
- package/dist/utils/frontmatter.d.ts +1 -1
- package/dist/utils/frontmatter.d.ts.map +1 -1
- package/dist/utils/frontmatter.js +15 -4
- package/dist/utils/frontmatter.js.map +1 -1
- package/dist/utils/media-body-refs.d.ts +81 -0
- package/dist/utils/media-body-refs.d.ts.map +1 -0
- package/dist/utils/media-body-refs.js +240 -0
- package/dist/utils/media-body-refs.js.map +1 -0
- package/dist/utils/media-refs.d.ts +44 -0
- package/dist/utils/media-refs.d.ts.map +1 -0
- package/dist/utils/media-refs.js +101 -0
- package/dist/utils/media-refs.js.map +1 -0
- package/dist/utils/naming.d.ts +31 -0
- package/dist/utils/naming.d.ts.map +1 -1
- package/dist/utils/naming.js +58 -0
- package/dist/utils/naming.js.map +1 -1
- package/package.json +1 -1
package/dist/sync/doctor.js
CHANGED
|
@@ -24,29 +24,88 @@ import path from 'node:path';
|
|
|
24
24
|
import yaml from 'js-yaml';
|
|
25
25
|
import { getPackageVersion } from '../utils/version.js';
|
|
26
26
|
import { parseMarkdown, lenientRepairMarkdownFrontmatter } from '../utils/frontmatter.js';
|
|
27
|
+
import { storageKeyToMarkdownRef } from '../utils/naming.js';
|
|
27
28
|
import { listConflicts } from './conflicts.js';
|
|
28
29
|
import { getWorkspaceStatus } from './status.js';
|
|
29
30
|
import { calculateHash } from './pull.js';
|
|
31
|
+
import { collectRemoteStorageKeys, storageKeyFromMediaMeta, } from './media.js';
|
|
30
32
|
import { checkMcpProcess } from '../mcp/heartbeat.js';
|
|
33
|
+
import { VALID_MEDIA_FILE_KEY, MEDIA_FILE_ROUTE_PREFIX, FENCE_POLICY, isCodeFenceDelimiter, } from '../utils/media-refs.js';
|
|
34
|
+
/**
|
|
35
|
+
* Counts the media assets this workspace actually mirrors: canonical binaries under `media/`, excluding
|
|
36
|
+
* `drafts/` staging and the `legacy_*` residue that backs no real record.
|
|
37
|
+
*/
|
|
38
|
+
function countMirroredMedia(mediaDir) {
|
|
39
|
+
if (!fs.existsSync(mediaDir))
|
|
40
|
+
return 0;
|
|
41
|
+
let count = 0;
|
|
42
|
+
for (const file of fs.readdirSync(mediaDir)) {
|
|
43
|
+
if (file.endsWith('.yaml') || file.startsWith('legacy_'))
|
|
44
|
+
continue;
|
|
45
|
+
if (!fs.statSync(path.join(mediaDir, file)).isFile())
|
|
46
|
+
continue;
|
|
47
|
+
count++;
|
|
48
|
+
}
|
|
49
|
+
return count;
|
|
50
|
+
}
|
|
51
|
+
/** Any storageKey-style ULID appearing inside a media file URL. */
|
|
52
|
+
const MEDIA_ULID_IN_URL = /[0-9A-HJ-NP-Z]{26}/g;
|
|
53
|
+
/** True when the captured image markup sits inside an inline code span on the same line. */
|
|
54
|
+
function isInlineCodeSpan(line, matchText, matchIndex) {
|
|
55
|
+
if (line.includes('`' + matchText))
|
|
56
|
+
return true;
|
|
57
|
+
const first = line.indexOf('`');
|
|
58
|
+
return first !== -1 && first < matchIndex && matchIndex < line.lastIndexOf('`');
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Classify a captured media URL. Anything that cannot name a real asset (template placeholders, or
|
|
62
|
+
* a key polluted by leaked markup) is reported separately so it never counts as missing media.
|
|
63
|
+
*/
|
|
64
|
+
function classifyMediaUrl(url) {
|
|
65
|
+
if (url.includes('<') || url.includes('>') || url.includes('${'))
|
|
66
|
+
return 'placeholder';
|
|
67
|
+
const keyMatch = url.match(/(?:^|\/)media\/file\/([^?#]*)/);
|
|
68
|
+
if (!keyMatch)
|
|
69
|
+
return 'valid'; // a plain local path: resolved by filesystem checks instead
|
|
70
|
+
if (VALID_MEDIA_FILE_KEY.test(keyMatch[1]))
|
|
71
|
+
return 'valid';
|
|
72
|
+
// A ULID is present but the captured key is polluted by leaked markup, as in the real legacy
|
|
73
|
+
// body line ``. The asset itself still
|
|
74
|
+
// resolves through the ULID, so only the surrounding capture is treated as residue.
|
|
75
|
+
return MEDIA_ULID_IN_URL.test(keyMatch[1]) ? 'valid' : 'invalid';
|
|
76
|
+
}
|
|
31
77
|
/**
|
|
32
78
|
* Scans article markdown to separate standard body image references from
|
|
33
|
-
* informational references (code blocks, inline code, HTML comments,
|
|
79
|
+
* informational references (code blocks, inline code, HTML comments, opaque custom-block fences,
|
|
80
|
+
* or placeholder URLs) and from structurally invalid captures.
|
|
34
81
|
*/
|
|
35
82
|
export function scanArticleMediaDetailed(bodyMarkdown) {
|
|
36
83
|
const normalRefs = new Set();
|
|
37
84
|
const informationalRefs = new Set();
|
|
85
|
+
const invalidRefs = new Set();
|
|
38
86
|
const lines = bodyMarkdown.split('\n');
|
|
39
87
|
let inFence = false;
|
|
40
88
|
let fenceDelimiter = '';
|
|
89
|
+
// EmDash serializes unknown Portable Text blocks as single-line opaque fences
|
|
90
|
+
// (`<!--ec:block {...} -->`). Their embedded JSON may contain media URLs that belong to the
|
|
91
|
+
// remote block, not to editable markdown, so they must never be reported as missing.
|
|
92
|
+
let inOpaqueFence = false;
|
|
41
93
|
for (const line of lines) {
|
|
42
94
|
const trimmed = line.trim();
|
|
95
|
+
if (/^<!--ec:block\s/.test(trimmed)) {
|
|
96
|
+
inOpaqueFence = !/^.*-->$/.test(trimmed);
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (inOpaqueFence)
|
|
100
|
+
continue;
|
|
43
101
|
// Note: Doctor treats both backticks and tildes (~~~) as fences for diagnostic
|
|
44
102
|
// reporting to avoid false warnings on code examples, whereas push guards (G5/G11)
|
|
45
103
|
// strictly exempt only backtick fences per AGENTS.md.
|
|
46
|
-
|
|
104
|
+
const delim = isCodeFenceDelimiter(trimmed, FENCE_POLICY.diagnostic);
|
|
105
|
+
if (delim) {
|
|
47
106
|
if (!inFence) {
|
|
48
107
|
inFence = true;
|
|
49
|
-
fenceDelimiter =
|
|
108
|
+
fenceDelimiter = delim;
|
|
50
109
|
continue;
|
|
51
110
|
}
|
|
52
111
|
else if (trimmed.startsWith(fenceDelimiter)) {
|
|
@@ -60,12 +119,13 @@ export function scanArticleMediaDetailed(bodyMarkdown) {
|
|
|
60
119
|
while ((match = mdRegex.exec(line)) !== null) {
|
|
61
120
|
const url = match[1].trim();
|
|
62
121
|
if (!url.startsWith('http://') && !url.startsWith('https://') && !url.startsWith('data:')) {
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
122
|
+
const kind = classifyMediaUrl(url);
|
|
123
|
+
if (kind === 'invalid') {
|
|
124
|
+
invalidRefs.add(url);
|
|
125
|
+
}
|
|
126
|
+
else if (inFence ||
|
|
127
|
+
isInlineCodeSpan(line, match[0], match.index) ||
|
|
128
|
+
kind === 'placeholder') {
|
|
69
129
|
informationalRefs.add(url);
|
|
70
130
|
}
|
|
71
131
|
else {
|
|
@@ -78,8 +138,11 @@ export function scanArticleMediaDetailed(bodyMarkdown) {
|
|
|
78
138
|
while ((match = htmlRegex.exec(line)) !== null) {
|
|
79
139
|
const url = match[1].trim();
|
|
80
140
|
if (!url.startsWith('http://') && !url.startsWith('https://') && !url.startsWith('data:')) {
|
|
81
|
-
const
|
|
82
|
-
if (
|
|
141
|
+
const kind = classifyMediaUrl(url);
|
|
142
|
+
if (kind === 'invalid') {
|
|
143
|
+
invalidRefs.add(url);
|
|
144
|
+
}
|
|
145
|
+
else if (inFence || kind === 'placeholder' || line.includes('<!--')) {
|
|
83
146
|
informationalRefs.add(url);
|
|
84
147
|
}
|
|
85
148
|
else {
|
|
@@ -87,10 +150,22 @@ export function scanArticleMediaDetailed(bodyMarkdown) {
|
|
|
87
150
|
}
|
|
88
151
|
}
|
|
89
152
|
}
|
|
153
|
+
// Legacy WordPress-derived custom container lines that survived the migration, e.g.
|
|
154
|
+
// `[dimage url="/_emdash/api/media/file/<ULID>.jpg" control=true]`. The URL is quoted inside
|
|
155
|
+
// the line, so the regexes above cannot capture it; a trailing attribute or bracket marks the
|
|
156
|
+
// capture as markup residue rather than a real asset reference.
|
|
157
|
+
const legacyContainerRegex = /\[([a-z]+)\s+url=["']?([^"'\s\]]+)["']?[^\]]*\]/gi;
|
|
158
|
+
while ((match = legacyContainerRegex.exec(line)) !== null) {
|
|
159
|
+
const url = match[2].trim();
|
|
160
|
+
if (!url.startsWith('http://') && !url.startsWith('https://') && !url.startsWith('data:')) {
|
|
161
|
+
informationalRefs.add(url);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
90
164
|
}
|
|
91
165
|
return {
|
|
92
166
|
normalRefs: Array.from(normalRefs).sort(),
|
|
93
167
|
informationalRefs: Array.from(informationalRefs).sort(),
|
|
168
|
+
invalidRefs: Array.from(invalidRefs).sort(),
|
|
94
169
|
};
|
|
95
170
|
}
|
|
96
171
|
/**
|
|
@@ -138,6 +213,7 @@ export async function runWorkspaceDoctor(options) {
|
|
|
138
213
|
}
|
|
139
214
|
// 3. Frontmatter Syntax & Schema Integrity Check
|
|
140
215
|
const invalidFiles = [];
|
|
216
|
+
const healableFiles = [];
|
|
141
217
|
let totalArticles = 0;
|
|
142
218
|
for (const status of ['draft', 'published']) {
|
|
143
219
|
const dir = path.join(options.workspaceRoot, collection, status);
|
|
@@ -157,6 +233,7 @@ export async function runWorkspaceDoctor(options) {
|
|
|
157
233
|
const healed = lenientRepairMarkdownFrontmatter(content);
|
|
158
234
|
if (healed) {
|
|
159
235
|
parseMarkdown(healed);
|
|
236
|
+
healableFiles.push(path.join(collection, status, file));
|
|
160
237
|
}
|
|
161
238
|
else {
|
|
162
239
|
throw new Error('Unrecoverable parse error');
|
|
@@ -177,6 +254,15 @@ export async function runWorkspaceDoctor(options) {
|
|
|
177
254
|
details: invalidFiles,
|
|
178
255
|
});
|
|
179
256
|
}
|
|
257
|
+
else if (healableFiles.length > 0) {
|
|
258
|
+
checks.push({
|
|
259
|
+
id: 'frontmatter_integrity',
|
|
260
|
+
name: 'Frontmatter Integrity',
|
|
261
|
+
status: 'warning',
|
|
262
|
+
message: `${healableFiles.length} article(s) have invalid frontmatter YAML that Emit can repair automatically. Run "emit pull" (syntax-only re-quoting; local body is strictly preserved).`,
|
|
263
|
+
details: healableFiles,
|
|
264
|
+
});
|
|
265
|
+
}
|
|
180
266
|
else {
|
|
181
267
|
checks.push({
|
|
182
268
|
id: 'frontmatter_integrity',
|
|
@@ -210,44 +296,109 @@ export async function runWorkspaceDoctor(options) {
|
|
|
210
296
|
message: `No unresolved conflicts or rescue backups in ${collection}/conflicts/.`,
|
|
211
297
|
});
|
|
212
298
|
}
|
|
213
|
-
// 4b.
|
|
299
|
+
// 4b. Conflict Pending Alignment & Auditing
|
|
214
300
|
const allStates = options.cache.getAllSyncStates();
|
|
215
|
-
const
|
|
216
|
-
const
|
|
217
|
-
const
|
|
218
|
-
|
|
219
|
-
|
|
301
|
+
const autoClearedBodySlugs = [];
|
|
302
|
+
const autoClearedTaxSlugs = [];
|
|
303
|
+
const manualBodySlugs = [];
|
|
304
|
+
const manualTaxSlugs = [];
|
|
305
|
+
const conflictMarkerRegex = /^<{7}\s|^={7}$|^>{7}\s/m;
|
|
306
|
+
for (const state of allStates) {
|
|
220
307
|
const fullPath = path.join(options.workspaceRoot, state.localFilePath);
|
|
308
|
+
let diskContent = null;
|
|
221
309
|
let hasDivergence = true;
|
|
310
|
+
let hasBodyMarkers = false;
|
|
222
311
|
if (fs.existsSync(fullPath)) {
|
|
223
|
-
|
|
312
|
+
diskContent = fs.readFileSync(fullPath, 'utf8');
|
|
313
|
+
const diskHash = calculateHash(diskContent);
|
|
224
314
|
hasDivergence = diskHash !== state.localHash;
|
|
315
|
+
hasBodyMarkers = conflictMarkerRegex.test(diskContent);
|
|
225
316
|
}
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
317
|
+
// Check body conflict pending
|
|
318
|
+
if (state.bodyConflictPending === 1) {
|
|
319
|
+
const hasConflictCopy = conflictItems.some((c) => (c.id === state.id || c.slug === state.slug) &&
|
|
320
|
+
(c.type === 'conflict' ||
|
|
321
|
+
c.type === 'dropped' ||
|
|
322
|
+
c.filePath.endsWith('.conflict.md') ||
|
|
323
|
+
c.filePath.endsWith('.local-dropped.md')));
|
|
324
|
+
if (!hasConflictCopy && !hasDivergence && !hasBodyMarkers) {
|
|
325
|
+
options.cache.clearConflictsByKind(state.id, 'body');
|
|
326
|
+
autoClearedBodySlugs.push(state.slug);
|
|
327
|
+
}
|
|
328
|
+
else {
|
|
329
|
+
manualBodySlugs.push(state.slug);
|
|
330
|
+
}
|
|
230
331
|
}
|
|
231
|
-
|
|
232
|
-
|
|
332
|
+
// Check taxonomy conflict pending
|
|
333
|
+
if (state.taxonomyConflictPending === 1) {
|
|
334
|
+
const hasConflictCopy = conflictItems.some((c) => c.id === state.id || c.slug === state.slug);
|
|
335
|
+
if (!hasConflictCopy && !hasDivergence) {
|
|
336
|
+
options.cache.clearConflictsByKind(state.id, 'taxonomy');
|
|
337
|
+
autoClearedTaxSlugs.push(state.slug);
|
|
338
|
+
}
|
|
339
|
+
else {
|
|
340
|
+
manualTaxSlugs.push(state.slug);
|
|
341
|
+
}
|
|
233
342
|
}
|
|
234
343
|
}
|
|
235
|
-
|
|
344
|
+
const totalAutoCleared = autoClearedBodySlugs.length + autoClearedTaxSlugs.length;
|
|
345
|
+
if (totalAutoCleared > 0) {
|
|
236
346
|
checks.push({
|
|
237
347
|
id: 'conflict_pending_autoclear',
|
|
238
348
|
name: 'Conflict Pending Auto-Clear',
|
|
239
349
|
status: 'ok',
|
|
240
|
-
message: `Automatically cleared stale conflict_pending flag for ${
|
|
241
|
-
details:
|
|
350
|
+
message: `Automatically cleared stale conflict_pending flag for ${totalAutoCleared} pending record(s) (no conflict copies and no divergence).`,
|
|
351
|
+
details: [...new Set([...autoClearedBodySlugs, ...autoClearedTaxSlugs])],
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
if (manualBodySlugs.length > 0) {
|
|
355
|
+
checks.push({
|
|
356
|
+
id: 'body_conflict_pending_manual',
|
|
357
|
+
name: 'Body Conflict Pending',
|
|
358
|
+
status: 'warning',
|
|
359
|
+
message: `${manualBodySlugs.length} article(s) have pending body conflicts. Resolve conflict markers in the file or run "emit conflicts diff / resolve".`,
|
|
360
|
+
details: manualBodySlugs,
|
|
242
361
|
});
|
|
243
362
|
}
|
|
244
|
-
if (
|
|
363
|
+
if (manualTaxSlugs.length > 0) {
|
|
245
364
|
checks.push({
|
|
246
365
|
id: 'conflict_pending_manual',
|
|
247
366
|
name: 'Taxonomy Conflict Pending',
|
|
248
367
|
status: 'warning',
|
|
249
|
-
message: `${
|
|
250
|
-
details:
|
|
368
|
+
message: `${manualTaxSlugs.length} article(s) have pending taxonomy conflicts with existing rescue copies or unpushed edits. Run "emit push <file> --resolve-taxonomy=union|local|remote" to resolve.`,
|
|
369
|
+
details: manualTaxSlugs,
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
// 4c. Taxonomy Apply Pending Check
|
|
373
|
+
const taxApplyPendingStates = allStates.filter((s) => s.taxonomyApplyPending === 1);
|
|
374
|
+
if (taxApplyPendingStates.length > 0) {
|
|
375
|
+
checks.push({
|
|
376
|
+
id: 'taxonomy_apply_pending',
|
|
377
|
+
name: 'Taxonomy Apply Pending',
|
|
378
|
+
status: 'warning',
|
|
379
|
+
message: `${taxApplyPendingStates.length} article(s) have unapplied taxonomy terms from a previous failed sync.`,
|
|
380
|
+
suggestion: 'Run "emit pull-taxonomies", verify the terms exist on remote (missing terms or permission errors must be fixed there first), then "emit push <file>".',
|
|
381
|
+
details: taxApplyPendingStates.map((s) => ({
|
|
382
|
+
id: s.id,
|
|
383
|
+
slug: s.slug,
|
|
384
|
+
filePath: s.localFilePath,
|
|
385
|
+
})),
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
// 4d. Delete Pending Check
|
|
389
|
+
const deletePendingStates = allStates.filter((s) => s.deletePending === 1);
|
|
390
|
+
if (deletePendingStates.length > 0) {
|
|
391
|
+
checks.push({
|
|
392
|
+
id: 'delete_pending',
|
|
393
|
+
name: 'Delete Pending Articles',
|
|
394
|
+
status: 'warning',
|
|
395
|
+
message: `${deletePendingStates.length} article(s) are marked as delete_pending (unconfirmed remote deletion).`,
|
|
396
|
+
suggestion: 'Confirm the remote deletion, then "emit delete <file>" to finish local cleanup, or "emit push <file> --revive" to intentionally revive.',
|
|
397
|
+
details: deletePendingStates.map((s) => ({
|
|
398
|
+
id: s.id,
|
|
399
|
+
slug: s.slug,
|
|
400
|
+
filePath: s.localFilePath,
|
|
401
|
+
})),
|
|
251
402
|
});
|
|
252
403
|
}
|
|
253
404
|
// 5. Sync Status & Pending Modifications Check
|
|
@@ -276,32 +427,56 @@ export async function runWorkspaceDoctor(options) {
|
|
|
276
427
|
});
|
|
277
428
|
}
|
|
278
429
|
// 6. Media References Integrity Check
|
|
279
|
-
// Index
|
|
430
|
+
// Index every local resolution source so a body URL can be matched even when the companion YAML
|
|
431
|
+
// is missing its storageKey field, which is common for legacy corpora (D1)
|
|
280
432
|
const mediaDir = path.join(options.workspaceRoot, 'media');
|
|
281
433
|
const knownStorageKeys = new Set();
|
|
282
434
|
const knownMediaIds = new Set();
|
|
283
435
|
const knownLocalFiles = new Set();
|
|
436
|
+
// StorageKey-derived stems of mirrored assets. A body URL carries the storageKey, and a mirror is now
|
|
437
|
+
// named after exactly that key, so the stem — not a filename tail parsed out of an id-based name — is
|
|
438
|
+
// what lets `/_emdash/api/media/file/<storageKey>` resolve to a file on disk.
|
|
439
|
+
const knownMediaStems = new Set();
|
|
284
440
|
if (fs.existsSync(mediaDir)) {
|
|
285
441
|
const mediaFiles = fs.readdirSync(mediaDir);
|
|
286
442
|
for (const f of mediaFiles) {
|
|
287
443
|
knownLocalFiles.add(f);
|
|
288
444
|
knownLocalFiles.add(path.posix.join('media', f));
|
|
445
|
+
if (f.startsWith('legacy_')) {
|
|
446
|
+
// Leftover mirrors from the removed file-route fallback must never feed the known-storageKey
|
|
447
|
+
// or known-media-id sets: they back no real media record, so a body referencing them stays a
|
|
448
|
+
// missing-file warning and `--check-media-urls` can still downgrade it to a served note.
|
|
449
|
+
continue;
|
|
450
|
+
}
|
|
289
451
|
if (f.endsWith('.yaml')) {
|
|
290
452
|
try {
|
|
291
453
|
const doc = yaml.load(fs.readFileSync(path.join(mediaDir, f), 'utf8'));
|
|
292
454
|
if (doc) {
|
|
293
|
-
if (typeof doc.storageKey === 'string' && doc.storageKey) {
|
|
294
|
-
knownStorageKeys.add(doc.storageKey);
|
|
295
|
-
}
|
|
296
455
|
if (typeof doc.id === 'string' && doc.id) {
|
|
297
456
|
knownMediaIds.add(doc.id);
|
|
298
457
|
}
|
|
458
|
+
// The storageKey field is missing on older mirrors, but their `url:` still names the key,
|
|
459
|
+
// so both forms count as confirmed local metadata.
|
|
460
|
+
const metaKey = storageKeyFromMediaMeta(doc);
|
|
461
|
+
if (metaKey)
|
|
462
|
+
knownStorageKeys.add(metaKey);
|
|
299
463
|
}
|
|
300
464
|
}
|
|
301
465
|
catch {
|
|
302
466
|
// ignore corrupted individual yaml
|
|
303
467
|
}
|
|
304
468
|
}
|
|
469
|
+
else {
|
|
470
|
+
// media/<markdownRef>.<ext>: the stem is the sanitized storageKey, so it is recorded whole and
|
|
471
|
+
// matched against the key a body URL carries. Legacy id-based names stay readable too, because a
|
|
472
|
+
// workspace that has not been re-pulled yet still holds them.
|
|
473
|
+
const stem = f.replace(/\.[^.]+$/, '');
|
|
474
|
+
knownMediaStems.add(stem);
|
|
475
|
+
knownMediaStems.add(stem.replace(/^\d{8}_/, ''));
|
|
476
|
+
const dash = stem.indexOf('-');
|
|
477
|
+
if (dash !== -1)
|
|
478
|
+
knownMediaStems.add(stem.slice(dash + 1));
|
|
479
|
+
}
|
|
305
480
|
}
|
|
306
481
|
const draftsDir = path.join(mediaDir, 'drafts');
|
|
307
482
|
if (fs.existsSync(draftsDir)) {
|
|
@@ -311,8 +486,48 @@ export async function runWorkspaceDoctor(options) {
|
|
|
311
486
|
}
|
|
312
487
|
}
|
|
313
488
|
}
|
|
489
|
+
// Cache-backed fallbacks: media_state records the confirmed storageKey of every asset Emit
|
|
490
|
+
// uploaded, and base snapshots preserve the remote featured_image object (which carries
|
|
491
|
+
// meta.storageKey) for articles whose local frontmatter only holds the media record id.
|
|
492
|
+
for (const record of options.cache.listMediaStates()) {
|
|
493
|
+
if (record.storageKey)
|
|
494
|
+
knownStorageKeys.add(record.storageKey);
|
|
495
|
+
if (record.mediaId)
|
|
496
|
+
knownMediaIds.add(record.mediaId);
|
|
497
|
+
}
|
|
498
|
+
const knownFeaturedStorageKeys = new Set();
|
|
499
|
+
for (const state of options.cache.getAllSyncStates()) {
|
|
500
|
+
const snapshot = options.cache.getBaseSnapshot(state.id);
|
|
501
|
+
if (!snapshot?.frontmatterYaml)
|
|
502
|
+
continue;
|
|
503
|
+
try {
|
|
504
|
+
const fm = JSON.parse(snapshot.frontmatterYaml);
|
|
505
|
+
const fi = fm.featuredImage ?? fm.featured_image;
|
|
506
|
+
const meta = fi?.meta;
|
|
507
|
+
if (typeof meta?.storageKey === 'string' && meta.storageKey) {
|
|
508
|
+
knownFeaturedStorageKeys.add(meta.storageKey);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
catch {
|
|
512
|
+
// baseline frontmatter is JSON-encoded by contract; ignore unreadable snapshots
|
|
513
|
+
}
|
|
514
|
+
}
|
|
314
515
|
const missingMedia = [];
|
|
516
|
+
// References that survived every local fallback yet are still served by the public file route. The
|
|
517
|
+
// live HEAD (only when --check-media-urls is set) proves the R2 object exists; it says nothing about
|
|
518
|
+
// whether a D1 record exists, because the probe addresses the storageKey and not the primary key.
|
|
519
|
+
const verifiedFileRouteOnly = [];
|
|
520
|
+
const unreachableFileRoute = [];
|
|
315
521
|
const informationalUnresolved = [];
|
|
522
|
+
const invalidMediaRefs = [];
|
|
523
|
+
// Candidates that survived every local fallback; probed live only when checkMediaUrls is enabled.
|
|
524
|
+
const pendingRemoteProbes = [];
|
|
525
|
+
const probeKey = (ref) => {
|
|
526
|
+
const m = ref.match(/(?:^|\/)media\/file\/([^?#]+)/);
|
|
527
|
+
if (!m)
|
|
528
|
+
return null;
|
|
529
|
+
return VALID_MEDIA_FILE_KEY.test(m[1]) ? m[1] : null;
|
|
530
|
+
};
|
|
316
531
|
for (const status of ['draft', 'published']) {
|
|
317
532
|
const dir = path.join(options.workspaceRoot, collection, status);
|
|
318
533
|
if (!fs.existsSync(dir))
|
|
@@ -327,38 +542,160 @@ export async function runWorkspaceDoctor(options) {
|
|
|
327
542
|
const storageKeyMatch = ref.match(/(?:^|\/)media\/file\/([^?#]+)/);
|
|
328
543
|
if (storageKeyMatch) {
|
|
329
544
|
const key = storageKeyMatch[1];
|
|
330
|
-
|
|
545
|
+
if (knownStorageKeys.has(key))
|
|
546
|
+
return true;
|
|
547
|
+
// The body URL may carry the media record id instead of the storageKey, or a key whose
|
|
548
|
+
// companion YAML lacks the field: resolve by ULID, then by the mirror stem that names the key.
|
|
549
|
+
const ulidMatch = key.match(/[0-9A-HJ-NP-Z]{26}/);
|
|
550
|
+
if (ulidMatch && knownMediaIds.has(ulidMatch[0].toUpperCase()))
|
|
551
|
+
return true;
|
|
552
|
+
const keyStem = storageKeyToMarkdownRef(key);
|
|
553
|
+
if (keyStem && knownMediaStems.has(keyStem))
|
|
554
|
+
return true;
|
|
555
|
+
for (const stem of knownMediaStems) {
|
|
556
|
+
if (key.endsWith(stem))
|
|
557
|
+
return true;
|
|
558
|
+
}
|
|
559
|
+
return knownFeaturedStorageKeys.has(key);
|
|
331
560
|
}
|
|
332
561
|
// Check 2: Direct relative or local filesystem path
|
|
333
562
|
const cleanRef = ref.replace(/^[./]+/, '');
|
|
334
563
|
const directFull = path.isAbsolute(ref) ? ref : path.join(options.workspaceRoot, ref);
|
|
335
|
-
|
|
564
|
+
if (fs.existsSync(directFull) ||
|
|
565
|
+
knownLocalFiles.has(ref) ||
|
|
566
|
+
knownLocalFiles.has(cleanRef)) {
|
|
567
|
+
return true;
|
|
568
|
+
}
|
|
569
|
+
// Check 3: A local mirror reference (`../../media/<storageKey>` or the older depth-free
|
|
570
|
+
// `media/<storageKey>`) names the key outright, so a collision-suffixed or truncated mirror stem
|
|
571
|
+
// leaves no file at that exact path. Resolve it through
|
|
572
|
+
// the same confirmed-key and stem indexes Check 1 uses instead of reporting a mirrored asset missing.
|
|
573
|
+
const localKeyMatch = cleanRef.match(/^media\/([^?#]+)/);
|
|
574
|
+
if (localKeyMatch) {
|
|
575
|
+
const key = localKeyMatch[1];
|
|
576
|
+
if (knownStorageKeys.has(key))
|
|
577
|
+
return true;
|
|
578
|
+
const keyStem = storageKeyToMarkdownRef(key);
|
|
579
|
+
if (keyStem && knownMediaStems.has(keyStem))
|
|
580
|
+
return true;
|
|
581
|
+
}
|
|
582
|
+
return false;
|
|
336
583
|
};
|
|
337
584
|
for (const ref of detailed.normalRefs) {
|
|
338
|
-
if (
|
|
339
|
-
|
|
585
|
+
if (checkRef(ref))
|
|
586
|
+
continue;
|
|
587
|
+
const source = path.join(collection, status, file);
|
|
588
|
+
const key = options.checkMediaUrls ? probeKey(ref) : null;
|
|
589
|
+
if (key) {
|
|
590
|
+
pendingRemoteProbes.push({ ref, key, source });
|
|
591
|
+
}
|
|
592
|
+
else {
|
|
593
|
+
missingMedia.push(`${ref} (referenced by ${source})`);
|
|
340
594
|
}
|
|
341
595
|
}
|
|
342
596
|
for (const ref of detailed.informationalRefs) {
|
|
343
597
|
if (!checkRef(ref)) {
|
|
344
|
-
informationalUnresolved.push(`${ref} (code-example/
|
|
598
|
+
informationalUnresolved.push(`${ref} (code-example/custom-block in ${path.join(collection, status, file)})`);
|
|
345
599
|
}
|
|
346
600
|
}
|
|
601
|
+
for (const ref of detailed.invalidRefs) {
|
|
602
|
+
invalidMediaRefs.push(`${ref} (malformed reference in ${path.join(collection, status, file)})`);
|
|
603
|
+
}
|
|
347
604
|
}
|
|
348
605
|
catch {
|
|
349
606
|
// Skip unreadable files
|
|
350
607
|
}
|
|
351
608
|
}
|
|
352
609
|
}
|
|
610
|
+
// Live file-route probe: legacy migrated assets can still be served by /_emdash/api/media/file/
|
|
611
|
+
// even though their media record returns 404, so confirm them against the origin instead of
|
|
612
|
+
// reporting them as missing files that pull-media cannot fetch.
|
|
613
|
+
if (options.checkMediaUrls && pendingRemoteProbes.length > 0) {
|
|
614
|
+
const origin = options.profileOrigin || options.config.origin;
|
|
615
|
+
const probeFetch = options.fetchFn || globalThis.fetch;
|
|
616
|
+
const reachableByKey = new Map();
|
|
617
|
+
for (const candidate of pendingRemoteProbes) {
|
|
618
|
+
if (reachableByKey.has(candidate.key))
|
|
619
|
+
continue;
|
|
620
|
+
let reachable = false;
|
|
621
|
+
try {
|
|
622
|
+
const url = new URL(`${MEDIA_FILE_ROUTE_PREFIX}${candidate.key}`, origin).toString();
|
|
623
|
+
const res = await probeFetch(url, { method: 'HEAD' });
|
|
624
|
+
reachable = res.ok;
|
|
625
|
+
}
|
|
626
|
+
catch {
|
|
627
|
+
reachable = false;
|
|
628
|
+
}
|
|
629
|
+
reachableByKey.set(candidate.key, reachable);
|
|
630
|
+
}
|
|
631
|
+
for (const candidate of pendingRemoteProbes) {
|
|
632
|
+
if (reachableByKey.get(candidate.key)) {
|
|
633
|
+
verifiedFileRouteOnly.push(`${candidate.ref} (served by the file route but matched by no local mirror or record lookup; referenced by ${candidate.source})`);
|
|
634
|
+
}
|
|
635
|
+
else {
|
|
636
|
+
unreachableFileRoute.push(`${candidate.ref} (referenced by ${candidate.source})`);
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
missingMedia.push(...unreachableFileRoute);
|
|
641
|
+
// Remote library census: the list operation is the only handle that reaches migrated assets, because
|
|
642
|
+
// it is the one endpoint that reports each record's own storageKey and exposes no key filter. A
|
|
643
|
+
// complete walk turns "447 references have no local file" into "the library holds M records, this
|
|
644
|
+
// workspace mirrors K of them", which is what tells a user to widen the window instead of chase data
|
|
645
|
+
// loss. Credentials are required, so plain doctor runs stay offline and skip this block.
|
|
646
|
+
let remoteLibrary = null;
|
|
647
|
+
if (options.checkMediaLibrary) {
|
|
648
|
+
const origin = options.profileOrigin || options.config.origin;
|
|
649
|
+
try {
|
|
650
|
+
const index = await collectRemoteStorageKeys({
|
|
651
|
+
workspaceRoot: options.workspaceRoot,
|
|
652
|
+
origin,
|
|
653
|
+
token: options.token || '',
|
|
654
|
+
refreshToken: options.refreshToken,
|
|
655
|
+
fetchFn: options.fetchFn,
|
|
656
|
+
});
|
|
657
|
+
// Live EmDash sends no `totalCount`, so the walk itself is the only library figure available.
|
|
658
|
+
remoteLibrary = {
|
|
659
|
+
totalCount: index.totalCount ?? (index.complete ? index.scannedCount : null),
|
|
660
|
+
complete: index.complete,
|
|
661
|
+
mirroredCount: countMirroredMedia(mediaDir),
|
|
662
|
+
};
|
|
663
|
+
}
|
|
664
|
+
catch {
|
|
665
|
+
// An unreachable or unauthenticated library is not a workspace defect; doctor stays silent.
|
|
666
|
+
}
|
|
667
|
+
}
|
|
353
668
|
if (missingMedia.length > 0) {
|
|
669
|
+
const coverage = remoteLibrary
|
|
670
|
+
? remoteLibrary.complete
|
|
671
|
+
? ` The remote library walk reached ${remoteLibrary.totalCount ?? 'no'} media record(s), and this workspace mirrors ${remoteLibrary.mirroredCount} of them, so most of these references were simply never downloaded.`
|
|
672
|
+
: ` The remote library is larger than the walk that could be completed here, and this workspace mirrors ${remoteLibrary.mirroredCount} record(s), so most of these references were simply never downloaded.`
|
|
673
|
+
: '';
|
|
354
674
|
checks.push({
|
|
355
675
|
id: 'media_references',
|
|
356
676
|
name: 'Media References',
|
|
357
677
|
status: 'warning',
|
|
358
|
-
message: `${missingMedia.length} referenced media file(s) are missing from disk
|
|
678
|
+
message: `${missingMedia.length} referenced media file(s) are missing from disk.${coverage} ` +
|
|
679
|
+
`Run "emit pull-media --article <id>" for the specific article, or "emit doctor --check-media-library" ` +
|
|
680
|
+
`to size the remote library (metadata-only walk). Reserve "emit pull-media --all" for deliberate full offline backups. ` +
|
|
681
|
+
`Body URLs carry R2 storage keys, so a missing local file does not mean the remote asset is broken: see docs/media-id-spaces.md.`,
|
|
682
|
+
suggestion: options.checkMediaUrls
|
|
683
|
+
? 'These keys are R2 storage keys, not media record ids, so a file-route hit proves only that the object exists. The full library index is reachable without binary downloads through "emit doctor --check-media-library" (or "emit pull-media --all" for complete offline backup), which reports every storageKey the server holds without any server-side query. Confirm by storageKey before registering anything — a blind INSERT can duplicate an existing record. Emit mirrors records, never repairs server data.'
|
|
684
|
+
: undefined,
|
|
359
685
|
details: missingMedia,
|
|
360
686
|
});
|
|
361
687
|
}
|
|
688
|
+
else if (remoteLibrary) {
|
|
689
|
+
const notes = [
|
|
690
|
+
`Remote media library holds ${remoteLibrary.totalCount ?? 'an unknown number of'} record(s); this workspace mirrors ${remoteLibrary.mirroredCount}`,
|
|
691
|
+
];
|
|
692
|
+
checks.push({
|
|
693
|
+
id: 'media_references',
|
|
694
|
+
name: 'Media References',
|
|
695
|
+
status: 'ok',
|
|
696
|
+
message: notes.join('; '),
|
|
697
|
+
});
|
|
698
|
+
}
|
|
362
699
|
else if (options.strictMedia && informationalUnresolved.length > 0) {
|
|
363
700
|
checks.push({
|
|
364
701
|
id: 'media_references',
|
|
@@ -369,15 +706,26 @@ export async function runWorkspaceDoctor(options) {
|
|
|
369
706
|
});
|
|
370
707
|
}
|
|
371
708
|
else {
|
|
372
|
-
const
|
|
373
|
-
|
|
374
|
-
|
|
709
|
+
const notes = [];
|
|
710
|
+
if (verifiedFileRouteOnly.length > 0) {
|
|
711
|
+
notes.push(`${verifiedFileRouteOnly.length} reference(s) served by the remote file route that no local mirror or record lookup matched`);
|
|
712
|
+
}
|
|
713
|
+
if (informationalUnresolved.length > 0) {
|
|
714
|
+
notes.push(`${informationalUnresolved.length} informational/custom-block reference(s) ignored`);
|
|
715
|
+
}
|
|
716
|
+
if (invalidMediaRefs.length > 0) {
|
|
717
|
+
notes.push(`${invalidMediaRefs.length} malformed reference(s) skipped (not valid media file keys)`);
|
|
718
|
+
}
|
|
375
719
|
checks.push({
|
|
376
720
|
id: 'media_references',
|
|
377
721
|
name: 'Media References',
|
|
378
722
|
status: 'ok',
|
|
379
|
-
message: `All referenced local media files exist on disk.${
|
|
380
|
-
...(
|
|
723
|
+
message: `All referenced local media files exist on disk.${notes.length ? ` (${notes.join('; ')})` : ''}`,
|
|
724
|
+
...(verifiedFileRouteOnly.length > 0 ||
|
|
725
|
+
informationalUnresolved.length > 0 ||
|
|
726
|
+
invalidMediaRefs.length > 0
|
|
727
|
+
? { details: [...verifiedFileRouteOnly, ...informationalUnresolved, ...invalidMediaRefs] }
|
|
728
|
+
: {}),
|
|
381
729
|
});
|
|
382
730
|
}
|
|
383
731
|
// 7. MCP Process Check
|