-
Notifications
You must be signed in to change notification settings - Fork 85
/
Copy pathPersistedDocumentArchive.js
426 lines (385 loc) · 13 KB
/
PersistedDocumentArchive.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
/* globals Blob */
import {
forEach, last, uuid, EventEmitter, platform, isString, documentHelpers, prettyPrintXML,
sendRequest
} from 'substance'
import { throwMethodIsAbstract } from '../kit/shared'
import ManifestLoader from './ManifestLoader'
import _require from './_require'
/*
A PersistedDocumentArchive is a 3-tier stack representing a document archive
at different application levels:
1. Editor: an application such as Texture works on an in-memory data model,
managed by EditorSessions.
2. Buffer: a short-term storage for pending changes. Until the document archive
is saved permanently, changes are recorded and can be persisted, e.g. to
avoid loosing changes when the browser is closed inadvertently.
3. Storage: a long-term storage where the document archive is persisted and versioned.
PersistedDocumentArchive manages the communication between the three layers, e.g.
when the user changes a document, it records the change and stores it into the buffer,
and eventually saving a new version of the ardhive.
*/
export default class PersistedDocumentArchive extends EventEmitter {
constructor (storage, buffer, context, config) {
super()
this.storage = storage
this.buffer = buffer
this._archiveId = null
this._upstreamArchive = null
this._documents = null
this._pendingFiles = new Map()
this._config = config
}
addDocument (type, name, xml) {
let documentId = uuid()
let documents = this._documents
let 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) {
let assetId = uuid()
let [name, ext] = _getNameAndExtension(file.name)
let filePath = this._getUniqueFileName(name, ext)
// TODO: this is not ready for collab
let manifest = this._documents['manifest']
let assetNode = manifest.create({
type: 'asset',
id: assetId,
path: filePath,
assetType: file.type
})
documentHelpers.append(manifest, ['dar', 'assets'], assetNode.id)
this.buffer.addBlob(assetId, {
id: assetId,
path: filePath,
blob: file
})
// ATTENTION: 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
if (platform.inBrowser) {
this._pendingFiles.set(filePath, {
blob: file,
blobUrl: URL.createObjectURL(file)
})
} else {
this._pendingFiles.set(filePath, {
blob: file,
blobUrl: `PSEUDO-BLOB-URL:${filePath}`
})
}
return filePath
}
getAsset (fileName) {
return this._documents['manifest'].getAssetByPath(fileName)
}
getAssetEntries () {
return this._documents['manifest'].getAssetNodes().map(node => node.toJSON())
}
getBlob (path) {
// 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
let blobEntry = this._pendingFiles.get(path)
if (blobEntry) {
return Promise.resolve(blobEntry.blob)
} else {
let fileRecord = this._upstreamArchive.resources[path]
if (fileRecord) {
if (fileRecord.encoding === 'url') {
if (platform.inBrowser) {
return sendRequest({
method: 'GET',
url: fileRecord.data,
responseType: 'blob'
})
} else {
// TODO: add a proper implementation for nodejs
const fs = _require('fs')
return new Promise((resolve, reject) => {
fs.readFile(fileRecord.data, (err, data) => {
if (err) reject(err)
else resolve(data)
})
})
}
} else {
let blob = platform.inBrowser ? new Blob([fileRecord.data]) : fileRecord.data
return Promise.resolve(blob)
}
} else {
return Promise.reject(new Error('File not found: ' + path))
}
}
}
getDocumentEntries () {
return this.getDocument('manifest').getDocumentEntries()
}
getDownloadLink (fileName) {
let manifest = this.getDocument('manifest')
let asset = manifest.getAssetByPath(fileName)
if (asset) {
return this.resolveUrl(fileName)
}
}
getDocument (docId) {
return this._documents[docId]
}
hasAsset (fileName) {
// TODO: at some point I want to introduce an index for files by fileName/path
return Boolean(this.getAsset(fileName))
}
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()) {
let localVersion = buffer.getVersion()
let 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)
let documents = this._ingest(upstreamArchive)
// contract: there must be a manifest
if (!documents['manifest']) {
throw new Error('There must be a 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._upstreamArchive = upstreamArchive
this._documents = documents
cb(null, this)
})
})
}
removeDocument (documentId) {
let document = this._documents[documentId]
if (document) {
this._unregisterFromDocument(document)
// TODO: this is not ready for collab
let manifest = this._documents['manifest']
documentHelpers.removeFromCollection(manifest, ['dar', 'documents'], documentId)
documentHelpers.deepDeleteNode(manifest, documentId)
}
}
renameDocument (documentId, name) {
// TODO: this is not ready for collab
let manifest = this._documents['manifest']
let documentNode = manifest.get(documentId)
documentNode.name = name
}
resolveUrl (path) {
// until saved, files have a blob URL
let blobEntry = this._pendingFiles.get(path)
if (blobEntry) {
return blobEntry.blobUrl
} else {
let fileRecord = this._upstreamArchive.resources[path]
if (fileRecord && fileRecord.encoding === 'url') {
return fileRecord.data
}
}
}
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 file
*/
_addDocumentRecord (documentId, type, name, path) {
// TODO: this is not collab ready
let manifest = this._documents['manifest']
let documentNode = manifest.create({
type: 'document',
id: documentId,
documentType: type,
name,
path
})
documentHelpers.append(manifest, ['dar', 'documents', documentNode.id])
}
_getUniqueFileName (name, ext) {
let candidate
// first try the canonical one
candidate = `${name}.${ext}`
if (this.hasAsset(candidate)) {
let count = 2
// now use a suffix counting up
while (true) {
candidate = `${name}_${count++}.${ext}`
if (!this.hasAsset(candidate)) break
}
}
return candidate
}
_loadManifest (record) {
if (!record) {
throw new Error('manifest.xml is missing')
}
return ManifestLoader.load(record.data)
}
_registerForAllChanges (documents) {
forEach(documents, (document, docId) => {
this._registerForChanges(document, docId)
})
}
_registerForChanges (document, docId) {
document.on('document:changed', change => {
this.buffer.addChange(docId, change)
// Apps can subscribe to this (e.g. to show there's pending changes)
this.emit('archive:changed')
}, this)
}
_repair () {
// no-op
}
/*
Create a raw archive for upload from the changed resources.
*/
_save (archiveId, cb) {
const buffer = this.buffer
const storage = this.storage
let rawArchiveUpdate = this._exportChanges(this._documents, buffer)
// 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 (let 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)
})
}
_unregisterFromDocument (document) {
document.off(this)
}
/*
Uses the current state of the buffer to generate a rawArchive object
containing all changed documents
*/
_exportChanges (documents, buffer) {
let rawArchive = {
version: buffer.getVersion(),
diff: buffer.getChanges(),
resources: {}
}
this._exportManifest(documents, buffer, rawArchive)
this._exportChangedDocuments(documents, buffer, rawArchive)
this._exportChangedAssets(documents, buffer, rawArchive)
return rawArchive
}
_exportManifest (documents, buffer, rawArchive) {
let manifest = documents['manifest']
if (buffer.hasResourceChanged('manifest')) {
let manifestDom = manifest.toXML()
let manifestXmlStr = prettyPrintXML(manifestDom)
rawArchive.resources['manifest.xml'] = {
id: 'manifest',
data: manifestXmlStr,
encoding: 'utf8',
updatedAt: Date.now()
}
}
}
// TODO: generalize the implementation so that it can live here
_exportChangedDocuments (documents, buffer, rawArchive) {
throwMethodIsAbstract()
}
_exportChangedAssets (documents, buffer, rawArchive) {
let manifest = documents['manifest']
let assetNodes = manifest.getAssetNodes()
assetNodes.forEach(asset => {
let assetId = asset.id
if (buffer.hasBlobChanged(assetId)) {
let path = asset.path || assetId
let blobRecord = buffer.getBlob(assetId)
rawArchive.resources[path] = {
assetId,
data: blobRecord.blob,
encoding: 'blob',
createdAt: Date.now(),
updatedAt: Date.now()
}
}
})
}
}
function _getNameAndExtension (name) {
let frags = name.split('.')
let ext = ''
if (frags.length > 1) {
ext = last(frags)
name = frags.slice(0, frags.length - 1).join('.')
}
return [name, ext]
}