@comapeo/core 3.1.2 → 4.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.
@@ -15,6 +15,13 @@
15
15
  "when": 1729783892753,
16
16
  "tag": "0001_medical_wendell_rand",
17
17
  "breakpoints": true
18
+ },
19
+ {
20
+ "idx": 2,
21
+ "version": "5",
22
+ "when": 1749657174497,
23
+ "tag": "0002_cooing_princess_powerful",
24
+ "breakpoints": true
18
25
  }
19
26
  ]
20
27
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@comapeo/core",
3
- "version": "3.1.2",
3
+ "version": "4.0.0",
4
4
  "description": "Offline p2p mapping library",
5
5
  "main": "src/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -112,12 +112,13 @@
112
112
  "@comapeo/core2.0.1": "npm:@comapeo/core@2.0.1",
113
113
  "@comapeo/ipc": "^2.1.0",
114
114
  "@mapeo/default-config": "5.0.0",
115
- "@mapeo/mock-data": "^2.1.5",
115
+ "@mapeo/mock-data": "^5.0.0",
116
116
  "@sinonjs/fake-timers": "^10.0.2",
117
117
  "@types/b4a": "^1.6.0",
118
118
  "@types/bogon": "^1.0.2",
119
119
  "@types/compact-encoding": "^2.15.0",
120
120
  "@types/debug": "^4.1.8",
121
+ "@types/geojson": "^7946.0.16",
121
122
  "@types/json-schema": "^7.0.11",
122
123
  "@types/json-stable-stringify": "^1.0.36",
123
124
  "@types/nanobench": "^3.0.0",
@@ -136,12 +137,14 @@
136
137
  "drizzle-kit": "^0.20.14",
137
138
  "eslint": "^8.57.0",
138
139
  "execa": "^9.5.1",
140
+ "filter-obj": "^6.1.0",
139
141
  "husky": "^8.0.0",
140
142
  "iterpal": "^0.4.0",
141
143
  "lint-staged": "^14.0.1",
142
144
  "mapeo-offline-map": "^2.0.0",
143
145
  "math-random-seed": "^2.0.0",
144
146
  "nanobench": "^3.0.0",
147
+ "node-stream-zip": "^1.15.0",
145
148
  "npm-run-all": "^4.1.5",
146
149
  "prettier": "^2.8.8",
147
150
  "random-access-file": "^4.0.7",
@@ -159,7 +162,7 @@
159
162
  },
160
163
  "dependencies": {
161
164
  "@comapeo/fallback-smp": "^1.0.0",
162
- "@comapeo/schema": "1.5.0",
165
+ "@comapeo/schema": "2.0.0",
163
166
  "@digidem/types": "^2.3.0",
164
167
  "@fastify/error": "^3.4.1",
165
168
  "@fastify/type-provider-typebox": "^4.1.0",
@@ -188,7 +191,7 @@
188
191
  "json-stable-stringify": "^1.1.1",
189
192
  "magic-bytes.js": "^1.10.0",
190
193
  "map-obj": "^5.0.2",
191
- "mime": "^4.0.3",
194
+ "mime": "^4.0.7",
192
195
  "multi-core-indexer": "^1.0.0",
193
196
  "p-defer": "^4.0.0",
194
197
  "p-event": "^6.0.1",
@@ -210,6 +213,7 @@
210
213
  "varint": "^6.0.0",
211
214
  "ws": "^8.18.0",
212
215
  "xstate": "^5.19.2",
213
- "yauzl-promise": "^4.0.0"
216
+ "yauzl-promise": "^4.0.0",
217
+ "zip-stream-promise": "^1.0.2"
214
218
  }
215
219
  }
package/src/blob-api.js CHANGED
@@ -4,35 +4,9 @@ import { Transform, pipelinePromise as pipeline } from 'streamx'
4
4
  import { createHash, randomBytes } from 'node:crypto'
5
5
  /** @import { BlobId, BlobType } from './types.js' */
6
6
 
