emdash-emit 0.1.16 → 0.1.18
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 +55 -0
- package/README.md +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 +2 -10
- package/dist/cli/commands/pull.d.ts.map +1 -1
- package/dist/cli/commands/pull.js +12 -30
- package/dist/cli/commands/pull.js.map +1 -1
- package/dist/client/auth.d.ts.map +1 -1
- package/dist/client/auth.js +2 -1
- package/dist/client/auth.js.map +1 -1
- package/dist/client/endpoints.d.ts +19 -0
- package/dist/client/endpoints.d.ts.map +1 -0
- package/dist/client/endpoints.js +69 -0
- package/dist/client/endpoints.js.map +1 -0
- package/dist/client/http.d.ts +4 -2
- package/dist/client/http.d.ts.map +1 -1
- package/dist/client/http.js.map +1 -1
- package/dist/client/lock.d.ts.map +1 -1
- package/dist/client/lock.js +4 -3
- package/dist/client/lock.js.map +1 -1
- package/dist/client/mapper.d.ts.map +1 -1
- package/dist/client/mapper.js +5 -3
- package/dist/client/mapper.js.map +1 -1
- package/dist/client/media-id.d.ts +2 -0
- package/dist/client/media-id.d.ts.map +1 -0
- package/dist/client/media-id.js +23 -0
- package/dist/client/media-id.js.map +1 -0
- package/dist/client/media.d.ts +8 -7
- package/dist/client/media.d.ts.map +1 -1
- package/dist/client/media.js +58 -17
- package/dist/client/media.js.map +1 -1
- package/dist/db/baseline.d.ts +37 -0
- package/dist/db/baseline.d.ts.map +1 -0
- package/dist/db/baseline.js +74 -0
- package/dist/db/baseline.js.map +1 -0
- package/dist/db/cache.d.ts +3 -2
- package/dist/db/cache.d.ts.map +1 -1
- package/dist/db/cache.js +3 -2
- package/dist/db/cache.js.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/mcp/tools/sync-new.d.ts.map +1 -1
- package/dist/mcp/tools/sync-new.js +1 -13
- 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 +15 -2
- package/dist/mcp/tools/sync-pull.js.map +1 -1
- package/dist/presentation/article_report.d.ts +40 -0
- package/dist/presentation/article_report.d.ts.map +1 -0
- package/dist/presentation/article_report.js +80 -0
- package/dist/presentation/article_report.js.map +1 -0
- package/dist/sync/bylines.d.ts.map +1 -1
- package/dist/sync/bylines.js +2 -1
- package/dist/sync/bylines.js.map +1 -1
- package/dist/sync/conflicts.js +1 -1
- package/dist/sync/conflicts.js.map +1 -1
- package/dist/sync/delete.d.ts +0 -5
- package/dist/sync/delete.d.ts.map +1 -1
- package/dist/sync/delete.js +5 -11
- package/dist/sync/delete.js.map +1 -1
- package/dist/sync/diff.d.ts.map +1 -1
- package/dist/sync/diff.js +4 -5
- package/dist/sync/diff.js.map +1 -1
- package/dist/sync/doctor.d.ts.map +1 -1
- package/dist/sync/doctor.js +11 -14
- package/dist/sync/doctor.js.map +1 -1
- package/dist/sync/guards.d.ts +5 -0
- package/dist/sync/guards.d.ts.map +1 -1
- package/dist/sync/guards.js +61 -0
- package/dist/sync/guards.js.map +1 -1
- package/dist/sync/media/body_scan.d.ts.map +1 -1
- package/dist/sync/media/body_scan.js +9 -3
- package/dist/sync/media/body_scan.js.map +1 -1
- package/dist/sync/media/companions.d.ts +2 -9
- package/dist/sync/media/companions.d.ts.map +1 -1
- package/dist/sync/media/companions.js +5 -23
- package/dist/sync/media/companions.js.map +1 -1
- package/dist/sync/media/mirror_item.d.ts.map +1 -1
- package/dist/sync/media/mirror_item.js +33 -21
- package/dist/sync/media/mirror_item.js.map +1 -1
- package/dist/sync/media/pull_article.d.ts.map +1 -1
- package/dist/sync/media/pull_article.js +3 -2
- 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 +16 -11
- package/dist/sync/media/pull_library.js.map +1 -1
- package/dist/sync/media/remote_index.d.ts.map +1 -1
- package/dist/sync/media/remote_index.js +2 -1
- package/dist/sync/media/remote_index.js.map +1 -1
- package/dist/sync/media/types.d.ts +4 -3
- package/dist/sync/media/types.d.ts.map +1 -1
- package/dist/sync/media/types.js.map +1 -1
- package/dist/sync/new.d.ts +1 -12
- package/dist/sync/new.d.ts.map +1 -1
- package/dist/sync/new.js +0 -39
- package/dist/sync/new.js.map +1 -1
- package/dist/sync/preflight.d.ts +3 -2
- package/dist/sync/preflight.d.ts.map +1 -1
- package/dist/sync/preflight.js +19 -10
- package/dist/sync/preflight.js.map +1 -1
- package/dist/sync/pull.d.ts +6 -44
- package/dist/sync/pull.d.ts.map +1 -1
- package/dist/sync/pull.js +37 -145
- package/dist/sync/pull.js.map +1 -1
- package/dist/sync/push/commit.d.ts +60 -0
- package/dist/sync/push/commit.d.ts.map +1 -0
- package/dist/sync/push/commit.js +230 -0
- package/dist/sync/push/commit.js.map +1 -0
- package/dist/sync/push/guards.d.ts +33 -0
- package/dist/sync/push/guards.d.ts.map +1 -0
- package/dist/sync/push/guards.js +110 -0
- package/dist/sync/push/guards.js.map +1 -0
- package/dist/sync/push/index.d.ts +9 -0
- package/dist/sync/push/index.d.ts.map +1 -0
- package/dist/sync/push/index.js +350 -0
- package/dist/sync/push/index.js.map +1 -0
- package/dist/sync/push/media_stage.d.ts +41 -0
- package/dist/sync/push/media_stage.d.ts.map +1 -0
- package/dist/sync/push/media_stage.js +309 -0
- package/dist/sync/push/media_stage.js.map +1 -0
- package/dist/sync/push/merge_stage.d.ts +37 -0
- package/dist/sync/push/merge_stage.d.ts.map +1 -0
- package/dist/sync/push/merge_stage.js +243 -0
- package/dist/sync/push/merge_stage.js.map +1 -0
- package/dist/sync/push/preflight_stage.d.ts +6 -0
- package/dist/sync/push/preflight_stage.d.ts.map +1 -0
- package/dist/sync/push/preflight_stage.js +40 -0
- package/dist/sync/push/preflight_stage.js.map +1 -0
- package/dist/sync/push/tax_audit.d.ts +24 -0
- package/dist/sync/push/tax_audit.d.ts.map +1 -0
- package/dist/sync/push/tax_audit.js +70 -0
- package/dist/sync/push/tax_audit.js.map +1 -0
- package/dist/sync/push/taxonomy_plan.d.ts +36 -0
- package/dist/sync/push/taxonomy_plan.d.ts.map +1 -0
- package/dist/sync/push/taxonomy_plan.js +136 -0
- package/dist/sync/push/taxonomy_plan.js.map +1 -0
- package/dist/sync/push/transport.d.ts +10 -0
- package/dist/sync/push/transport.d.ts.map +1 -0
- package/dist/sync/push/transport.js +74 -0
- package/dist/sync/push/transport.js.map +1 -0
- package/dist/sync/push/types.d.ts +62 -0
- package/dist/sync/push/types.d.ts.map +1 -0
- package/dist/sync/push/types.js +23 -0
- package/dist/sync/push/types.js.map +1 -0
- package/dist/sync/push.d.ts +2 -57
- package/dist/sync/push.d.ts.map +1 -1
- package/dist/sync/push.js +2 -1137
- package/dist/sync/push.js.map +1 -1
- package/dist/sync/status.d.ts.map +1 -1
- package/dist/sync/status.js +5 -4
- package/dist/sync/status.js.map +1 -1
- package/dist/sync/taxonomies.d.ts.map +1 -1
- package/dist/sync/taxonomies.js +2 -1
- package/dist/sync/taxonomies.js.map +1 -1
- package/dist/sync/terms.d.ts.map +1 -1
- package/dist/sync/terms.js +2 -1
- package/dist/sync/terms.js.map +1 -1
- package/dist/sync/workspace/index.d.ts +41 -0
- package/dist/sync/workspace/index.d.ts.map +1 -0
- package/dist/sync/workspace/index.js +124 -0
- package/dist/sync/workspace/index.js.map +1 -0
- package/dist/utils/frontmatter.d.ts.map +1 -1
- package/dist/utils/frontmatter.js +1 -3
- package/dist/utils/frontmatter.js.map +1 -1
- package/dist/utils/media-body-refs.d.ts +24 -0
- package/dist/utils/media-body-refs.d.ts.map +1 -1
- package/dist/utils/media-body-refs.js +51 -0
- package/dist/utils/media-body-refs.js.map +1 -1
- package/dist/utils/media-companion.d.ts +13 -0
- package/dist/utils/media-companion.d.ts.map +1 -0
- package/dist/utils/media-companion.js +42 -0
- package/dist/utils/media-companion.js.map +1 -0
- package/dist/utils/media-id.d.ts +46 -0
- package/dist/utils/media-id.d.ts.map +1 -0
- package/dist/utils/media-id.js +84 -0
- package/dist/utils/media-id.js.map +1 -0
- package/dist/utils/naming.d.ts.map +1 -1
- package/dist/utils/naming.js +3 -2
- package/dist/utils/naming.js.map +1 -1
- package/dist/utils/ulid.d.ts +31 -0
- package/dist/utils/ulid.d.ts.map +1 -0
- package/dist/utils/ulid.js +65 -0
- package/dist/utils/ulid.js.map +1 -0
- package/package.json +1 -1
package/dist/sync/push.js
CHANGED
|
@@ -19,1143 +19,8 @@
|
|
|
19
19
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
20
20
|
* SOFTWARE.
|
|
21
21
|
*/
|
|
22
|
-
import fs from 'node:fs';
|
|
23
|
-
import path from 'node:path';
|
|
24
|
-
import yaml from 'js-yaml';
|
|
25
|
-
import { withEntryLock } from '../client/lock.js';
|
|
26
|
-
import { apiResponseToLocal, extractMediaUlid, localToApiPayload, } from '../client/mapper.js';
|
|
27
|
-
import { ApiError } from '../client/error.js';
|
|
28
|
-
import { createAuthedFetch } from '../client/http.js';
|
|
29
|
-
import { unwrapItem, extractRev } from '../client/api.js';
|
|
30
|
-
import { classifyCodeFenceLines } from '../client/portable-text.js';
|
|
31
|
-
import { computeArticleRelativePath, extractDatePrefixFromPath, parseMarkdown, serializeMarkdown, } from '../utils/frontmatter.js';
|
|
32
|
-
import { loadLocalTaxonomyMaps, loadLocalTaxonomyCandidates } from './taxonomies.js';
|
|
33
|
-
import { localizeArticleMediaRefs } from '../utils/media-body-refs.js';
|
|
34
|
-
import { runPreflightChecks } from './preflight.js';
|
|
35
|
-
import { calculateHash, carriesDepthFreeMediaRef, CONFLICTS_SUBDIR, isQuarantinedRelativePath, quarantineLocalCopy, } from './pull.js';
|
|
36
|
-
import { hybrid3WayMerge } from './merge.js';
|
|
37
|
-
import { syncRemoteBylines } from './bylines.js';
|
|
38
|
-
import { updateConfigSyncedVersion } from '../utils/config.js';
|
|
39
|
-
import { fetchAssignedTaxonomies, computeTaxonomyHash } from './terms.js';
|
|
40
|
-
import { fetchRemoteMediaDetail, resolveImageMimeType, toRootRelativeMediaUrl, uploadMediaFile, } from '../client/media.js';
|
|
41
|
-
import { promoteDraftMediaFile } from './media.js';
|
|
42
|
-
export class PushValidationError extends Error {
|
|
43
|
-
errors;
|
|
44
|
-
constructor(message, errors) {
|
|
45
|
-
super(message);
|
|
46
|
-
this.name = 'PushValidationError';
|
|
47
|
-
this.errors = errors;
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
22
|
/**
|
|
51
|
-
*
|
|
23
|
+
* Re-exports pushArticle and push pipeline stages including [LOCAL_IMAGE_AUTO_UPLOAD] guards.
|
|
52
24
|
*/
|
|
53
|
-
|
|
54
|
-
const isPublishing = targetStatus === 'published';
|
|
55
|
-
const endpoint = new URL(`/_emdash/api/content/${collection}/${id}/${isPublishing ? 'publish' : 'unpublish'}`, options.origin).toString();
|
|
56
|
-
const body = { _rev: currentRev };
|
|
57
|
-
if (isPublishing && publishedAt) {
|
|
58
|
-
body.publishedAt = publishedAt;
|
|
59
|
-
}
|
|
60
|
-
const res = await fetchFn(endpoint, {
|
|
61
|
-
method: 'POST',
|
|
62
|
-
headers: {
|
|
63
|
-
Authorization: `Bearer ${options.token}`,
|
|
64
|
-
'Content-Type': 'application/json',
|
|
65
|
-
},
|
|
66
|
-
body: JSON.stringify(body),
|
|
67
|
-
});
|
|
68
|
-
if (!res.ok) {
|
|
69
|
-
const errText = await res.text().catch(() => '');
|
|
70
|
-
throw new ApiError({
|
|
71
|
-
message: `Failed to ${isPublishing ? 'publish' : 'unpublish'} content: ${res.statusText}${errText ? ` - ${errText}` : ''}`,
|
|
72
|
-
code: isPublishing ? 'PUBLISH_FAILED' : 'UNPUBLISH_FAILED',
|
|
73
|
-
status: res.status,
|
|
74
|
-
endpoint,
|
|
75
|
-
suggestion: isPublishing
|
|
76
|
-
? 'Verify publish permissions and revision state.'
|
|
77
|
-
: 'Verify unpublish permissions and revision state.',
|
|
78
|
-
});
|
|
79
|
-
}
|
|
80
|
-
const json = (await res.json().catch(() => ({})));
|
|
81
|
-
return extractRev(json, currentRev);
|
|
82
|
-
}
|
|
83
|
-
/**
|
|
84
|
-
* Determines the featured_image value to send to EmDash, returning any warnings.
|
|
85
|
-
*
|
|
86
|
-
* Contract (confirmed against production EmDash): `featured_image: { id: <ULID> }` is
|
|
87
|
-
* accepted on its own and resolved server-side - the server stores the media id and
|
|
88
|
-
* normalizes `provider` itself for media held in the media library. Clients must never
|
|
89
|
-
* fabricate provider, src, storageKey or dimensions.
|
|
90
|
-
*
|
|
91
|
-
* - undefined : leave the payload untouched (field omitted, remote value preserved).
|
|
92
|
-
* - null : send an explicit null to clear the hero image.
|
|
93
|
-
* - otherwise : if the cached remote entry references the same media ULID, resend the remote
|
|
94
|
-
* object verbatim so its original shape (provider "external" with src, or
|
|
95
|
-
* provider "local" with meta.storageKey, blurhash and dimensions) survives the
|
|
96
|
-
* round-trip byte-for-byte rather than being re-resolved from a bare id.
|
|
97
|
-
* Otherwise send the canonical minimal { id: <ULID> }.
|
|
98
|
-
*
|
|
99
|
-
* Local media/*.yaml is never a payload source: it lacks meta.storageKey, and re-sending
|
|
100
|
-
* possibly stale companion metadata would overwrite authoritative remote state. The companion
|
|
101
|
-
* file is used only to cross-check that a referenced ULID is one this workspace knows about.
|
|
102
|
-
*/
|
|
103
|
-
function preserveOrResolveFeaturedImage(payload, featuredImage, cache, id, workspaceRoot) {
|
|
104
|
-
const warnings = [];
|
|
105
|
-
if (featuredImage === undefined)
|
|
106
|
-
return warnings;
|
|
107
|
-
if (featuredImage === null) {
|
|
108
|
-
payload.data.featured_image = null;
|
|
109
|
-
return warnings;
|
|
110
|
-
}
|
|
111
|
-
// Canonicalize once: uppercase Crockford base32. extractMediaUlid() also folds a media
|
|
112
|
-
// URL-shaped value back to its ULID so cached-remote comparison still succeeds.
|
|
113
|
-
const targetUlid = (extractMediaUlid(featuredImage) ?? featuredImage.trim()).toUpperCase();
|
|
114
|
-
// 1. Resend the exact remote object when it already points at this media ULID.
|
|
115
|
-
if (cache) {
|
|
116
|
-
try {
|
|
117
|
-
const cachedEntry = cache.getEntry(id);
|
|
118
|
-
if (cachedEntry) {
|
|
119
|
-
const raw = JSON.parse(cachedEntry.rawJson);
|
|
120
|
-
const item = (raw.item || raw);
|
|
121
|
-
const itemData = (item.data || {});
|
|
122
|
-
const remoteFi = itemData.featured_image;
|
|
123
|
-
if (remoteFi && extractMediaUlid(remoteFi) === targetUlid) {
|
|
124
|
-
payload.data.featured_image = remoteFi;
|
|
125
|
-
return warnings;
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
catch (err) {
|
|
130
|
-
// Non-fatal: cache inspection failure falls through to canonical minimal payload.
|
|
131
|
-
warnings.push(`[push] Unable to inspect cache for featured_image restoration: ${err.message}`);
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
// 2. Cross-check the reference against the workspace media mirror (verification only).
|
|
135
|
-
// A missing entry here is NOT a payload problem - { id: <ULID> } is a valid request that
|
|
136
|
-
// EmDash resolves. It only means Emit cannot confirm the media exists, which is exactly
|
|
137
|
-
// where a hallucinated or mistyped ULID would surface. A workspace that does not mirror
|
|
138
|
-
// media (no media/, or an empty one) is not warned about.
|
|
139
|
-
if (workspaceRoot) {
|
|
140
|
-
const mediaDir = path.join(workspaceRoot, 'media');
|
|
141
|
-
const mirrored = fs.existsSync(mediaDir) ? fs.readdirSync(mediaDir) : [];
|
|
142
|
-
if (mirrored.length > 0) {
|
|
143
|
-
// Anchored on the id segment so a ULID appearing inside another media item's
|
|
144
|
-
// filename can never be mistaken for its own metadata file.
|
|
145
|
-
const companion = mirrored.find((f) => f.endsWith('.yaml') && (f.includes(`_${targetUlid}-`) || f.startsWith(`${targetUlid}-`)));
|
|
146
|
-
if (companion) {
|
|
147
|
-
try {
|
|
148
|
-
const meta = yaml.load(fs.readFileSync(path.join(mediaDir, companion), 'utf8'));
|
|
149
|
-
const metaId = typeof meta?.id === 'string' ? meta.id.trim().toUpperCase() : undefined;
|
|
150
|
-
if (metaId && metaId !== targetUlid) {
|
|
151
|
-
warnings.push(`Local media metadata '${companion}' describes a different media ID (${metaId}); it was ignored for featuredImage ${targetUlid}.`);
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
catch (err) {
|
|
155
|
-
// Non-fatal: metadata inspection is advisory only.
|
|
156
|
-
warnings.push(`[push] Failed to read local media metadata '${companion}': ${err.message}`);
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
else {
|
|
160
|
-
warnings.push(`featuredImage ${targetUlid} was not found in the mirrored media under media/, so Emit could not confirm this media ID exists. The ID is sent as-is and EmDash resolves it, but if the ID is wrong the hero image will be missing. Upload the image first (EmDash Admin or media_upload) and use the ULID it returns, or run "emit pull-media" to mirror remote media locally.`);
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
// 3. Canonical minimal payload (identical to what localToApiPayload produces).
|
|
165
|
-
payload.data.featured_image = { id: targetUlid };
|
|
166
|
-
return warnings;
|
|
167
|
-
}
|
|
168
|
-
/**
|
|
169
|
-
* Rewrites standalone local draft image lines in Markdown body to their confirmed
|
|
170
|
-
* canonical root-relative URLs (`/_emdash/api/media/file/<storageKey>`), skipping
|
|
171
|
-
* fenced code blocks and opaque block fences.
|
|
172
|
-
*/
|
|
173
|
-
function rewriteDraftImagesInBody(body, draftImages, uploadedByAbsPath) {
|
|
174
|
-
if (draftImages.length === 0)
|
|
175
|
-
return body;
|
|
176
|
-
const rawPathToResult = new Map();
|
|
177
|
-
for (const ref of draftImages) {
|
|
178
|
-
const res = uploadedByAbsPath.get(ref.resolvedAbsPath);
|
|
179
|
-
if (res) {
|
|
180
|
-
rawPathToResult.set(ref.rawPath, res);
|
|
181
|
-
rawPathToResult.set(ref.rawPath.trim(), res);
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
const lines = body.split('\n');
|
|
185
|
-
// Same CommonMark-aware fence walker that G5 / G11 use, so a line can never be
|
|
186
|
-
// rewritten here while being skipped there (or vice versa).
|
|
187
|
-
const fenceStates = classifyCodeFenceLines(body);
|
|
188
|
-
const standaloneRegex = /^!\[([^\]]*)\]\(([^)]+)\)$/;
|
|
189
|
-
return lines
|
|
190
|
-
.map((line, index) => {
|
|
191
|
-
const state = fenceStates[index];
|
|
192
|
-
if (state.isFenceDelimiter || state.insideCodeFence) {
|
|
193
|
-
return line;
|
|
194
|
-
}
|
|
195
|
-
const hasCr = line.endsWith('\r');
|
|
196
|
-
const cleanLine = hasCr ? line.slice(0, -1) : line;
|
|
197
|
-
const trimmed = cleanLine.trim();
|
|
198
|
-
// Opaque EmDash block fences are managed by G9 and must stay untouched
|
|
199
|
-
if (/^<!--ec:block .+ -->$/.test(trimmed)) {
|
|
200
|
-
return line;
|
|
201
|
-
}
|
|
202
|
-
const match = trimmed.match(standaloneRegex);
|
|
203
|
-
if (!match)
|
|
204
|
-
return line;
|
|
205
|
-
const alt = match[1];
|
|
206
|
-
const rawPath = match[2];
|
|
207
|
-
const uploaded = rawPathToResult.get(rawPath) ?? rawPathToResult.get(rawPath.trim());
|
|
208
|
-
if (!uploaded)
|
|
209
|
-
return line;
|
|
210
|
-
const replaced = ``;
|
|
211
|
-
return hasCr ? `${replaced}\r` : replaced;
|
|
212
|
-
})
|
|
213
|
-
.join('\n');
|
|
214
|
-
}
|
|
215
|
-
/**
|
|
216
|
-
* Promotes uploaded draft images from `media/drafts/<filename>` into
|
|
217
|
-
* `media/<markdownRef>.<ext>` along with companion `.yaml` metadata, where markdownRef is derived from the
|
|
218
|
-
* and commits `status = 'ready'` in `media_state`.
|
|
219
|
-
*
|
|
220
|
-
* Protected by try/catch per asset (C-2) so that any local filesystem failure
|
|
221
|
-
* after a successful remote article PUT/POST is downgraded to a warning rather than
|
|
222
|
-
* aborting `saveBaseSnapshot` / `saveSyncState`.
|
|
223
|
-
*/
|
|
224
|
-
function finalizePromotedDraftImages(draftImages, uploadedByAbsPath, options, warnings) {
|
|
225
|
-
if (draftImages.length === 0)
|
|
226
|
-
return;
|
|
227
|
-
const processedAbsPaths = new Set();
|
|
228
|
-
const promotedRelPaths = [];
|
|
229
|
-
const reusedRelPaths = [];
|
|
230
|
-
const unverifiedAssets = [];
|
|
231
|
-
for (const ref of draftImages) {
|
|
232
|
-
if (processedAbsPaths.has(ref.resolvedAbsPath))
|
|
233
|
-
continue;
|
|
234
|
-
processedAbsPaths.add(ref.resolvedAbsPath);
|
|
235
|
-
const uploaded = uploadedByAbsPath.get(ref.resolvedAbsPath);
|
|
236
|
-
if (ref.alreadyPromoted) {
|
|
237
|
-
reusedRelPaths.push(ref.alreadyPromoted.promotedRelPath);
|
|
238
|
-
if (uploaded?.remoteVerification === 'unknown') {
|
|
239
|
-
unverifiedAssets.push(`${ref.alreadyPromoted.promotedRelPath} (${ref.alreadyPromoted.mediaId})${uploaded.remoteVerificationNote ? `: ${uploaded.remoteVerificationNote}` : ''}`);
|
|
240
|
-
}
|
|
241
|
-
continue;
|
|
242
|
-
}
|
|
243
|
-
if (!uploaded)
|
|
244
|
-
continue;
|
|
245
|
-
if (uploaded.remoteVerification === 'unknown') {
|
|
246
|
-
unverifiedAssets.push(`${uploaded.filename} (${uploaded.mediaId})${uploaded.remoteVerificationNote ? `: ${uploaded.remoteVerificationNote}` : ''}`);
|
|
247
|
-
}
|
|
248
|
-
try {
|
|
249
|
-
const promoted = promoteDraftMediaFile({
|
|
250
|
-
workspaceRoot: options.workspaceRoot,
|
|
251
|
-
origin: options.origin,
|
|
252
|
-
draftAbsPath: ref.resolvedAbsPath,
|
|
253
|
-
item: {
|
|
254
|
-
id: uploaded.mediaId,
|
|
255
|
-
filename: uploaded.filename,
|
|
256
|
-
mimeType: uploaded.mimeType,
|
|
257
|
-
size: uploaded.size,
|
|
258
|
-
width: uploaded.width ?? null,
|
|
259
|
-
height: uploaded.height ?? null,
|
|
260
|
-
alt: ref.alt || '',
|
|
261
|
-
caption: '',
|
|
262
|
-
storageKey: uploaded.storageKey,
|
|
263
|
-
contentHash: uploaded.contentHash,
|
|
264
|
-
status: 'ready',
|
|
265
|
-
url: uploaded.url,
|
|
266
|
-
createdAt: uploaded.createdAt || new Date().toISOString(),
|
|
267
|
-
},
|
|
268
|
-
removeDraft: true,
|
|
269
|
-
});
|
|
270
|
-
options.cache.saveMediaState({
|
|
271
|
-
localHash: uploaded.localHash,
|
|
272
|
-
mediaId: uploaded.mediaId,
|
|
273
|
-
storageKey: uploaded.storageKey,
|
|
274
|
-
url: uploaded.url,
|
|
275
|
-
workspacePath: promoted.filePath,
|
|
276
|
-
markdownRef: promoted.markdownRef,
|
|
277
|
-
status: 'ready',
|
|
278
|
-
});
|
|
279
|
-
promotedRelPaths.push(promoted.filePath);
|
|
280
|
-
}
|
|
281
|
-
catch (err) {
|
|
282
|
-
// Keep status='uploaded' in media_state so subsequent runs know the remote upload succeeded
|
|
283
|
-
warnings.push(`[LOCAL_IMAGE_AUTO_UPLOAD] Uploaded '${ref.filename}' (${uploaded.mediaId}) to remote, but failed to promote local file to media/: ${err.message}`);
|
|
284
|
-
}
|
|
285
|
-
}
|
|
286
|
-
if (promotedRelPaths.length > 0) {
|
|
287
|
-
warnings.push(`[LOCAL_IMAGE_AUTO_UPLOAD] Auto-uploaded ${promotedRelPaths.length} draft image(s) and promoted to: ${promotedRelPaths.join(', ')}`);
|
|
288
|
-
}
|
|
289
|
-
if (reusedRelPaths.length > 0) {
|
|
290
|
-
warnings.push(`[LOCAL_IMAGE_AUTO_UPLOAD] Reused ${reusedRelPaths.length} already-promoted media asset(s): ${reusedRelPaths.join(', ')}`);
|
|
291
|
-
}
|
|
292
|
-
if (unverifiedAssets.length > 0) {
|
|
293
|
-
warnings.push(`[MEDIA_EXISTENCE_UNVERIFIED] The remote server did not confirm these reused media records (GET /_emdash/api/media/{id} unsupported or failed): ${unverifiedAssets.join(', ')}. The cached /_emdash/api/media/file/<storageKey> URL was reused as-is; run "emit pull-media" to re-verify or re-upload if the images 404.`);
|
|
294
|
-
}
|
|
295
|
-
}
|
|
296
|
-
/**
|
|
297
|
-
* Executes P2–P4 (upload/resolve draft images and rewrite Markdown body in-memory).
|
|
298
|
-
* Already-promoted assets are verified against the remote before their canonical URL is
|
|
299
|
-
* baked into the body, so a media record deleted on the EmDash side can never produce an
|
|
300
|
-
* article that renders HTTP 404 images in production.
|
|
301
|
-
*/
|
|
302
|
-
async function uploadAndRewriteDraftImages(body, draftImages, options, fetchFn) {
|
|
303
|
-
const uploadedByAbsPath = new Map();
|
|
304
|
-
if (draftImages.length === 0) {
|
|
305
|
-
return { body, uploadedByAbsPath };
|
|
306
|
-
}
|
|
307
|
-
for (const draftRef of draftImages) {
|
|
308
|
-
if (uploadedByAbsPath.has(draftRef.resolvedAbsPath)) {
|
|
309
|
-
continue;
|
|
310
|
-
}
|
|
311
|
-
if (draftRef.alreadyPromoted) {
|
|
312
|
-
const promoted = draftRef.alreadyPromoted;
|
|
313
|
-
const remoteCheck = await fetchRemoteMediaDetail(promoted.mediaId, {
|
|
314
|
-
origin: options.origin,
|
|
315
|
-
token: options.token,
|
|
316
|
-
fetchFn,
|
|
317
|
-
});
|
|
318
|
-
if (remoteCheck.exists === false) {
|
|
319
|
-
// Purge the stale local record so the next run does not keep reusing a dead URL
|
|
320
|
-
const staleState = options.cache.getMediaStateByMediaId(promoted.mediaId);
|
|
321
|
-
if (staleState) {
|
|
322
|
-
options.cache.deleteMediaState(staleState.storageKey);
|
|
323
|
-
}
|
|
324
|
-
throw new ApiError({
|
|
325
|
-
message: `Image '${draftRef.filename}' was already promoted to '${promoted.promotedRelPath}' (media id ${promoted.mediaId}), but that media record no longer exists on remote.`,
|
|
326
|
-
code: 'PROMOTED_MEDIA_NOT_FOUND_REMOTE',
|
|
327
|
-
status: 404,
|
|
328
|
-
endpoint: `/_emdash/api/media/${promoted.mediaId}`,
|
|
329
|
-
suggestion: 'Restore the media on the EmDash side (then run "emit pull-media"), or place the image back under media/drafts/ and push again so Emit uploads a fresh record.',
|
|
330
|
-
});
|
|
331
|
-
}
|
|
332
|
-
const detail = remoteCheck.exists === true ? remoteCheck.item : undefined;
|
|
333
|
-
const promotedStorageKey = detail?.storageKey || promoted.storageKey;
|
|
334
|
-
uploadedByAbsPath.set(draftRef.resolvedAbsPath, {
|
|
335
|
-
mediaId: promoted.mediaId,
|
|
336
|
-
storageKey: promotedStorageKey,
|
|
337
|
-
url: toRootRelativeMediaUrl(detail?.url || promoted.url, promotedStorageKey, options.origin),
|
|
338
|
-
localHash: '',
|
|
339
|
-
contentHash: '',
|
|
340
|
-
size: 0,
|
|
341
|
-
mimeType: resolveImageMimeType(draftRef.filename) || 'image/png',
|
|
342
|
-
filename: draftRef.filename,
|
|
343
|
-
deduplicated: true,
|
|
344
|
-
remoteVerification: remoteCheck.exists === true ? 'verified' : 'unknown',
|
|
345
|
-
remoteVerificationNote: remoteCheck.exists === 'unknown' ? remoteCheck.reason : undefined,
|
|
346
|
-
});
|
|
347
|
-
continue;
|
|
348
|
-
}
|
|
349
|
-
const uploadRes = await uploadMediaFile({
|
|
350
|
-
filePath: draftRef.resolvedAbsPath,
|
|
351
|
-
filename: draftRef.filename,
|
|
352
|
-
alt: draftRef.alt || undefined,
|
|
353
|
-
origin: options.origin,
|
|
354
|
-
token: options.token,
|
|
355
|
-
refreshToken: options.refreshToken,
|
|
356
|
-
cache: options.cache,
|
|
357
|
-
fetchFn: options.fetchFn,
|
|
358
|
-
});
|
|
359
|
-
uploadedByAbsPath.set(draftRef.resolvedAbsPath, uploadRes);
|
|
360
|
-
}
|
|
361
|
-
const rewrittenBody = rewriteDraftImagesInBody(body, draftImages, uploadedByAbsPath);
|
|
362
|
-
return { body: rewrittenBody, uploadedByAbsPath };
|
|
363
|
-
}
|
|
364
|
-
function throwRemoteArticleDeleted(collection, id, targetFilePath, endpoint) {
|
|
365
|
-
throw new ApiError({
|
|
366
|
-
endpoint,
|
|
367
|
-
code: 'REMOTE_ARTICLE_DELETED',
|
|
368
|
-
status: 404,
|
|
369
|
-
message: `Article ${id} (${targetFilePath}) was deleted on the remote server.`,
|
|
370
|
-
suggestion: [
|
|
371
|
-
'1. Keep your edits and re-create as a new article: remove the `id:` field from frontmatter, then "emit push <file>".',
|
|
372
|
-
`2. Discard local edits and untrack: "emit pull ${id} --force" (local copy is preserved under posts/conflicts/)`,
|
|
373
|
-
`3. Delete with rescue backup: "emit delete ${targetFilePath} --discard-local-edits"`,
|
|
374
|
-
].join('\n '),
|
|
375
|
-
});
|
|
376
|
-
}
|
|
377
|
-
/**
|
|
378
|
-
* Pushes a local markdown article to remote EmDash with full preflight guards,
|
|
379
|
-
* two-tier lock lease protection, and automatic 3-way merge on conflict.
|
|
380
|
-
*/
|
|
381
|
-
export async function pushArticle(targetFilePath, options) {
|
|
382
|
-
const fetchFn = createAuthedFetch(options);
|
|
383
|
-
const fullPath = path.isAbsolute(targetFilePath)
|
|
384
|
-
? targetFilePath
|
|
385
|
-
: path.join(options.workspaceRoot, targetFilePath);
|
|
386
|
-
if (!fs.existsSync(fullPath)) {
|
|
387
|
-
throw new Error(`Target file not found: ${targetFilePath}`);
|
|
388
|
-
}
|
|
389
|
-
const originalRelPath = path.relative(options.workspaceRoot, fullPath);
|
|
390
|
-
// Rescue copies under "<collection>/conflicts/" are deliberately unpushable. They exist only to
|
|
391
|
-
// retain divergent local content that a pull was about to overwrite; pushing one as-is would
|
|
392
|
-
// replace the canonical article's remote title/slug/body with stale divergent content and
|
|
393
|
-
// regenerate the duplicate-file-for-one-ULID state that quarantine exists to repair.
|
|
394
|
-
if (isQuarantinedRelativePath(originalRelPath)) {
|
|
395
|
-
throw new PushValidationError(`Refusing to push a preserved rescue copy: ${originalRelPath}`, [
|
|
396
|
-
{
|
|
397
|
-
code: 'QUARANTINED_COPY_NOT_PUSHABLE',
|
|
398
|
-
message: `'${originalRelPath}' is a preserved rescue copy under "${CONFLICTS_SUBDIR}/". ` +
|
|
399
|
-
'Rescue copies hold stale divergent content captured before a pull overwrote it; ' +
|
|
400
|
-
'pushing one would overwrite the canonical article of the same ULID and recreate duplicate local files.',
|
|
401
|
-
suggestion: 'Merge the valuable lines into the canonical article file (posts/draft/... or posts/published/...), then delete this copy. ' +
|
|
402
|
-
'To intentionally replace the remote version, copy the rescue file over the canonical path and push that canonical path instead.',
|
|
403
|
-
},
|
|
404
|
-
]);
|
|
405
|
-
}
|
|
406
|
-
const rawContent = fs.readFileSync(fullPath, 'utf8');
|
|
407
|
-
const parsed = parseMarkdown(rawContent, targetFilePath);
|
|
408
|
-
const id = parsed.frontmatter.id;
|
|
409
|
-
const collection = parsed.frontmatter.collection;
|
|
410
|
-
// Retrieve sync state and base snapshot
|
|
411
|
-
const syncState = options.cache.getSyncState(id);
|
|
412
|
-
const baseSnapshot = options.cache.getBaseSnapshot(id);
|
|
413
|
-
// Pre-load local taxonomy maps once for canonicalization, G4 guard, and remote sync
|
|
414
|
-
const taxonomyMaps = loadLocalTaxonomyMaps(options.workspaceRoot);
|
|
415
|
-
const resolvedTaxonomies = {};
|
|
416
|
-
const canonicalizeTerms = (taxName, terms) => {
|
|
417
|
-
const list = [];
|
|
418
|
-
const canonicalized = terms.map((term) => {
|
|
419
|
-
// 1. If term is a ULID in idToSlug
|
|
420
|
-
if (taxonomyMaps.idToSlug[taxName]?.[term]) {
|
|
421
|
-
const canonicalSlug = taxonomyMaps.idToSlug[taxName][term];
|
|
422
|
-
list.push({ input: term, resolved: canonicalSlug, id: term });
|
|
423
|
-
return canonicalSlug;
|
|
424
|
-
}
|
|
425
|
-
// 2. If term is in slugToId (can be canonical slug, decoded slug, or human name alias)
|
|
426
|
-
const id = taxonomyMaps.slugToId[taxName]?.[term];
|
|
427
|
-
if (id && taxonomyMaps.idToSlug[taxName]?.[id]) {
|
|
428
|
-
const canonicalSlug = taxonomyMaps.idToSlug[taxName][id];
|
|
429
|
-
if (term !== canonicalSlug) {
|
|
430
|
-
list.push({ input: term, resolved: canonicalSlug, id });
|
|
431
|
-
}
|
|
432
|
-
return canonicalSlug;
|
|
433
|
-
}
|
|
434
|
-
return term;
|
|
435
|
-
});
|
|
436
|
-
if (list.length > 0) {
|
|
437
|
-
resolvedTaxonomies[taxName === 'category' ? 'categories' : 'tags'] = list;
|
|
438
|
-
}
|
|
439
|
-
return canonicalized;
|
|
440
|
-
};
|
|
441
|
-
// Canonicalize frontmatter taxonomy terms in-place before preflight and payload creation
|
|
442
|
-
if (parsed.frontmatter.taxonomies) {
|
|
443
|
-
if (parsed.frontmatter.taxonomies.categories) {
|
|
444
|
-
parsed.frontmatter.taxonomies.categories = canonicalizeTerms('category', parsed.frontmatter.taxonomies.categories);
|
|
445
|
-
}
|
|
446
|
-
if (parsed.frontmatter.taxonomies.tags) {
|
|
447
|
-
parsed.frontmatter.taxonomies.tags = canonicalizeTerms('tag', parsed.frontmatter.taxonomies.tags);
|
|
448
|
-
}
|
|
449
|
-
}
|
|
450
|
-
// Pre-build taxonomy candidates with slugToName for frontmatter serialization with inline '# <Name>' comments
|
|
451
|
-
const candidates = options.candidates || loadLocalTaxonomyCandidates(options.workspaceRoot);
|
|
452
|
-
// If knownTaxonomies is not explicitly passed, populate from local categories/ and tags/ (if present)
|
|
453
|
-
let knownTaxonomies = options.knownTaxonomies;
|
|
454
|
-
if (!knownTaxonomies) {
|
|
455
|
-
const knownCats = Object.keys(taxonomyMaps.slugToId.category);
|
|
456
|
-
const knownTags = Object.keys(taxonomyMaps.slugToId.tag);
|
|
457
|
-
if (knownCats.length > 0 || knownTags.length > 0) {
|
|
458
|
-
knownTaxonomies = {
|
|
459
|
-
...(knownCats.length > 0 ? { categories: knownCats } : {}),
|
|
460
|
-
...(knownTags.length > 0 ? { tags: knownTags } : {}),
|
|
461
|
-
};
|
|
462
|
-
}
|
|
463
|
-
}
|
|
464
|
-
// 1. Guard against blind re-push when article is marked delete_pending (G15)
|
|
465
|
-
if (syncState?.deletePending === 1 && !options.revive && !options.force) {
|
|
466
|
-
throw new ApiError({
|
|
467
|
-
endpoint: `/_emdash/api/content/${collection}/${id}`,
|
|
468
|
-
code: 'DELETE_PENDING_ARTICLE',
|
|
469
|
-
status: 409,
|
|
470
|
-
message: `Article ${id} (${targetFilePath}) is marked as delete-pending (previous remote deletion was unconfirmed).`,
|
|
471
|
-
suggestion: `To intentionally revive and push this article to remote, pass --revive (or --force): "emit push ${targetFilePath} --revive"`,
|
|
472
|
-
});
|
|
473
|
-
}
|
|
474
|
-
// 2. Preflight Guards (including G6: UNRESOLVED_CONFLICT_MARKERS)
|
|
475
|
-
// Evaluated before taxonomy conflict pending so markers in markdown body are caught first
|
|
476
|
-
const preflight = runPreflightChecks(targetFilePath, parsed, {
|
|
477
|
-
allowPublish: options.allowPublish,
|
|
478
|
-
knownTaxonomies,
|
|
479
|
-
baseMarkdown: baseSnapshot?.bodyMarkdown,
|
|
480
|
-
previousStatus: syncState?.status || 'draft',
|
|
481
|
-
workspaceRoot: options.workspaceRoot,
|
|
482
|
-
cache: options.cache,
|
|
483
|
-
});
|
|
484
|
-
if (!preflight.valid) {
|
|
485
|
-
throw new PushValidationError(`Push blocked by Preflight Guards (${preflight.errors.length} error(s)).`, preflight.errors);
|
|
486
|
-
}
|
|
487
|
-
// Update parsed with cleaned body (e.g. trimmed duplicate heading)
|
|
488
|
-
parsed.body = preflight.cleanedBody;
|
|
489
|
-
// 3. Guard against blind re-push when taxonomy conflict is still pending (G16)
|
|
490
|
-
const isTaxConflictPending = syncState?.taxonomyConflictPending !== undefined
|
|
491
|
-
? syncState.taxonomyConflictPending === 1
|
|
492
|
-
: syncState?.conflictPending === 1 && (syncState?.bodyConflictPending ?? 0) === 0;
|
|
493
|
-
if (isTaxConflictPending && !options.resolveTaxonomy && !options.force) {
|
|
494
|
-
throw new ApiError({
|
|
495
|
-
endpoint: `/_emdash/api/content/${collection}/${id}`,
|
|
496
|
-
code: 'TAXONOMY_CONFLICT_PENDING',
|
|
497
|
-
status: 409,
|
|
498
|
-
message: `Article ${id} (${targetFilePath}) has an unresolved taxonomy conflict from a previous sync.`,
|
|
499
|
-
suggestion: `Pass --resolve-taxonomy=union|local|remote (or --force) to explicitly resolve and confirm: "emit push ${targetFilePath} --resolve-taxonomy=union"`,
|
|
500
|
-
});
|
|
501
|
-
}
|
|
502
|
-
// 4. Guard against blind re-push when previous taxonomy update failed (G17)
|
|
503
|
-
if (syncState?.taxonomyApplyPending === 1 &&
|
|
504
|
-
!options.allowUnappliedTaxonomies &&
|
|
505
|
-
!options.force) {
|
|
506
|
-
throw new ApiError({
|
|
507
|
-
endpoint: `/_emdash/api/content/${collection}/${id}`,
|
|
508
|
-
code: 'TAXONOMY_APPLY_PENDING',
|
|
509
|
-
status: 409,
|
|
510
|
-
message: `Article ${id} (${targetFilePath}) has an unconfirmed taxonomy assignment from a previous failed update.`,
|
|
511
|
-
suggestion: `Run "emit pull-taxonomies" to synchronize taxonomy definitions, then re-push (or pass --allow-unapplied-taxonomies / --force).`,
|
|
512
|
-
});
|
|
513
|
-
}
|
|
514
|
-
const currentRev = syncState?.remoteRev || baseSnapshot?.rev || '';
|
|
515
|
-
const warnings = [...preflight.warnings];
|
|
516
|
-
// Load local bylines map for converting frontmatter byline slugs to ULIDs
|
|
517
|
-
let bylineSlugToId = options.bylineSlugToId;
|
|
518
|
-
if (!bylineSlugToId) {
|
|
519
|
-
const cachedMaps = options.cache.getBylineMaps();
|
|
520
|
-
if (Object.keys(cachedMaps.slugToId).length > 0) {
|
|
521
|
-
bylineSlugToId = cachedMaps.slugToId;
|
|
522
|
-
}
|
|
523
|
-
else {
|
|
524
|
-
const freshMaps = await syncRemoteBylines({
|
|
525
|
-
origin: options.origin,
|
|
526
|
-
token: options.token,
|
|
527
|
-
refreshToken: options.refreshToken,
|
|
528
|
-
cache: options.cache,
|
|
529
|
-
fetchFn: options.fetchFn,
|
|
530
|
-
}).catch(() => ({ slugToId: {}, idToSlug: {} }));
|
|
531
|
-
bylineSlugToId = freshMaps.slugToId;
|
|
532
|
-
}
|
|
533
|
-
}
|
|
534
|
-
const effectiveOptions = {
|
|
535
|
-
...options,
|
|
536
|
-
bylineSlugToId,
|
|
537
|
-
};
|
|
538
|
-
// 2. Determine whether this is a new article create (POST) or update (PUT)
|
|
539
|
-
const isNewArticle = !syncState;
|
|
540
|
-
// Helper to synchronize taxonomy assignments
|
|
541
|
-
const syncArticleTaxonomies = async (articleId) => {
|
|
542
|
-
const failures = [];
|
|
543
|
-
if (!parsed.frontmatter.taxonomies)
|
|
544
|
-
return failures;
|
|
545
|
-
const taxMaps = taxonomyMaps;
|
|
546
|
-
const updateTaxonomy = async (taxonomyName, termSlugsOrIds) => {
|
|
547
|
-
const slugMap = taxMaps.slugToId[taxonomyName] || {};
|
|
548
|
-
const idMap = taxMaps.idToSlug[taxonomyName] || {};
|
|
549
|
-
const resolvedTermIds = Array.from(new Set(termSlugsOrIds
|
|
550
|
-
.map((item) => {
|
|
551
|
-
// 1. If item is already a known ULID, preserve it directly to avoid silent substitution
|
|
552
|
-
if (idMap[item])
|
|
553
|
-
return item;
|
|
554
|
-
// 2. If item was canonicalized from a specific ULID or alias, preserve the bound term ID
|
|
555
|
-
const tracked = resolvedTaxonomies[taxonomyName === 'category' ? 'categories' : 'tags'];
|
|
556
|
-
const match = tracked?.find((t) => t.resolved === item);
|
|
557
|
-
if (match?.id)
|
|
558
|
-
return match.id;
|
|
559
|
-
// 3. Fallback to slugMap
|
|
560
|
-
return slugMap[item] || item;
|
|
561
|
-
})
|
|
562
|
-
.filter((tid) => Boolean(tid))));
|
|
563
|
-
if (resolvedTermIds.length === 0) {
|
|
564
|
-
// Check if remote previously had assignments in baseline
|
|
565
|
-
const baseFm = baseSnapshot?.frontmatterYaml
|
|
566
|
-
? JSON.parse(baseSnapshot.frontmatterYaml)
|
|
567
|
-
: null;
|
|
568
|
-
const prevTerms = taxonomyName === 'category' ? baseFm?.taxonomies?.categories : baseFm?.taxonomies?.tags;
|
|
569
|
-
if (prevTerms && prevTerms.length > 0) {
|
|
570
|
-
if (!options.allowWipeTaxonomies && !options.force && !options.resolveTaxonomy) {
|
|
571
|
-
throw new ApiError({
|
|
572
|
-
endpoint: `/_emdash/api/content/${collection}/${articleId}/terms/${taxonomyName}`,
|
|
573
|
-
code: 'DESTRUCTIVE_TAXONOMY_WIPE',
|
|
574
|
-
status: 400,
|
|
575
|
-
message: `Local article specified empty array for ${taxonomyName}, which would remove ${prevTerms.length} existing remote assignment(s).`,
|
|
576
|
-
suggestion: `Pass --allow-wipe-taxonomies (or --force) to confirm wiping all ${taxonomyName} assignments.`,
|
|
577
|
-
});
|
|
578
|
-
}
|
|
579
|
-
warnings.push(`🏷️ Cleared all ${prevTerms.length} ${taxonomyName} assignment(s) from remote (local specified empty array).`);
|
|
580
|
-
}
|
|
581
|
-
}
|
|
582
|
-
const termsUrl = new URL(`/_emdash/api/content/${collection}/${articleId}/terms/${taxonomyName}`, options.origin).toString();
|
|
583
|
-
const termRes = await fetchFn(termsUrl, {
|
|
584
|
-
method: 'POST',
|
|
585
|
-
headers: {
|
|
586
|
-
Authorization: `Bearer ${options.token}`,
|
|
587
|
-
'Content-Type': 'application/json',
|
|
588
|
-
},
|
|
589
|
-
body: JSON.stringify({ termIds: resolvedTermIds }),
|
|
590
|
-
});
|
|
591
|
-
if (!termRes.ok) {
|
|
592
|
-
const errBody = await termRes.text().catch(() => '');
|
|
593
|
-
failures.push({
|
|
594
|
-
taxonomy: taxonomyName,
|
|
595
|
-
status: termRes.status,
|
|
596
|
-
error: errBody || termRes.statusText,
|
|
597
|
-
endpoint: termsUrl,
|
|
598
|
-
});
|
|
599
|
-
warnings.push(`Failed to update ${taxonomyName} terms: ${termRes.statusText} ${errBody}`);
|
|
600
|
-
}
|
|
601
|
-
};
|
|
602
|
-
if (parsed.frontmatter.taxonomies.categories) {
|
|
603
|
-
await updateTaxonomy('category', parsed.frontmatter.taxonomies.categories);
|
|
604
|
-
}
|
|
605
|
-
if (parsed.frontmatter.taxonomies.tags) {
|
|
606
|
-
await updateTaxonomy('tag', parsed.frontmatter.taxonomies.tags);
|
|
607
|
-
}
|
|
608
|
-
return failures;
|
|
609
|
-
};
|
|
610
|
-
if (isNewArticle) {
|
|
611
|
-
// [P2-P4] Upload/resolve standalone draft images and rewrite body
|
|
612
|
-
const { body: rewrittenBody, uploadedByAbsPath } = await uploadAndRewriteDraftImages(parsed.body, preflight.draftImages, options, fetchFn);
|
|
613
|
-
parsed.body = rewrittenBody;
|
|
614
|
-
let payload = localToApiPayload(parsed, '', effectiveOptions);
|
|
615
|
-
warnings.push(...preserveOrResolveFeaturedImage(payload, parsed.frontmatter.featuredImage, options.cache, id, options.workspaceRoot));
|
|
616
|
-
const postUrl = new URL(`/_emdash/api/content/${collection}`, options.origin).toString();
|
|
617
|
-
const res = await fetchFn(postUrl, {
|
|
618
|
-
method: 'POST',
|
|
619
|
-
headers: {
|
|
620
|
-
Authorization: `Bearer ${options.token}`,
|
|
621
|
-
'Content-Type': 'application/json',
|
|
622
|
-
},
|
|
623
|
-
body: JSON.stringify(payload),
|
|
624
|
-
});
|
|
625
|
-
if (!res.ok) {
|
|
626
|
-
const errBody = await res.text().catch(() => '');
|
|
627
|
-
throw new ApiError({
|
|
628
|
-
message: `Create push failed: ${res.statusText} - ${errBody}`,
|
|
629
|
-
code: 'PUSH_CREATE_FAILED',
|
|
630
|
-
status: res.status,
|
|
631
|
-
endpoint: postUrl,
|
|
632
|
-
suggestion: 'Check if an article with this slug already exists or if token has create permissions.',
|
|
633
|
-
});
|
|
634
|
-
}
|
|
635
|
-
const createdJson = await res.json();
|
|
636
|
-
const createdItem = unwrapItem(createdJson);
|
|
637
|
-
const actualId = String(createdItem.id || id);
|
|
638
|
-
const newRev = extractRev(createdJson, '1');
|
|
639
|
-
// Update parsed frontmatter with the actual ID returned by EmDash
|
|
640
|
-
parsed.frontmatter.id = actualId;
|
|
641
|
-
// Sync taxonomies for newly created article
|
|
642
|
-
const taxFailures = await syncArticleTaxonomies(actualId);
|
|
643
|
-
const hasTaxFailures = taxFailures.length > 0;
|
|
644
|
-
const currentPrefix = extractDatePrefixFromPath(fullPath);
|
|
645
|
-
const dateInput = parsed.frontmatter.publishedAt || currentPrefix || createdItem.createdAt || null;
|
|
646
|
-
const newRelPath = computeArticleRelativePath(parsed.frontmatter.collection, parsed.frontmatter.status, actualId, parsed.frontmatter.title || parsed.frontmatter.slug, dateInput);
|
|
647
|
-
const newFullPath = path.join(options.workspaceRoot, newRelPath);
|
|
648
|
-
const serializedCanonical = serializeMarkdown(parsed.frontmatter, parsed.body, candidates);
|
|
649
|
-
// `parsed.body` carries the canonical URL because preflight rewrote every local reference. The file that
|
|
650
|
-
// lands on disk must carry the workspace's own spelling again, or a push would silently re-canonicalize
|
|
651
|
-
// the article and the next status pass would report an edit nobody made. The baseline keeps the canonical
|
|
652
|
-
// body untouched so `diff` and the 3-way merge stay byte-identical to what a remote pull would write.
|
|
653
|
-
// An already-tracked file keeps the spelling its bytes already carry; only a fresh article (or one whose
|
|
654
|
-
// path just changed, which changes the depth) is written in the article-relative form.
|
|
655
|
-
const serializedContent = options.localMediaRefs
|
|
656
|
-
? localizeArticleMediaRefs(serializedCanonical, {
|
|
657
|
-
workspaceRoot: options.workspaceRoot,
|
|
658
|
-
articleRelPath: newRelPath,
|
|
659
|
-
fs,
|
|
660
|
-
preferLegacyRef: carriesDepthFreeMediaRef(rawContent),
|
|
661
|
-
})
|
|
662
|
-
: serializedCanonical;
|
|
663
|
-
const newHash = calculateHash(serializedContent);
|
|
664
|
-
// Transition status to published if requested and permitted
|
|
665
|
-
let finalRev = newRev;
|
|
666
|
-
if (parsed.frontmatter.status === 'published' && options.allowPublish) {
|
|
667
|
-
finalRev = await transitionContentStatus(collection, actualId, 'published', newRev, parsed.frontmatter.publishedAt, options, fetchFn);
|
|
668
|
-
}
|
|
669
|
-
// Save newly serialized file (with actual server ID) and clean up provisional file
|
|
670
|
-
fs.mkdirSync(path.dirname(newFullPath), { recursive: true });
|
|
671
|
-
fs.writeFileSync(newFullPath, serializedContent, 'utf8');
|
|
672
|
-
if (fullPath !== newFullPath && fs.existsSync(fullPath)) {
|
|
673
|
-
try {
|
|
674
|
-
fs.unlinkSync(fullPath);
|
|
675
|
-
}
|
|
676
|
-
catch (unlinkErr) {
|
|
677
|
-
warnings.push(`Failed to remove provisional file at ${fullPath}: ${unlinkErr.message}.`);
|
|
678
|
-
}
|
|
679
|
-
}
|
|
680
|
-
// [P6] Promote uploaded draft images from media/drafts/ into media/ and commit media_state
|
|
681
|
-
finalizePromotedDraftImages(preflight.draftImages, uploadedByAbsPath, options, warnings);
|
|
682
|
-
const finalTaxHash = hasTaxFailures
|
|
683
|
-
? null
|
|
684
|
-
: computeTaxonomyHash(parsed.frontmatter.taxonomies?.categories, parsed.frontmatter.taxonomies?.tags);
|
|
685
|
-
// Update Base snapshot and Sync state
|
|
686
|
-
options.cache.saveBaseSnapshot({
|
|
687
|
-
id: actualId,
|
|
688
|
-
rev: finalRev,
|
|
689
|
-
bodyMarkdown: parsed.body,
|
|
690
|
-
frontmatterYaml: JSON.stringify(parsed.frontmatter),
|
|
691
|
-
hash: newHash,
|
|
692
|
-
savedAt: Math.floor(Date.now() / 1000),
|
|
693
|
-
});
|
|
694
|
-
options.cache.saveSyncState({
|
|
695
|
-
id: actualId,
|
|
696
|
-
localFilePath: newRelPath,
|
|
697
|
-
localHash: newHash,
|
|
698
|
-
remoteRev: finalRev,
|
|
699
|
-
status: parsed.frontmatter.status,
|
|
700
|
-
slug: parsed.frontmatter.slug,
|
|
701
|
-
lastSyncedAt: Math.floor(Date.now() / 1000),
|
|
702
|
-
conflictPending: 0,
|
|
703
|
-
bodyConflictPending: 0,
|
|
704
|
-
taxonomyConflictPending: 0,
|
|
705
|
-
conflictCopyHash: null,
|
|
706
|
-
conflictCopyPath: null,
|
|
707
|
-
conflictAt: null,
|
|
708
|
-
remoteTaxonomyHash: finalTaxHash,
|
|
709
|
-
taxonomyApplyPending: hasTaxFailures ? 1 : 0,
|
|
710
|
-
});
|
|
711
|
-
if (hasTaxFailures) {
|
|
712
|
-
throw new ApiError({
|
|
713
|
-
endpoint: taxFailures[0].endpoint,
|
|
714
|
-
code: 'TAXONOMY_UPDATE_FAILED',
|
|
715
|
-
status: taxFailures[0].status || 400,
|
|
716
|
-
message: `Failed to update ${taxFailures.map((f) => f.taxonomy).join(', ')} terms on remote: ${taxFailures.map((f) => f.error).join('; ')}`,
|
|
717
|
-
suggestion: 'Run "emit pull-taxonomies" to update local definitions, verify taxonomy permissions, and retry push.',
|
|
718
|
-
});
|
|
719
|
-
}
|
|
720
|
-
return {
|
|
721
|
-
success: true,
|
|
722
|
-
id: actualId,
|
|
723
|
-
rev: finalRev,
|
|
724
|
-
filePath: newRelPath,
|
|
725
|
-
...(actualId !== id ? { previousId: id } : {}),
|
|
726
|
-
...(originalRelPath !== newRelPath ? { previousFilePath: originalRelPath } : {}),
|
|
727
|
-
...(Object.keys(resolvedTaxonomies).length > 0 ? { resolvedTaxonomies } : {}),
|
|
728
|
-
conflict: false,
|
|
729
|
-
warnings,
|
|
730
|
-
};
|
|
731
|
-
}
|
|
732
|
-
// Existing article: execute within full 7-minute entry lock lease (B-3: acquire lock BEFORE uploading media)
|
|
733
|
-
return await withEntryLock(collection, id, options, async () => {
|
|
734
|
-
// [P2-P4] Upload/resolve standalone draft images and rewrite body after entry lock is acquired
|
|
735
|
-
const { body: rewrittenBody, uploadedByAbsPath } = await uploadAndRewriteDraftImages(parsed.body, preflight.draftImages, options, fetchFn);
|
|
736
|
-
parsed.body = rewrittenBody;
|
|
737
|
-
let payload = localToApiPayload(parsed, currentRev, effectiveOptions);
|
|
738
|
-
warnings.push(...preserveOrResolveFeaturedImage(payload, parsed.frontmatter.featuredImage, options.cache, id, options.workspaceRoot));
|
|
739
|
-
const putUrl = new URL(`/_emdash/api/content/${collection}/${id}`, options.origin).toString();
|
|
740
|
-
// R1: Pre-PUT Taxonomy Audit to detect out-of-band remote taxonomy changes
|
|
741
|
-
let remoteTaxAudit = null;
|
|
742
|
-
let taxonomyDivergenceDetected = false;
|
|
743
|
-
const baseFm = baseSnapshot?.frontmatterYaml
|
|
744
|
-
? JSON.parse(baseSnapshot.frontmatterYaml)
|
|
745
|
-
: null;
|
|
746
|
-
const hasLocalTaxonomies = Boolean(parsed.frontmatter.taxonomies &&
|
|
747
|
-
(parsed.frontmatter.taxonomies.categories !== undefined ||
|
|
748
|
-
parsed.frontmatter.taxonomies.tags !== undefined));
|
|
749
|
-
const hasBaseTaxonomies = Boolean(baseFm?.taxonomies && (baseFm.taxonomies.categories || baseFm.taxonomies.tags));
|
|
750
|
-
if ((hasLocalTaxonomies || hasBaseTaxonomies) && !options.noTaxonomyAudit) {
|
|
751
|
-
remoteTaxAudit = await fetchAssignedTaxonomies({
|
|
752
|
-
origin: options.origin,
|
|
753
|
-
token: options.token,
|
|
754
|
-
collection,
|
|
755
|
-
id,
|
|
756
|
-
fetchFn,
|
|
757
|
-
});
|
|
758
|
-
if (remoteTaxAudit.categories === null && remoteTaxAudit.tags === null) {
|
|
759
|
-
warnings.push('⚠️ Remote taxonomy state unknown (could not audit remote terms).');
|
|
760
|
-
}
|
|
761
|
-
else {
|
|
762
|
-
if (remoteTaxAudit.categories === null) {
|
|
763
|
-
warnings.push('⚠️ Remote category taxonomy state unknown (could not audit categories).');
|
|
764
|
-
}
|
|
765
|
-
if (remoteTaxAudit.tags === null) {
|
|
766
|
-
warnings.push('⚠️ Remote tag taxonomy state unknown (could not audit tags).');
|
|
767
|
-
}
|
|
768
|
-
const baselineTaxHash = syncState?.remoteTaxonomyHash ||
|
|
769
|
-
(baseFm?.taxonomies
|
|
770
|
-
? computeTaxonomyHash(baseFm.taxonomies.categories, baseFm.taxonomies.tags)
|
|
771
|
-
: null);
|
|
772
|
-
const currentRemoteCats = remoteTaxAudit.categories !== null
|
|
773
|
-
? remoteTaxAudit.categories
|
|
774
|
-
: baseFm?.taxonomies?.categories || [];
|
|
775
|
-
const currentRemoteTags = remoteTaxAudit.tags !== null ? remoteTaxAudit.tags : baseFm?.taxonomies?.tags || [];
|
|
776
|
-
const currentRemoteTaxHash = computeTaxonomyHash(currentRemoteCats, currentRemoteTags);
|
|
777
|
-
if (baselineTaxHash && currentRemoteTaxHash !== baselineTaxHash) {
|
|
778
|
-
taxonomyDivergenceDetected = true;
|
|
779
|
-
}
|
|
780
|
-
}
|
|
781
|
-
}
|
|
782
|
-
let res = taxonomyDivergenceDetected
|
|
783
|
-
? {
|
|
784
|
-
status: 409,
|
|
785
|
-
ok: false,
|
|
786
|
-
json: async () => ({ error: { code: 'TAXONOMY_DIVERGENCE' } }),
|
|
787
|
-
text: async () => '',
|
|
788
|
-
}
|
|
789
|
-
: await fetchFn(putUrl, {
|
|
790
|
-
method: 'PUT',
|
|
791
|
-
headers: {
|
|
792
|
-
Authorization: `Bearer ${options.token}`,
|
|
793
|
-
'Content-Type': 'application/json',
|
|
794
|
-
},
|
|
795
|
-
body: JSON.stringify(payload),
|
|
796
|
-
});
|
|
797
|
-
if (!taxonomyDivergenceDetected && (res.status === 404 || res.status === 410)) {
|
|
798
|
-
throwRemoteArticleDeleted(collection, id, targetFilePath, putUrl);
|
|
799
|
-
}
|
|
800
|
-
// 3. Handle 409 Conflict
|
|
801
|
-
if (res.status === 409) {
|
|
802
|
-
const errJson = (await res.json().catch(() => ({})));
|
|
803
|
-
const errorCode = errJson.error?.code || 'CONFLICT';
|
|
804
|
-
if (errorCode === 'ENTRY_LOCKED') {
|
|
805
|
-
throw new ApiError({
|
|
806
|
-
message: `Entry is currently locked by another user. (${errJson.error?.message || ''})`,
|
|
807
|
-
code: 'ENTRY_LOCKED',
|
|
808
|
-
status: 409,
|
|
809
|
-
endpoint: putUrl,
|
|
810
|
-
suggestion: 'Wait for the other user to finish editing or release their lock before retrying.',
|
|
811
|
-
});
|
|
812
|
-
}
|
|
813
|
-
// True revision mismatch conflict or taxonomy divergence: fetch remote latest and run Hybrid 3-Way Merge
|
|
814
|
-
const getUrl = new URL(`/_emdash/api/content/${collection}/${id}`, options.origin).toString();
|
|
815
|
-
const getRes = await fetchFn(getUrl, {
|
|
816
|
-
method: 'GET',
|
|
817
|
-
headers: {
|
|
818
|
-
Authorization: `Bearer ${options.token}`,
|
|
819
|
-
Accept: 'application/json',
|
|
820
|
-
},
|
|
821
|
-
});
|
|
822
|
-
if (!getRes.ok) {
|
|
823
|
-
if (getRes.status === 404 || getRes.status === 410) {
|
|
824
|
-
throwRemoteArticleDeleted(collection, id, targetFilePath, getUrl);
|
|
825
|
-
}
|
|
826
|
-
throw new ApiError({
|
|
827
|
-
message: `Failed to fetch latest remote version during conflict resolution: ${getRes.statusText}`,
|
|
828
|
-
code: 'CONFLICT_REMOTE_FETCH_FAILED',
|
|
829
|
-
status: getRes.status,
|
|
830
|
-
endpoint: getUrl,
|
|
831
|
-
suggestion: 'Check remote server availability to resolve conflict.',
|
|
832
|
-
});
|
|
833
|
-
}
|
|
834
|
-
const remoteItem = (await getRes.json());
|
|
835
|
-
const remoteParsed = apiResponseToLocal(remoteItem, options);
|
|
836
|
-
// Fetch remote taxonomy assignments (reuse remoteTaxAudit if available)
|
|
837
|
-
const remoteTax = remoteTaxAudit ||
|
|
838
|
-
(await fetchAssignedTaxonomies({
|
|
839
|
-
origin: options.origin,
|
|
840
|
-
token: options.token,
|
|
841
|
-
collection,
|
|
842
|
-
id,
|
|
843
|
-
fetchFn,
|
|
844
|
-
}));
|
|
845
|
-
if (!remoteParsed.frontmatter.taxonomies) {
|
|
846
|
-
remoteParsed.frontmatter.taxonomies = {};
|
|
847
|
-
}
|
|
848
|
-
if (remoteTax.categories !== null) {
|
|
849
|
-
remoteParsed.frontmatter.taxonomies.categories = remoteTax.categories;
|
|
850
|
-
}
|
|
851
|
-
else if (baseSnapshot) {
|
|
852
|
-
const baseFm = JSON.parse(baseSnapshot.frontmatterYaml);
|
|
853
|
-
if (baseFm.taxonomies?.categories) {
|
|
854
|
-
remoteParsed.frontmatter.taxonomies.categories = baseFm.taxonomies.categories;
|
|
855
|
-
}
|
|
856
|
-
}
|
|
857
|
-
if (remoteTax.tags !== null) {
|
|
858
|
-
remoteParsed.frontmatter.taxonomies.tags = remoteTax.tags;
|
|
859
|
-
}
|
|
860
|
-
else if (baseSnapshot) {
|
|
861
|
-
const baseFm = JSON.parse(baseSnapshot.frontmatterYaml);
|
|
862
|
-
if (baseFm.taxonomies?.tags) {
|
|
863
|
-
remoteParsed.frontmatter.taxonomies.tags = baseFm.taxonomies.tags;
|
|
864
|
-
}
|
|
865
|
-
}
|
|
866
|
-
// Perform Hybrid 3-Way Merge
|
|
867
|
-
const baseInfo = baseSnapshot
|
|
868
|
-
? {
|
|
869
|
-
frontmatter: JSON.parse(baseSnapshot.frontmatterYaml),
|
|
870
|
-
body: baseSnapshot.bodyMarkdown,
|
|
871
|
-
}
|
|
872
|
-
: null;
|
|
873
|
-
const mergeResult = hybrid3WayMerge(parsed, baseInfo, {
|
|
874
|
-
frontmatter: remoteParsed.frontmatter,
|
|
875
|
-
body: remoteParsed.body,
|
|
876
|
-
}, {
|
|
877
|
-
strategy: options.resolveTaxonomy,
|
|
878
|
-
});
|
|
879
|
-
warnings.push(...mergeResult.warnings);
|
|
880
|
-
// Check destructive taxonomy wipe guard during 3-way merge (exempt if strategy is explicitly specified)
|
|
881
|
-
if (!options.resolveTaxonomy &&
|
|
882
|
-
mergeResult.wipeWarnings &&
|
|
883
|
-
mergeResult.wipeWarnings.length > 0) {
|
|
884
|
-
if (!options.allowWipeTaxonomies && !options.force) {
|
|
885
|
-
const details = mergeResult.wipeWarnings
|
|
886
|
-
.map((w) => `${w.name}: clearing ${w.remoteTerms.length} assignment(s)`)
|
|
887
|
-
.join(', ');
|
|
888
|
-
throw new ApiError({
|
|
889
|
-
endpoint: `/_emdash/api/content/${collection}/${id}`,
|
|
890
|
-
code: 'DESTRUCTIVE_TAXONOMY_WIPE',
|
|
891
|
-
status: 400,
|
|
892
|
-
message: `Local article specified empty array for taxonomies (${details}), which would wipe existing remote assignments.`,
|
|
893
|
-
suggestion: `Pass --allow-wipe-taxonomies (or --force) to confirm clearing: "emit push ${targetFilePath} --allow-wipe-taxonomies"`,
|
|
894
|
-
});
|
|
895
|
-
}
|
|
896
|
-
}
|
|
897
|
-
// Log clean auto-merged taxonomy changes
|
|
898
|
-
if (mergeResult.autoMergedTaxonomies && mergeResult.autoMergedTaxonomies.length > 0) {
|
|
899
|
-
for (const item of mergeResult.autoMergedTaxonomies) {
|
|
900
|
-
const parts = [];
|
|
901
|
-
if (item.remoteAdded.length > 0)
|
|
902
|
-
parts.push(`+${item.remoteAdded.join(', ')}`);
|
|
903
|
-
if (item.remoteRemoved.length > 0)
|
|
904
|
-
parts.push(`-${item.remoteRemoved.join(', ')}`);
|
|
905
|
-
warnings.push(`🏷️ Merged remote taxonomy change(s) for ${item.name}: ${parts.join(' ')}`);
|
|
906
|
-
}
|
|
907
|
-
}
|
|
908
|
-
// Log strategy adoptions
|
|
909
|
-
if (mergeResult.strategyAdoptions && mergeResult.strategyAdoptions.length > 0) {
|
|
910
|
-
for (const item of mergeResult.strategyAdoptions) {
|
|
911
|
-
const parts = [];
|
|
912
|
-
if (item.discardedAdded.length > 0)
|
|
913
|
-
parts.push(`+${item.discardedAdded.join(', ')}`);
|
|
914
|
-
if (item.discardedRemoved.length > 0)
|
|
915
|
-
parts.push(`-${item.discardedRemoved.join(', ')}`);
|
|
916
|
-
const discardedStr = parts.length > 0 ? `: discarded ${parts.join(' ')}` : '';
|
|
917
|
-
warnings.push(`🏷️ Resolved '${item.name}' with strategy '${item.side}'${discardedStr}.`);
|
|
918
|
-
}
|
|
919
|
-
}
|
|
920
|
-
// Fallback post-merge resolution only if strategy was NOT specified and taxonomy conflicts occurred
|
|
921
|
-
if (!options.resolveTaxonomy &&
|
|
922
|
-
mergeResult.taxonomyConflicts &&
|
|
923
|
-
mergeResult.taxonomyConflicts.length > 0) {
|
|
924
|
-
if (options.force) {
|
|
925
|
-
const strategy = 'union';
|
|
926
|
-
const unionTaxonomies = {};
|
|
927
|
-
const allTaxNames = new Set([
|
|
928
|
-
...Object.keys(parsed.frontmatter.taxonomies || {}),
|
|
929
|
-
...Object.keys(remoteParsed.frontmatter.taxonomies || {}),
|
|
930
|
-
]);
|
|
931
|
-
for (const tName of allTaxNames) {
|
|
932
|
-
const lTerms = parsed.frontmatter.taxonomies?.[tName] || [];
|
|
933
|
-
const rTerms = remoteParsed.frontmatter.taxonomies?.[tName] || [];
|
|
934
|
-
unionTaxonomies[tName] = Array.from(new Set([...lTerms, ...rTerms]));
|
|
935
|
-
}
|
|
936
|
-
mergeResult.frontmatter.taxonomies = unionTaxonomies;
|
|
937
|
-
warnings.push(`Resolved taxonomy conflicts using strategy '${strategy}'.`);
|
|
938
|
-
}
|
|
939
|
-
else {
|
|
940
|
-
mergeResult.hasConflict = true;
|
|
941
|
-
warnings.push(`Conflict on taxonomies (${mergeResult.taxonomyConflicts.join(', ')}): local and remote changes could not be cleanly merged. Push aborted to prevent data loss.`);
|
|
942
|
-
}
|
|
943
|
-
}
|
|
944
|
-
if (mergeResult.hasConflict) {
|
|
945
|
-
const isTaxonomyConflict = Boolean(mergeResult.taxonomyConflicts && mergeResult.taxonomyConflicts.length > 0);
|
|
946
|
-
const hasBodyConflict = Boolean(mergeResult.body.includes('<<<<<<< LOCAL') || mergeResult.body.includes('<<<<<<< local'));
|
|
947
|
-
const isTaxonomyOnlyConflict = isTaxonomyConflict && !hasBodyConflict;
|
|
948
|
-
const conflictedMarkdown = serializeMarkdown(mergeResult.frontmatter, mergeResult.body, options.candidates);
|
|
949
|
-
let quarantineRel = null;
|
|
950
|
-
// Only write conflict markers into the article body if body actually has conflicts
|
|
951
|
-
if (!isTaxonomyOnlyConflict) {
|
|
952
|
-
const currentDisk = fs.readFileSync(fullPath, 'utf8');
|
|
953
|
-
quarantineRel = quarantineLocalCopy(options.workspaceRoot, collection, targetFilePath, currentDisk, { isForce: false });
|
|
954
|
-
fs.writeFileSync(fullPath, conflictedMarkdown, 'utf8');
|
|
955
|
-
}
|
|
956
|
-
const conflictedHash = calculateHash(conflictedMarkdown);
|
|
957
|
-
// Update sync state:
|
|
958
|
-
// Do NOT overwrite localHash with marker hash, so `status` remains clean when markers are cleaned up!
|
|
959
|
-
options.cache.saveSyncState({
|
|
960
|
-
id,
|
|
961
|
-
localFilePath: targetFilePath,
|
|
962
|
-
localHash: syncState?.localHash || '',
|
|
963
|
-
remoteRev: remoteParsed.rev,
|
|
964
|
-
status: parsed.frontmatter.status,
|
|
965
|
-
slug: parsed.frontmatter.slug,
|
|
966
|
-
lastSyncedAt: Math.floor(Date.now() / 1000),
|
|
967
|
-
conflictPending: 1,
|
|
968
|
-
bodyConflictPending: hasBodyConflict ? 1 : 0,
|
|
969
|
-
taxonomyConflictPending: isTaxonomyConflict ? 1 : 0,
|
|
970
|
-
conflictCopyHash: conflictedHash,
|
|
971
|
-
conflictCopyPath: quarantineRel,
|
|
972
|
-
conflictAt: Math.floor(Date.now() / 1000),
|
|
973
|
-
remoteTaxonomyHash: computeTaxonomyHash(remoteParsed.frontmatter.taxonomies?.categories, remoteParsed.frontmatter.taxonomies?.tags),
|
|
974
|
-
taxonomyApplyPending: 0,
|
|
975
|
-
});
|
|
976
|
-
options.cache.saveBaseSnapshot({
|
|
977
|
-
id,
|
|
978
|
-
rev: remoteParsed.rev,
|
|
979
|
-
bodyMarkdown: remoteParsed.body,
|
|
980
|
-
frontmatterYaml: JSON.stringify(remoteParsed.frontmatter),
|
|
981
|
-
hash: calculateHash(serializeMarkdown(remoteParsed.frontmatter, remoteParsed.body, candidates)),
|
|
982
|
-
savedAt: Math.floor(Date.now() / 1000),
|
|
983
|
-
});
|
|
984
|
-
return {
|
|
985
|
-
success: false,
|
|
986
|
-
id,
|
|
987
|
-
rev: remoteParsed.rev,
|
|
988
|
-
filePath: targetFilePath,
|
|
989
|
-
conflict: true,
|
|
990
|
-
taxonomyConflict: isTaxonomyOnlyConflict,
|
|
991
|
-
warnings,
|
|
992
|
-
};
|
|
993
|
-
}
|
|
994
|
-
// Auto-merged cleanly: retry PUT with remote's _rev
|
|
995
|
-
parsed.frontmatter = mergeResult.frontmatter;
|
|
996
|
-
parsed.body = mergeResult.body;
|
|
997
|
-
payload = localToApiPayload(parsed, remoteParsed.rev, effectiveOptions);
|
|
998
|
-
warnings.push(...preserveOrResolveFeaturedImage(payload, parsed.frontmatter.featuredImage, options.cache, id, options.workspaceRoot));
|
|
999
|
-
res = await fetchFn(putUrl, {
|
|
1000
|
-
method: 'PUT',
|
|
1001
|
-
headers: {
|
|
1002
|
-
Authorization: `Bearer ${options.token}`,
|
|
1003
|
-
'Content-Type': 'application/json',
|
|
1004
|
-
},
|
|
1005
|
-
body: JSON.stringify(payload),
|
|
1006
|
-
});
|
|
1007
|
-
if (!res.ok) {
|
|
1008
|
-
if (res.status === 404 || res.status === 410) {
|
|
1009
|
-
throwRemoteArticleDeleted(collection, id, targetFilePath, putUrl);
|
|
1010
|
-
}
|
|
1011
|
-
const errText = await res.text().catch(() => '');
|
|
1012
|
-
// Check if server rejected the merged taxonomy union or strategy (e.g. single category limit)
|
|
1013
|
-
if ((res.status === 400 || res.status === 422) &&
|
|
1014
|
-
((mergeResult.autoMergedTaxonomies && mergeResult.autoMergedTaxonomies.length > 0) ||
|
|
1015
|
-
(mergeResult.strategyAdoptions && mergeResult.strategyAdoptions.length > 0))) {
|
|
1016
|
-
const wasStrategy = mergeResult.strategyAdoptions && mergeResult.strategyAdoptions.length > 0;
|
|
1017
|
-
throw new ApiError({
|
|
1018
|
-
message: `Remote server rejected the merged taxonomy union (HTTP ${res.status}): ${errText || res.statusText}`,
|
|
1019
|
-
code: 'TAXONOMY_UNION_REJECTED',
|
|
1020
|
-
status: res.status,
|
|
1021
|
-
endpoint: putUrl,
|
|
1022
|
-
suggestion: wasStrategy
|
|
1023
|
-
? 'The remote server rejected the taxonomy adopted by your strategy (e.g. single-category constraint). Switch strategy with "--resolve-taxonomy=remote" or edit the frontmatter manually before pushing.'
|
|
1024
|
-
: 'The remote server enforces constraints that reject combining local and remote taxonomy terms (e.g. single-category constraint). Resolve by explicitly choosing a side: "emit push <file> --resolve-taxonomy=local" (or remote), or edit the frontmatter manually.',
|
|
1025
|
-
});
|
|
1026
|
-
}
|
|
1027
|
-
throw new ApiError({
|
|
1028
|
-
message: `Retry push after auto-merge failed: ${res.statusText}${errText ? ` - ${errText}` : ''}`,
|
|
1029
|
-
code: 'PUSH_RETRY_FAILED',
|
|
1030
|
-
status: res.status,
|
|
1031
|
-
endpoint: putUrl,
|
|
1032
|
-
suggestion: 'Another change might have been committed in the meantime. Try pulling again.',
|
|
1033
|
-
});
|
|
1034
|
-
}
|
|
1035
|
-
}
|
|
1036
|
-
if (!res.ok) {
|
|
1037
|
-
if (res.status === 404 || res.status === 410) {
|
|
1038
|
-
throwRemoteArticleDeleted(collection, id, targetFilePath, putUrl);
|
|
1039
|
-
}
|
|
1040
|
-
const errText = await res.text().catch(() => '');
|
|
1041
|
-
throw new ApiError({
|
|
1042
|
-
message: `Push failed: ${res.statusText}${errText ? ` - ${errText}` : ''}`,
|
|
1043
|
-
code: 'PUSH_FAILED',
|
|
1044
|
-
status: res.status,
|
|
1045
|
-
endpoint: putUrl,
|
|
1046
|
-
suggestion: 'Verify network connectivity, token permissions, and article status.',
|
|
1047
|
-
});
|
|
1048
|
-
}
|
|
1049
|
-
const updatedJson = await res.json().catch(() => ({}));
|
|
1050
|
-
const newRev = extractRev(updatedJson, currentRev);
|
|
1051
|
-
// 4. Post-push updates & Path tracking
|
|
1052
|
-
// Sync taxonomies on updated article
|
|
1053
|
-
const taxFailures = await syncArticleTaxonomies(id);
|
|
1054
|
-
const hasTaxFailures = taxFailures.length > 0;
|
|
1055
|
-
const currentPrefix = extractDatePrefixFromPath(fullPath);
|
|
1056
|
-
const existingSync = options.cache.getSyncState(parsed.frontmatter.id);
|
|
1057
|
-
const dateInput = parsed.frontmatter.publishedAt ||
|
|
1058
|
-
currentPrefix ||
|
|
1059
|
-
(existingSync?.lastSyncedAt ? new Date(existingSync.lastSyncedAt * 1000) : null);
|
|
1060
|
-
const newRelPath = computeArticleRelativePath(parsed.frontmatter.collection, parsed.frontmatter.status, parsed.frontmatter.id, parsed.frontmatter.title || parsed.frontmatter.slug, dateInput);
|
|
1061
|
-
const newFullPath = path.join(options.workspaceRoot, newRelPath);
|
|
1062
|
-
const serializedCanonical = serializeMarkdown(parsed.frontmatter, parsed.body, candidates);
|
|
1063
|
-
// Same disk spelling rule as the clean-push path: keep the workspace's own local references on disk while
|
|
1064
|
-
// the canonical form goes to remote and into the baseline. A file that already carries the depth-free form
|
|
1065
|
-
// keeps it; a path change (draft→published) re-spells at the new depth.
|
|
1066
|
-
const serializedContent = options.localMediaRefs
|
|
1067
|
-
? localizeArticleMediaRefs(serializedCanonical, {
|
|
1068
|
-
workspaceRoot: options.workspaceRoot,
|
|
1069
|
-
articleRelPath: newRelPath,
|
|
1070
|
-
fs,
|
|
1071
|
-
preferLegacyRef: carriesDepthFreeMediaRef(rawContent),
|
|
1072
|
-
})
|
|
1073
|
-
: serializedCanonical;
|
|
1074
|
-
const newHash = calculateHash(serializedContent);
|
|
1075
|
-
// Transition status (publish or unpublish) via dedicated endpoints if needed
|
|
1076
|
-
let finalRev = newRev;
|
|
1077
|
-
const previousStatus = syncState?.status || 'draft';
|
|
1078
|
-
if (parsed.frontmatter.status === 'published' && options.allowPublish) {
|
|
1079
|
-
finalRev = await transitionContentStatus(collection, id, 'published', newRev, parsed.frontmatter.publishedAt, options, fetchFn);
|
|
1080
|
-
}
|
|
1081
|
-
else if (parsed.frontmatter.status === 'draft' && previousStatus === 'published') {
|
|
1082
|
-
finalRev = await transitionContentStatus(collection, id, 'draft', newRev, null, options, fetchFn);
|
|
1083
|
-
}
|
|
1084
|
-
// Only move/rename file on disk AFTER remote update & status transition have succeeded!
|
|
1085
|
-
if (fullPath !== newFullPath) {
|
|
1086
|
-
fs.mkdirSync(path.dirname(newFullPath), { recursive: true });
|
|
1087
|
-
fs.writeFileSync(newFullPath, serializedContent, 'utf8');
|
|
1088
|
-
if (fs.existsSync(fullPath)) {
|
|
1089
|
-
try {
|
|
1090
|
-
fs.unlinkSync(fullPath);
|
|
1091
|
-
}
|
|
1092
|
-
catch (unlinkErr) {
|
|
1093
|
-
warnings.push(`Failed to remove old file at ${fullPath} after path transition: ${unlinkErr.message}. Please remove it manually to prevent duplicates.`);
|
|
1094
|
-
}
|
|
1095
|
-
}
|
|
1096
|
-
}
|
|
1097
|
-
else {
|
|
1098
|
-
fs.writeFileSync(fullPath, serializedContent, 'utf8');
|
|
1099
|
-
}
|
|
1100
|
-
// [P6] Promote uploaded draft images from media/drafts/ into media/ and commit media_state
|
|
1101
|
-
finalizePromotedDraftImages(preflight.draftImages, uploadedByAbsPath, options, warnings);
|
|
1102
|
-
// Update Base snapshot
|
|
1103
|
-
const savedFrontmatter = { ...parsed.frontmatter };
|
|
1104
|
-
if (hasTaxFailures && baseFm?.taxonomies) {
|
|
1105
|
-
savedFrontmatter.taxonomies = { ...baseFm.taxonomies };
|
|
1106
|
-
}
|
|
1107
|
-
const finalTaxHash = hasTaxFailures
|
|
1108
|
-
? syncState?.remoteTaxonomyHash ||
|
|
1109
|
-
(baseFm?.taxonomies
|
|
1110
|
-
? computeTaxonomyHash(baseFm.taxonomies.categories, baseFm.taxonomies.tags)
|
|
1111
|
-
: null)
|
|
1112
|
-
: computeTaxonomyHash(parsed.frontmatter.taxonomies?.categories, parsed.frontmatter.taxonomies?.tags);
|
|
1113
|
-
options.cache.saveBaseSnapshot({
|
|
1114
|
-
id,
|
|
1115
|
-
rev: finalRev,
|
|
1116
|
-
bodyMarkdown: parsed.body,
|
|
1117
|
-
frontmatterYaml: JSON.stringify(savedFrontmatter),
|
|
1118
|
-
hash: newHash,
|
|
1119
|
-
savedAt: Math.floor(Date.now() / 1000),
|
|
1120
|
-
});
|
|
1121
|
-
// Update sync state
|
|
1122
|
-
options.cache.saveSyncState({
|
|
1123
|
-
id,
|
|
1124
|
-
localFilePath: newRelPath,
|
|
1125
|
-
localHash: newHash,
|
|
1126
|
-
remoteRev: finalRev,
|
|
1127
|
-
status: parsed.frontmatter.status,
|
|
1128
|
-
slug: parsed.frontmatter.slug,
|
|
1129
|
-
lastSyncedAt: Math.floor(Date.now() / 1000),
|
|
1130
|
-
conflictPending: hasTaxFailures ? (syncState?.conflictPending ?? 0) : 0,
|
|
1131
|
-
bodyConflictPending: hasTaxFailures ? (syncState?.bodyConflictPending ?? 0) : 0,
|
|
1132
|
-
taxonomyConflictPending: hasTaxFailures ? (syncState?.taxonomyConflictPending ?? 0) : 0,
|
|
1133
|
-
conflictCopyHash: hasTaxFailures ? syncState?.conflictCopyHash : null,
|
|
1134
|
-
conflictCopyPath: hasTaxFailures ? syncState?.conflictCopyPath : null,
|
|
1135
|
-
conflictAt: hasTaxFailures ? syncState?.conflictAt : null,
|
|
1136
|
-
remoteTaxonomyHash: finalTaxHash,
|
|
1137
|
-
taxonomyApplyPending: hasTaxFailures ? 1 : 0,
|
|
1138
|
-
});
|
|
1139
|
-
if (hasTaxFailures) {
|
|
1140
|
-
throw new ApiError({
|
|
1141
|
-
endpoint: taxFailures[0].endpoint,
|
|
1142
|
-
code: 'TAXONOMY_UPDATE_FAILED',
|
|
1143
|
-
status: taxFailures[0].status || 400,
|
|
1144
|
-
message: `Failed to update ${taxFailures.map((f) => f.taxonomy).join(', ')} terms on remote: ${taxFailures.map((f) => f.error).join('; ')}`,
|
|
1145
|
-
suggestion: 'Run "emit pull-taxonomies" to update local definitions, verify taxonomy permissions, and retry push.',
|
|
1146
|
-
});
|
|
1147
|
-
}
|
|
1148
|
-
updateConfigSyncedVersion(options.workspaceRoot);
|
|
1149
|
-
return {
|
|
1150
|
-
success: true,
|
|
1151
|
-
id,
|
|
1152
|
-
rev: finalRev,
|
|
1153
|
-
filePath: newRelPath,
|
|
1154
|
-
...(originalRelPath !== newRelPath ? { previousFilePath: originalRelPath } : {}),
|
|
1155
|
-
...(Object.keys(resolvedTaxonomies).length > 0 ? { resolvedTaxonomies } : {}),
|
|
1156
|
-
conflict: false,
|
|
1157
|
-
warnings,
|
|
1158
|
-
};
|
|
1159
|
-
});
|
|
1160
|
-
}
|
|
25
|
+
export * from './push/index.js';
|
|
1161
26
|
//# sourceMappingURL=push.js.map
|