UNPKG

@ocfl/ocfl

Version:
500 lines (449 loc) 17.9 kB
//@ts-check "use strict"; const path = require('path'); const validation = require('./validation.js'); const { enumeration } = require('./enum.js'); const { OcflObjectInventory, OcflObjectInventoryMut, VersionNumber } = require('./inventory.js'); const { OcflDigest } = require('./digest.js'); const { OcflObjectTransaction, OcflObjectTransactionImpl } = require('./transaction.js'); const { OcflStore } = require('./store.js'); const { OcflExtension } = require('./extension.js'); const { NotImplementedError } = require('./error.js'); const { parallelize, isDirEmpty, findNamasteVersion } = require('./utils'); const { OcflObjectFile } = require('./file.js'); const { OCFL_VERSION, OCFL_VERSIONS, INVENTORY_NAME, NAMASTE_PREFIX_OBJECT, NAMASTE_T } = require('./constants').OcflConstants; const UPDATE_MODES = /** @type { const } */(['MERGE', 'REPLACE']); const UPDATE_MODE = enumeration(UPDATE_MODES); const DIGEST = OcflDigest.CONTENT; /** * @typedef {(typeof UPDATE_MODES)[number]} UpdateModeStr * @typedef { InstanceType<typeof UPDATE_MODE> | UpdateModeStr} UpdateMode * @typedef {string} LogicalPath * @typedef {{logicalPath: string, version?: string}} FileRefLogical * @typedef {{digest: string, version?: string}} FileRefDigest * @typedef {{contentPath: string, version?: string}} FileRefContent * @typedef {import('./types').Inventory} Inventory * @typedef {import('./types').OcflVersion} OcflVersion * @typedef {import('./types').OcflObjectConfig} OcflObjectConfig * */ /** * A class representing an OCFL Object allowing to read and write the content of an OCFL Object in the underlying store. */ class OcflObject { /** @type {OcflVersion} */ #ocflVersion; /** @type {string} */ #root; /** @type {string} */ #workspace; /** @type {string} */ #id; /** @type {('sha256' | 'sha512')} */ #digestAlgorithm; /** @type {OcflExtension[]} */ #extensions; /** @type {OcflStore} */ #store; /** * A default list of digest algorithms to be calculated for each file in this object and added to the fixity block. * @type {string[]} */ fixityAlgorithms; /** * Create a new OCFL Object * @param {OcflObjectConfig} config * @param {OcflStore} store */ constructor(config, store) { if (!store) throw new TypeError('[OcflObject] store is required.'); this.#store = store; if (!config.root) throw new TypeError('[OcflObject] config.root is required.'); if (config.root === config.workspace) throw new Error('[OcflObject] config.root and config.workspace must be different.'); this.#root = path.resolve(config.root); this.#ocflVersion = config.ocflVersion || /** @type {OcflVersion} */(OCFL_VERSION); //this.contentVersion = null; // No content yet this.#id = config.id; this.#extensions = config.extensions; //this.fixityDigest = config.fixityDigest ?? this.digestAlgorithm; this.digestAlgorithm = config.digestAlgorithm || 'sha512'; this.#workspace = config.workspace ? path.resolve(config.workspace) : undefined; this.contentDirectory = config.contentDirectory ?? 'content'; /** @type Object.<string, OcflObjectInventory> */ this._inventory = {}; this.fixityAlgorithms = config.fixityAlgorithms; } /** the digest algorithm used to identify files within the OCFL object */ get digestAlgorithm() { return this.#digestAlgorithm; } set digestAlgorithm(algorithm) { this.#digestAlgorithm = algorithm; // @ts-ignore this.#digestAlgorithm = enumeration.of(DIGEST, algorithm || 'sha512')?.name; if (!this.#digestAlgorithm) throw new Error('Invalid digest algorithm. Must be one of `sha256` or `sha512`.'); } /** * Identifier of the OCFL Object * @return {string} */ get id() { return this.inventory?.id || this.#id; } /** * Latest version number of the OCFL Object * @return {VersionNumber} */ get headVersion() { const head = this.inventory?.head; if (head) return VersionNumber.fromString(head); } /** * All available version numbers in the OCFL Object * @return {VersionNumber[]} */ get versions() { return Object.keys(this.inventory?.versions || {}).map(v => VersionNumber.fromString(v)); } /** * The absolute path to the root of this OCFL Object * @return {string} */ get root() { return this.#root; } /** * The absolute path to the temporary workspace of this OCFL Object * @return {string} */ get workspace() { return this.#workspace; } /** The raw OCFL version number the object adheres to , eg 1.0, 1.1) */ get ocflVersion() { return this.#ocflVersion; } /** * Create a new backend-specific update transaction * @param {OcflObjectInventoryMut} inventory * @return {Promise<OcflObjectTransaction>} */ async #createTransaction(inventory) { await this._validateObjectPath(); if (!this.workspace) await this._ensureNamaste(); let workspacePath = this.workspace || this.root; return OcflObjectTransactionImpl.create(this, this.#store, inventory, workspacePath); } async _ensureNamaste() { let prefix = NAMASTE_PREFIX_OBJECT; let nv = await findNamasteVersion(this.#store, prefix, this.root); if (nv === '') throw validation.createError(6, this.root); if (nv) { if (!this.ocflVersion) this.#ocflVersion = /** @type {OcflVersion} */(nv); return; } // namaste not found, check if object root is not empty let files; try { files = await this.#store.readdir(this.root); } catch (error) { } if (files && files.length > 0) throw new Error('Cannot create an OCFL Object in a non-empty directory'); //create the namaste file let filePath = path.join(this.root, NAMASTE_T + prefix + this.ocflVersion); await this.#store.writeFile(filePath, prefix + this.ocflVersion + '\n', 'utf8'); } /** * Read and parse the inventory.json file * @param {string} [version] - Full name of the version, eg: v1, v2, v3 * @returns {Promise<OcflObjectInventory>} */ async _readInventory(version = 'latest') { let invPath = version === 'latest' ? INVENTORY_NAME : path.join(version, INVENTORY_NAME); let datastr; try { datastr = /** @type string */(await this.readFile(invPath, 'utf8')); } catch (err) { if (err.code !== 'ENOENT') throw err; //inventory does not exist yet return; } let data = /** @type Inventory */(JSON.parse(datastr)); let [digest, actualDigest] = /** @type [string,string] */ (await Promise.all([ this.readFile(invPath + '.' + data.digestAlgorithm, 'utf8'), OcflDigest.digest(data.digestAlgorithm, datastr) ])); digest = digest.match(/[^\s]+/)?.[0]; if (digest !== actualDigest) throw new Error(`Inventory file ${this.root}/${invPath} digest mismatch: recorded=${digest} actual=${actualDigest}`); //return new OcflObjectInventory({ data, digest }); return new OcflObjectInventory(data); } /** * Set the latest version of inventory in the cache * @param {*} inventory * @param {*} version */ _setInventory(inventory, version = 'latest') { //this._inventory['latest'] = this._inventory[inventory.head] = inventory; this._inventory[version] = inventory; if (version === 'latest') this._inventory[inventory.head] = inventory; } // all the parent directories cannot be an ocfl object async _validateObjectPath() { var p = this.root; while (p !== '.' && p !== '/') { p = path.dirname(p); let nv = await findNamasteVersion(this.#store, NAMASTE_PREFIX_OBJECT, p); if (nv) throw new Error(`Object root cannot be nested under another object ${p}`); } } /** * If a cache exists, return the cached inventory. Otherwise read it from the storage and cache it. * If the inventory file does not exist, eg in a newly created object, returns undefined. * @param {string} [version] - Full name of the version, eg: v1, v2, v3 * @returns {Promise<OcflObjectInventory>} */ async getInventory(version = 'latest') { if (!this._inventory[version]) { let inv = await this._readInventory(version); if (inv) this._setInventory(inv, version); } return this._inventory[version]; } _baseInventory() { return { id: this.id, digestAlgorithm: this.digestAlgorithm || 'sha512', type: /** @type {Inventory["type"]} */(`https://ocfl.io/${this.ocflVersion}/spec/#inventory`), contentDirectory: this.contentDirectory || 'content' }; } /** * Update the content files or directories as one transaction and commit the changes as a new version. * After obtaining the OcflObjectTransaction instance, make sure to either call the * {@link OcflObjectTransaction#commit} or {@link OcflObjectTransaction#rollback} method to complete the transaction. * If callback function `cb` is provided, all update operations can be done in the callback function * and all changes are automatically commited at the end of the function. * @param {function(OcflObjectTransaction):Promise<*>} [updater] * @param {UpdateMode} [mode_] * @return {Promise<?OcflObjectTransaction>} */ async update(updater, mode_ = UPDATE_MODE.MERGE) { const mode = enumeration.of(UPDATE_MODE, mode_); if (!mode) throw new TypeError(`Invalid mode '${mode.toString()}'`); const inv = await this.getInventory() ?? this._baseInventory(); const newInv = await OcflObjectInventory.newVersion(inv, mode === UPDATE_MODE.REPLACE); const t = await this.#createTransaction(newInv); if (typeof updater === 'function') { try { await updater(t); } catch (error) { // console.log(error); await t.rollback(); throw error; } await t.commit(); return; } return t; } /** * Import one or more content files or directories to the object * in one transaction and commit the changes as a new version. * Use this method to simplify copying all contents of a directory * in local filesystem to the OCFL Object content directory * @param {string|string[]|string[][]} content - The source path(s) or an array of a tuple [source, logical path] * @param {UpdateMode} [mode=UPDATE_MODE.MERGE] - Update mode. */ async import(content, mode = UPDATE_MODE.MERGE) { //let entries = /**@type {string[][]}*/(content); if (typeof content === 'string') content = [[content, '']]; //else if (typeof content[0] === 'string') entries = /**@type {string[]}*/(content).map(src => [src, '']); await this.update(async (t) => { let res = await parallelize(/**@type {string[][]}*/(content), async (p) => { if (typeof p === 'string') p = [p, '']; let [source, target] = p; return t.import(source, target); }); let errors = []; for (let i = 0; i < res.length; ++i) { if (res[i] instanceof Error) errors.push(content[i][0]); } if (errors.length) { let msg = "Cannot add the files: " + errors.join(', '); throw new Error(msg); } }, mode); } /** * Alias for import * @see {@link OcflObject.import} */ async add(sourceDir, mode = UPDATE_MODE.MERGE) { await this.import(sourceDir, mode); } /** * Count the number of files contained in this object * @param {string} [version] */ async count(version) { let inv = await this.getInventory(); return [...inv.files(version)].length; } async createReadable(filePath, options) { return this.#store.createReadable(path.join(this.root, filePath), options); } /** * Check if the object root path points to an existing file or non-empty directory * in the underlying backend store. * The existing directory may or may not be a valid OCFL Object. * @return {Promise<boolean>} */ async exists() { return !(await isDirEmpty(this.#store, this.root)); } /** * Copy all content of this Object to a directory in local filesystem * @param {string} targetDir * @param {string} version */ async export(targetDir, version) { throw new Error('Not Implemented'); } /** * Iterate through the content files contained in the specified version. * Returns an Iterator that contains FileRef which consists of logical path, content path, and digest, in no particular order. * The iterator is implemented as a generator. * @param {string} [version] * @return {Promise<Generator<OcflObjectFile, void, unknown>>} */ async files(version) { let inv = await this.getInventory(); let o = this; function* files() { for (const f of inv.files(version)) { yield new OcflObjectFile(o, f); } } return files(); } /** * Return a file representation by the content path and version, * @param { string } contentPath * @param { string } [version='latest'] */ async fileByContentPath(contentPath, version) { const inv = await this.getInventory(); const fr = inv?.getFileByContentPath(contentPath, version); if (fr) return new OcflObjectFile(this, fr); }; /** * Return a file representation by the digest and version, * @param { string } digest * @param { string } [version='latest'] */ async fileByDigest(digest, version) { const inv = await this.getInventory(); const fr = inv?.getFileByDigest(digest, version); if (fr) return new OcflObjectFile(this, fr); }; /** * Return a file representation by the logical path and version, * @param { string } logicalPath * @param { string } [version='latest'] */ async fileByLogicalPath(logicalPath, version) { const inv = await this.getInventory(); const fr = inv?.getFile(logicalPath, version); if (fr) return new OcflObjectFile(this, fr); }; /** * Return a file representation by either the [logical path and version], digest, or content path. * The returned file object can be used to retrieve the content of a file either as string, buffer, or stream. * If the object inventory file is not loaded, this file object will not include the full file metadata information. * If version is omitted, it defaults to the last version. * @param { LogicalPath | FileRefLogical | FileRefDigest | FileRefContent } opt A choice of logical path (eg: 'test') and version (eg: 'v1'), * digest of the file content, or content path (eg: 'v1/content/test') * @param {string} [version='latest'] The object version name, eg: 'v1', 'v2'. Default to 'latest'. Only used if opt is a LogicalPath. * @param {string} [lazy] If true, always return a file instance even if the object inventory file is not loaded. */ getFile(opt, version, lazy) { const inv = this.inventory; /** @type {any} */ const file = typeof opt === 'string' ? { logicalPath: opt, version: version } : { ...opt }; if (inv) { let fr; if (file.logicalPath) { fr = inv?.getFile(file.logicalPath, file.version); } else if (file.digest) { fr = inv?.getFileByDigest(file.digest, file.version); } else if (file.contentPath) { fr = inv?.getFileByContentPath(file.contentPath, file.version); } if (fr) return new OcflObjectFile(this, fr); } else if (lazy) { return new OcflObjectFile(this, file); } }; /** * Return the latest version of the cached data from inventory.json which can be read using {@link getInventory()} */ get inventory() { return this._inventory.latest; } /** * Reads the object inventory file and cache it in the memory. * Optionally, preloads and reads all files metadata from the underlying storage. * @param {object} [options] * @param {boolean} [options.filesMetadata] Whether to read file metadata from the underlying storage. */ async load({ filesMetadata } = {}) { this._inventory = {}; // reset inventory cache const inv = await this.getInventory(); //await this.loadExtensions(); if (inv && filesMetadata) { //load file metadata from underlying storage const metadata = inv.metadata = {}; for await (const file of await this.#store.list(this.root, { recursive: true })) { const contentPath = file.path; metadata[contentPath] = { size: file.size, lastModified: file.lastModified.getTime() } } } } /** * Read the content of a file inside an object. * @param {string} relPath - The file path relative to the object root. * @param {*} options - Options to be passed to the underlying method */ async readFile(relPath, options) { return this.#store.readFile(path.join(this.root, relPath), options); }; /** * Get the file metadata such as size and modification time. * @param {string} relPath - The file path relative to the object root. */ async stat(relPath) { return this.#store.stat(path.join(this.root, relPath)); }; toString() { return `OcflObject { root: ${this.root}, id: ${this.id} }`; } toJSON() { return { root: this.root, id: this.id }; } } /** * @implements {OcflObject} */ class OcflObjectImpl extends OcflObject { // async _resolveContentPath({ logicalPath = '', contentPath = '', digest = '', version = '' }) { // if (!contentPath) { // let inv = await this.getInventory(); // version = version && version !== 'latest' ? version : inv.head; // contentPath = inv.getContentPath(digest || inv.getDigest(logicalPath, version)); // } // return contentPath; // } async isObject(aPath) { } static status(rootPath) { } static exists(rootPath) { } /** * Validate this object * @abstract */ async validate() { throw new Error('Not Implemented'); } async verify() { return this.validate(); } }; module.exports = { OcflObject, OcflObjectInventory, //OcflObjectTransaction, OcflObjectTransactionImpl };