7
- /**
8
- * Location coordinate data. Based on [Expo's `LocationObjectCoords`][0].
9
- * [0]: https://docs.expo.dev/versions/latest/sdk/location/#locationobjectcoords
10
- *
11
- * @typedef {object} LocationObjectCoords
12
- * @prop {number | null} accuracy
13
- * @prop {number | null} altitude
14
- * @prop {number | null} altitudeAccuracy
15
- * @prop {number | null} heading
16
- * @prop {number} latitude
17
- * @prop {number} longitude
18
- * @prop {number | null} speed
19
- */
20
-
21
- /**
22
- * Location metadata for a blob. Based on [Expo's `LocationObject`][0].
23
- * [0]: https://docs.expo.dev/versions/latest/sdk/location/#locationobject
24
- *
25
- * @typedef {object} LocationObject
26
- * @prop {LocationObjectCoords} coords
27
- * @prop {boolean} [mocked]
28
- * @prop {number} timestamp
29
- */
30
-
31
7
  /**
32
8
  * @typedef {object} Metadata
33
9
  * @prop {string} mimeType
34
- * @prop {number} timestamp
35
- * @prop {LocationObject} [location]
36
10
  */
37
11
 
38
12
  export class BlobApi {
@@ -4,6 +4,11 @@ import { decodeBlockPrefix, decode, parseVersionId } from '@comapeo/schema'
4
4
  import { drizzle } from 'drizzle-orm/better-sqlite3'
5
5
  import { discoveryKey } from 'hypercore-crypto'
6
6
  import { TypedEmitter } from 'tiny-typed-emitter'
7
+ import ZipArchive from 'zip-stream-promise'
8
+ import * as b4a from 'b4a'
9
+ import mime from 'mime/lite'
10
+ // @ts-expect-error
11
+ import { Readable, pipelinePromise } from 'streamx'
7
12
 
8
13
  import { NAMESPACES, NAMESPACE_SCHEMAS } from './constants.js'
9
14
  import { CoreManager } from './core-manager/index.js'
@@ -38,6 +43,7 @@ import {
38
43
  } from './roles.js'
39
44
  import {
40
45
  assert,
46
+ buildBlobId,
41
47
  ExhaustivenessError,
42
48
  getDeviceId,
43
49
  projectKeyToId,
@@ -58,11 +64,16 @@ import { readConfig } from './config-import.js'
58
64
  import TranslationApi from './translation-api.js'
59
65
  import { NotFoundError, nullIfNotFound } from './errors.js'
60
66
  import { WebSocket } from 'ws'
61
- /** @import { ProjectSettingsValue } from '@comapeo/schema' */
62
- /** @import { CoreStorage, BlobFilter, BlobStoreEntriesStream, KeyPair, Namespace, ReplicationStream, GenericBlobFilter } from './types.js' */
63
-
67
+ import { createWriteStream } from 'fs'
68
+ /** @import { ProjectSettingsValue, Observation, Track } from '@comapeo/schema' */
69
+ /** @import { Attachment, CoreStorage, BlobFilter, BlobId, BlobStoreEntriesStream, KeyPair, Namespace, ReplicationStream, GenericBlobFilter, MapeoValueMap, MapeoDocMap } from './types.js' */
64
70
  /** @typedef {Omit<ProjectSettingsValue, 'schemaName'>} EditableProjectSettings */
65
71
  /** @typedef {ProjectSettingsValue['configMetadata']} ConfigMetadata */
72
+ /** @typedef {Map<string,Attachment>} SeenAttachments*/
73
+ /** @typedef {object} BlobRef
74
+ * @prop {string|undefined} mimeType
75
+ * @prop {BlobId} blobId
76
+ */
66
77
 
67
78
  const CORESTORE_STORAGE_FOLDER_NAME = 'corestore'
68
79
  const INDEXER_STORAGE_FOLDER_NAME = 'indexer'
@@ -76,9 +87,13 @@ export const kProjectLeave = Symbol('leave project')
76
87
  export const kClearDataIfLeft = Symbol('clear data if left project')
77
88
  export const kSetIsArchiveDevice = Symbol('set isArchiveDevice')
78
89
  export const kIsArchiveDevice = Symbol('isArchiveDevice (temp - test only)')
90
+ export const kGeoJSONFileName = Symbol('geoJSONFileName')
79
91
 
80
92
  const EMPTY_PROJECT_SETTINGS = Object.freeze({})
81
93
 
94
+ /** @type BlobId['variant'][]*/
95
+ const VARIANT_EXPORT_ORDER = ['original', 'preview', 'thumbnail']
96
+
82
97
  /**
83
98
  * @extends {TypedEmitter<{ close: () => void }>}
84
99
  */
@@ -749,6 +764,450 @@ export class MapeoProject extends TypedEmitter {
749
764
  return this.#iconApi
750
765
  }
751
766
 
767
+ /**
768
+ * @param {Iterable<Observation>} observations
769
+ * @param {Object} options
770
+ * @param {Set<string>} [options.seenObservations=new Set()]
771
+ * @param {SeenAttachments} [options.seenAttachments]
772
+ * @returns {AsyncIterable<Buffer | Uint8Array>}
773
+ */
774
+ async *#exportObservations(
775
+ observations,
776
+ { seenObservations = new Set(), seenAttachments = new Map() }
777
+ ) {
778
+ let first = true
779
+ for (const observation of observations) {
780
+ const { lat, lon, docId } = observation
781
+ if (seenObservations.has(docId)) {
782
+ continue
783
+ }
784
+ seenObservations.add(docId)
785
+ for (const attachment of observation.attachments) {
786
+ const { hash } = attachment
787
+ if (!seenAttachments.has(hash)) {
788
+ seenAttachments.set(hash, attachment)
789
+ }
790
+ }
791
+
792
+ const metadataCoords = observation.metadata?.position?.coords
793
+ const altitude = metadataCoords?.altitude
794
+
795
+ /** @type {[number, number] | [number, number, number] | null} */
796
+ let coordinates = null
797
+
798
+ // Prioritize using the observation's `lat` and `lon` fields
799
+ if (typeof lat === 'number' && typeof lon === 'number') {
800
+ coordinates =
801
+ typeof altitude === 'number' ? [lon, lat, altitude] : [lon, lat]
802
+ } else {
803
+ // Fall back to using the observation metadata's position if possible
804
+ if (
805
+ typeof metadataCoords?.latitude === 'number' &&
806
+ typeof metadataCoords?.longitude === 'number'
807
+ ) {
808
+ coordinates =
809
+ typeof altitude === 'number'
810
+ ? [metadataCoords.longitude, metadataCoords.latitude, altitude]
811
+ : [metadataCoords.longitude, metadataCoords.latitude]
812
+ }
813
+ }
814
+
815
+ /** @type {import('geojson').Feature<import('geojson').Point | null>} */
816
+ const feature = {
817
+ type: 'Feature',
818
+ properties: observation,
819
+ geometry: coordinates
820
+ ? {
821
+ type: 'Point',
822
+ coordinates,
823
+ }
824
+ : null,
825
+ }
826
+ const comma = first ? '' : ','
827
+ first = false
828
+ yield b4a.from(`${comma}\n ` + JSON.stringify(feature))
829
+ }
830
+ }
831
+
832
+ /**
833
+ * @param {Iterable<Track>} tracks
834
+ * @param {Object} options
835
+ * @param {Set<string>} [options.seenObservations=new Set()]
836
+ * @param {SeenAttachments} [options.seenAttachments]
837
+ * @param {string} [options.lang]
838
+ * @returns {AsyncIterable<Buffer | Uint8Array>}
839
+ */
840
+ async *#exportTracks(
841
+ tracks,
842
+ { lang, seenObservations = new Set(), seenAttachments = new Map() } = {}
843
+ ) {
844
+ let first = true
845
+ for (const track of tracks) {
846
+ const { observationRefs } = track
847
+
848
+ const observations = await Promise.all(
849
+ observationRefs.map(({ docId }) =>
850
+ this.#dataTypes.observation.getByDocId(docId, { lang })
851
+ )
852
+ )
853
+
854
+ const coordinates = track.locations.map(
855
+ ({ coords: { longitude, latitude, altitude } }) => [
856
+ longitude,
857
+ latitude,
858
+ altitude,
859
+ ]
860
+ )
861
+ const comma = first ? '' : ','
862
+ first = false
863
+ yield b4a.from(
864
+ `${comma}\n ` +
865
+ JSON.stringify({
866
+ type: 'Feature',
867
+ properties: track,
868
+ geometry: {
869
+ type: 'LineString',
870
+ coordinates,
871
+ },
872
+ }) +
873
+ '\n'
874
+ )
875
+
876
+ let firstObs = true
877
+ for await (const chunk of this.#exportObservations(observations, {
878
+ seenObservations,
879
+ seenAttachments,
880
+ })) {
881
+ if (firstObs) {
882
+ yield b4a.from(',')
883
+ firstObs = false
884
+ }
885
+ yield chunk
886
+ }
887
+ }
888
+ }
889
+
890
+ /**
891
+ * @param {Object} [options={}]
892
+ * @param {boolean} [options.observations=true] Whether observations should be exported
893
+ * @param {boolean} [options.tracks=true] Whether all tracks and their observations should be exported
894
+ * @param {SeenAttachments} [options.seenAttachments]
895
+ * @param {string} [options.lang]
896
+ * @returns {AsyncIterable<Buffer | Uint8Array>}
897
+ */
898
+ async *#exportGeoJSONIterator({
899
+ observations = true,
900
+ tracks = true,
901
+ lang,
902
+ seenAttachments = new Map(),
903
+ } = {}) {
904
+ yield b4a.from(`{
905
+ "type": "FeatureCollection",
906
+ "features": [
907
+ `)
908
+
909
+ const seenObservations = new Set()
910
+
911
+ let hadTracks = false
912
+ if (tracks) {
913
+ const rows = await this.#dataTypes.track.getMany({ lang })
914
+ for await (const chunk of this.#exportTracks(rows, {
915
+ lang,
916
+ seenObservations,
917
+ seenAttachments,
918
+ })) {
919
+ hadTracks = true
920
+ yield chunk
921
+ }
922
+ }
923
+
924
+ if (observations) {
925
+ if (hadTracks) {
926
+ yield b4a.from(',')
927
+ }
928
+ const rows = await this.#dataTypes.observation.getMany({ lang })
929
+ yield* this.#exportObservations(rows, {
930
+ seenObservations,
931
+ seenAttachments,
932
+ })
933
+ }
934
+
935
+ yield b4a.from(`
936
+ ]
937
+ }
938
+ `)
939
+ }
940
+
941
+ /**
942
+ * Export observations and or tracks as a stream of GeoJSON data
943
+ * @param {Object} [options={}]
944
+ * @param {boolean} [options.observations=true] Whether observations should be exported
945
+ * @param {boolean} [options.tracks=true] Whether all tracks and their observations should be exported
946
+ * @param {SeenAttachments} [options.seenAttachments]
947
+ * @param {string} [options.lang]
948
+ * @returns {Readable<Buffer | Uint8Array>}
949
+ */
950
+ #exportGeoJSONStream({
951
+ observations = true,
952
+ tracks = true,
953
+ lang,
954
+ seenAttachments = new Map(),
955
+ } = {}) {
956
+ // Format based on https://doc.arcgis.com/en/arcgis-online/reference/geojson.htm
957
+
958
+ return Readable.from(
959
+ this.#exportGeoJSONIterator({
960
+ observations,
961
+ tracks,
962
+ lang,
963
+ seenAttachments,
964
+ })
965
+ )
966
+ }
967
+
968
+ /**
969
+ *
970
+ * @param {string} type Type of export this will be
971
+ * @returns {Promise<string>}
972
+ */
973
+ async #exportPrefix(type = '') {
974
+ const name = await this.#getProjectName()
975
+ const date = new Date()
976
+ const dateSection = date
977
+ .toLocaleDateString('nu', {
978
+ year: 'numeric',
979
+ month: 'numeric',
980
+ day: 'numeric',
981
+ })
982
+ .replaceAll('/', '_')
983
+ .replaceAll('-', '_')
984
+ return `CoMapeo_${name}_${type}_${dateSection}`
985
+ }
986
+
987
+ /**
988
+ * @param {boolean} observations
989
+ * @param {boolean} tracks
990
+ * @returns {Promise<string>}
991
+ */
992
+ async [kGeoJSONFileName](observations, tracks) {
993
+ let exportType = ''
994
+ if (observations) exportType += 'Obsvns'
995
+ if (tracks) {
996
+ if (observations) exportType += '_'
997
+ exportType += 'Tracks'
998
+ }
999
+ const prefix = await this.#exportPrefix(exportType)
1000
+
1001
+ return prefix + '.geojson'
1002
+ }
1003
+
1004
+ /**
1005
+ * @param {boolean} observations
1006
+ * @param {boolean} tracks
1007
+ * @returns {Promise<string>}
1008
+ */
1009
+ async #zipFileName(observations, tracks) {
1010
+ let exportType = ''
1011
+ if (observations) exportType += 'Obsvns'
1012
+ if (tracks) {
1013
+ if (observations) exportType += '_'
1014
+ exportType += 'Tracks'
1015
+ }
1016
+ const prefix = await this.#exportPrefix(exportType)
1017
+
1018
+ return prefix + '.zip'
1019
+ }
1020
+
1021
+ /**
1022
+ * Export observations and or tracks as a GeoJSON file
1023
+ * @param {string} exportFolder Path to save the file. The file name is auto-generated
1024
+ * @param {Object} [options={}]
1025
+ * @param {boolean} [options.observations=true] Whether observations should be exported
1026
+ * @param {boolean} [options.tracks=true] Whether all tracks and their observations should be exported
1027
+ * @param {string} [options.lang]
1028
+ * @returns {Promise<string>} The full path that the file was exported at
1029
+ */
1030
+ async exportGeoJSONFile(
1031
+ exportFolder,
1032
+ { observations = true, tracks = true, lang } = {}
1033
+ ) {
1034
+ const fileName = await this[kGeoJSONFileName](observations, tracks)
1035
+ const filePath = path.join(exportFolder, fileName)
1036
+ const source = this.#exportGeoJSONStream({ observations, tracks, lang })
1037
+ const sink = createWriteStream(filePath)
1038
+ await pipelinePromise(source, sink)
1039
+
1040
+ return filePath
1041
+ }
1042
+
1043
+ /**
1044
+ * @param {Attachment} attachment
1045
+ * @returns {Promise<null | BlobRef>}
1046
+ */
1047
+ async #tryGetAttachmentBlob(attachment) {
1048
+ // Audio must not have variants
1049
+ for (const variant of VARIANT_EXPORT_ORDER) {
1050
+ try {
1051
+ const blobId = buildBlobId(attachment, variant)
1052
+ const entry = await this.#blobStore.entry(blobId)
1053
+ if (!entry) continue
1054
+ const metadata = entry.value.metadata
1055
+ if (!metadata || typeof metadata !== 'object') continue
1056
+ let mimeType = undefined
1057
+ if ('mimeType' in metadata) {
1058
+ if (typeof metadata.mimeType === 'string') {
1059
+ mimeType = metadata.mimeType
1060
+ } else {
1061
+ this.#l.log('Invalid type for mimeType in blob', blobId, entry)
1062
+ continue
1063
+ }
1064
+ }
1065
+ return { blobId, mimeType }
1066
+ } catch (e) {
1067
+ if (!(e instanceof Error)) throw e
1068
+ this.#l.log(
1069
+ 'Error loading blob id for attachment',
1070
+ attachment,
1071
+ variant,
1072
+ e.message
1073
+ )
1074
+ continue
1075
+ }
1076
+ }
1077
+
1078
+ return null
1079
+ }
1080
+
1081
+ /**
1082
+ * @param {ZipArchive} archive
1083
+ * @param {Object} [options={}]
1084
+ * @param {boolean} [options.observations=true] Whether observations should be exported
1085
+ * @param {boolean} [options.tracks=true] Whether all tracks and their observations should be exported
1086
+ * @param {boolean} [options.attachments=true] Whether all attachments for observations should be exported
1087
+ * @param {string} [options.lang]
1088
+ * @returns {Promise<void>}
1089
+ */
1090
+ async #exportToArchive(
1091
+ archive,
1092
+ { observations = true, tracks = true, attachments = true, lang } = {}
1093
+ ) {
1094
+ // GeoJSON
1095
+ const geoJSONFileName = await this[kGeoJSONFileName](observations, tracks)
1096
+ const seenAttachments = new Map()
1097
+ const geoJSONStream = this.#exportGeoJSONStream({
1098
+ observations,
1099
+ tracks,
1100
+ lang,
1101
+ seenAttachments,
1102
+ })
1103
+
1104
+ // @ts-expect-error
1105
+ await archive.entry(geoJSONStream, { name: geoJSONFileName })
1106
+
1107
+ const missingAttachments = []
1108
+ // Attachments
1109
+ if (attachments) {
1110
+ const mediaFolder = (await this.#exportPrefix('Media')) + '/'
1111
+ for (const attachment of seenAttachments.values()) {
1112
+ const ref = await this.#tryGetAttachmentBlob(attachment)
1113
+ if (ref === null) {
1114
+ missingAttachments.push(attachment)
1115
+ continue
1116
+ }
1117
+
1118
+ const { blobId, mimeType } = ref
1119
+ let extensionString = ''
1120
+ if (mimeType) {
1121
+ const extension = mime.getExtension(mimeType)
1122
+ if (extension) {
1123
+ extensionString = '.' + extension
1124
+ } else {
1125
+ this.#l.log('Got unknown mime type in attachment blob', attachment)
1126
+ }
1127
+ }
1128
+
1129
+ const stream = this.#blobStore.createReadStream(blobId)
1130
+ const name = path.posix.join(
1131
+ mediaFolder,
1132
+ blobId.variant,
1133
+ `${attachment.name}${extensionString}`
1134
+ )
1135
+
1136
+ // @ts-expect-error
1137
+ await archive.entry(stream, { name })
1138
+ }
1139
+
1140
+ if (missingAttachments.length) {
1141
+ this.#l.log(`Found missing attachments during export`)
1142
+ const missingContent = missingAttachments
1143
+ .map((attachment) => JSON.stringify(attachment))
1144
+ .join('\n')
1145
+
1146
+ await archive.entry(missingContent, {
1147
+ name: mediaFolder + 'missing.ndjson',
1148
+ })
1149
+ }
1150
+ }
1151
+ // Finalize
1152
+ archive.finalize()
1153
+ }
1154
+
1155
+ /**
1156
+ * Export observations, tracks, and or attachments as a zip file stream.
1157
+ * @param {Object} [options={}]
1158
+ * @param {boolean} [options.observations=true] Whether observations should be exported
1159
+ * @param {boolean} [options.tracks=true] Whether all tracks and their observations should be exported
1160
+ * @param {boolean} [options.attachments=true] Whether all attachments for observations should be exported
1161
+ * @param {string} [options.lang]
1162
+ * @returns {Readable<Buffer | Uint8Array>}
1163
+ */
1164
+ #exportZipStream({
1165
+ observations = true,
1166
+ tracks = true,
1167
+ attachments = true,
1168
+ lang,
1169
+ } = {}) {
1170
+ const archive = new ZipArchive()
1171
+
1172
+ this.#exportToArchive(archive, {
1173
+ observations,
1174
+ tracks,
1175
+ attachments,
1176
+ lang,
1177
+ }).catch((e) => archive.emit('error', e))
1178
+
1179
+ // @ts-expect-error
1180
+ return archive
1181
+ }
1182
+
1183
+ /**
1184
+ * Export observations, tracks, and or attachments as a zip file.
1185
+ * @param {string} exportFolder Path to save the file. The file name is auto-generated
1186
+ * @param {Object} [options={}]
1187
+ * @param {boolean} [options.observations=true] Whether observations should be exported
1188
+ * @param {boolean} [options.tracks=true] Whether all tracks and their observations should be exported
1189
+ * @param {boolean} [options.attachments=true] Whether all attachments for observations should be exported
1190
+ * @param {string} [options.lang]
1191
+ * @returns {Promise<string>} The full path that the file was exported at
1192
+ */
1193
+ async exportZipFile(
1194
+ exportFolder,
1195
+ { observations = true, tracks = true, attachments = true, lang } = {}
1196
+ ) {
1197
+ const fileName = await this.#zipFileName(observations, tracks)
1198
+ const filePath = path.join(exportFolder, fileName)
1199
+ const source = this.#exportZipStream({
1200
+ observations,
1201
+ tracks,
1202
+ attachments,
1203
+ lang,
1204
+ })
1205
+ const sink = createWriteStream(filePath)
1206
+ await pipelinePromise(source, sink)
1207
+
1208
+ return filePath
1209
+ }
1210
+
752
1211
  /**
753
1212
  * @returns {Promise<void>}
754
1213
  */
