@comapeo/core 1.0.0

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 (186) hide show
  1. package/LICENSE.md +9 -0
  2. package/README.md +31 -0
  3. package/dist/blob-api.d.ts +92 -0
  4. package/dist/blob-api.d.ts.map +1 -0
  5. package/dist/blob-store/index.d.ts +163 -0
  6. package/dist/blob-store/index.d.ts.map +1 -0
  7. package/dist/blob-store/live-download.d.ts +107 -0
  8. package/dist/blob-store/live-download.d.ts.map +1 -0
  9. package/dist/config-import.d.ts +74 -0
  10. package/dist/config-import.d.ts.map +1 -0
  11. package/dist/constants.d.ts +14 -0
  12. package/dist/constants.d.ts.map +1 -0
  13. package/dist/core-manager/bitfield-rle.d.ts +25 -0
  14. package/dist/core-manager/bitfield-rle.d.ts.map +1 -0
  15. package/dist/core-manager/core-index.d.ts +56 -0
  16. package/dist/core-manager/core-index.d.ts.map +1 -0
  17. package/dist/core-manager/index.d.ts +125 -0
  18. package/dist/core-manager/index.d.ts.map +1 -0
  19. package/dist/core-manager/random-access-file-pool.d.ts +17 -0
  20. package/dist/core-manager/random-access-file-pool.d.ts.map +1 -0
  21. package/dist/core-manager/remote-bitfield.d.ts +146 -0
  22. package/dist/core-manager/remote-bitfield.d.ts.map +1 -0
  23. package/dist/core-ownership.d.ts +112 -0
  24. package/dist/core-ownership.d.ts.map +1 -0
  25. package/dist/datastore/index.d.ts +91 -0
  26. package/dist/datastore/index.d.ts.map +1 -0
  27. package/dist/datatype/index.d.ts +108 -0
  28. package/dist/discovery/local-discovery.d.ts +64 -0
  29. package/dist/discovery/local-discovery.d.ts.map +1 -0
  30. package/dist/errors.d.ts +4 -0
  31. package/dist/errors.d.ts.map +1 -0
  32. package/dist/fastify-controller.d.ts +27 -0
  33. package/dist/fastify-controller.d.ts.map +1 -0
  34. package/dist/fastify-plugins/blobs.d.ts +6 -0
  35. package/dist/fastify-plugins/blobs.d.ts.map +1 -0
  36. package/dist/fastify-plugins/constants.d.ts +3 -0
  37. package/dist/fastify-plugins/constants.d.ts.map +1 -0
  38. package/dist/fastify-plugins/icons.d.ts +6 -0
  39. package/dist/fastify-plugins/icons.d.ts.map +1 -0
  40. package/dist/fastify-plugins/maps/index.d.ts +11 -0
  41. package/dist/fastify-plugins/maps/index.d.ts.map +1 -0
  42. package/dist/fastify-plugins/maps/offline-fallback-map.d.ts +12 -0
  43. package/dist/fastify-plugins/maps/offline-fallback-map.d.ts.map +1 -0
  44. package/dist/fastify-plugins/maps/static-maps.d.ts +11 -0
  45. package/dist/fastify-plugins/maps/static-maps.d.ts.map +1 -0
  46. package/dist/fastify-plugins/utils.d.ts +23 -0
  47. package/dist/fastify-plugins/utils.d.ts.map +1 -0
  48. package/dist/generated/extensions.d.ts +44 -0
  49. package/dist/generated/extensions.d.ts.map +1 -0
  50. package/dist/generated/keys.d.ts +36 -0
  51. package/dist/generated/keys.d.ts.map +1 -0
  52. package/dist/generated/rpc.d.ts +87 -0
  53. package/dist/generated/rpc.d.ts.map +1 -0
  54. package/dist/icon-api.d.ts +109 -0
  55. package/dist/icon-api.d.ts.map +1 -0
  56. package/dist/index-writer/index.d.ts +51 -0
  57. package/dist/index-writer/index.d.ts.map +1 -0
  58. package/dist/index.d.ts +14 -0
  59. package/dist/index.d.ts.map +1 -0
  60. package/dist/invite-api.d.ts +70 -0
  61. package/dist/invite-api.d.ts.map +1 -0
  62. package/dist/lib/hashmap.d.ts +62 -0
  63. package/dist/lib/hashmap.d.ts.map +1 -0
  64. package/dist/lib/hypercore-helpers.d.ts +6 -0
  65. package/dist/lib/hypercore-helpers.d.ts.map +1 -0
  66. package/dist/lib/noise-secret-stream-helpers.d.ts +45 -0
  67. package/dist/lib/noise-secret-stream-helpers.d.ts.map +1 -0
  68. package/dist/lib/ponyfills.d.ts +10 -0
  69. package/dist/lib/ponyfills.d.ts.map +1 -0
  70. package/dist/lib/string.d.ts +2 -0
  71. package/dist/lib/string.d.ts.map +1 -0
  72. package/dist/lib/timing-safe-equal.d.ts +15 -0
  73. package/dist/lib/timing-safe-equal.d.ts.map +1 -0
  74. package/dist/local-peers.d.ts +151 -0
  75. package/dist/local-peers.d.ts.map +1 -0
  76. package/dist/logger.d.ts +32 -0
  77. package/dist/logger.d.ts.map +1 -0
  78. package/dist/mapeo-manager.d.ts +178 -0
  79. package/dist/mapeo-manager.d.ts.map +1 -0
  80. package/dist/mapeo-project.d.ts +3233 -0
  81. package/dist/mapeo-project.d.ts.map +1 -0
  82. package/dist/member-api.d.ts +114 -0
  83. package/dist/member-api.d.ts.map +1 -0
  84. package/dist/roles.d.ts +157 -0
  85. package/dist/roles.d.ts.map +1 -0
  86. package/dist/schema/client.d.ts +284 -0
  87. package/dist/schema/client.d.ts.map +1 -0
  88. package/dist/schema/project.d.ts +1812 -0
  89. package/dist/schema/project.d.ts.map +1 -0
  90. package/dist/schema/schema-to-drizzle.d.ts +20 -0
  91. package/dist/schema/schema-to-drizzle.d.ts.map +1 -0
  92. package/dist/schema/types.d.ts +98 -0
  93. package/dist/schema/types.d.ts.map +1 -0
  94. package/dist/schema/utils.d.ts +55 -0
  95. package/dist/schema/utils.d.ts.map +1 -0
  96. package/dist/sync/core-sync-state.d.ts +252 -0
  97. package/dist/sync/core-sync-state.d.ts.map +1 -0
  98. package/dist/sync/namespace-sync-state.d.ts +47 -0
  99. package/dist/sync/namespace-sync-state.d.ts.map +1 -0
  100. package/dist/sync/peer-sync-controller.d.ts +44 -0
  101. package/dist/sync/peer-sync-controller.d.ts.map +1 -0
  102. package/dist/sync/sync-api.d.ts +158 -0
  103. package/dist/sync/sync-api.d.ts.map +1 -0
  104. package/dist/sync/sync-state.d.ts +40 -0
  105. package/dist/sync/sync-state.d.ts.map +1 -0
  106. package/dist/translation-api.d.ts +288 -0
  107. package/dist/translation-api.d.ts.map +1 -0
  108. package/dist/types.d.ts +115 -0
  109. package/dist/types.d.ts.map +1 -0
  110. package/dist/utils.d.ts +115 -0
  111. package/dist/utils.d.ts.map +1 -0
  112. package/dist/utils_types.d.ts +14 -0
  113. package/drizzle/client/0000_bumpy_carnage.sql +33 -0
  114. package/drizzle/client/meta/0000_snapshot.json +199 -0
  115. package/drizzle/client/meta/_journal.json +13 -0
  116. package/drizzle/project/0000_spooky_lady_ursula.sql +192 -0
  117. package/drizzle/project/meta/0000_snapshot.json +1137 -0
  118. package/drizzle/project/meta/_journal.json +13 -0
  119. package/package.json +202 -0
  120. package/src/blob-api.js +139 -0
  121. package/src/blob-store/index.js +325 -0
  122. package/src/blob-store/live-download.js +373 -0
  123. package/src/config-import.js +604 -0
  124. package/src/constants.js +34 -0
  125. package/src/core-manager/bitfield-rle.js +235 -0
  126. package/src/core-manager/core-index.js +87 -0
  127. package/src/core-manager/index.js +504 -0
  128. package/src/core-manager/random-access-file-pool.js +30 -0
  129. package/src/core-manager/remote-bitfield.js +416 -0
  130. package/src/core-ownership.js +235 -0
  131. package/src/datastore/README.md +46 -0
  132. package/src/datastore/index.js +234 -0
  133. package/src/datatype/README.md +33 -0
  134. package/src/datatype/index.d.ts +108 -0
  135. package/src/datatype/index.js +358 -0
  136. package/src/discovery/local-discovery.js +303 -0
  137. package/src/errors.js +5 -0
  138. package/src/fastify-controller.js +84 -0
  139. package/src/fastify-plugins/blobs.js +139 -0
  140. package/src/fastify-plugins/constants.js +5 -0
  141. package/src/fastify-plugins/icons.js +158 -0
  142. package/src/fastify-plugins/maps/index.js +173 -0
  143. package/src/fastify-plugins/maps/offline-fallback-map.js +114 -0
  144. package/src/fastify-plugins/maps/static-maps.js +271 -0
  145. package/src/fastify-plugins/utils.js +52 -0
  146. package/src/generated/README.md +3 -0
  147. package/src/generated/extensions.d.ts +44 -0
  148. package/src/generated/extensions.js +196 -0
  149. package/src/generated/extensions.ts +237 -0
  150. package/src/generated/keys.d.ts +36 -0
  151. package/src/generated/keys.js +148 -0
  152. package/src/generated/keys.ts +185 -0
  153. package/src/generated/rpc.d.ts +87 -0
  154. package/src/generated/rpc.js +389 -0
  155. package/src/generated/rpc.ts +463 -0
  156. package/src/icon-api.js +282 -0
  157. package/src/index-writer/README.md +38 -0
  158. package/src/index-writer/index.js +124 -0
  159. package/src/index.js +16 -0
  160. package/src/invite-api.js +450 -0
  161. package/src/lib/hashmap.js +91 -0
  162. package/src/lib/hypercore-helpers.js +18 -0
  163. package/src/lib/noise-secret-stream-helpers.js +37 -0
  164. package/src/lib/ponyfills.js +25 -0
  165. package/src/lib/string.js +7 -0
  166. package/src/lib/timing-safe-equal.js +34 -0
  167. package/src/local-peers.js +737 -0
  168. package/src/logger.js +99 -0
  169. package/src/mapeo-manager.js +914 -0
  170. package/src/mapeo-project.js +980 -0
  171. package/src/member-api.js +319 -0
  172. package/src/roles.js +412 -0
  173. package/src/schema/client.js +55 -0
  174. package/src/schema/project.js +44 -0
  175. package/src/schema/schema-to-drizzle.js +118 -0
  176. package/src/schema/types.ts +153 -0
  177. package/src/schema/utils.js +51 -0
  178. package/src/sync/core-sync-state.js +440 -0
  179. package/src/sync/namespace-sync-state.js +193 -0
  180. package/src/sync/peer-sync-controller.js +332 -0
  181. package/src/sync/sync-api.js +588 -0
  182. package/src/sync/sync-state.js +63 -0
  183. package/src/translation-api.js +141 -0
  184. package/src/types.ts +149 -0
  185. package/src/utils.js +210 -0
  186. package/src/utils_types.d.ts +14 -0
