@heliosgraphics/epoque 0.0.1-alpha.1 → 0.0.1-alpha.3
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/package.json +1 -1
- package/src/sync.ts +63 -11
package/package.json
CHANGED
package/src/sync.ts
CHANGED
|
@@ -35,10 +35,25 @@ const EMPTY_SUMMARY: SyncSummary = {
|
|
|
35
35
|
const isUuid = (value: string): boolean =>
|
|
36
36
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value)
|
|
37
37
|
|
|
38
|
+
const TIMEZONE_PATTERN: RegExp = /[zZ]|[+-]\d{2}:?\d{2}$/
|
|
39
|
+
const TIME_PATTERN: RegExp = /T|\d{2}:\d{2}/
|
|
40
|
+
|
|
41
|
+
const parseRemoteTime = (value: string | null | undefined): number | null => {
|
|
42
|
+
const trimmed = value?.trim()
|
|
43
|
+
if (!trimmed) return null
|
|
44
|
+
|
|
45
|
+
const normalized = TIME_PATTERN.test(trimmed) ? trimmed.replace(" ", "T") : `${trimmed}T00:00:00`
|
|
46
|
+
const withTimezone = TIMEZONE_PATTERN.test(normalized) ? normalized : `${normalized}Z`
|
|
47
|
+
const parsed = Date.parse(withTimezone)
|
|
48
|
+
|
|
49
|
+
return Number.isNaN(parsed) ? null : parsed
|
|
50
|
+
}
|
|
51
|
+
|
|
38
52
|
const isAfter = (left: string | null | undefined, right: string | null | undefined): boolean => {
|
|
39
|
-
|
|
53
|
+
const leftTime = parseRemoteTime(left)
|
|
54
|
+
const rightTime = parseRemoteTime(right)
|
|
40
55
|
|
|
41
|
-
return
|
|
56
|
+
return leftTime !== null && rightTime !== null && leftTime > rightTime
|
|
42
57
|
}
|
|
43
58
|
|
|
44
59
|
const getCollectionSearchTerms = (input: string): Array<string> => {
|
|
@@ -107,7 +122,7 @@ const getLocalById = (articles: Array<LocalArticle>): Map<string, LocalArticle>
|
|
|
107
122
|
const getRemoteById = (archive: RemoteArchive): Map<string, RemoteArticle> =>
|
|
108
123
|
new Map(archive.articles.map((article) => [article.id, article]))
|
|
109
124
|
|
|
110
|
-
const
|
|
125
|
+
const getUpstreamConflicts = ({
|
|
111
126
|
archive,
|
|
112
127
|
config,
|
|
113
128
|
localArticles,
|
|
@@ -115,7 +130,7 @@ const assertNoUpstreamChanges = ({
|
|
|
115
130
|
archive: RemoteArchive
|
|
116
131
|
config: ProjectConfig
|
|
117
132
|
localArticles: Array<LocalArticle>
|
|
118
|
-
}):
|
|
133
|
+
}): Array<string> => {
|
|
119
134
|
const conflicts: Array<string> = []
|
|
120
135
|
const localById = getLocalById(localArticles)
|
|
121
136
|
|
|
@@ -147,9 +162,24 @@ const assertNoUpstreamChanges = ({
|
|
|
147
162
|
}
|
|
148
163
|
}
|
|
149
164
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
165
|
+
return conflicts
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const formatUpstreamConflicts = (conflicts: Array<string>): string =>
|
|
169
|
+
`remote has newer changes:\n${conflicts.map((conflict) => `- ${conflict}`).join("\n")}`
|
|
170
|
+
|
|
171
|
+
const isConfirmed = (value: string): boolean => ["y", "yes"].includes(value.trim().toLowerCase())
|
|
172
|
+
|
|
173
|
+
const confirmUpstreamOverride = async ({ dryRun, prompt }: SyncOptions, conflicts: Array<string>): Promise<boolean> => {
|
|
174
|
+
if (!conflicts.length) return false
|
|
175
|
+
|
|
176
|
+
const question = dryRun
|
|
177
|
+
? `${formatUpstreamConflicts(conflicts)}\nContinue dry run using local folder as source of truth? [y/N] `
|
|
178
|
+
: `${formatUpstreamConflicts(conflicts)}\nOverride remote changes with local folder? [y/N] `
|
|
179
|
+
|
|
180
|
+
if (isConfirmed(await prompt(question))) return true
|
|
181
|
+
|
|
182
|
+
throw new Error(`sync aborted because upstream is newer`)
|
|
153
183
|
}
|
|
154
184
|
|
|
155
185
|
const getUpdateBody = (article: LocalArticle): Record<string, unknown> => ({
|
|
@@ -176,18 +206,32 @@ const syncAttachments = async ({
|
|
|
176
206
|
api,
|
|
177
207
|
article,
|
|
178
208
|
dryRun,
|
|
209
|
+
overwriteUpstream,
|
|
210
|
+
remoteArticle,
|
|
179
211
|
state,
|
|
180
212
|
summary,
|
|
181
213
|
}: {
|
|
182
214
|
api: EpoqueApi
|
|
183
215
|
article: LocalArticle
|
|
184
216
|
dryRun: boolean
|
|
217
|
+
overwriteUpstream: boolean
|
|
218
|
+
remoteArticle: RemoteArticle | undefined
|
|
185
219
|
state: ArticleSyncState
|
|
186
220
|
summary: SyncSummary
|
|
187
221
|
}): Promise<ArticleSyncState> => {
|
|
188
222
|
const nextState: ArticleSyncState = { ...state, attachments: {} }
|
|
189
223
|
const localPaths = new Set(article.attachments.map((attachment) => attachment.relativePath))
|
|
190
224
|
const imageIds: Array<string> = []
|
|
225
|
+
const trackedImageIds = new Set(Object.values(state.attachments).map((attachment) => attachment.id))
|
|
226
|
+
|
|
227
|
+
if (overwriteUpstream && remoteArticle) {
|
|
228
|
+
for (const image of remoteArticle.images) {
|
|
229
|
+
if (trackedImageIds.has(image.id)) continue
|
|
230
|
+
|
|
231
|
+
summary.deletedAttachments += 1
|
|
232
|
+
if (!dryRun) await api.deleteAttachment(image.url)
|
|
233
|
+
}
|
|
234
|
+
}
|
|
191
235
|
|
|
192
236
|
for (const attachment of article.attachments) {
|
|
193
237
|
const existing = state.attachments[attachment.relativePath]
|
|
@@ -236,14 +280,20 @@ export const syncFolder = async (options: SyncOptions): Promise<SyncSummary> =>
|
|
|
236
280
|
const remoteById = getRemoteById(archive)
|
|
237
281
|
const localById = getLocalById(localArticles)
|
|
238
282
|
const summary: SyncSummary = { ...EMPTY_SUMMARY }
|
|
283
|
+
const overwriteUpstream = await confirmUpstreamOverride(options, getUpstreamConflicts({ archive, config, localArticles }))
|
|
284
|
+
|
|
285
|
+
for (const remoteArticle of archive.articles) {
|
|
286
|
+
if (localById.has(remoteArticle.id)) continue
|
|
287
|
+
if (!overwriteUpstream && !config.articles[remoteArticle.id]) continue
|
|
239
288
|
|
|
240
|
-
|
|
289
|
+
summary.deletedArticles += 1
|
|
290
|
+
if (!options.dryRun) await options.api.deleteArticle(remoteArticle.id)
|
|
291
|
+
delete config.articles[remoteArticle.id]
|
|
292
|
+
}
|
|
241
293
|
|
|
242
294
|
for (const articleId of Object.keys(config.articles)) {
|
|
243
|
-
if (localById.has(articleId) ||
|
|
295
|
+
if (localById.has(articleId) || remoteById.has(articleId)) continue
|
|
244
296
|
|
|
245
|
-
summary.deletedArticles += 1
|
|
246
|
-
if (!options.dryRun) await options.api.deleteArticle(articleId)
|
|
247
297
|
delete config.articles[articleId]
|
|
248
298
|
}
|
|
249
299
|
|
|
@@ -281,6 +331,8 @@ export const syncFolder = async (options: SyncOptions): Promise<SyncSummary> =>
|
|
|
281
331
|
api: options.api,
|
|
282
332
|
article: localArticle,
|
|
283
333
|
dryRun: options.dryRun,
|
|
334
|
+
overwriteUpstream,
|
|
335
|
+
remoteArticle,
|
|
284
336
|
state: { ...state, file: localArticle.file, remoteUpdatedAt: remoteArticle?.updatedAt ?? state.remoteUpdatedAt },
|
|
285
337
|
summary,
|
|
286
338
|
})
|