package/src/types.ts CHANGED
@@ -6,7 +6,13 @@ import type {
6
6
  SetOptional,
7
7
  } from 'type-fest'
8
8
  import { SUPPORTED_BLOB_VARIANTS } from './blob-store/index.js'
9
- import { MapeoCommon, MapeoDoc, MapeoValue, decode } from '@comapeo/schema'
9
+ import {
10
+ MapeoCommon,
11
+ MapeoDoc,
12
+ MapeoValue,
13
+ Observation,
14
+ decode,
15
+ } from '@comapeo/schema'
10
16
  import type BigSparseArray from 'big-sparse-array'
11
17
  import type Protomux from 'protomux'
12
18
  import type NoiseStream from '@hyperswarm/secret-stream'
@@ -156,6 +162,8 @@ export type BlobStoreEntriesStream = Readable & {
156
162
  >
157
163
  }
158
164
 
165
+ export type Attachment = Observation['attachments'][number]
166
+
159
167
  export type StringToTaggedUnion<T extends string> = {
160
168
  [K in T]: {
161
169
  type: K
package/src/utils.js CHANGED
@@ -4,6 +4,8 @@ import { createHash } from 'node:crypto'
4
4
  import stableStringify from 'json-stable-stringify'
5
5
  import { omit } from './lib/omit.js'
6
6
 
7
+ /** @import {Attachment, BlobId} from "./types.js" */
8
+
7
9
  const PROJECT_INVITE_ID_SALT = Buffer.from('mapeo project invite id', 'ascii')
8
10
 
9
11
  /**
@@ -185,3 +187,35 @@ export function hashObject(obj) {
185
187
  .digest()
186
188
  .toString('hex')
187
189
  }
190
+
191
+ /**
192
+ * Convert attachments to BlobIds for use in the BlobStore, adapted from comapeo-mobile
193
+ * @param {Attachment} attachment
194
+ * @param {'original' | 'thumbnail' | 'preview'} requestedVariant
195
+ * @returns {BlobId}
196
+ */
197
+ export function buildBlobId(attachment, requestedVariant) {
198
+ if (
199
+ attachment.type !== 'photo' &&
200
+ attachment.type !== 'audio' &&
201
+ attachment.type !== 'video'
202
+ ) {
203
+ throw new Error(`Cannot fetch URL for attachment type "${attachment.type}"`)
204
+ }
205
+
206
+ if (attachment.type === 'photo') {
207
+ return {
208
+ type: 'photo',
209
+ variant: requestedVariant,
210
+ name: attachment.name,
211
+ driveId: attachment.driveDiscoveryId,
212
+ }
213
+ }
214
+
215
+ return {
216
+ type: attachment.type,
217
+ variant: 'original',
218
+ name: attachment.name,
219
+ driveId: attachment.driveDiscoveryId,
220
+ }
221
+ }