UNPKG

substance

Version:

Substance is a JavaScript library for web-based content editing. It provides building blocks for realizing custom text editors and web-based publishing system. It is developed to power our online editing platform [Substance](http://substance.io).

580 lines (518 loc) 17.7 kB
import { forEach, uuid, EventEmitter, platform, isString, getFilenameAndExtension } from '../util' import { documentHelpers, DocumentIndex } from '../model' import { AbstractEditorSession } from '../editor' import ManifestLoader from './ManifestLoader' export default class DocumentArchive extends EventEmitter { constructor (storage, buffer, context, config) { super() this.storage = storage this.buffer = buffer this._config = config this._archiveId = null this._documents = null this._pendingFiles = new Map() this._assetRefs = new AssetRefCountIndex() } _loadDocument (type, record, documents) { const loader = this._config.getDocumentLoader(type) if (!loader) { const msg = `No loader defined for document type ${type}` console.error(msg, type, record) throw new Error(msg) } const doc = loader.load(record.data, { archive: this, config: this._config }) return doc } addDocument (type, name, xml) { const documentId = uuid() const documents = this._documents const document = this._loadDocument(type, { data: xml }, documents) documents[documentId] = document this._registerForChanges(document, documentId) this._addDocumentRecord(documentId, type, name, documentId + '.xml') return documentId } addAsset (file, blob) { // sometimes it is desired to override the native file data e.g. file.name // in that case, you can provide the file data seperate from the blob if (!blob) blob = file const filename = file.name if (this.isFilenameUsed(filename)) { throw new Error('A file with this name already exists: ' + filename) } let assetId this._manifestSession.transaction(tx => { const assetNode = tx.create({ type: 'asset', id: assetId, filename, mimetype: file.type }) assetId = assetNode.id documentHelpers.append(tx, ['dar', 'assets'], assetId) }) this.buffer.addBlob(assetId, { id: assetId, filename, blob }) // NOTE: blob urls are not supported in nodejs and I do not see that this is really necessary // For sake of testing we use `PSEUDO-BLOB-URL:${filePath}` // so that we can see if the rest of the system is working const blobUrl = platform.inBrowser ? URL.createObjectURL(blob) : `PSEUDO-BLOB-URL:${filename}` this._pendingFiles.set(assetId, { id: assetId, filename, blob, blobUrl }) return assetId } getFilename (resourceId) { const resource = this._documents.manifest.get(resourceId) if (resource) { return resource.filename } } getAssetById (assetId) { return this._documents.manifest.get(assetId) } getAssetForFilename (filename) { return this._documents.manifest.getAssetByFilename(filename) } getAssetEntries () { return this._documents.manifest.getAssetNodes().map(node => node.toJSON()) } renameAsset (assetId, newFilename) { const asset = this.getAssetById(assetId) if (!asset) { throw new Error(`No asset is registered with id ${assetId}`) } if (this.isFilenameUsed(newFilename)) { throw new Error('A file with this name already exists: ' + newFilename) } this._manifestSession.transaction(tx => { tx.set([asset.id, 'filename'], newFilename) }) } getBlob (assetId) { // There are the following cases // 1. the asset is on a different server (remote url) // 2. the asset is on the local server (local url / relative path) // 3. an unsaved is present as a blob in memory const blobEntry = this._pendingFiles.get(assetId) if (blobEntry) { return Promise.resolve(blobEntry.blob) } else { return new Promise((resolve, reject) => { this.storage.getAssetBlob(this._archiveId, assetId, (err, buffer) => { if (err) { reject(err) } else { resolve(buffer) } }) }) } } getConfig () { return this._config } getDocumentEntries () { return this.getDocument('manifest').getDocumentEntries() } getDownloadLink (filename) { const manifest = this.getDocument('manifest') const asset = manifest.getAssetByFilename(filename) if (asset) { return this.resolveUrl(filename) } } getDocument (docId) { return this._documents[docId] } getDocuments () { return this.getDocumentEntries().map(entry => this._documents[entry.id]).filter(Boolean) } getManifestSession () { return this._manifestSession } isFilenameUsed (filename) { // check all document entries and referenced assets if the filename // TODO: this could be optimized by keeping a set of used filenames up-to-date for (const entry of this.getDocumentEntries()) { if (entry.filename === filename) return true } const assetIds = this._assetRefs.getReferencedAssetIds() for (const assetId of assetIds) { const asset = this.getAssetById(assetId) if (asset) { if (asset.filename === filename) return true } } } hasPendingChanges () { return this.buffer.hasPendingChanges() } load (archiveId, cb) { const storage = this.storage const buffer = this.buffer storage.read(archiveId, (err, upstreamArchive) => { if (err) return cb(err) buffer.load(archiveId, err => { if (err) return cb(err) // Ensure that the upstream version is compatible with the buffer. // The buffer may contain pending changes. // In this case the buffer should be based on the same version // as the latest version in the storage. if (!buffer.hasPendingChanges()) { const localVersion = buffer.getVersion() const upstreamVersion = upstreamArchive.version if (localVersion && upstreamVersion && localVersion !== upstreamVersion) { // If the local version is out-of-date, it would be necessary to 'rebase' the // local changes. console.error('Upstream document has changed. Discarding local changes') this.buffer.reset(upstreamVersion) } else { buffer.reset(upstreamVersion) } } // convert raw archive to documents (=ingestion) const documents = this._ingest(upstreamArchive) // contract: there must be a manifest if (!documents.manifest) { throw new Error('There must be a manifest.') } // Creating an EditorSession for the manifest this._manifestSession = new AbstractEditorSession('manifest', documents.manifest) // apply pending changes if (!buffer.hasPendingChanges()) { // TODO: when we have a persisted buffer we need to apply all pending // changes. // For now, we always start with a fresh buffer } else { buffer.reset(upstreamArchive.version) } // register for any changes in each document this._registerForAllChanges(documents) this._archiveId = archiveId this._documents = documents cb(null, this) }) }) } removeDocument (documentId) { const document = this._documents[documentId] if (document) { this._unregisterFromDocument(document) // TODO: this is not ready for collab const manifest = this._documents.manifest documentHelpers.removeFromCollection(manifest, ['dar', 'documents'], documentId) documentHelpers.deepDeleteNode(manifest, documentId) } } renameDocument (documentId, name) { // TODO: this is not ready for collab const manifest = this._documents.manifest const documentNode = manifest.get(documentId) documentNode.name = name } resolveUrl (idOrFilename) { // console.log('Resolving url for', idOrFilename) let url = null const asset = this.getAssetById(idOrFilename) || this.getAssetForFilename(idOrFilename) if (asset) { // until saved, files have a blob URL const blobEntry = this._pendingFiles.get(asset.id) if (blobEntry) { url = blobEntry.blobUrl } else { // Note: arguments for getAssetUrl() must be serializable as this is an RPC url = this.storage.getAssetUrl(this._archiveId, { id: asset.id, filename: asset.filename }) } } // console.log('... url =', url) return url } save (cb) { // FIXME: buffer.hasPendingChanges() is not working this.buffer._isDirty.manuscript = true this._save(this._archiveId, cb) } /* Save as is implemented as follows. 1. clone: copy all files from original archive to new archive (backend) 2. save: perform a regular save using user buffer (over new archive, including pending documents and blobs) */ saveAs (newArchiveId, cb) { this.storage.clone(this._archiveId, newArchiveId, (err) => { if (err) return cb(err) this._save(newArchiveId, cb) }) } /* Adds a document record to the manifest */ _addDocumentRecord (documentId, type, name, filename) { this._manifestSession.transaction(tx => { const documentNode = tx.create({ type: 'document', id: documentId, documentType: type, name, filename }) documentHelpers.append(tx, ['dar', 'documents', documentNode.id]) }) } getUniqueFileName (filename) { const [name, ext] = getFilenameAndExtension(filename) let candidate // first try the canonical one candidate = `${name}.${ext}` if (this.isFilenameUsed(candidate)) { let count = 2 // now use a suffix counting up while (true) { candidate = `${name}_${count++}.${ext}` if (!this.isFilenameUsed(candidate)) break } } return candidate } _loadManifest (record) { return ManifestLoader.load(record.data) } _registerForAllChanges (documents) { forEach(documents, (document, docId) => { this._registerForChanges(document, docId) }) } _registerForChanges (doc, docId) { // record any change to allow for incremental synchronisation, or storage of incremental data doc.on('document:changed', change => { this.buffer.addChange(docId, change) setTimeout(() => { // Apps can subscribe to this (e.g. to show there's pending changes) this.emit('archive:changed') }, 0) }, this) // add an index for counting refs to assets doc.addIndex('_assetRefs', this._assetRefs) } _repair () { // no-op } /* Create a raw archive for upload from the changed resources. */ _save (archiveId, cb) { const buffer = this.buffer const storage = this.storage this._exportChanges(this._documents, buffer) .then(rawArchiveUpdate => { // CHALLENGE: we either need to lock the buffer, so that // new changes are interfering with ongoing sync // or we need something pretty smart caching changes until the // sync has succeeded or failed, e.g. we could use a second buffer in the meantime // probably a fast first-level buffer (in-mem) is necessary anyways, even in conjunction with // a slower persisted buffer storage.write(archiveId, rawArchiveUpdate, (err, res) => { // TODO: this need to implemented in a more robust fashion // i.e. we should only reset the buffer if storage.write was successful if (err) return cb(err) // TODO: if successful we should receive the new version as response // and then we can reset the buffer let _res = { version: '0' } if (isString(res)) { try { _res = JSON.parse(res) } catch (err) { console.error('Invalid response from storage.write()') } } // console.log('Saved. New version:', res.version) buffer.reset(_res.version) // revoking object urls if (platform.inBrowser) { for (const blobEntry of this._pendingFiles.values()) { window.URL.revokeObjectURL(blobEntry.blobUrl) } } this._pendingFiles.clear() // After successful save the archiveId may have changed (save as use case) this._archiveId = archiveId this.emit('archive:saved') cb(null, rawArchiveUpdate) }) }) .catch(cb) } _unregisterFromDocument (document) { document.off(this) } /* Uses the current state of the buffer to generate a rawArchive object containing all changed documents */ async _exportChanges (documents, buffer) { const resources = {} const manifestUpdate = this._exportManifest(documents, buffer) if (manifestUpdate) { resources.manifest = manifestUpdate } Object.assign(resources, this._exportChangedDocuments(documents, buffer)) const assetUpdates = await this._exportChangedAssets(documents, buffer) Object.assign(resources, assetUpdates) const rawArchive = { resources, version: buffer.getVersion(), diff: buffer.getChanges() } return rawArchive } _exportManifest (documents, buffer, rawArchive) { const manifest = documents.manifest if (buffer.hasResourceChanged('manifest')) { const manifestXmlStr = manifest.toXml({ assetRefIndex: this._assetRefs, prettyPrint: true }) return { id: 'manifest', filename: 'manifest.xml', data: manifestXmlStr, encoding: 'utf8', updatedAt: Date.now() } } } async _exportChangedAssets (documents, buffer) { const manifest = documents.manifest const assetNodes = manifest.getAssetNodes() const resources = {} for (const asset of assetNodes) { const assetId = asset.id if (buffer.hasBlobChanged(assetId)) { const filename = asset.filename || assetId const blobRecord = buffer.getBlob(assetId) // convert the blob into an array buffer // so that it can be serialized correctly const data = await blobRecord.blob.arrayBuffer() resources[assetId] = { id: assetId, filename, data, encoding: 'blob', createdAt: Date.now(), updatedAt: Date.now() } } } return resources } _exportChangedDocuments (documents, buffer, rawArchive) { // Note: we are only adding resources that have changed // and only those which are registered in the manifest const entries = this.getDocumentEntries() const resources = {} for (const entry of entries) { const { id, type, filename } = entry const document = documents[id] // TODO: how should we communicate file renamings? resources[id] = { id, filename, data: this._exportDocument(type, document, documents), encoding: 'utf8', updatedAt: Date.now() } } return resources } _exportDocument (type, document, documents) { // eslint-disable-line no-unused-vars // TODO: we need better concept for handling errors const context = { archive: this, config: this._config } const options = { prettyPrint: true } return document.toXml(context, options) } _getManifestXML (rawArchive) { return rawArchive.resources.manifest.data } /* Creates EditorSessions from a raw archive. This might involve some consolidation and ingestion. */ _ingest (rawArchive) { const documents = {} const manifestXML = this._getManifestXML(rawArchive) const manifest = this._loadManifest({ data: manifestXML }) documents.manifest = manifest // HACK: assigning loaded documents here already, so that loaders can // access other documents, e.g. the manifest this._documents = documents const entries = manifest.getDocumentEntries() entries.forEach(entry => { const id = entry.id const record = rawArchive.resources[id] // Note: this happens when a resource is referenced in the manifest // but is not there actually // we skip loading here and will fix the manuscript later on if (!record) return // TODO: we need better concept for handling errors const document = this._loadDocument(entry.type, record, documents) documents[id] = document }) return documents } } class AssetRefCountIndex extends DocumentIndex { constructor () { super() this._refCounts = new Map() } select (node) { return node.isInstanceOf('@asset') } clear () { this._refCounts = new Map() } create (node) { this._incRef(node.src) } delete (node) { this._decRef(node.src) } update (node, path, newValue, oldValue) { if (path[1] === 'src') { this._decRef(oldValue) this._incRef(newValue) } } getReferencedAssetIds () { const ids = [] for (const [id, count] of this._refCounts.entries()) { if (count > 0) { ids.push(id) } } return ids } hasRef (assetId) { return this._refCounts.has(assetId) && this._refCounts.get(assetId) > 0 } _incRef (assetId) { if (!assetId) return let refCount = 0 if (this._refCounts.has(assetId)) { refCount = this._refCounts.get(assetId) } refCount = Math.max(0, refCount + 1) this._refCounts.set(assetId, refCount) } _decRef (assetId) { if (!assetId) return if (this._refCounts.has(assetId)) { const refCount = Math.max(0, this._refCounts.get(assetId) - 1) this._refCounts.set(assetId, refCount) } } }