emdash-emit 0.1.25 → 0.1.30
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 +132 -4
- package/dist/cli/commands/conflicts.d.ts.map +1 -1
- package/dist/cli/commands/conflicts.js +2 -22
- package/dist/cli/commands/conflicts.js.map +1 -1
- package/dist/cli/commands/delete.d.ts.map +1 -1
- package/dist/cli/commands/delete.js +1 -13
- 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 +1 -13
- package/dist/cli/commands/doctor.js.map +1 -1
- package/dist/cli/commands/media.d.ts.map +1 -1
- package/dist/cli/commands/media.js +1 -13
- package/dist/cli/commands/media.js.map +1 -1
- package/dist/cli/commands/new.d.ts.map +1 -1
- package/dist/cli/commands/new.js +1 -13
- package/dist/cli/commands/new.js.map +1 -1
- package/dist/cli/commands/pull.d.ts +1 -1
- package/dist/cli/commands/pull.d.ts.map +1 -1
- package/dist/cli/commands/pull.js +110 -79
- package/dist/cli/commands/pull.js.map +1 -1
- package/dist/cli/commands/push.d.ts +1 -1
- package/dist/cli/commands/push.d.ts.map +1 -1
- package/dist/cli/commands/push.js +32 -90
- package/dist/cli/commands/push.js.map +1 -1
- package/dist/cli/context.d.ts +12 -0
- package/dist/cli/context.d.ts.map +1 -1
- package/dist/cli/context.js +27 -1
- package/dist/cli/context.js.map +1 -1
- package/dist/cli/report-failure.d.ts +6 -0
- package/dist/cli/report-failure.d.ts.map +1 -0
- package/dist/cli/report-failure.js +71 -0
- package/dist/cli/report-failure.js.map +1 -0
- package/dist/client/api.d.ts.map +1 -1
- package/dist/client/api.js +1 -1
- package/dist/client/api.js.map +1 -1
- package/dist/client/lock.js.map +1 -1
- package/dist/client/mapper.js.map +1 -1
- package/dist/client/media.d.ts.map +1 -1
- package/dist/client/media.js.map +1 -1
- package/dist/db/baseline.d.ts +16 -1
- package/dist/db/baseline.d.ts.map +1 -1
- package/dist/db/baseline.js +25 -0
- package/dist/db/baseline.js.map +1 -1
- package/dist/db/cache.d.ts +2 -3
- package/dist/db/cache.d.ts.map +1 -1
- package/dist/db/cache.js +3 -295
- package/dist/db/cache.js.map +1 -1
- package/dist/db/migrations.d.ts +7 -0
- package/dist/db/migrations.d.ts.map +1 -0
- package/dist/db/migrations.js +320 -0
- package/dist/db/migrations.js.map +1 -0
- package/dist/mcp/heartbeat.d.ts +1 -34
- package/dist/mcp/heartbeat.d.ts.map +1 -1
- package/dist/mcp/heartbeat.js +1 -113
- package/dist/mcp/heartbeat.js.map +1 -1
- package/dist/mcp/tool-errors.d.ts +12 -1
- package/dist/mcp/tool-errors.d.ts.map +1 -1
- package/dist/mcp/tool-errors.js +29 -0
- package/dist/mcp/tool-errors.js.map +1 -1
- package/dist/mcp/tools/sync-conflicts.d.ts.map +1 -1
- package/dist/mcp/tools/sync-conflicts.js +46 -58
- package/dist/mcp/tools/sync-conflicts.js.map +1 -1
- package/dist/mcp/tools/sync-delete.d.ts.map +1 -1
- package/dist/mcp/tools/sync-delete.js +18 -30
- package/dist/mcp/tools/sync-delete.js.map +1 -1
- package/dist/mcp/tools/sync-diff.d.ts.map +1 -1
- package/dist/mcp/tools/sync-diff.js +10 -31
- package/dist/mcp/tools/sync-diff.js.map +1 -1
- package/dist/mcp/tools/sync-doctor.d.ts.map +1 -1
- package/dist/mcp/tools/sync-doctor.js +18 -30
- package/dist/mcp/tools/sync-doctor.js.map +1 -1
- package/dist/mcp/tools/sync-media-search.d.ts.map +1 -1
- package/dist/mcp/tools/sync-media-search.js +40 -52
- package/dist/mcp/tools/sync-media-search.js.map +1 -1
- package/dist/mcp/tools/sync-new.d.ts.map +1 -1
- package/dist/mcp/tools/sync-new.js +34 -55
- package/dist/mcp/tools/sync-new.js.map +1 -1
- package/dist/mcp/tools/sync-pull.d.ts.map +1 -1
- package/dist/mcp/tools/sync-pull.js +194 -220
- 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 +64 -84
- 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 +3 -13
- package/dist/mcp/tools/sync-status.js.map +1 -1
- package/dist/sync/baseline-reconstruction.d.ts +43 -0
- package/dist/sync/baseline-reconstruction.d.ts.map +1 -0
- package/dist/sync/baseline-reconstruction.js +103 -0
- package/dist/sync/baseline-reconstruction.js.map +1 -0
- package/dist/sync/bylines.d.ts +5 -0
- package/dist/sync/bylines.d.ts.map +1 -1
- package/dist/sync/bylines.js +29 -0
- package/dist/sync/bylines.js.map +1 -1
- package/dist/sync/conflicts.d.ts.map +1 -1
- package/dist/sync/conflicts.js +21 -4
- package/dist/sync/conflicts.js.map +1 -1
- package/dist/sync/delete.d.ts.map +1 -1
- package/dist/sync/delete.js +12 -1
- package/dist/sync/delete.js.map +1 -1
- package/dist/sync/diff.d.ts.map +1 -1
- package/dist/sync/diff.js +58 -27
- package/dist/sync/diff.js.map +1 -1
- package/dist/sync/doctor.d.ts.map +1 -1
- package/dist/sync/doctor.js +13 -3
- package/dist/sync/doctor.js.map +1 -1
- package/dist/sync/guards.d.ts +1 -1
- package/dist/sync/guards.d.ts.map +1 -1
- package/dist/sync/guards.js +1 -0
- package/dist/sync/guards.js.map +1 -1
- package/dist/sync/media/companions.d.ts.map +1 -1
- package/dist/sync/media/companions.js.map +1 -1
- package/dist/sync/media/naming_mirror.d.ts.map +1 -1
- package/dist/sync/media/naming_mirror.js +11 -2
- package/dist/sync/media/naming_mirror.js.map +1 -1
- package/dist/sync/media/pull_article.d.ts +2 -0
- package/dist/sync/media/pull_article.d.ts.map +1 -1
- package/dist/sync/media/pull_article.js +74 -25
- package/dist/sync/media/pull_article.js.map +1 -1
- package/dist/sync/media/pull_library.d.ts.map +1 -1
- package/dist/sync/media/pull_library.js +34 -61
- package/dist/sync/media/pull_library.js.map +1 -1
- package/dist/sync/media/remote_index.d.ts +16 -1
- package/dist/sync/media/remote_index.d.ts.map +1 -1
- package/dist/sync/media/remote_index.js +2 -2
- package/dist/sync/media/remote_index.js.map +1 -1
- package/dist/sync/merge.d.ts.map +1 -1
- package/dist/sync/merge.js +5 -5
- package/dist/sync/merge.js.map +1 -1
- package/dist/sync/new.d.ts.map +1 -1
- package/dist/sync/new.js +5 -4
- package/dist/sync/new.js.map +1 -1
- package/dist/sync/orchestrator.d.ts +34 -0
- package/dist/sync/orchestrator.d.ts.map +1 -1
- package/dist/sync/orchestrator.js +89 -80
- package/dist/sync/orchestrator.js.map +1 -1
- package/dist/sync/preflight/image_probe.d.ts +28 -0
- package/dist/sync/preflight/image_probe.d.ts.map +1 -0
- package/dist/sync/preflight/image_probe.js +70 -0
- package/dist/sync/preflight/image_probe.js.map +1 -0
- package/dist/sync/preflight.d.ts.map +1 -1
- package/dist/sync/preflight.js +48 -45
- package/dist/sync/preflight.js.map +1 -1
- package/dist/sync/pull/fast_path.d.ts +9 -0
- package/dist/sync/pull/fast_path.d.ts.map +1 -0
- package/dist/sync/pull/fast_path.js +341 -0
- package/dist/sync/pull/fast_path.js.map +1 -0
- package/dist/sync/pull/index.d.ts +15 -0
- package/dist/sync/pull/index.d.ts.map +1 -0
- package/dist/sync/pull/index.js +363 -0
- package/dist/sync/pull/index.js.map +1 -0
- package/dist/sync/pull/list.d.ts +22 -0
- package/dist/sync/pull/list.d.ts.map +1 -0
- package/dist/sync/pull/list.js +175 -0
- package/dist/sync/pull/list.js.map +1 -0
- package/dist/sync/pull/media_stage.d.ts +67 -0
- package/dist/sync/pull/media_stage.d.ts.map +1 -0
- package/dist/sync/pull/media_stage.js +599 -0
- package/dist/sync/pull/media_stage.js.map +1 -0
- package/dist/sync/pull/quarantine.d.ts +22 -0
- package/dist/sync/pull/quarantine.d.ts.map +1 -0
- package/dist/sync/pull/quarantine.js +174 -0
- package/dist/sync/pull/quarantine.js.map +1 -0
- package/dist/sync/pull/types.d.ts +125 -0
- package/dist/sync/pull/types.d.ts.map +1 -0
- package/dist/sync/pull/types.js +23 -0
- package/dist/sync/pull/types.js.map +1 -0
- package/dist/sync/pull.d.ts +1 -114
- package/dist/sync/pull.d.ts.map +1 -1
- package/dist/sync/pull.js +1 -861
- package/dist/sync/pull.js.map +1 -1
- package/dist/sync/push/commit.d.ts.map +1 -1
- package/dist/sync/push/commit.js +1 -2
- package/dist/sync/push/commit.js.map +1 -1
- package/dist/sync/push/index.js +3 -3
- package/dist/sync/push/index.js.map +1 -1
- package/dist/sync/push/merge_stage.js +1 -1
- package/dist/sync/push/merge_stage.js.map +1 -1
- package/dist/sync/push/transport.d.ts +1 -1
- package/dist/sync/push/transport.d.ts.map +1 -1
- package/dist/sync/push/transport.js +1 -1
- package/dist/sync/push/transport.js.map +1 -1
- package/dist/sync/status.d.ts.map +1 -1
- package/dist/sync/status.js +11 -57
- package/dist/sync/status.js.map +1 -1
- package/dist/sync/taxonomies.d.ts +25 -0
- package/dist/sync/taxonomies.d.ts.map +1 -1
- package/dist/sync/taxonomies.js +161 -119
- package/dist/sync/taxonomies.js.map +1 -1
- package/dist/utils/frontmatter.d.ts.map +1 -1
- package/dist/utils/frontmatter.js.map +1 -1
- package/dist/utils/mcp-heartbeat.d.ts +36 -0
- package/dist/utils/mcp-heartbeat.d.ts.map +1 -0
- package/dist/utils/mcp-heartbeat.js +126 -0
- package/dist/utils/mcp-heartbeat.js.map +1 -0
- package/dist/utils/media-body-refs.d.ts.map +1 -1
- package/dist/utils/media-body-refs.js +2 -2
- package/dist/utils/media-body-refs.js.map +1 -1
- package/dist/utils/taxonomy-key.d.ts +25 -0
- package/dist/utils/taxonomy-key.d.ts.map +1 -0
- package/dist/utils/taxonomy-key.js +51 -0
- package/dist/utils/taxonomy-key.js.map +1 -0
- package/package.json +1 -1
package/dist/sync/pull.js
CHANGED
|
@@ -19,865 +19,5 @@
|
|
|
19
19
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
20
20
|
* SOFTWARE.
|
|
21
21
|
*/
|
|
22
|
-
|
|
23
|
-
import path from 'node:path';
|
|
24
|
-
import { apiResponseToLocal } from '../client/mapper.js';
|
|
25
|
-
import { ApiError } from '../client/error.js';
|
|
26
|
-
import { createAuthedFetch } from '../client/http.js';
|
|
27
|
-
import { unwrapEnvelope, unwrapItem, extractRev, extractComparisonRev, isSameRev, } from '../client/api.js';
|
|
28
|
-
import { articleContentEndpoint, openApiEndpoint } from '../client/endpoints.js';
|
|
29
|
-
import { carriesPercentEncodedTaxonomy, extractDatePrefixFromPath, parseMarkdown, serializeMarkdown, lenientRepairMarkdownFrontmatter, } from '../utils/frontmatter.js';
|
|
30
|
-
import { hasUncommittedDraftRevision } from '../utils/draft-revision.js';
|
|
31
|
-
import { localizeArticleMediaRefs, carriesDepthFreeMediaRef, shouldPreferLegacyRef, } from '../utils/media-body-refs.js';
|
|
32
|
-
import { CONFLICTS_SUBDIR, calculateHash, commitArticleSnapshot, findLocalFilesByUlid, isQuarantinedRelativePath, quarantineLocalCopy, resolveCanonicalArticlePath, } from './workspace/index.js';
|
|
33
|
-
export { CONFLICTS_SUBDIR, calculateHash, findLocalFilesByUlid, isQuarantinedRelativePath, quarantineLocalCopy, resolveCanonicalArticlePath, carriesDepthFreeMediaRef, shouldPreferLegacyRef, };
|
|
34
|
-
import { loadLocalTaxonomyCandidates } from './taxonomies.js';
|
|
35
|
-
import { syncRemoteBylines } from './bylines.js';
|
|
36
|
-
import { updateConfigSyncedVersion } from '../utils/config.js';
|
|
37
|
-
import { fetchAssignedTerms as fetchAssignedTermsShared, computeTaxonomyHash } from './terms.js';
|
|
38
|
-
/**
|
|
39
|
-
* Human/agent readable notice describing preserved local copies.
|
|
40
|
-
* Returns an empty string when nothing was preserved.
|
|
41
|
-
*/
|
|
42
|
-
export function formatQuarantineNotice(quarantined) {
|
|
43
|
-
if (!quarantined || quarantined.length === 0)
|
|
44
|
-
return '';
|
|
45
|
-
const lines = [
|
|
46
|
-
`🩹 ${quarantined.length} divergent local copy(ies) preserved under ${CONFLICTS_SUBDIR}/ before being overwritten:`,
|
|
47
|
-
...quarantined.map((q) => ` - ${q.sourceFilePath} -> ${q.quarantineFilePath} (${q.reason})`),
|
|
48
|
-
' Review each rescue copy, merge any valuable content into the canonical article, then delete it.',
|
|
49
|
-
` Preserved copies are never pushed or pruned; pushing a rescue copy directly is refused (QUARANTINED_COPY_NOT_PUSHABLE).`,
|
|
50
|
-
];
|
|
51
|
-
return lines.join('\n');
|
|
52
|
-
}
|
|
53
|
-
/**
|
|
54
|
-
* Preserves a local Markdown copy verbatim under `<collection>/conflicts/` before Emit
|
|
55
|
-
* overwrites or removes the original path. Used to guarantee that no divergent local
|
|
56
|
-
* content (e.g. an uncached file written by another tool or an older serializer) is
|
|
57
|
-
* silently discarded during a pull.
|
|
58
|
-
* Returns the relative path of the preserved copy, or null when preservation failed.
|
|
59
|
-
*/
|
|
60
|
-
/**
|
|
61
|
-
* Resolves the canonical article title from remote data symmetrically across
|
|
62
|
-
* fast-path and full-pull (EMIT-005 / v0.1.14 backlog §1).
|
|
63
|
-
*/
|
|
64
|
-
export function extractCanonicalRemoteTitle(rawObj, fallbackSlug) {
|
|
65
|
-
const item = unwrapItem(rawObj);
|
|
66
|
-
const data = (item.data || {}) || {};
|
|
67
|
-
const rawTitle = String(data.title || item.title || '').trim();
|
|
68
|
-
if (rawTitle)
|
|
69
|
-
return rawTitle;
|
|
70
|
-
const rawSlug = String(item.slug || fallbackSlug || '').trim();
|
|
71
|
-
if (rawSlug)
|
|
72
|
-
return rawSlug;
|
|
73
|
-
return 'Untitled';
|
|
74
|
-
}
|
|
75
|
-
/**
|
|
76
|
-
* Discovers local files for this article that are about to be overwritten or unlinked, and
|
|
77
|
-
* preserves every copy whose content does not match the last synced baseline hash or the
|
|
78
|
-
* content Emit is about to write.
|
|
79
|
-
*/
|
|
80
|
-
function quarantineDivergentLocalCopies(options, collection, localRelPaths, baselineHash, canonicalContent, existingCopyHash) {
|
|
81
|
-
const quarantined = [];
|
|
82
|
-
for (const localRel of localRelPaths) {
|
|
83
|
-
const localFull = path.resolve(options.workspaceRoot, localRel);
|
|
84
|
-
if (!fs.existsSync(localFull))
|
|
85
|
-
continue;
|
|
86
|
-
let localContent;
|
|
87
|
-
try {
|
|
88
|
-
localContent = fs.readFileSync(localFull, 'utf8');
|
|
89
|
-
}
|
|
90
|
-
catch {
|
|
91
|
-
continue;
|
|
92
|
-
}
|
|
93
|
-
// Already identical to what is about to be written: nothing to preserve.
|
|
94
|
-
if (localContent === canonicalContent)
|
|
95
|
-
continue;
|
|
96
|
-
// Identical to the last synced state: carries no information worth preserving.
|
|
97
|
-
if (baselineHash && calculateHash(localContent) === baselineHash)
|
|
98
|
-
continue;
|
|
99
|
-
const quarantineRel = quarantineLocalCopy(options.workspaceRoot, collection, localRel, localContent, {
|
|
100
|
-
isForce: Boolean(options.force),
|
|
101
|
-
existingCopyHash,
|
|
102
|
-
});
|
|
103
|
-
if (quarantineRel) {
|
|
104
|
-
quarantined.push({
|
|
105
|
-
sourceFilePath: localRel,
|
|
106
|
-
quarantineFilePath: quarantineRel,
|
|
107
|
-
reason: baselineHash ? 'diverged-from-baseline' : 'no-cache-baseline',
|
|
108
|
-
});
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
return quarantined;
|
|
112
|
-
}
|
|
113
|
-
/**
|
|
114
|
-
* Discovers the server's maximum allowed limit for content list queries from /_emdash/api/openapi.json.
|
|
115
|
-
* Defaults to 100 if openapi.json cannot be fetched or parsed.
|
|
116
|
-
*/
|
|
117
|
-
export async function getRemoteMaxBatchLimit(options) {
|
|
118
|
-
const fetchFn = options.fetchFn || fetch;
|
|
119
|
-
try {
|
|
120
|
-
const url = openApiEndpoint(options.origin);
|
|
121
|
-
const headers = { Accept: 'application/json' };
|
|
122
|
-
if (options.token) {
|
|
123
|
-
headers.Authorization = `Bearer ${options.token}`;
|
|
124
|
-
}
|
|
125
|
-
const res = await fetchFn(url, { headers });
|
|
126
|
-
if (!res.ok)
|
|
127
|
-
return 100;
|
|
128
|
-
const json = (await res.json());
|
|
129
|
-
const paths = (json.paths || {});
|
|
130
|
-
const getEndpoint = paths['/_emdash/api/content/{collection}']?.get || paths['/content/{collection}']?.get;
|
|
131
|
-
if (getEndpoint && Array.isArray(getEndpoint.parameters)) {
|
|
132
|
-
const params = getEndpoint.parameters;
|
|
133
|
-
const limitParam = params.find((p) => p.name === 'limit');
|
|
134
|
-
const schema = (limitParam?.schema || {});
|
|
135
|
-
if (typeof schema.maximum === 'number' && schema.maximum > 0) {
|
|
136
|
-
return schema.maximum;
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
catch {
|
|
141
|
-
// Ignore network or parse error, fallback to safe 100
|
|
142
|
-
}
|
|
143
|
-
return 100;
|
|
144
|
-
}
|
|
145
|
-
/**
|
|
146
|
-
* Fetches articles for a specific status ('draft' or 'published') using cursor pagination.
|
|
147
|
-
*/
|
|
148
|
-
async function fetchStatusArticles(collection, status, options, serverMaxLimit) {
|
|
149
|
-
const fetchFn = createAuthedFetch(options);
|
|
150
|
-
const targetTotal = options.all ? Infinity : (options.limit ?? 100);
|
|
151
|
-
const items = [];
|
|
152
|
-
let cursor = undefined;
|
|
153
|
-
let batchLimit = serverMaxLimit;
|
|
154
|
-
while (items.length < targetTotal) {
|
|
155
|
-
const remaining = targetTotal - items.length;
|
|
156
|
-
const currentBatch = Math.min(remaining, batchLimit);
|
|
157
|
-
const url = new URL(articleContentEndpoint(options.origin, collection));
|
|
158
|
-
url.searchParams.set('status', status);
|
|
159
|
-
url.searchParams.set('limit', String(currentBatch));
|
|
160
|
-
if (cursor) {
|
|
161
|
-
url.searchParams.set('cursor', cursor);
|
|
162
|
-
}
|
|
163
|
-
let res = await fetchFn(url.toString(), {
|
|
164
|
-
method: 'GET',
|
|
165
|
-
headers: {
|
|
166
|
-
Authorization: `Bearer ${options.token}`,
|
|
167
|
-
Accept: 'application/json',
|
|
168
|
-
},
|
|
169
|
-
});
|
|
170
|
-
// Adaptive fallback if server responds with validation error on limit
|
|
171
|
-
if (!res.ok && res.status === 400) {
|
|
172
|
-
try {
|
|
173
|
-
const errJson = (await res.clone().json());
|
|
174
|
-
const errMsg = JSON.stringify(errJson);
|
|
175
|
-
const match = errMsg.match(/expected number to be <=(\d+)/);
|
|
176
|
-
if (match && Number(match[1]) < batchLimit) {
|
|
177
|
-
batchLimit = Number(match[1]);
|
|
178
|
-
url.searchParams.set('limit', String(Math.min(remaining, batchLimit)));
|
|
179
|
-
res = await fetchFn(url.toString(), {
|
|
180
|
-
method: 'GET',
|
|
181
|
-
headers: {
|
|
182
|
-
Authorization: `Bearer ${options.token}`,
|
|
183
|
-
Accept: 'application/json',
|
|
184
|
-
},
|
|
185
|
-
});
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
catch {
|
|
189
|
-
// Continue to normal error handling below
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
if (!res.ok) {
|
|
193
|
-
throw new ApiError({
|
|
194
|
-
message: `Failed to list remote articles (${status}): ${res.statusText}`,
|
|
195
|
-
code: 'ARTICLE_LIST_FAILED',
|
|
196
|
-
status: res.status,
|
|
197
|
-
endpoint: url.toString(),
|
|
198
|
-
suggestion: 'Verify network connection, permissions, or try lowering the limit.',
|
|
199
|
-
});
|
|
200
|
-
}
|
|
201
|
-
const json = await res.json();
|
|
202
|
-
const data = unwrapEnvelope(json);
|
|
203
|
-
const rawItems = (Array.isArray(data.items) ? data.items : Array.isArray(data) ? data : []);
|
|
204
|
-
if (rawItems.length === 0) {
|
|
205
|
-
break;
|
|
206
|
-
}
|
|
207
|
-
for (const raw of rawItems) {
|
|
208
|
-
const rawData = (raw.data || {});
|
|
209
|
-
const rev = extractRev(raw, '1');
|
|
210
|
-
items.push({
|
|
211
|
-
id: String(raw.id),
|
|
212
|
-
slug: String(raw.slug || ''),
|
|
213
|
-
status: String(raw.status || status),
|
|
214
|
-
title: String(rawData.title || raw.title || 'Untitled'),
|
|
215
|
-
_rev: rev,
|
|
216
|
-
rawItem: raw,
|
|
217
|
-
});
|
|
218
|
-
if (items.length >= targetTotal) {
|
|
219
|
-
break;
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
if (!data.nextCursor || typeof data.nextCursor !== 'string') {
|
|
223
|
-
break;
|
|
224
|
-
}
|
|
225
|
-
cursor = data.nextCursor;
|
|
226
|
-
}
|
|
227
|
-
return items;
|
|
228
|
-
}
|
|
229
|
-
/**
|
|
230
|
-
* Lists articles from EmDash API with cursor pagination and dynamic batch size detection.
|
|
231
|
-
* When status is not specified or 'all', fetches up to `limit` published articles AND
|
|
232
|
-
* up to `limit` draft articles in parallel, ensuring drafts and published posts are both covered.
|
|
233
|
-
*/
|
|
234
|
-
export async function listRemoteArticles(collection, options) {
|
|
235
|
-
const serverMaxLimit = options.batchSize || (await getRemoteMaxBatchLimit(options));
|
|
236
|
-
// If a specific status is explicitly requested
|
|
237
|
-
if (options.status === 'draft' || options.status === 'published') {
|
|
238
|
-
return fetchStatusArticles(collection, options.status, options, serverMaxLimit);
|
|
239
|
-
}
|
|
240
|
-
// Default: Fetch both published (up to limit) and draft (up to limit) in parallel
|
|
241
|
-
const [draftItems, publishedItems] = await Promise.all([
|
|
242
|
-
fetchStatusArticles(collection, 'draft', options, serverMaxLimit),
|
|
243
|
-
fetchStatusArticles(collection, 'published', options, serverMaxLimit),
|
|
244
|
-
]);
|
|
245
|
-
return [...draftItems, ...publishedItems];
|
|
246
|
-
}
|
|
247
|
-
/**
|
|
248
|
-
* Pulls a content item from EmDash and updates local files and cache.
|
|
249
|
-
* Implements Differential Skip: if remote _rev matches cached rev and local file is untouched,
|
|
250
|
-
* skips disk write and AST conversion.
|
|
251
|
-
*/
|
|
252
|
-
export async function pullArticle(collection, id, options) {
|
|
253
|
-
const fetchFn = createAuthedFetch(options);
|
|
254
|
-
let rawItem = options.rawItem;
|
|
255
|
-
// Helper to fetch assigned terms for a taxonomy ('category' | 'tag')
|
|
256
|
-
const fetchAssignedTerms = (taxonomy) => fetchAssignedTermsShared({
|
|
257
|
-
origin: options.origin,
|
|
258
|
-
token: options.token,
|
|
259
|
-
collection,
|
|
260
|
-
id,
|
|
261
|
-
taxonomy,
|
|
262
|
-
fetchFn,
|
|
263
|
-
});
|
|
264
|
-
// Helper to resolve byline ID-to-slug mapping
|
|
265
|
-
let bylineIdToSlug = options.bylineIdToSlug;
|
|
266
|
-
const getBylineIdToSlug = async () => {
|
|
267
|
-
if (bylineIdToSlug)
|
|
268
|
-
return bylineIdToSlug;
|
|
269
|
-
const cachedMaps = options.cache.getBylineMaps();
|
|
270
|
-
if (Object.keys(cachedMaps.idToSlug).length > 0) {
|
|
271
|
-
bylineIdToSlug = cachedMaps.idToSlug;
|
|
272
|
-
return bylineIdToSlug;
|
|
273
|
-
}
|
|
274
|
-
const freshMaps = await syncRemoteBylines({
|
|
275
|
-
origin: options.origin,
|
|
276
|
-
token: options.token,
|
|
277
|
-
refreshToken: options.refreshToken,
|
|
278
|
-
cache: options.cache,
|
|
279
|
-
fetchFn: options.fetchFn,
|
|
280
|
-
}).catch(() => ({ slugToId: {}, idToSlug: {} }));
|
|
281
|
-
bylineIdToSlug = freshMaps.idToSlug;
|
|
282
|
-
return bylineIdToSlug;
|
|
283
|
-
};
|
|
284
|
-
// Helper to resolve taxonomy candidates for frontmatter inline comments
|
|
285
|
-
let candidates = options.candidates;
|
|
286
|
-
const getTaxonomyCandidates = () => {
|
|
287
|
-
if (candidates)
|
|
288
|
-
return candidates;
|
|
289
|
-
candidates = loadLocalTaxonomyCandidates(options.workspaceRoot);
|
|
290
|
-
return candidates;
|
|
291
|
-
};
|
|
292
|
-
/**
|
|
293
|
-
* Applies the workspace's local-media-ref spelling to a serialized document. `sync_state.local_hash` always
|
|
294
|
-
* records the exact bytes on disk, so every comparison against a tracked file must run through this same
|
|
295
|
-
* transform: reconstructing the canonical form from a remote response and then localizing it keeps the
|
|
296
|
-
* fast-path skip honest whether or not `localMediaRefs` is enabled.
|
|
297
|
-
*
|
|
298
|
-
* `articleRelPath` decides the depth of the resulting reference — an article spells its mirror relative to its
|
|
299
|
-
* own directory so a Markdown preview resolves it offline. Pass `preferLegacyRef` only when reproducing bytes
|
|
300
|
-
* for a file that already sits on disk in the depth-free form, so an untouched article is never re-spelled
|
|
301
|
-
* into a phantom edit; a write that overwrites the file instead targets the article-relative form.
|
|
302
|
-
*/
|
|
303
|
-
const localizeDiskMarkdown = (canonicalContent, articleRelPath, preferLegacyRef) => options.localMediaRefs
|
|
304
|
-
? localizeArticleMediaRefs(canonicalContent, {
|
|
305
|
-
workspaceRoot: options.workspaceRoot,
|
|
306
|
-
articleRelPath: articleRelPath ?? existingFilePathForDepth(),
|
|
307
|
-
fs,
|
|
308
|
-
preferLegacyRef,
|
|
309
|
-
})
|
|
310
|
-
: canonicalContent;
|
|
311
|
-
/** The path a tracked article currently sits at, used when the caller has not resolved a new one yet. */
|
|
312
|
-
const existingFilePathForDepth = () => {
|
|
313
|
-
const tracked = options.cache.getSyncState(id)?.localFilePath;
|
|
314
|
-
if (tracked)
|
|
315
|
-
return tracked;
|
|
316
|
-
const onDisk = findLocalFilesByUlid(options.workspaceRoot, collection, id)[0];
|
|
317
|
-
return onDisk ?? `${collection}/draft/${id}.md`;
|
|
318
|
-
};
|
|
319
|
-
// Helper to test fast-path (unmodified skip or non-destructive frontmatter heal)
|
|
320
|
-
const tryFastPathOrHeal = async (item) => {
|
|
321
|
-
if (options.force)
|
|
322
|
-
return null;
|
|
323
|
-
const existingSync = options.cache.getSyncState(id);
|
|
324
|
-
const rawObj = (item.rawItem || item);
|
|
325
|
-
const rawRevCandidate = String(item._rev || item.rev || item.version || rawObj._rev || '');
|
|
326
|
-
const remoteRev = extractRev(rawObj, rawRevCandidate);
|
|
327
|
-
const remoteComparisonRev = extractComparisonRev(rawObj, remoteRev);
|
|
328
|
-
if (!existingSync || !remoteRev || !isSameRev(existingSync.remoteRev, remoteComparisonRev)) {
|
|
329
|
-
return null;
|
|
330
|
-
}
|
|
331
|
-
const fullExistingPath = path.join(options.workspaceRoot, existingSync.localFilePath);
|
|
332
|
-
if (!fs.existsSync(fullExistingPath)) {
|
|
333
|
-
return null;
|
|
334
|
-
}
|
|
335
|
-
try {
|
|
336
|
-
const currentDiskContent = fs.readFileSync(fullExistingPath, 'utf8');
|
|
337
|
-
// `sync_state.local_hash` always tracks the exact bytes on disk, so a workspace that spells mirrored
|
|
338
|
-
// images as local references must be compared against the same spelling — not against the canonical
|
|
339
|
-
// remote form. Reconstructing the expected disk content keeps the fast-path skip honest under both
|
|
340
|
-
// settings and prevents a phantom "modified" for an article nothing changed.
|
|
341
|
-
const currentDiskHash = calculateHash(currentDiskContent);
|
|
342
|
-
// Check if file path matches current naming standard
|
|
343
|
-
const existingPrefix = extractDatePrefixFromPath(existingSync.localFilePath);
|
|
344
|
-
const fallbackSlug = String(item.slug || existingSync.slug || '');
|
|
345
|
-
const rawTitle = extractCanonicalRemoteTitle(rawObj, fallbackSlug);
|
|
346
|
-
const targetStatus = String(rawObj.status || item.status || 'draft');
|
|
347
|
-
const expectedRelPath = resolveCanonicalArticlePath(collection, targetStatus, id, rawTitle, {
|
|
348
|
-
publishedAt: rawObj.publishedAt || item.publishedAt || null,
|
|
349
|
-
createdAt: rawObj.createdAt || item.createdAt || null,
|
|
350
|
-
updatedAt: rawObj.updatedAt || item.updatedAt || null,
|
|
351
|
-
}, existingPrefix);
|
|
352
|
-
// If path matches and contents are unchanged, safely skip (only if local frontmatter is valid YAML)
|
|
353
|
-
if (existingSync.localFilePath === expectedRelPath) {
|
|
354
|
-
// A workspace built before article-relative refs carries the depth-free `media/<storageKey>` spelling,
|
|
355
|
-
// and its tracked hash records exactly those bytes. Repairing the spelling here — rather than falling
|
|
356
|
-
// through to a full remote re-pull — keeps the skip honest: the content is untouched, only the reference
|
|
357
|
-
// form advances, and the file re-tracks against its own repaired hash so nothing reports a phantom edit.
|
|
358
|
-
if (options.localMediaRefs &&
|
|
359
|
-
currentDiskHash === existingSync.localHash &&
|
|
360
|
-
carriesDepthFreeMediaRef(currentDiskContent)) {
|
|
361
|
-
const respelled = localizeArticleMediaRefs(currentDiskContent, {
|
|
362
|
-
workspaceRoot: options.workspaceRoot,
|
|
363
|
-
articleRelPath: existingSync.localFilePath,
|
|
364
|
-
fs,
|
|
365
|
-
});
|
|
366
|
-
if (respelled !== currentDiskContent) {
|
|
367
|
-
try {
|
|
368
|
-
parseMarkdown(respelled);
|
|
369
|
-
fs.writeFileSync(fullExistingPath, respelled, 'utf8');
|
|
370
|
-
options.cache.saveSyncState({
|
|
371
|
-
...existingSync,
|
|
372
|
-
localHash: calculateHash(respelled),
|
|
373
|
-
lastSyncedAt: Math.floor(Date.now() / 1000),
|
|
374
|
-
});
|
|
375
|
-
return {
|
|
376
|
-
id,
|
|
377
|
-
slug: existingSync.slug,
|
|
378
|
-
status: existingSync.status,
|
|
379
|
-
filePath: existingSync.localFilePath,
|
|
380
|
-
updated: true,
|
|
381
|
-
healed: true,
|
|
382
|
-
meaningChanged: false,
|
|
383
|
-
title: rawTitle,
|
|
384
|
-
};
|
|
385
|
-
}
|
|
386
|
-
catch {
|
|
387
|
-
// Re-spelling broke the parse (it should not): fall through to the normal paths untouched.
|
|
388
|
-
}
|
|
389
|
-
}
|
|
390
|
-
}
|
|
391
|
-
if (currentDiskHash === existingSync.localHash) {
|
|
392
|
-
try {
|
|
393
|
-
const parsedCurrent = parseMarkdown(currentDiskContent);
|
|
394
|
-
// Branch 1: Valid YAML on hash-matched untouched file, but carries percent-encoded taxonomy terms or out-of-sync draft revision annotation -> heal in-place
|
|
395
|
-
const hasDraft = hasUncommittedDraftRevision(existingSync);
|
|
396
|
-
const carriesDraftComment = /^\s*status:\s*published\s+#\s*draft revision\s*$/m.test(currentDiskContent);
|
|
397
|
-
const needsDraftRevisionHeal = parsedCurrent.frontmatter.status === 'published' && hasDraft !== carriesDraftComment;
|
|
398
|
-
if (carriesPercentEncodedTaxonomy(currentDiskContent) || needsDraftRevisionHeal) {
|
|
399
|
-
const respelledCanon = serializeMarkdown(parsedCurrent.frontmatter, parsedCurrent.body, getTaxonomyCandidates(), { hasDraftRevision: hasDraft });
|
|
400
|
-
const respelled = localizeDiskMarkdown(respelledCanon, existingSync.localFilePath);
|
|
401
|
-
if (respelled !== currentDiskContent) {
|
|
402
|
-
try {
|
|
403
|
-
parseMarkdown(respelled);
|
|
404
|
-
commitArticleSnapshot(options.cache, options.workspaceRoot, {
|
|
405
|
-
id,
|
|
406
|
-
relPath: existingSync.localFilePath,
|
|
407
|
-
diskContent: respelled,
|
|
408
|
-
canonicalContent: respelledCanon,
|
|
409
|
-
frontmatter: parsedCurrent.frontmatter,
|
|
410
|
-
bodyMarkdown: parsedCurrent.body,
|
|
411
|
-
revForTransport: existingSync.remoteRev,
|
|
412
|
-
revForComparison: existingSync.remoteRev,
|
|
413
|
-
bumpBaselineHashOnly: true,
|
|
414
|
-
});
|
|
415
|
-
return {
|
|
416
|
-
id,
|
|
417
|
-
slug: existingSync.slug,
|
|
418
|
-
status: existingSync.status,
|
|
419
|
-
filePath: existingSync.localFilePath,
|
|
420
|
-
updated: true,
|
|
421
|
-
healed: true,
|
|
422
|
-
meaningChanged: false,
|
|
423
|
-
title: rawTitle,
|
|
424
|
-
};
|
|
425
|
-
}
|
|
426
|
-
catch {
|
|
427
|
-
// Re-spelling broke the parse (should not happen): fall through to skip
|
|
428
|
-
}
|
|
429
|
-
}
|
|
430
|
-
}
|
|
431
|
-
return {
|
|
432
|
-
id,
|
|
433
|
-
slug: existingSync.slug,
|
|
434
|
-
status: existingSync.status,
|
|
435
|
-
filePath: existingSync.localFilePath,
|
|
436
|
-
updated: false,
|
|
437
|
-
skipped: true,
|
|
438
|
-
title: rawTitle,
|
|
439
|
-
};
|
|
440
|
-
}
|
|
441
|
-
catch {
|
|
442
|
-
// Branch 2: Broken YAML on hash-matched untouched file.
|
|
443
|
-
// Heal syntax via lenient repair, then serialize cleanly to decoded representation with zero network fetches.
|
|
444
|
-
const repaired = lenientRepairMarkdownFrontmatter(currentDiskContent);
|
|
445
|
-
if (repaired !== null) {
|
|
446
|
-
const parsedRepaired = parseMarkdown(repaired);
|
|
447
|
-
const hasDraft = hasUncommittedDraftRevision({
|
|
448
|
-
status: parsedRepaired.frontmatter.status,
|
|
449
|
-
remoteRev: remoteComparisonRev,
|
|
450
|
-
});
|
|
451
|
-
const decodedCanon = serializeMarkdown(parsedRepaired.frontmatter, parsedRepaired.body, getTaxonomyCandidates(), { hasDraftRevision: hasDraft });
|
|
452
|
-
const decodedDisk = localizeDiskMarkdown(decodedCanon, existingSync.localFilePath);
|
|
453
|
-
commitArticleSnapshot(options.cache, options.workspaceRoot, {
|
|
454
|
-
id,
|
|
455
|
-
relPath: existingSync.localFilePath,
|
|
456
|
-
diskContent: decodedDisk,
|
|
457
|
-
frontmatter: parsedRepaired.frontmatter,
|
|
458
|
-
bodyMarkdown: parsedRepaired.body,
|
|
459
|
-
revForTransport: remoteRev,
|
|
460
|
-
revForComparison: remoteComparisonRev,
|
|
461
|
-
bumpBaselineHashOnly: true,
|
|
462
|
-
});
|
|
463
|
-
return {
|
|
464
|
-
id,
|
|
465
|
-
slug: parsedRepaired.frontmatter.slug,
|
|
466
|
-
status: parsedRepaired.frontmatter.status,
|
|
467
|
-
filePath: existingSync.localFilePath,
|
|
468
|
-
updated: true,
|
|
469
|
-
healed: true,
|
|
470
|
-
meaningChanged: false,
|
|
471
|
-
title: rawTitle,
|
|
472
|
-
};
|
|
473
|
-
}
|
|
474
|
-
// Not healable locally: fall through to the full remote pull, which re-serializes canonical
|
|
475
|
-
// frontmatter from the API response and quarantines any body that diverged from the baseline.
|
|
476
|
-
}
|
|
477
|
-
}
|
|
478
|
-
// Local file has unpushed edits. Check if frontmatter parses cleanly.
|
|
479
|
-
try {
|
|
480
|
-
parseMarkdown(currentDiskContent);
|
|
481
|
-
return {
|
|
482
|
-
id,
|
|
483
|
-
slug: existingSync.slug,
|
|
484
|
-
status: existingSync.status,
|
|
485
|
-
filePath: existingSync.localFilePath,
|
|
486
|
-
updated: false,
|
|
487
|
-
skipped: true,
|
|
488
|
-
localEditsPreserved: true,
|
|
489
|
-
title: rawTitle,
|
|
490
|
-
};
|
|
491
|
-
}
|
|
492
|
-
catch {
|
|
493
|
-
// Local frontmatter is invalid. Attempt non-destructive lenient frontmatter repair.
|
|
494
|
-
const repaired = lenientRepairMarkdownFrontmatter(currentDiskContent);
|
|
495
|
-
if (repaired !== null) {
|
|
496
|
-
fs.writeFileSync(fullExistingPath, repaired, 'utf8');
|
|
497
|
-
const parsedRepaired = parseMarkdown(repaired);
|
|
498
|
-
const resolvedBylineMap = await getBylineIdToSlug();
|
|
499
|
-
const effectiveOptions = {
|
|
500
|
-
...options,
|
|
501
|
-
bylineIdToSlug: resolvedBylineMap,
|
|
502
|
-
};
|
|
503
|
-
const remoteLocal = apiResponseToLocal(rawObj, effectiveOptions);
|
|
504
|
-
const [categoriesTerms, tagsTerms] = await Promise.all([
|
|
505
|
-
fetchAssignedTerms('category'),
|
|
506
|
-
fetchAssignedTerms('tag'),
|
|
507
|
-
]);
|
|
508
|
-
if (categoriesTerms !== null || tagsTerms !== null) {
|
|
509
|
-
if (!remoteLocal.frontmatter.taxonomies) {
|
|
510
|
-
remoteLocal.frontmatter.taxonomies = {};
|
|
511
|
-
}
|
|
512
|
-
if (categoriesTerms !== null) {
|
|
513
|
-
remoteLocal.frontmatter.taxonomies.categories = categoriesTerms;
|
|
514
|
-
}
|
|
515
|
-
if (tagsTerms !== null) {
|
|
516
|
-
remoteLocal.frontmatter.taxonomies.tags = tagsTerms;
|
|
517
|
-
}
|
|
518
|
-
}
|
|
519
|
-
const hasDraft = hasUncommittedDraftRevision({
|
|
520
|
-
status: remoteLocal.frontmatter.status,
|
|
521
|
-
remoteRev: remoteComparisonRev,
|
|
522
|
-
});
|
|
523
|
-
const remoteCanonMarkdown = serializeMarkdown(remoteLocal.frontmatter, remoteLocal.body, getTaxonomyCandidates(), { hasDraftRevision: hasDraft });
|
|
524
|
-
const remoteCanonHash = calculateHash(remoteCanonMarkdown);
|
|
525
|
-
// The repaired file on disk carries the workspace's local-media-ref spelling, so the tracked hash
|
|
526
|
-
// must be the hash of that spelling. The baseline keeps the canonical form untouched.
|
|
527
|
-
const remoteDiskHash = calculateHash(localizeDiskMarkdown(remoteCanonMarkdown, existingSync.localFilePath, true));
|
|
528
|
-
const existingBase = options.cache.getBaseSnapshot(id);
|
|
529
|
-
const bumpBaselineHashOnly = Boolean(categoriesTerms === null && tagsTerms === null && existingBase);
|
|
530
|
-
commitArticleSnapshot(options.cache, options.workspaceRoot, {
|
|
531
|
-
id,
|
|
532
|
-
relPath: existingSync.localFilePath,
|
|
533
|
-
diskContent: repaired,
|
|
534
|
-
localHash: remoteDiskHash,
|
|
535
|
-
canonicalContent: remoteCanonMarkdown,
|
|
536
|
-
frontmatter: remoteLocal.frontmatter,
|
|
537
|
-
bodyMarkdown: remoteLocal.body,
|
|
538
|
-
revForTransport: remoteRev,
|
|
539
|
-
revForComparison: remoteComparisonRev,
|
|
540
|
-
bumpBaselineHashOnly,
|
|
541
|
-
skipDiskWrite: true,
|
|
542
|
-
flags: {
|
|
543
|
-
remoteTaxonomyHash: computeTaxonomyHash(remoteLocal.frontmatter.taxonomies?.categories, remoteLocal.frontmatter.taxonomies?.tags),
|
|
544
|
-
taxonomyApplyPending: 0,
|
|
545
|
-
conflictPending: existingSync.conflictPending,
|
|
546
|
-
bodyConflictPending: existingSync.bodyConflictPending,
|
|
547
|
-
taxonomyConflictPending: existingSync.taxonomyConflictPending,
|
|
548
|
-
conflictCopyPath: existingSync.conflictCopyPath,
|
|
549
|
-
conflictCopyHash: existingSync.conflictCopyHash,
|
|
550
|
-
conflictAt: existingSync.conflictAt,
|
|
551
|
-
},
|
|
552
|
-
});
|
|
553
|
-
return {
|
|
554
|
-
id,
|
|
555
|
-
slug: parsedRepaired.frontmatter.slug,
|
|
556
|
-
status: parsedRepaired.frontmatter.status,
|
|
557
|
-
filePath: existingSync.localFilePath,
|
|
558
|
-
updated: true,
|
|
559
|
-
healed: true,
|
|
560
|
-
meaningChanged: false,
|
|
561
|
-
title: rawTitle,
|
|
562
|
-
};
|
|
563
|
-
}
|
|
564
|
-
}
|
|
565
|
-
}
|
|
566
|
-
}
|
|
567
|
-
catch {
|
|
568
|
-
// If error, fall through to full pull
|
|
569
|
-
}
|
|
570
|
-
return null;
|
|
571
|
-
};
|
|
572
|
-
// 1. Fast-path check: If rawItem is provided in options, test fast-path / heal
|
|
573
|
-
if (rawItem) {
|
|
574
|
-
const fastResult = await tryFastPathOrHeal(rawItem);
|
|
575
|
-
if (fastResult)
|
|
576
|
-
return fastResult;
|
|
577
|
-
// Fast-path missed: remote content changed or local repair required.
|
|
578
|
-
// EmDash list API returns stale creation-time posts.data for drafts, and liveData for published articles.
|
|
579
|
-
// When a published article has unpublished draft changes (draftRevisionId exists), list API does NOT expand
|
|
580
|
-
// the draft body (it returns only liveData).
|
|
581
|
-
// Therefore, if this is an existing tracked article whose remote draft revision has advanced, we must discard
|
|
582
|
-
// the stale list rawItem so Step 2 fetches the full canonical item via single GET!
|
|
583
|
-
const rawObj = rawItem;
|
|
584
|
-
const hasDraftRevision = Boolean(rawObj.draftRevisionId);
|
|
585
|
-
const isDraft = String(rawObj.status || 'draft') !== 'published';
|
|
586
|
-
const needsFullDraftFetch = isDraft || hasDraftRevision;
|
|
587
|
-
const existingSync = options.cache.getSyncState(id);
|
|
588
|
-
const rawRevCandidate = String(rawItem._rev || rawItem.rev || rawItem.version || rawObj._rev || '');
|
|
589
|
-
const remoteRev = extractRev(rawObj, rawRevCandidate);
|
|
590
|
-
const remoteComparisonRev = extractComparisonRev(rawObj, remoteRev);
|
|
591
|
-
const remoteChanged = !existingSync || !isSameRev(existingSync.remoteRev, remoteComparisonRev);
|
|
592
|
-
if (needsFullDraftFetch && existingSync && remoteChanged) {
|
|
593
|
-
rawItem = undefined;
|
|
594
|
-
}
|
|
595
|
-
}
|
|
596
|
-
// 2. Fetch raw item if not passed in options (or discarded above for draft updates)
|
|
597
|
-
if (!rawItem) {
|
|
598
|
-
const url = articleContentEndpoint(options.origin, collection, id);
|
|
599
|
-
const res = await fetchFn(url, {
|
|
600
|
-
method: 'GET',
|
|
601
|
-
headers: {
|
|
602
|
-
Authorization: `Bearer ${options.token}`,
|
|
603
|
-
Accept: 'application/json',
|
|
604
|
-
},
|
|
605
|
-
});
|
|
606
|
-
if (!res.ok) {
|
|
607
|
-
if (res.status === 404 || res.status === 410) {
|
|
608
|
-
// Remote article was deleted (404/410)
|
|
609
|
-
const existingSync = options.cache.getSyncState(id);
|
|
610
|
-
if (existingSync) {
|
|
611
|
-
const fullPath = path.join(options.workspaceRoot, existingSync.localFilePath);
|
|
612
|
-
if (fs.existsSync(fullPath)) {
|
|
613
|
-
const diskContent = fs.readFileSync(fullPath, 'utf8');
|
|
614
|
-
const diskHash = calculateHash(diskContent);
|
|
615
|
-
// Only delete if local file has no unpushed edits
|
|
616
|
-
if (diskHash === existingSync.localHash) {
|
|
617
|
-
try {
|
|
618
|
-
fs.unlinkSync(fullPath);
|
|
619
|
-
}
|
|
620
|
-
catch {
|
|
621
|
-
// Ignore unlink error
|
|
622
|
-
}
|
|
623
|
-
options.cache.deleteEntry(id);
|
|
624
|
-
return {
|
|
625
|
-
id,
|
|
626
|
-
slug: existingSync.slug,
|
|
627
|
-
status: existingSync.status,
|
|
628
|
-
filePath: existingSync.localFilePath,
|
|
629
|
-
updated: false,
|
|
630
|
-
deleted: true,
|
|
631
|
-
};
|
|
632
|
-
}
|
|
633
|
-
else if (options.force) {
|
|
634
|
-
const quarantineRel = quarantineLocalCopy(options.workspaceRoot, collection, existingSync.localFilePath, diskContent, { isForce: true });
|
|
635
|
-
try {
|
|
636
|
-
fs.unlinkSync(fullPath);
|
|
637
|
-
}
|
|
638
|
-
catch {
|
|
639
|
-
// Ignore unlink error
|
|
640
|
-
}
|
|
641
|
-
options.cache.deleteEntry(id);
|
|
642
|
-
return {
|
|
643
|
-
id,
|
|
644
|
-
slug: existingSync.slug,
|
|
645
|
-
status: existingSync.status,
|
|
646
|
-
filePath: existingSync.localFilePath,
|
|
647
|
-
updated: false,
|
|
648
|
-
deleted: true,
|
|
649
|
-
quarantined: quarantineRel
|
|
650
|
-
? [
|
|
651
|
-
{
|
|
652
|
-
sourceFilePath: existingSync.localFilePath,
|
|
653
|
-
quarantineFilePath: quarantineRel,
|
|
654
|
-
reason: 'diverged-from-baseline',
|
|
655
|
-
},
|
|
656
|
-
]
|
|
657
|
-
: undefined,
|
|
658
|
-
quarantineFilePath: quarantineRel ?? undefined,
|
|
659
|
-
};
|
|
660
|
-
}
|
|
661
|
-
else {
|
|
662
|
-
// Local file has unpushed edits: preserve as orphan!
|
|
663
|
-
return {
|
|
664
|
-
id,
|
|
665
|
-
slug: existingSync.slug,
|
|
666
|
-
status: existingSync.status,
|
|
667
|
-
filePath: existingSync.localFilePath,
|
|
668
|
-
updated: false,
|
|
669
|
-
orphan: true,
|
|
670
|
-
localEditsPreserved: true,
|
|
671
|
-
suggestion: 'Pass --force to preserve your edits under posts/conflicts/ and remove tracking.',
|
|
672
|
-
};
|
|
673
|
-
}
|
|
674
|
-
}
|
|
675
|
-
}
|
|
676
|
-
}
|
|
677
|
-
throw new ApiError({
|
|
678
|
-
message: `Failed to pull article ${collection}/${id}: ${res.statusText}`,
|
|
679
|
-
code: 'ARTICLE_PULL_FAILED',
|
|
680
|
-
status: res.status,
|
|
681
|
-
endpoint: url,
|
|
682
|
-
suggestion: `Verify that article '${id}' exists in collection '${collection}'.`,
|
|
683
|
-
});
|
|
684
|
-
}
|
|
685
|
-
rawItem = (await res.json());
|
|
686
|
-
// Step 2b (D5): Test fast-path / non-destructive heal for single-article pull as well!
|
|
687
|
-
const singleHealResult = await tryFastPathOrHeal(rawItem);
|
|
688
|
-
if (singleHealResult)
|
|
689
|
-
return singleHealResult;
|
|
690
|
-
}
|
|
691
|
-
// Ensure byline ID-to-slug mapping is loaded
|
|
692
|
-
const resolvedBylineMap = await getBylineIdToSlug();
|
|
693
|
-
const effectiveOptions = {
|
|
694
|
-
...options,
|
|
695
|
-
bylineIdToSlug: resolvedBylineMap,
|
|
696
|
-
};
|
|
697
|
-
const { frontmatter, body, rev } = apiResponseToLocal(rawItem, effectiveOptions);
|
|
698
|
-
const [categoriesTerms, tagsTerms] = await Promise.all([
|
|
699
|
-
fetchAssignedTerms('category'),
|
|
700
|
-
fetchAssignedTerms('tag'),
|
|
701
|
-
]);
|
|
702
|
-
if (categoriesTerms !== null || tagsTerms !== null) {
|
|
703
|
-
if (!frontmatter.taxonomies) {
|
|
704
|
-
frontmatter.taxonomies = {};
|
|
705
|
-
}
|
|
706
|
-
if (categoriesTerms !== null) {
|
|
707
|
-
frontmatter.taxonomies.categories = categoriesTerms;
|
|
708
|
-
}
|
|
709
|
-
if (tagsTerms !== null) {
|
|
710
|
-
frontmatter.taxonomies.tags = tagsTerms;
|
|
711
|
-
}
|
|
712
|
-
}
|
|
713
|
-
// Format full Markdown. `canonicalMarkdown` is the remote form and is what every cache hash and every
|
|
714
|
-
// base snapshot records; `markdownContent` is what lands on disk, which may spell mirrored images as local
|
|
715
|
-
// references when `localMediaRefs` is enabled. Keeping the two apart is what lets an editor preview render a
|
|
716
|
-
// mirror while the baseline stays byte-identical to a remote pull.
|
|
717
|
-
const comparisonRev = extractComparisonRev(rawItem, rev);
|
|
718
|
-
const hasDraft = hasUncommittedDraftRevision({
|
|
719
|
-
status: frontmatter.status,
|
|
720
|
-
remoteRev: comparisonRev,
|
|
721
|
-
});
|
|
722
|
-
const canonicalMarkdown = serializeMarkdown(frontmatter, body, getTaxonomyCandidates(), {
|
|
723
|
-
hasDraftRevision: hasDraft,
|
|
724
|
-
});
|
|
725
|
-
const contentHash = calculateHash(canonicalMarkdown);
|
|
726
|
-
// Determine standard relative file path: <collection>/<status>/<yyyyMMdd>_<ulid>-<title>.md
|
|
727
|
-
const existingSync = options.cache.getSyncState(frontmatter.id);
|
|
728
|
-
const existingDiskMatches = findLocalFilesByUlid(options.workspaceRoot, frontmatter.collection, frontmatter.id);
|
|
729
|
-
const preferredDiskMatch = existingDiskMatches.find((p) => p.startsWith(`${frontmatter.collection}/${frontmatter.status}/`)) ?? existingDiskMatches[0];
|
|
730
|
-
const existingPrefix = existingSync?.localFilePath
|
|
731
|
-
? extractDatePrefixFromPath(existingSync.localFilePath)
|
|
732
|
-
: preferredDiskMatch
|
|
733
|
-
? extractDatePrefixFromPath(preferredDiskMatch)
|
|
734
|
-
: null;
|
|
735
|
-
const canonicalTitle = rawItem
|
|
736
|
-
? extractCanonicalRemoteTitle(rawItem, frontmatter.slug)
|
|
737
|
-
: frontmatter.title || frontmatter.slug;
|
|
738
|
-
const relPath = resolveCanonicalArticlePath(frontmatter.collection, frontmatter.status, frontmatter.id, canonicalTitle, {
|
|
739
|
-
publishedAt: frontmatter.publishedAt || rawItem?.publishedAt || null,
|
|
740
|
-
createdAt: rawItem?.createdAt || null,
|
|
741
|
-
updatedAt: rawItem?.updatedAt || null,
|
|
742
|
-
}, existingPrefix);
|
|
743
|
-
// Localizing needs the depth of the path the article is about to occupy, so it runs after `relPath` is
|
|
744
|
-
// resolved. A file that already sits on disk in the depth-free legacy spelling keeps that spelling; a fresh
|
|
745
|
-
// write (or an article moving between `draft/` and `published/`) gets the article-relative form its preview
|
|
746
|
-
// can resolve.
|
|
747
|
-
const markdownContent = localizeDiskMarkdown(canonicalMarkdown, relPath, shouldPreferLegacyRef(options.workspaceRoot, existingDiskMatches));
|
|
748
|
-
const fullFilePath = path.join(options.workspaceRoot, relPath);
|
|
749
|
-
// Path tracking: Remove any previous file(s) for this ULID at a different path
|
|
750
|
-
// BEFORE writing fullFilePath so case-insensitive filesystems (e.g. macOS APFS)
|
|
751
|
-
// do not unlink the newly written file when only casing differs.
|
|
752
|
-
const staleRelPaths = new Set(existingDiskMatches);
|
|
753
|
-
if (existingSync?.localFilePath) {
|
|
754
|
-
staleRelPaths.add(existingSync.localFilePath);
|
|
755
|
-
}
|
|
756
|
-
const canonicalResolved = path.resolve(fullFilePath);
|
|
757
|
-
// Data safety: preserve every local copy whose content diverges from the last synced
|
|
758
|
-
// baseline (or has no cache baseline at all) before it is overwritten or unlinked.
|
|
759
|
-
const quarantined = quarantineDivergentLocalCopies(options, frontmatter.collection, staleRelPaths, existingSync?.localHash, markdownContent, existingSync?.conflictCopyHash);
|
|
760
|
-
for (const staleRel of staleRelPaths) {
|
|
761
|
-
const staleFull = path.resolve(options.workspaceRoot, staleRel);
|
|
762
|
-
if (staleFull !== canonicalResolved && fs.existsSync(staleFull)) {
|
|
763
|
-
try {
|
|
764
|
-
fs.unlinkSync(staleFull);
|
|
765
|
-
}
|
|
766
|
-
catch {
|
|
767
|
-
// Ignore removal failure
|
|
768
|
-
}
|
|
769
|
-
}
|
|
770
|
-
}
|
|
771
|
-
// Update sync state with explicit conflict pending preservation
|
|
772
|
-
let conflictInfo = {};
|
|
773
|
-
// Take the most recently quarantined divergent copy: `quarantineDivergentLocalCopies` walks
|
|
774
|
-
// every stale path for this ULID, so find() would point conflict metadata at an arbitrary
|
|
775
|
-
// (often already-superseded) rescue file when a canonical path change coincides with edits.
|
|
776
|
-
const divergedQuarantines = !options.force
|
|
777
|
-
? (quarantined ?? []).filter((q) => q.reason === 'diverged-from-baseline')
|
|
778
|
-
: [];
|
|
779
|
-
const divergedQuarantine = divergedQuarantines[divergedQuarantines.length - 1];
|
|
780
|
-
if (divergedQuarantine) {
|
|
781
|
-
// Preserve the most recently written divergent copy so conflict metadata always points at
|
|
782
|
-
// an existing rescue file, even when several stale paths were quarantined in one pull.
|
|
783
|
-
const qFull = path.join(options.workspaceRoot, divergedQuarantine.quarantineFilePath);
|
|
784
|
-
let qHash = null;
|
|
785
|
-
if (fs.existsSync(qFull)) {
|
|
786
|
-
qHash = calculateHash(fs.readFileSync(qFull, 'utf8'));
|
|
787
|
-
}
|
|
788
|
-
conflictInfo = {
|
|
789
|
-
conflictPending: 1,
|
|
790
|
-
bodyConflictPending: 1,
|
|
791
|
-
taxonomyConflictPending: existingSync?.taxonomyConflictPending ?? 0,
|
|
792
|
-
conflictCopyPath: divergedQuarantine.quarantineFilePath,
|
|
793
|
-
conflictCopyHash: qHash,
|
|
794
|
-
conflictAt: Math.floor(Date.now() / 1000),
|
|
795
|
-
};
|
|
796
|
-
}
|
|
797
|
-
else if (!options.force && existingSync?.conflictCopyPath) {
|
|
798
|
-
// Keep existing conflict state if conflict copy still exists on disk
|
|
799
|
-
const existingFull = path.join(options.workspaceRoot, existingSync.conflictCopyPath);
|
|
800
|
-
if (fs.existsSync(existingFull)) {
|
|
801
|
-
conflictInfo = {
|
|
802
|
-
conflictPending: existingSync.conflictPending,
|
|
803
|
-
bodyConflictPending: existingSync.bodyConflictPending,
|
|
804
|
-
taxonomyConflictPending: existingSync.taxonomyConflictPending,
|
|
805
|
-
conflictCopyPath: existingSync.conflictCopyPath,
|
|
806
|
-
conflictCopyHash: existingSync.conflictCopyHash,
|
|
807
|
-
conflictAt: existingSync.conflictAt,
|
|
808
|
-
};
|
|
809
|
-
}
|
|
810
|
-
else {
|
|
811
|
-
// Conflict copy was removed, clear body conflict while keeping taxonomy conflict
|
|
812
|
-
const taxPending = existingSync.taxonomyConflictPending ?? 0;
|
|
813
|
-
conflictInfo = {
|
|
814
|
-
conflictPending: taxPending,
|
|
815
|
-
bodyConflictPending: 0,
|
|
816
|
-
taxonomyConflictPending: taxPending,
|
|
817
|
-
conflictCopyPath: null,
|
|
818
|
-
conflictCopyHash: null,
|
|
819
|
-
conflictAt: null,
|
|
820
|
-
};
|
|
821
|
-
}
|
|
822
|
-
}
|
|
823
|
-
else if (options.force) {
|
|
824
|
-
conflictInfo = {
|
|
825
|
-
conflictPending: 0,
|
|
826
|
-
bodyConflictPending: 0,
|
|
827
|
-
taxonomyConflictPending: 0,
|
|
828
|
-
conflictCopyPath: null,
|
|
829
|
-
conflictCopyHash: null,
|
|
830
|
-
conflictAt: null,
|
|
831
|
-
};
|
|
832
|
-
}
|
|
833
|
-
else {
|
|
834
|
-
const taxPending = existingSync?.taxonomyConflictPending ?? 0;
|
|
835
|
-
conflictInfo = {
|
|
836
|
-
conflictPending: taxPending,
|
|
837
|
-
bodyConflictPending: 0,
|
|
838
|
-
taxonomyConflictPending: taxPending,
|
|
839
|
-
conflictCopyPath: null,
|
|
840
|
-
conflictCopyHash: null,
|
|
841
|
-
conflictAt: null,
|
|
842
|
-
};
|
|
843
|
-
}
|
|
844
|
-
// Commit canonical Markdown and sync state via commitArticleSnapshot
|
|
845
|
-
commitArticleSnapshot(options.cache, options.workspaceRoot, {
|
|
846
|
-
id: frontmatter.id,
|
|
847
|
-
relPath,
|
|
848
|
-
diskContent: markdownContent,
|
|
849
|
-
canonicalContent: canonicalMarkdown,
|
|
850
|
-
frontmatter,
|
|
851
|
-
bodyMarkdown: body,
|
|
852
|
-
revForTransport: rev,
|
|
853
|
-
revForComparison: comparisonRev,
|
|
854
|
-
flags: {
|
|
855
|
-
remoteTaxonomyHash: computeTaxonomyHash(frontmatter.taxonomies?.categories, frontmatter.taxonomies?.tags),
|
|
856
|
-
taxonomyApplyPending: 0,
|
|
857
|
-
...conflictInfo,
|
|
858
|
-
},
|
|
859
|
-
});
|
|
860
|
-
// Update entries cache
|
|
861
|
-
options.cache.saveEntry({
|
|
862
|
-
id: frontmatter.id,
|
|
863
|
-
collection: frontmatter.collection,
|
|
864
|
-
slug: frontmatter.slug,
|
|
865
|
-
status: frontmatter.status,
|
|
866
|
-
title: frontmatter.title,
|
|
867
|
-
rev,
|
|
868
|
-
publishedAt: frontmatter.publishedAt,
|
|
869
|
-
rawJson: JSON.stringify(rawItem),
|
|
870
|
-
updatedAt: Math.floor(Date.now() / 1000),
|
|
871
|
-
});
|
|
872
|
-
updateConfigSyncedVersion(options.workspaceRoot);
|
|
873
|
-
return {
|
|
874
|
-
id: frontmatter.id,
|
|
875
|
-
slug: frontmatter.slug,
|
|
876
|
-
status: frontmatter.status,
|
|
877
|
-
filePath: relPath,
|
|
878
|
-
updated: true,
|
|
879
|
-
title: frontmatter.title,
|
|
880
|
-
quarantined: quarantined.length > 0 ? quarantined : undefined,
|
|
881
|
-
};
|
|
882
|
-
}
|
|
22
|
+
export * from './pull/index.js';
|
|
883
23
|
//# sourceMappingURL=pull.js.map
|