@ocfl/ocfl 0.1.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/lib/object.js ADDED
@@ -0,0 +1,530 @@
1
+ //@ts-check
2
+ "use strict";
3
+
4
+ const path = require('path');
5
+ const validation = require('./validation.js');
6
+ const { Enum } = require('./enum.js');
7
+ const { OcflObjectInventory, OcflObjectInventoryMut } = require('./inventory.js');
8
+ const { OcflDigest } = require('./digest.js');
9
+ const { createTransactionProxy, OcflObjectTransaction, OcflObjectTransactionImpl } = require('./transaction.js');
10
+ const { OcflStore } = require('./store.js');
11
+ const { OcflExtension } = require('./extension.js');
12
+ const { NotImplementedError } = require('./error.js');
13
+ const { parallelize, dataSourceAsIterable, isDirEmpty, findNamasteVersion } = require('./utils');
14
+ const { OCFL_VERSION, OCFL_VERSIONS, INVENTORY_NAME, NAMASTE_PREFIX_OBJECT, NAMASTE_T } = require('./constants').OcflConstants;
15
+
16
+ class UPDATE_MODE extends Enum {
17
+ /** Merge all changes with the last version during update */
18
+ static MERGE = new this();
19
+ /** New version will assume that all existing files are removed first before adding new ones */
20
+ static REPLACE = new this();
21
+ };
22
+ const DIGEST = OcflDigest.CONTENT;
23
+
24
+ /**
25
+ * @typedef {Object} OcflObjectConfig
26
+ * @property {string} root - Absolute path to the ocfl object root
27
+ * @property {string} [workspace] - Absolute path to object workspace directory
28
+ * @property {('sha256' | 'sha512')} [digestAlgorithm] - Digest algorithm for content-addressing, must use either sha512 or sha256
29
+ * @property {string} [id] - Identifier for the object. Only be used in a newly created object.
30
+ * @property {string} [contentDirectory='content'] - Content directory name. Only applies to a newly created object.
31
+ * @property {string} [ocflVersion=c.OCFL_VERSION] - Ocfl version. Only applies to a newly created object.
32
+ * @property {OcflExtension[]} [extensions] - Reference to existing extensions defined outside of the object, such as in the storage root.
33
+ */
34
+ /**
35
+ * @typedef {{logicalPath: string; digest: string; contentPath: string;}} FileRef
36
+ */
37
+ /**
38
+ * @typedef {UPDATE_MODE | ('MERGE' | 'REPLACE') } ObjectUpdateMode
39
+ */
40
+ /**
41
+ *
42
+ */
43
+ const defaultConfig = {
44
+ ocflVersion: OCFL_VERSION
45
+ };
46
+
47
+ /**
48
+ * Interface class representing OCFL Object
49
+ * @interface
50
+ */
51
+ class OcflObject {
52
+
53
+ /**
54
+ * Identifier of the OCFL Object
55
+ * @return {string}
56
+ */
57
+ get id() { throw new NotImplementedError() }
58
+
59
+ /**
60
+ * The non-storage specific absolute path to the root of this OCFL Object
61
+ * @return {string}
62
+ */
63
+ get root() { throw new NotImplementedError() }
64
+
65
+ toString() { return `OcflObject { root: ${this.root}, id: ${this.id} }`; }
66
+
67
+ toJSON() { return { root: this.root, id: this.id }; }
68
+
69
+ async inventory(id, dir) { }
70
+
71
+ /**
72
+ * If a cache exists, return the cached inventory. Otherwise read it from the storage and cache it.
73
+ * @param {string} [version] - Full name of the version, eg: v1, v2, v3
74
+ * @returns {Promise<OcflObjectInventory>}
75
+ */
76
+ async getInventory(version = 'latest') { throw new NotImplementedError() }
77
+
78
+ /**
79
+ * Update the content files or directories as one transaction and commit the changes as a new version.
80
+ * If callback function `cb` is provided, all update operations can be done in the callback function
81
+ * and all changes are automatically commited at the end of the function.
82
+ * @param {function(OcflObjectTransaction):*} [updater]
83
+ * @param {ObjectUpdateMode} [mode=UPDATE_MODE.MERGE]
84
+ * @return {Promise<?OcflObjectTransaction>}
85
+ */
86
+ async update(updater, mode = UPDATE_MODE.MERGE) { throw new NotImplementedError() }
87
+
88
+ /**
89
+ * Import one or more content files or directories to the object
90
+ * in one transaction and commit the changes as a new version.
91
+ * Use this method to simplify copying all contents of a directory
92
+ * in local filesystem to the OCFL Object content directory
93
+ * @param {string|string[]|string[][]} content - The source path(s) or an array of a tuple [source, logical path]
94
+ * @param {ObjectUpdateMode} [mode=UPDATE_MODE.MERGE] - Update mode.
95
+ */
96
+ async import(content, mode = UPDATE_MODE.MERGE) {
97
+ //let entries = /**@type {string[][]}*/(content);
98
+ if (typeof content === 'string') content = [[content, '']];
99
+ //else if (typeof content[0] === 'string') entries = /**@type {string[]}*/(content).map(src => [src, '']);
100
+
101
+ await this.update(async (t) => {
102
+ let res = await parallelize(/**@type {string[][]}*/(content), async (p) => {
103
+ if (typeof p === 'string') p = [p, ''];
104
+ let [source, target] = p;
105
+ return t.import(source, target);
106
+ });
107
+ let errors = [];
108
+ for (let i = 0; i < res.length; ++i) {
109
+ if (res[i] instanceof Error) errors.push(content[i][0]);
110
+ }
111
+ if (errors.length) {
112
+ let msg = "Cannot add the files: " + errors.join(', ');
113
+ throw new Error(msg);
114
+ }
115
+ }, mode);
116
+ }
117
+
118
+ /**
119
+ * Alias for import
120
+ * @see {@link OcflObject.import}
121
+ */
122
+ async add(sourceDir, mode = UPDATE_MODE.MERGE) {
123
+ await this.import(sourceDir, mode);
124
+ }
125
+
126
+ async load() {
127
+ await this.getInventory();
128
+ //await this.loadExtensions();
129
+ return;
130
+ }
131
+
132
+ /**
133
+ * Iterate through the content files contained in the specified version.
134
+ * Returns an array of logical paths in no particular order
135
+ * @param {string} [version]
136
+ * @return {Promise<Generator<FileRef, void, unknown>>}
137
+ */
138
+ async files(version) { throw new Error('Not Implemented'); }
139
+
140
+ /**
141
+ * Get content file as buffer. Either one of the logicalPath, contentPath, or digest parameters must be specified.
142
+ * @param {Object} opt
143
+ * @param {string} [opt.logicalPath] - Logical path of the file, eg: `test`
144
+ * @param {string} [opt.contentPath] - Content path of the file, eg: `v1/content/test`
145
+ * @param {string} [opt.digest] - Digest of the file content
146
+ * @param {string} [opt.version] - Version name
147
+ * @param {*} [opt.options] - Options to be passed to underlying fs
148
+ * @return {Promise<Buffer>}
149
+ */
150
+ async getAsBuffer(opt) { throw new Error('Not Implemented'); }
151
+
152
+ /**
153
+ * Get content file as string
154
+ * @param {Object} opt
155
+ * @param {string} [opt.logicalPath]
156
+ * @param {string} [opt.contentPath]
157
+ * @param {string} [opt.digest]
158
+ * @param {string} [opt.version]
159
+ * @param {BufferEncoding} [opt.encoding]
160
+ * @return {Promise<string>}
161
+ */
162
+ async getAsString(opt) { throw new Error('Not Implemented'); }
163
+
164
+ /**
165
+ * Get content file as a stream. Either one of the logicalPath, contentPath, or digest parameters must be specified.
166
+ * @param {Object} opt
167
+ * @param {string} [opt.logicalPath] - Logical path of the file, eg: `test`
168
+ * @param {string} [opt.contentPath] - Content path of the file, eg: `v1/content/test`
169
+ * @param {string} [opt.digest] - Digest of the file content
170
+ * @param {string} [opt.version] - Version name
171
+ * @param {*} [opt.options] - Options to be passed to underlying fs
172
+ * @return {Promise<NodeJS.ReadableStream>}
173
+ */
174
+ async getAsStream(opt) { throw new Error('Not Implemented'); }
175
+
176
+ /**
177
+ * Count the number of files contained in this object
178
+ * @param {string} [version]
179
+ */
180
+ async count(version) {
181
+ return [...await this.files(version)].length;
182
+ }
183
+
184
+ /**
185
+ * Check if the object root path points to an existing file or non-empty directory
186
+ * in the underlying backend store.
187
+ * The existing directory may or may not be a valid OCFL Object.
188
+ * @return {Promise<boolean>}
189
+ */
190
+ async exists() { throw new Error('Not Implemented'); }
191
+
192
+ }
193
+
194
+
195
+ /**
196
+ * Abstract class implementing {@link OcflObject}.
197
+ * This class provides common functionalities for the subclasses and
198
+ * at the same provide encapsulation emulating private and protected methods.
199
+ * @implements {OcflObject}
200
+ */
201
+ class OcflObjectImpl extends OcflObject {
202
+
203
+ /** @type {string} */
204
+ #root;
205
+ /** @type {string} */
206
+ #id;
207
+ /** @type {OcflExtension[]} */
208
+ #extensions;
209
+ /** @type {OcflStore} */
210
+ #store;
211
+
212
+ /**
213
+ * Create a new OCFL Object
214
+ * @param {OcflObjectConfig} config
215
+ * @param {OcflStore} store
216
+ */
217
+ constructor(config, store) {
218
+ super();
219
+ if (!store) throw new TypeError('[OcflObject] store is required.');
220
+ this.#store = store;
221
+ if (!config.root) throw new TypeError('[OcflObject] config.root is required.');
222
+ if (config.root === config.workspace) throw new Error('[OcflObject] config.root and config.workspace must be different.');
223
+ this.#root = path.resolve(config.root);
224
+ this.ocflVersion = config.ocflVersion || OCFL_VERSION;
225
+ //this.contentVersion = null; // No content yet
226
+ this.#id = config.id;
227
+ this.#extensions = config.extensions;
228
+ this.digestAlgorithm = DIGEST.of(config.digestAlgorithm || 'sha512');
229
+ if (!this.digestAlgorithm) throw new Error('Invalid digest algorithm. Must be one of `sha256` or `sha512`.');
230
+ //this.fixityDigest = config.fixityDigest ?? this.digestAlgorithm;
231
+ this.workspace = config.workspace ? path.resolve(config.workspace) : undefined;
232
+ this.contentDirectory = config.contentDirectory ?? 'content';
233
+ /** @type Object.<string, OcflObjectInventory> */
234
+ this._inventory = {};
235
+ }
236
+ get id() { return this._inventory?.latest?.id || this.#id; }
237
+ get root() { return this.#root; }
238
+
239
+ async getInventory(version = 'latest') {
240
+ if (!this._inventory[version]) {
241
+ let inv = await this._readInventory(version);
242
+ if (inv) this._inventory[version] = inv;
243
+ }
244
+ return this._inventory[version];
245
+ }
246
+
247
+ _setInventory(inventory) {
248
+ this._inventory['latest'] = this._inventory[inventory.head] = inventory;
249
+ }
250
+
251
+ /**
252
+ * Read and parse the inventory.json file
253
+ * @param {string} [version] - Full name of the version, eg: v1, v2, v3
254
+ * @returns {Promise<OcflObjectInventory>}
255
+ */
256
+ async _readInventory(version = 'latest') {
257
+ let invPath = version === 'latest' ? INVENTORY_NAME : path.join(version, INVENTORY_NAME);
258
+ let datastr;
259
+ try {
260
+ datastr = /** @type string */(await this.readFile(invPath, 'utf8'));
261
+ } catch (err) {
262
+ if (err.code !== 'ENOENT') throw err;
263
+ //inventory does not exist yet
264
+ return;
265
+ }
266
+ let data = /** @type import('./inventory.js').Inventory */(JSON.parse(datastr));
267
+ let [digest, actualDigest] = /** @type [string,string] */ (await Promise.all([
268
+ this.readFile(invPath + '.' + data.digestAlgorithm, 'utf8'),
269
+ OcflDigest.digestAsync(data.digestAlgorithm, datastr)
270
+ ]));
271
+ digest = digest.match(/[^\s]+/)?.[0];
272
+ if (digest !== actualDigest) throw new Error('corrupted file');
273
+ //return new OcflObjectInventory({ data, digest });
274
+ return new OcflObjectInventory(data);
275
+ }
276
+
277
+ /**
278
+ * @param {function(OcflObjectTransaction):Promise<*>} [updater]
279
+ * @param {ObjectUpdateMode} [mode=UPDATE_MODE.MERGE]
280
+ */
281
+ async update(updater, mode = UPDATE_MODE.MERGE) {
282
+ if (typeof mode === 'string') mode = UPDATE_MODE.of(mode.toUpperCase());
283
+ if (!mode || !UPDATE_MODE.has(mode)) throw new TypeError(`Invalid mode '${mode.toString()}'`);
284
+ let inv = await this.getInventory();
285
+ let newInv = await this._createInventory(inv, mode === UPDATE_MODE.REPLACE);
286
+ let t = await this.#createTransaction(newInv);
287
+ if (typeof updater === 'function') {
288
+ try {
289
+ await updater(t);
290
+ } catch (error) {
291
+ // console.log(error);
292
+ await t.rollback();
293
+ throw error;
294
+ }
295
+ await t.commit();
296
+ return;
297
+ }
298
+ return t;
299
+ }
300
+
301
+ /**
302
+ * Return absolute path to the `contentPath` given either the `logicalPath`, `digest` or `contentPath` with a specific version
303
+ * @param {*} param0
304
+ * @returns
305
+ */
306
+ async _resolveContentPath({ logicalPath = '', contentPath = '', digest = '', version = '' }) {
307
+ if (!contentPath) {
308
+ let inv = await this.getInventory();
309
+ version = version || inv.head;
310
+ contentPath = inv.getContentPath(digest || inv.getDigest(logicalPath, version));
311
+ }
312
+ return contentPath;
313
+ }
314
+
315
+ /**
316
+ @typedef {import('fs').OpenMode} OpenMode
317
+ @typedef {import('events').Abortable} Abortable
318
+ @typedef {{
319
+ (relPath: string, options?:({encoding?: null | undefined, flag?: OpenMode | undefined} & Abortable) | null): Promise<Buffer>
320
+ (relPath: string, options:({encoding: BufferEncoding, flag?: OpenMode | undefined} & Abortable)|BufferEncoding): Promise<string>
321
+ }} ReadFileFn
322
+ */
323
+ /**
324
+ * Read a file inside an object.
325
+ * @param filePath - The file path relative to the object root.
326
+ * @param options - Options to be passed to the underlying method
327
+ * @type ReadFileFn
328
+ */
329
+ readFile = async function (filePath, options) {
330
+ return this.#store.readFile(path.join(this.root, filePath), options);
331
+ }
332
+
333
+ /**
334
+ *
335
+ * @param {Object} opt
336
+ * @param {string} [opt.logicalPath]
337
+ * @param {string} [opt.contentPath]
338
+ * @param {string} [opt.digest]
339
+ * @param {string} [opt.version]
340
+ * @param {*} [opt.options]
341
+ */
342
+ async getAsBuffer(opt) {
343
+ let p = await this._resolveContentPath(opt);
344
+ if (!p) throw new Error(`Cannot find content "${opt.logicalPath || opt.contentPath || opt.digest}" in the OCFL Object "${this.#id}" version "${opt.version || 'latest'}"`);
345
+ return this.readFile(p, opt.options);
346
+ }
347
+
348
+ /**
349
+ *
350
+ * @param {Object} opt
351
+ * @param {string} [opt.logicalPath]
352
+ * @param {string} [opt.contentPath]
353
+ * @param {string} [opt.digest]
354
+ * @param {string} [opt.version]
355
+ * @param {BufferEncoding} [opt.encoding]
356
+ * @return {Promise<string>}
357
+ */
358
+ async getAsString(opt) {
359
+ // @ts-ignore
360
+ return this.getAsBuffer({ ...opt, options: opt.encoding || 'utf8' });
361
+ }
362
+
363
+ async createReadStream(filePath, options) {
364
+ return this.#store.createReadStream(path.join(this.root, filePath), options);
365
+ }
366
+
367
+ async getAsStream(opt) {
368
+ let p = await this._resolveContentPath(opt);
369
+ if (!p) throw new Error(`Cannot find content "${opt.logicalPath || opt.contentPath || opt.digest}" in the OCFL Object "${this.#id}" version "${opt.version || 'latest'}"`);
370
+ return this.#store.createReadStream(p, opt.options);
371
+ }
372
+
373
+ /**
374
+ * Copy all content of this Object to a directory in local filesystem
375
+ * @param {*} targetDir
376
+ */
377
+ async export(targetDir, version) { }
378
+
379
+ async exists() {
380
+ return !(await isDirEmpty(this.#store, this.root));
381
+ }
382
+
383
+ /**
384
+ * @param {string} [version]
385
+ */
386
+ async files(version) {
387
+ let inv = await this.getInventory();
388
+ return inv.files(version);
389
+ }
390
+
391
+
392
+ /**
393
+ * Create a new inventory from the object config or clone an existing inventory.
394
+ * @param {OcflObjectInventory} [inventory]
395
+ * @param {boolean} [cleanState]
396
+ * @return {OcflObjectInventoryMut}
397
+ */
398
+ _createInventory(inventory, cleanState) {
399
+ /** @type {Object} */
400
+ let data;
401
+ if (inventory) {
402
+ data = inventory.toJSON();
403
+ } else {
404
+ data = {
405
+ id: this.id,
406
+ digestAlgorithm: this.digestAlgorithm.value,
407
+ type: `https://ocfl.io/${this.ocflVersion}/spec/#inventory`
408
+ };
409
+ if (this.contentDirectory !== 'content') data.contentDirectory = this.contentDirectory;
410
+ }
411
+ return OcflObjectInventory.newVersion(data, cleanState);
412
+ }
413
+
414
+ /**
415
+ *
416
+ * @param {string} absOrRelPath
417
+ * @return {string}
418
+ */
419
+ toAbsPath(absOrRelPath = '') {
420
+ if (!path.isAbsolute(absOrRelPath)) return path.join(this.root, absOrRelPath);
421
+ return absOrRelPath;
422
+ }
423
+
424
+ /**
425
+ * Create a new backend-specific update transaction
426
+ * @param {OcflObjectInventoryMut} inventory
427
+ * @return {Promise<OcflObjectTransaction>}
428
+ */
429
+ async #createTransaction(inventory) {
430
+ // @todo: validate existing object
431
+ // check existing
432
+ let workspacePath = this.workspace || this.root;
433
+ let workspaceVersionPath = path.join(workspacePath, inventory.head);
434
+
435
+ if (await this.#store.exists(workspaceVersionPath)) {
436
+ // workspace dir exists, abort update
437
+ throw new Error('Uncommitted changes detected. Object is being updated by other process or there has been a failed update attempt');
438
+ }
439
+ let createdDir = await this.#store.mkdir(workspacePath);
440
+ if (!this.workspace) await this._ensureNamaste();
441
+ return createTransactionProxy(new OcflObjectTransactionImpl(this, this.#store, inventory, workspaceVersionPath, createdDir));
442
+ }
443
+
444
+ async _ensureNamaste() {
445
+ let prefix = NAMASTE_PREFIX_OBJECT;
446
+ let nv = await findNamasteVersion(this.#store, prefix, this.root);
447
+ if (nv === '') throw validation.createError(6, this.root);
448
+ if (nv) {
449
+ if (!this.ocflVersion) this.ocflVersion = nv;
450
+ return;
451
+ }
452
+ // namaste not found, check if object root is not empty
453
+ try {
454
+ let files = await this.#store.readdir(this.root);
455
+ if (files.length > 0) throw new Error('Cannot create an OCFL Object in a non-empty directory');
456
+ } catch (error) {
457
+ }
458
+ //create the namaste file
459
+ let filePath = path.join(this.root, NAMASTE_T + prefix + this.ocflVersion);
460
+ await this.#store.writeFile(filePath, prefix + this.ocflVersion + '\n', 'utf8');
461
+ }
462
+
463
+ async isObject(aPath) { }
464
+ async importDir(id, sourceDir) { }
465
+ getVersionString(i) { }
466
+ async determineVersion() { }
467
+ nameVersion(version) {
468
+ return 'ocfl_object_' + version;
469
+ }
470
+ async digest_dir(dir) { }
471
+ async hash_file(p) { }
472
+ async removeEmptyDirectories(folder) { }
473
+ static status(rootPath) { }
474
+ static exists(rootPath) { }
475
+
476
+ /**
477
+ * Validate this object
478
+ * @abstract
479
+ */
480
+ async validate() { throw new Error('Not Implemented'); }
481
+ async verify() { return this.validate(); }
482
+
483
+ };
484
+
485
+
486
+ const objectInterface = new OcflObject();
487
+ const proxyHandler = {
488
+ /**
489
+ * @param {OcflObject} target
490
+ * @param {string} prop
491
+ */
492
+ get(target, prop, receiver) {
493
+ if (prop in objectInterface) {
494
+ let p = target[prop];
495
+ if (typeof p === 'function') {
496
+ return p.bind(target);
497
+ } else {
498
+ return p;
499
+ }
500
+ }
501
+ }
502
+ }
503
+
504
+ /**
505
+ * Return any OcflObject implementation as OcflObject.
506
+ * @param {OcflObject} impl
507
+ */
508
+ function createObjectProxy(impl) {
509
+ return new Proxy(impl, proxyHandler);
510
+ }
511
+
512
+ /**
513
+ * Create an OCFL Object
514
+ * @param {OcflObjectConfig} config
515
+ * @return {OcflObject}
516
+ */
517
+ function createObject(config) {
518
+ throw new Error('Not Implemented');
519
+ }
520
+
521
+ module.exports = {
522
+ OcflObject,
523
+ OcflObjectImpl,
524
+ OcflObjectInventory,
525
+ //OcflObjectTransaction,
526
+ OcflObjectTransactionImpl,
527
+ createTransactionProxy,
528
+ createObjectProxy,
529
+ createObject
530
+ };