@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.
- package/dist/blob-api.d.ts +0 -48
- package/dist/blob-api.d.ts.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/mapeo-project.d.ts +96 -36
- package/dist/mapeo-project.d.ts.map +1 -1
- package/dist/schema/project.d.ts +16 -6
- package/dist/schema/project.d.ts.map +1 -1
- package/dist/types.d.ts +2 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/utils.d.ts +9 -0
- package/dist/utils.d.ts.map +1 -1
- package/drizzle/project/0002_cooing_princess_powerful.sql +1 -0
- package/drizzle/project/meta/0002_snapshot.json +1274 -0
- package/drizzle/project/meta/_journal.json +7 -0
- package/package.json +9 -5
- package/src/blob-api.js +0 -26
- package/src/mapeo-project.js +462 -3
- package/src/types.ts +9 -1
- package/src/utils.js +34 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@comapeo/core",
|
|
3
|
-
"version": "
|
|
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": "^
|
|
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": "
|
|
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.
|
|
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 {
|
package/src/mapeo-project.js
CHANGED
|
@@ -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
|
-
|
|
62
|
-
/** @import {
|
|
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 {
|
|
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
|
+
}
|