diff --git a/ReactNativeClient/lib/models/Resource.js b/ReactNativeClient/lib/models/Resource.js index 94389df2b..1737278d0 100644 --- a/ReactNativeClient/lib/models/Resource.js +++ b/ReactNativeClient/lib/models/Resource.js @@ -1,5 +1,6 @@ const BaseModel = require('lib/BaseModel.js'); const BaseItem = require('lib/models/BaseItem.js'); +const ItemChange = require('lib/models/ItemChange.js'); const NoteResource = require('lib/models/NoteResource.js'); const ResourceLocalState = require('lib/models/ResourceLocalState.js'); const Setting = require('lib/models/Setting.js'); @@ -327,7 +328,7 @@ class Resource extends BaseItem { const fileStat = await this.fsDriver().stat(newBlobFilePath); await this.fsDriver().copy(newBlobFilePath, Resource.fullPath(resource)); - await Resource.save({ + return await Resource.save({ id: resource.id, size: fileStat.size, }); @@ -339,6 +340,40 @@ class Resource extends BaseItem { return await this.fsDriver().readFile(Resource.fullPath(resource), encoding); } + static async duplicateResource(resourceId) { + const resource = await Resource.load(resourceId); + const localState = await Resource.localState(resource); + + let newResource = { ...resource }; + delete newResource.id; + newResource = await Resource.save(newResource); + + const newLocalState = { ...localState }; + newLocalState.resource_id = newResource.id; + delete newLocalState.id; + + await Resource.setLocalState(newResource, newLocalState); + + const sourcePath = Resource.fullPath(resource); + if (await this.fsDriver().exists(sourcePath)) { + await this.fsDriver().copy(sourcePath, Resource.fullPath(newResource)); + } + + return newResource; + } + + static async createConflictResourceNote(resource) { + const Note = this.getClass('Note'); + + const conflictResource = await Resource.duplicateResource(resource.id); + + await Note.save({ + title: _('Attachment conflict: "%s"', resource.title), + body: _('There was a [conflict](%s) on the attachment below.\n\n%s', 'https://joplinapp.org/conflict', Resource.markdownTag(conflictResource)), + is_conflict: 1, + }, { changeSource: ItemChange.SOURCE_SYNC }); + } + } Resource.IMAGE_MAX_DIMENSION = 1920; diff --git a/ReactNativeClient/lib/services/ResourceEditWatcher.ts b/ReactNativeClient/lib/services/ResourceEditWatcher.ts index ba334fc23..e092f1932 100644 --- a/ReactNativeClient/lib/services/ResourceEditWatcher.ts +++ b/ReactNativeClient/lib/services/ResourceEditWatcher.ts @@ -10,7 +10,8 @@ import AsyncActionQueue from '../AsyncActionQueue'; interface WatchedItem { resourceId: string, - updatedTime: number, + lastFileUpdatedTime: number, + lastResourceUpdatedTime: number, path:string, asyncSaveQueue: AsyncActionQueue, } @@ -77,7 +78,19 @@ export default class ResourceEditWatcher { const makeSaveAction = (resourceId:string, path:string) => { return async () => { this.logger().info(`ResourceEditWatcher: Saving resource ${resourceId}`); - await Resource.updateResourceBlob(resourceId, path); + const resource = await Resource.load(resourceId); + const watchedItem = this.watchedItemByResourceId(resourceId); + + if (resource.updated_time !== watchedItem.lastResourceUpdatedTime) { + this.logger().info(`ResourceEditWatcher: Conflict was detected (resource was modified from somewhere else, possibly via sync). Conflict note will be created: ${resourceId}`); + // The resource has been modified from elsewhere, for example via sync + // so copy the current version to the Conflict notebook, and overwrite + // the resource content. + await Resource.createConflictResourceNote(resource); + } + + const savedResource = await Resource.updateResourceBlobContent(resourceId, path); + watchedItem.lastResourceUpdatedTime = savedResource.updated_time; this.eventEmitter_.emit('resourceChange', { id: resourceId }); }; }; @@ -104,9 +117,9 @@ export default class ResourceEditWatcher { } const stat = await shim.fsDriver().stat(path); - const updatedTime = stat.mtime.getTime(); + const editedFileUpdatedTime = stat.mtime.getTime(); - if (watchedItem.updatedTime === updatedTime) { + if (watchedItem.lastFileUpdatedTime === editedFileUpdatedTime) { // chokidar is buggy and emits "change" events even when nothing has changed // so double-check the modified time and skip processing if there's no change. // In particular it emits two such events just after the file has been copied @@ -118,7 +131,7 @@ export default class ResourceEditWatcher { this.logger().debug(`ResourceEditWatcher: Queuing save action: ${resourceId}`); watchedItem.asyncSaveQueue.push(makeSaveAction(resourceId, path)); - watchedItem.updatedTime = updatedTime; + watchedItem.lastFileUpdatedTime = editedFileUpdatedTime; } else if (event === 'error') { this.logger().error('ResourceEditWatcher: error'); } @@ -147,7 +160,8 @@ export default class ResourceEditWatcher { watchedItem = { resourceId: resourceId, - updatedTime: 0, + lastFileUpdatedTime: 0, + lastResourceUpdatedTime: 0, asyncSaveQueue: new AsyncActionQueue(1000), path: '', }; @@ -163,7 +177,8 @@ export default class ResourceEditWatcher { const stat = await shim.fsDriver().stat(editFilePath); watchedItem.path = editFilePath; - watchedItem.updatedTime = stat.mtime; + watchedItem.lastFileUpdatedTime = stat.mtime.getTime(); + watchedItem.lastResourceUpdatedTime = resource.updated_time; this.watch(editFilePath); } diff --git a/ReactNativeClient/lib/shim-init-node.js b/ReactNativeClient/lib/shim-init-node.js index 253433579..b3b67c5ff 100644 --- a/ReactNativeClient/lib/shim-init-node.js +++ b/ReactNativeClient/lib/shim-init-node.js @@ -205,28 +205,6 @@ function shimInit() { return Resource.save(resource, { isNew: true }); }; - shim.duplicateResource = async function(resourceId) { - const resource = await Resource.load(resourceId); - const localState = await Resource.localState(resource); - - let newResource = { ...resource }; - delete newResource.id; - newResource = await Resource.save(newResource); - - const newLocalState = { ...localState }; - newLocalState.resource_id = newResource.id; - delete newLocalState.id; - - await Resource.setLocalState(newResource, newLocalState); - - const sourcePath = Resource.fullPath(resource); - if (await shim.fsDriver().exists(sourcePath)) { - await shim.fsDriver().copy(sourcePath, Resource.fullPath(newResource)); - } - - return newResource; - }; - shim.attachFileToNoteBody = async function(noteBody, filePath, position = null, options = null) { options = Object.assign({}, { createFileURL: false, diff --git a/ReactNativeClient/lib/synchronizer.js b/ReactNativeClient/lib/synchronizer.js index 635d5280e..0ee644f3c 100644 --- a/ReactNativeClient/lib/synchronizer.js +++ b/ReactNativeClient/lib/synchronizer.js @@ -539,18 +539,14 @@ class Synchronizer { // Unlike notes we always handle the conflict for resources // ------------------------------------------------------------------------------ - const conflictResource = await shim.duplicateResource(local.id); + await Resource.createConflictResourceNote(local); - await Note.save({ - title: _('Attachment conflict: "%s"', local.title), - body: _('There was a [conflict](%s) on the attachment below.\n\n%s', 'https://joplinapp.org/conflict', Resource.markdownTag(conflictResource)), - is_conflict: 1, - }, { changeSource: ItemChange.SOURCE_SYNC }); - - // The local content we have is no longer valid and should be re-downloaded - await Resource.setLocalState(local.id, { - fetch_status: Resource.FETCH_STATUS_IDLE, - }); + if (remote) { + // The local content we have is no longer valid and should be re-downloaded + await Resource.setLocalState(local.id, { + fetch_status: Resource.FETCH_STATUS_IDLE, + }); + } } if (['noteConflict', 'resourceConflict'].includes(action)) {