@@ -0,0 +1,604 @@
1
+ import yauzl from 'yauzl-promise'
2
+ import { validate, valueSchemas } from '@comapeo/schema'
3
+ import { json, buffer } from 'node:stream/consumers'
4
+ import { assert, isDefined } from './utils.js'
5
+ import path from 'node:path'
6
+ import { parse as parseBCP47 } from 'bcp-47'
7
+ import { SUPPORTED_CONFIG_VERSION } from './constants.js'
8
+
9
+ // Throw error if a zipfile contains more than 10,000 entries
10
+ const MAX_ENTRIES = 10_000
11
+ const MAX_ICON_SIZE = 10_000_000
12
+ const ICON_NAME_REGEX = /([a-zA-Z0-9-]+)-([a-zA-Z]+)@(\d+)x\.[a-zA-Z]+$/
13
+
14
+ /**
15
+ * @typedef {yauzl.Entry} Entry
16
+ */
17
+ /**
18
+ * @typedef {{
19
+ * presets: { [id: string]: unknown }
20
+ * fields: { [id: string]: unknown }
21
+ * }} PresetsFile
22
+ */
23
+
24
+ /** @typedef {('presets' | 'fields')} ValidDocTypes */
25
+ /**
26
+ * @typedef {{
27
+ * [lang: string]: unknown
28
+ * }} TranslationsFile
29
+ */
30
+
31
+ /** @typedef {NonNullable<import('@comapeo/schema').ProjectSettingsValue['configMetadata']>} MetadataFile */
32
+
33
+ /**
34
+ * @typedef {Parameters<import('./icon-api.js').IconApi['create']>[0]} IconData
35
+ */
36
+
37
+ /**
38
+ * @param {string} configPath
39
+ */
40
+ export async function readConfig(configPath) {
41
+ /** @type {Error[]} */
42
+ const warnings = []
43
+ const importDate = new Date().toISOString()
44
+
45
+ const zip = await yauzl.open(configPath)
46
+ if (zip.entryCount > MAX_ENTRIES) {
47
+ // MAX_ENTRIES in MAC can be inacurrate
48
+ throw new Error(`Zip file contains too many entries. Max is ${MAX_ENTRIES}`)
49
+ }
50
+ const entries = await zip.readEntries(MAX_ENTRIES)
51
+ const presetsFile = await findPresetsFile(entries)
52
+ const translationsFile = await findTranslationsFile(entries)
53
+ const metadataFile = await findMetadataFile(entries)
54
+ const iconEntries = getIconEntries(entries)
55
+ assert(
56
+ isValidConfigFile(metadataFile),
57
+ `invalid or missing config file version ${metadataFile.fileVersion}. We support version ${SUPPORTED_CONFIG_VERSION}}`
58
+ )
59
+
60
+ return {
61
+ get warnings() {
62
+ return warnings
63
+ },
64
+
65
+ get metadata() {
66
+ return { ...metadataFile, importDate }
67
+ },
68
+
69
+ async close() {
70
+ zip.close()
71
+ },
72
+
73
+ /**
74
+ * @returns {AsyncIterable<IconData>}
75
+ */
76
+ async *icons() {
77
+ /** @type {IconData | undefined} */
78
+ let icon
79
+
80
+ for (const entry of iconEntries) {
81
+ if (entry.uncompressedSize > MAX_ICON_SIZE) {
82
+ warnings.push(
83
+ new Error(
84
+ `icon ${entry.filename} is bigger than maximum allowed size (10MB) `
85
+ )
86
+ )
87
+ continue
88
+ }
89
+ const buf = await buffer(await entry.openReadStream())
90
+ const iconFilename = entry.filename.replace(/^icons\//, '')
91
+ try {
92
+ const { name, variant } = parseIcon(iconFilename, buf)
93
+ // new icon (first pass)
94
+ if (!icon) {
95
+ icon = {
96
+ name,
97
+ variants: [variant],
98
+ }
99
+ // icon already exists, push new variant
100
+ } else if (icon.name === name) {
101
+ icon.variants.push(variant)
102
+ } else {
103
+ // icon has change
104
+ yield icon
105
+ icon = {
106
+ name,
107
+ variants: [variant],
108
+ }
109
+ }
110
+ } catch (err) {
111
+ warnings.push(
112
+ err instanceof Error
113
+ ? err
114
+ : new Error('Unknown error importing icon')
115
+ )
116
+ }
117
+ }
118
+ if (icon) {
119
+ yield icon
120
+ }
121
+ },
122
+
123
+ /**
124
+ * @returns {Iterable<{ name: string, value: import('@comapeo/schema').FieldValue }>}
125
+ */
126
+ *fields() {
127
+ const { fields } = presetsFile
128
+ for (const [name, field] of Object.entries(fields)) {
129
+ if (!isRecord(field)) {
130
+ warnings.push(new Error(`Invalid field ${name}`))
131
+ continue
132
+ }
133
+ /** @type {Record<string, unknown>} */
134
+ const fieldValue = {
135
+ schemaName: 'field',
136
+ }
137
+ for (const key of Object.keys(valueSchemas.field.properties)) {
138
+ if (Object.hasOwn(field, key)) {
139
+ fieldValue[key] = field[key]
140
+ }
141
+ }
142
+ if (!validate('field', fieldValue)) {
143
+ warnings.push(new Error(`Invalid field ${name}`))
144
+ continue
145
+ }
146
+ yield {
147
+ name,
148
+ value: fieldValue,
149
+ }
150
+ }
151
+ },
152
+
153
+ /**
154
+ * @returns {Iterable<{ fieldNames: string[], iconName: string | undefined, value: import('@comapeo/schema').PresetValue, name: string}>}
155
+ */
156
+ *presets() {
157
+ const { presets } = presetsFile
158
+ // sort presets using the sort field, turn them into an array
159
+ /** @type {Array<{preset:Record<string, unknown>, name: String}>} */
160
+ const sortedPresets = []
161
+ for (const [presetName, preset] of Object.entries(presets)) {
162
+ if (isRecord(preset)) {
163
+ sortedPresets.push({ name: presetName, preset })
164
+ } else {
165
+ warnings.push(new Error(`invalid preset ${presetName}`))
166
+ }
167
+ }
168
+ sortedPresets.sort(({ preset }, { preset: nextPreset }) => {
169
+ const sort = typeof preset.sort === 'number' ? preset.sort : Infinity
170
+ const nextSort =
171
+ typeof nextPreset.sort === 'number' ? nextPreset.sort : Infinity
172
+ return sort - nextSort
173
+ })
174
+
175
+ const iconFilenames = new Set(
176
+ iconEntries
177
+ .map((icon) => {
178
+ const matches = path.basename(icon.filename).match(ICON_NAME_REGEX)
179
+ if (matches) {
180
+ const [_, name] = matches
181
+ return name
182
+ }
183
+ })
184
+ .filter(isDefined)
185
+ )
186
+
187
+ // 5. for each preset get the corresponding fieldId and iconId, add them to the db
188
+ for (const { preset, name } of sortedPresets) {
189
+ /** @type {Record<string, unknown>} */
190
+ const presetValue = {
191
+ schemaName: 'preset',
192
+ fieldRefs: [],
193
+ iconRef: { docId: 'placeholder', versionId: 'placeholder' },
194
+ addTags: {},
195
+ removeTags: {},
196
+ terms: [],
197
+ }
198
+ for (const key of Object.keys(valueSchemas.preset.properties)) {
199
+ if (Object.hasOwn(preset, key)) {
200
+ presetValue[key] = preset[key]
201
+ }
202
+ }
203
+
204
+ if (!('icon' in preset) || typeof preset.icon !== 'string') {
205
+ warnings.push(new Error(`Preset ${preset.name} doesn't have an icon`))
206
+ return
207
+ }
208
+ if (!iconFilenames.has(preset.icon)) {
209
+ warnings.push(
210
+ new Error(
211
+ `preset references icon with name ${preset.icon} but file doesn't exist`
212
+ )
213
+ )
214
+ return
215
+ }
216
+
217
+ if (!validate('preset', presetValue)) {
218
+ warnings.push(new Error(`Invalid preset ${preset.name}`))
219
+ continue
220
+ }
221
+ yield {
222
+ fieldNames:
223
+ 'fields' in preset && Array.isArray(preset.fields)
224
+ ? preset.fields
225
+ : [],
226
+ iconName:
227
+ 'icon' in preset && typeof preset.icon === 'string'
228
+ ? preset.icon
229
+ : undefined,
230
+ value: presetValue,
231
+ name,
232
+ }
233
+ }
234
+ },
235
+ /** @returns {Iterable<{ name: string, value:Omit<import('@comapeo/schema').TranslationValue, 'docRef'>}>} */
236
+ *translations() {
237
+ if (!translationsFile) return
238
+ for (const [lang, languageTranslations] of Object.entries(
239
+ translationsFile
240
+ )) {
241
+ if (!isRecord(languageTranslations)) {
242
+ throw new Error('invalid language translations object')
243
+ }
244
+ for (const { name, value } of translationsForLanguage(warnings)(
245
+ lang,
246
+ languageTranslations
247
+ )) {
248
+ yield { name, value }
249
+ }
250
+ }
251
+ },
252
+ }
253
+ }
254
+
255
+ /**
256
+ * @param {ReadonlyArray<Entry>} entries
257
+ * @rejects if the presets file cannot be found or is invalid
258
+ * @returns {Promise<PresetsFile>}
259
+ */
260
+ async function findPresetsFile(entries) {
261
+ const presetsEntry = entries.find(
262
+ (entry) => entry.filename === 'presets.json'
263
+ )
264
+ assert(presetsEntry, 'Zip file does not contain presets.json')
265
+
266
+ /** @type {unknown} */
267
+ let result
268
+ try {
269
+ result = await json(await presetsEntry.openReadStream())
270
+ } catch (err) {
271
+ throw new Error('Could not parse presets.json')
272
+ }
273
+
274
+ assert(isRecord(result), 'Invalid presets.json file')
275
+ const { presets, fields } = result
276
+ assert(isRecord(presets) && isRecord(fields), 'Invalid presets.json file')
277
+
278
+ return { presets, fields }
279
+ }
280
+
281
+ /**
282
+ * @param {ReadonlyArray<Entry>} entries
283
+ * @returns {Promise<TranslationsFile | undefined>}
284
+ */
285
+ async function findTranslationsFile(entries) {
286
+ const translationEntry = entries.find(
287
+ (entry) => entry.filename === 'translations.json'
288
+ )
289
+ if (!translationEntry) return
290
+
291
+ /** @type {unknown} */
292
+ let result
293
+ try {
294
+ result = await json(await translationEntry.openReadStream())
295
+ } catch (err) {
296
+ throw new Error('Could not parse translations.json')
297
+ }
298
+ assert(isRecord(result), 'Invalid translations.json file')
299
+ return result
300
+ }
301
+
302
+ /**
303
+ * @param {ReadonlyArray<Entry>} entries
304
+ * @returns {Promise<Omit<MetadataFile, 'importDate'>>}
305
+ */
306
+ async function findMetadataFile(entries) {
307
+ const metadataEntry = entries.find(
308
+ (entry) => entry.filename === 'metadata.json'
309
+ )
310
+ assert(metadataEntry, 'Zip file does not contain metadata.json')
311
+ let result
312
+ try {
313
+ result = await json(await metadataEntry.openReadStream())
314
+ } catch (err) {
315
+ throw new Error('Could not parse metadata.json')
316
+ }
317
+ assert(isRecord(result), 'Invalid metadata.json file')
318
+ assert(isValidMetadataFile(result), 'Invalid structure of metadata file')
319
+
320
+ return result
321
+ }
322
+
323
+ /**
324
+ * @param {Error[]} warnings
325
+ */
326
+ function translationsForLanguage(warnings) {
327
+ /**
328
+ * @param {string} lang
329
+ * @param {Record<string, unknown>} languageTranslations
330
+ *
331
+ */
332
+ return function* (lang, languageTranslations) {
333
+ const parsed = parseBCP47(lang)
334
+ const { language: languageCode } = parsed
335
+ if (!languageCode) {
336
+ warnings.push(new Error(`invalid translation language ${lang}`))
337
+ return
338
+ }
339
+ let { region: regionCode } = parsed
340
+ regionCode ||= undefined
341
+ for (const [
342
+ schemaNamePlural,
343
+ languageTranslationsForDocType,
344
+ ] of Object.entries(languageTranslations)) {
345
+ // TODO: remove categories check when removed from default config
346
+ if (!(schemaNamePlural === 'fields' || schemaNamePlural === 'presets')) {
347
+ if (schemaNamePlural !== 'categories') {
348
+ warnings.push(new Error(`invalid docRef.type ${schemaNamePlural}`))
349
+ }
350
+ continue
351
+ }
352
+ if (!isRecord(languageTranslationsForDocType)) {
353
+ warnings.push(new Error('invalid translation for docType object'))
354
+ continue
355
+ }
356
+ yield* translationsForDocType(warnings)({
357
+ languageCode,
358
+ regionCode,
359
+ docRefType: schemaNamePluralToDocRefType(schemaNamePlural),
360
+ languageTranslationsForDocType,
361
+ })
362
+ }
363
+ }
364
+ }
365
+ /**
366
+ * schemaNames in configs are in plural but in the schemas are in singular
367
+ * @param {ValidDocTypes} schemaNamePlural
368
+ * @returns {import('@comapeo/schema').TranslationValue['docRefType']}
369
+ */
370
+ function schemaNamePluralToDocRefType(schemaNamePlural) {
371
+ if (schemaNamePlural === 'fields') return 'field'
372
+ if (schemaNamePlural === 'presets') return 'preset'
373
+ throw new Error(
374
+ `invalid schemaNamePlural ${schemaNamePlural} for config import`
375
+ )
376
+ }
377
+
378
+ /**
379
+ * @param {Error[]} warnings
380
+ */
381
+ function translationsForDocType(warnings) {
382
+ /** @param {Object} opts
383
+ * @param {string} opts.languageCode
384
+ * @param {string | undefined} opts.regionCode
385
+ * @param {import('@comapeo/schema').TranslationValue['docRefType']} opts.docRefType
386
+ * @param {Record<ValidDocTypes, unknown>} opts.languageTranslationsForDocType
387
+ */
388
+ return function* ({
389
+ languageCode,
390
+ regionCode,
391
+ docRefType,
392
+ languageTranslationsForDocType,
393
+ }) {
394
+ for (const [docName, fieldsToTranslate] of Object.entries(
395
+ languageTranslationsForDocType
396
+ )) {
397
+ if (!isRecord(fieldsToTranslate)) {
398
+ warnings.push(new Error(`invalid translation field`))
399
+ return
400
+ }
401
+ yield* translationForValue(warnings)({
402
+ languageCode,
403
+ regionCode,
404
+ docRefType,
405
+ docName,
406
+ fieldsToTranslate,
407
+ })
408
+ }
409
+ }
410
+ }
411
+
412
+ /**
413
+ * @param {Error[]} warnings
414
+ */
415
+ function translationForValue(warnings) {
416
+ /**
417
+ * @param {Object} opts
418
+ * @param {string} opts.languageCode
419
+ * @param {string | undefined} opts.regionCode
420
+ * @param {import('@comapeo/schema').TranslationValue['docRefType']} opts.docRefType
421
+ * @param {string} opts.docName
422
+ * @param {Record<string,unknown>} opts.fieldsToTranslate
423
+ */
424
+ return function* ({
425
+ languageCode,
426
+ regionCode,
427
+ docRefType,
428
+ docName,
429
+ fieldsToTranslate,
430
+ }) {
431
+ for (const [propertyRef, message] of Object.entries(fieldsToTranslate)) {
432
+ let value = {
433
+ /** @type {'translation'} */
434
+ schemaName: 'translation',
435
+ languageCode,
436
+ regionCode,
437
+ docRefType,
438
+ propertyRef: '',
439
+ message: '',
440
+ }
441
+
442
+ if (isRecord(message) && isFieldOptions(message)) {
443
+ yield* translateMessageObject(warnings)({ value, message, docName })
444
+ } else if (typeof message === 'string') {
445
+ value = { ...value, propertyRef, message }
446
+ yield { name: docName, value }
447
+ } else {
448
+ warnings.push(
449
+ new Error(`Invalid translation message type ${typeof message}`)
450
+ )
451
+ continue
452
+ }
453
+ }
454
+ }
455
+ }
456
+
457
+ /**
458
+ * @param {Error[]} warnings
459
+ */
460
+ function translateMessageObject(warnings) {
461
+ /**
462
+ * @param {Object} opts
463
+ * @param {Omit<import('@comapeo/schema').TranslationValue, 'docRef'>} opts.value
464
+ * @param {string} opts.docName
465
+ * @param {Record<string,{label:string,value:string}>} opts.message
466
+ */
467
+ return function* ({ value, message, docName }) {
468
+ let idx = 0
469
+ for (const translation of Object.values(message)) {
470
+ if (isRecord(translation)) {
471
+ for (const [key, msg] of Object.entries(translation)) {
472
+ if (typeof msg === 'string') {
473
+ value = {
474
+ ...value,
475
+ propertyRef: `${value.propertyRef}[${idx}].${key}`,
476
+ message: msg,
477
+ }
478
+ if (
479
+ !validate('translation', {
480
+ ...value,
481
+ docRef: { docId: 'placeholder', versionId: 'placeholder' },
482
+ })
483
+ ) {
484
+ warnings.push(new Error(`Invalid translation ${value.message}`))
485
+ continue
486
+ }
487
+ yield {
488
+ value,
489
+ name: docName,
490
+ }
491
+ }
492
+ }
493
+ idx++
494
+ }
495
+ }
496
+ }
497
+ }
498
+
499
+ /**
500
+ * @param {ReadonlyArray<Entry>} entries
501
+ */
502
+ function getIconEntries(entries) {
503
+ return entries
504
+ .filter((entry) => entry.filename.match(/^icons\/([^/]+)$/))
505
+ .sort((icon, nextIcon) => icon.filename.localeCompare(nextIcon.filename))
506
+ }
507
+
508
+ /**
509
+ * @param {string} filename
510
+ * @param {Buffer} buf
511
+ * @returns {{ name: string, variant: IconData['variants'][Number] }}}
512
+ */
513
+ function parseIcon(filename, buf) {
514
+ const parsedFilename = path.parse(filename)
515
+ const matches = parsedFilename.base.match(ICON_NAME_REGEX)
516
+ if (!matches) {
517
+ throw new Error(`Unexpected icon filename ${filename}`)
518
+ }
519
+ /* eslint-disable no-unused-vars */
520
+ const [_, name, size, pixelDensityStr] = matches
521
+ const pixelDensity = Number(pixelDensityStr)
522
+ if (!(pixelDensity === 1 || pixelDensity === 2 || pixelDensity === 3)) {
523
+ throw new Error(`Error loading icon. invalid pixel density ${pixelDensity}`)
524
+ }
525
+ if (!(size === 'small' || size === 'medium' || size === 'large')) {
526
+ throw new Error(`Error loading icon. invalid size ${size}`)
527
+ }
528
+ if (!name) {
529
+ throw new Error('Error loading icon. missing name')
530
+ }
531
+ /** @type {'image/png' | 'image/svg+xml'} */
532
+ let mimeType
533
+ switch (parsedFilename.ext.toLowerCase()) {
534
+ case '.png':
535
+ mimeType = 'image/png'
536
+ break
537
+ case '.svg':
538
+ mimeType = 'image/svg+xml'
539
+ break
540
+ default:
541
+ throw new Error(`Unexpected icon extension ${parsedFilename.ext}`)
542
+ }
543
+ return {
544
+ name,
545
+ variant: {
546
+ size,
547
+ mimeType,
548
+ pixelDensity,
549
+ blob: buf,
550
+ },
551
+ }
552
+ }
553
+
554
+ /**
555
+ * @param {Record<string,unknown>} obj
556
+ * @returns {obj is Omit<MetadataFile, 'importDate'>}
557
+ */
558
+ function isValidMetadataFile(obj) {
559
+ // extra fields are valid
560
+ return (
561
+ 'name' in obj &&
562
+ 'buildDate' in obj &&
563
+ 'fileVersion' in obj &&
564
+ typeof obj['name'] === 'string' &&
565
+ typeof obj['buildDate'] === 'string' &&
566
+ typeof obj['fileVersion'] === 'string'
567
+ )
568
+ }
569
+
570
+ /**
571
+ * @param {Record<string, unknown>} message
572
+ * @returns {message is Record<string,{label:string, value:string}>}
573
+ */
574
+ function isFieldOptions(message) {
575
+ return Object.values(message).every(
576
+ (val) => isRecord(val) && 'label' in val && 'value' in val
577
+ )
578
+ }
579
+
580
+ /**
581
+ * @param {unknown} value
582
+ * @returns {value is Record<string, unknown>}
583
+ */
584
+ function isRecord(value) {
585
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
586
+ }
587
+
588
+ /**
589
+ * @param {Object} obj
590
+ * @param {string | undefined} [obj.fileVersion]
591
+ * @returns {boolean}
592
+ */
593
+ function isValidConfigFile({ fileVersion }) {
594
+ if (!fileVersion) return false
595
+ const regex = /^(\d+)\.(\d+)$/
596
+ const match = fileVersion.match(regex)
597
+
598
+ if (!match) return false
599
+
600
+ const major = parseInt(match[1], 10)
601
+ //const minor = parseInt(match[2], 10)
602
+
603
+ return major >= SUPPORTED_CONFIG_VERSION
604
+ }
@@ -0,0 +1,34 @@
1
+ /** @import { Namespace } from './types.js' */
2
+
3
+ // WARNING: Changing these will break things for existing apps, since namespaces
4
+ // are used for key derivation
5
+ export const NAMESPACES = /** @type {const} */ ([
6
+ 'auth',
7
+ 'config',
8
+ 'data',
9
+ 'blobIndex',
10
+ 'blob',
11
+ ])
12
+
13
+ /** @type {ReadonlyArray<Namespace>} */
14
+ export const PRESYNC_NAMESPACES = ['auth', 'config', 'blobIndex']
15
+
16
+ /** @type {ReadonlyArray<Namespace>} */
17
+ export const DATA_NAMESPACES = NAMESPACES.filter(
18
+ (namespace) => !PRESYNC_NAMESPACES.includes(namespace)
19
+ )
20
+
21
+ export const NAMESPACE_SCHEMAS = /** @type {const} */ ({
22
+ data: ['observation', 'track'],
23
+ config: [
24
+ 'translation',
25
+ 'preset',
26
+ 'field',
27
+ 'projectSettings',
28
+ 'deviceInfo',
29
+ 'icon',
30
+ ],
31
+ auth: ['coreOwnership', 'role'],
32
+ })
33
+
34
+ export const SUPPORTED_CONFIG_VERSION = 1