@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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/sync.ts +63 -11
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heliosgraphics/epoque",
3
- "version": "0.0.1-alpha.1",
3
+ "version": "0.0.1-alpha.3",
4
4
  "author": "Chris Puska <chris@puska.org>",
5
5
  "description": "sync a local folder",
6
6
  "type": "module",
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
- if (!left || !right) return false
53
+ const leftTime = parseRemoteTime(left)
54
+ const rightTime = parseRemoteTime(right)
40
55
 
41
- return new Date(left).getTime() > new Date(right).getTime()
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 assertNoUpstreamChanges = ({
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
- }): void => {
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
- if (conflicts.length) {
151
- throw new Error(`sync aborted because upstream is newer:\n${conflicts.map((conflict) => `- ${conflict}`).join("\n")}`)
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
- assertNoUpstreamChanges({ archive, config, localArticles })
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) || !remoteById.has(articleId)) continue
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
  })