2020-07-22 20:03:31 +02:00
import AsyncActionQueue from '../../AsyncActionQueue' ;
2020-10-09 19:35:46 +02:00
import shim from 'lib/shim' ;
import { _ } from 'lib/locale' ;
const Logger = require ( 'lib/Logger' ) . default ;
const Setting = require ( 'lib/models/Setting' ) . default ;
2020-05-30 14:25:05 +02:00
const Resource = require ( 'lib/models/Resource' ) ;
const EventEmitter = require ( 'events' ) ;
const chokidar = require ( 'chokidar' ) ;
2020-10-09 19:35:46 +02:00
const bridge = require ( 'electron' ) . remote . require ( './bridge' ) . default ;
2020-05-30 14:25:05 +02:00
interface WatchedItem {
2020-05-30 18:49:29 +02:00
resourceId : string ,
2020-05-31 18:43:51 +02:00
lastFileUpdatedTime : number ,
lastResourceUpdatedTime : number ,
2020-05-30 18:49:29 +02:00
path :string ,
asyncSaveQueue : AsyncActionQueue ,
2020-10-16 17:26:19 +02:00
size : number ,
2020-05-30 18:49:29 +02:00
}
interface WatchedItems {
[ key :string ] : WatchedItem ,
2020-05-30 14:25:05 +02:00
}
export default class ResourceEditWatcher {
private static instance_ :ResourceEditWatcher ;
private logger_ :any ;
2020-07-22 20:03:31 +02:00
private dispatch :Function ;
2020-05-30 14:25:05 +02:00
private watcher_ :any ;
private chokidar_ :any ;
2020-05-30 18:49:29 +02:00
private watchedItems_ :WatchedItems = { } ;
2020-05-30 14:25:05 +02:00
private eventEmitter_ :any ;
2020-05-30 18:49:29 +02:00
private tempDir_ :string = '' ;
2020-05-30 14:25:05 +02:00
constructor ( ) {
this . logger_ = new Logger ( ) ;
2020-07-22 20:03:31 +02:00
this . dispatch = ( ) = > { } ;
2020-05-30 14:25:05 +02:00
this . watcher_ = null ;
this . chokidar_ = chokidar ;
this . eventEmitter_ = new EventEmitter ( ) ;
}
2020-07-22 20:03:31 +02:00
initialize ( logger :any , dispatch :Function ) {
2020-05-30 14:25:05 +02:00
this . logger_ = logger ;
2020-07-22 20:03:31 +02:00
this . dispatch = dispatch ;
2020-05-30 14:25:05 +02:00
}
static instance() {
if ( this . instance_ ) return this . instance_ ;
this . instance_ = new ResourceEditWatcher ( ) ;
return this . instance_ ;
}
2020-05-30 18:49:29 +02:00
private async tempDir() {
if ( ! this . tempDir_ ) {
this . tempDir_ = ` ${ Setting . value ( 'tempDir' ) } /edited_resources ` ;
await shim . fsDriver ( ) . mkdir ( this . tempDir_ ) ;
}
return this . tempDir_ ;
2020-05-30 14:25:05 +02:00
}
logger() {
return this . logger_ ;
}
on ( eventName :string , callback :Function ) {
return this . eventEmitter_ . on ( eventName , callback ) ;
}
off ( eventName :string , callback :Function ) {
return this . eventEmitter_ . removeListener ( eventName , callback ) ;
}
private watch ( fileToWatch :string ) {
if ( ! this . chokidar_ ) return ;
2020-05-30 18:10:25 +02:00
const makeSaveAction = ( resourceId :string , path :string ) = > {
return async ( ) = > {
this . logger ( ) . info ( ` ResourceEditWatcher: Saving resource ${ resourceId } ` ) ;
2020-05-31 18:43:51 +02:00
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 ;
2020-05-30 18:10:25 +02:00
this . eventEmitter_ . emit ( 'resourceChange' , { id : resourceId } ) ;
} ;
} ;
2020-07-22 20:03:31 +02:00
const handleChangeEvent = async ( path :string ) = > {
2020-07-24 01:06:58 +02:00
this . logger ( ) . debug ( ` ResourceEditWatcher: handleChangeEvent: ${ path } ` ) ;
2020-07-22 20:03:31 +02:00
const watchedItem = this . watchedItemByPath ( path ) ;
if ( ! watchedItem ) {
// The parent directory of the edited resource often gets a change event too
// and ends up here. Print a warning, but most likely it's nothing important.
this . logger ( ) . debug ( ` ResourceEditWatcher: could not find resource ID from path: ${ path } ` ) ;
return ;
}
const resourceId = watchedItem . resourceId ;
const stat = await shim . fsDriver ( ) . stat ( path ) ;
const editedFileUpdatedTime = stat . mtime . getTime ( ) ;
2020-10-16 17:26:19 +02:00
// To check if the item has really changed we look at the updated time and size, which
// in most cases is sufficient. It could be a problem if the editing tool is making a change
// that neither changes the timestamp nor the file size. The alternative would be to compare
// the files byte for byte but that could be slow and the file might have changed again by
// the time we finished comparing.
if ( watchedItem . lastFileUpdatedTime === editedFileUpdatedTime && watchedItem . size === stat . size ) {
2020-07-22 20:03:31 +02:00
// 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
// in openAndWatch().
//
// We also need this because some events are handled twice - once in the "all" event
// handle and once in the "raw" event handler, due to a bug in chokidar. So having
// this check means we don't unecessarily save the resource twice when the file is
// modified by the user.
2020-10-16 17:26:19 +02:00
this . logger ( ) . debug ( ` ResourceEditWatcher: No timestamp and file size change - skip: ${ resourceId } ` ) ;
2020-07-22 20:03:31 +02:00
return ;
}
this . logger ( ) . debug ( ` ResourceEditWatcher: Queuing save action: ${ resourceId } ` ) ;
watchedItem . asyncSaveQueue . push ( makeSaveAction ( resourceId , path ) ) ;
watchedItem . lastFileUpdatedTime = editedFileUpdatedTime ;
2020-10-16 17:26:19 +02:00
watchedItem . size = stat . size ;
2020-07-24 01:06:58 +02:00
} ;
2020-07-22 20:03:31 +02:00
2020-05-30 14:25:05 +02:00
if ( ! this . watcher_ ) {
this . watcher_ = this . chokidar_ . watch ( fileToWatch ) ;
this . watcher_ . on ( 'all' , async ( event :any , path :string ) = > {
this . logger ( ) . info ( ` ResourceEditWatcher: Event: ${ event } : ${ path } ` ) ;
if ( event === 'unlink' ) {
// File are unwatched in the stopWatching functions below. When we receive an unlink event
// here it might be that the file is quickly moved to a different location and replaced by
// another file with the same name, as it happens with emacs. So because of this
// we keep watching anyway.
// See: https://github.com/laurent22/joplin/issues/710#issuecomment-420997167
// this.watcher_.unwatch(path);
} else if ( event === 'change' ) {
2020-07-22 20:03:31 +02:00
handleChangeEvent ( path ) ;
2020-05-30 14:25:05 +02:00
} else if ( event === 'error' ) {
this . logger ( ) . error ( 'ResourceEditWatcher: error' ) ;
}
} ) ;
2020-07-22 20:03:31 +02:00
2020-05-30 14:25:05 +02:00
// Hack to support external watcher on some linux applications (gedit, gvim, etc)
// taken from https://github.com/paulmillr/chokidar/issues/591
2020-07-22 20:03:31 +02:00
//
// 2020-07-22: It also applies when editing Excel files, which copy the new file
// then rename, so handling the "change" event alone is not enough as sometimes
// that event is not event triggered.
// https://github.com/laurent22/joplin/issues/3407
//
2020-05-30 14:25:05 +02:00
// @ts-ignore Leave unused path variable
this . watcher_ . on ( 'raw' , async ( event :string , path :string , options :any ) = > {
2020-07-22 20:03:31 +02:00
this . logger ( ) . debug ( ` ResourceEditWatcher: Raw event: ${ event } : ${ options . watchedPath } ` ) ;
2020-05-30 14:25:05 +02:00
if ( event === 'rename' ) {
this . watcher_ . unwatch ( options . watchedPath ) ;
this . watcher_ . add ( options . watchedPath ) ;
2020-07-22 20:03:31 +02:00
handleChangeEvent ( options . watchedPath ) ;
2020-05-30 14:25:05 +02:00
}
} ) ;
} else {
this . watcher_ . add ( fileToWatch ) ;
}
return this . watcher_ ;
}
public async openAndWatch ( resourceId :string ) {
2020-05-30 18:49:29 +02:00
let watchedItem = this . watchedItemByResourceId ( resourceId ) ;
if ( ! watchedItem ) {
// Immediately create and push the item to prevent race conditions
watchedItem = {
resourceId : resourceId ,
2020-05-31 18:43:51 +02:00
lastFileUpdatedTime : 0 ,
lastResourceUpdatedTime : 0 ,
2020-05-30 18:49:29 +02:00
asyncSaveQueue : new AsyncActionQueue ( 1000 ) ,
path : '' ,
2020-10-16 17:26:19 +02:00
size : - 1 ,
2020-05-30 18:49:29 +02:00
} ;
this . watchedItems_ [ resourceId ] = watchedItem ;
2020-05-30 14:25:05 +02:00
const resource = await Resource . load ( resourceId ) ;
if ( ! ( await Resource . isReady ( resource ) ) ) throw new Error ( _ ( 'This attachment is not downloaded or not decrypted yet' ) ) ;
const sourceFilePath = Resource . fullPath ( resource ) ;
2020-05-30 18:49:29 +02:00
const tempDir = await this . tempDir ( ) ;
const editFilePath = await shim . fsDriver ( ) . findUniqueFilename ( ` ${ tempDir } / ${ Resource . friendlySafeFilename ( resource ) } ` ) ;
2020-05-30 14:25:05 +02:00
await shim . fsDriver ( ) . copy ( sourceFilePath , editFilePath ) ;
const stat = await shim . fsDriver ( ) . stat ( editFilePath ) ;
2020-05-30 18:49:29 +02:00
watchedItem . path = editFilePath ;
2020-05-31 18:43:51 +02:00
watchedItem . lastFileUpdatedTime = stat . mtime . getTime ( ) ;
watchedItem . lastResourceUpdatedTime = resource . updated_time ;
2020-10-16 17:26:19 +02:00
watchedItem . size = stat . size ;
2020-05-30 14:25:05 +02:00
this . watch ( editFilePath ) ;
2020-07-22 20:03:31 +02:00
this . dispatch ( {
type : 'RESOURCE_EDIT_WATCHER_SET' ,
id : resource.id ,
title : resource.title ,
} ) ;
2020-05-30 14:25:05 +02:00
}
2020-05-30 18:49:29 +02:00
bridge ( ) . openItem ( watchedItem . path ) ;
this . logger ( ) . info ( ` ResourceEditWatcher: Started watching ${ watchedItem . path } ` ) ;
}
async stopWatching ( resourceId :string ) {
if ( ! resourceId ) return ;
const item = this . watchedItemByResourceId ( resourceId ) ;
if ( ! item ) {
this . logger ( ) . error ( ` ResourceEditWatcher: Trying to stop watching non-watched resource ${ resourceId } ` ) ;
return ;
}
await item . asyncSaveQueue . waitForAllDone ( ) ;
2020-07-22 20:03:31 +02:00
try {
if ( this . watcher_ ) this . watcher_ . unwatch ( item . path ) ;
await shim . fsDriver ( ) . remove ( item . path ) ;
} catch ( error ) {
this . logger ( ) . warn ( ` ResourceEditWatcher: There was an error unwatching resource ${ resourceId } . Joplin will ignore the file regardless. ` , error ) ;
}
2020-05-30 18:49:29 +02:00
delete this . watchedItems_ [ resourceId ] ;
2020-07-22 20:03:31 +02:00
this . dispatch ( {
type : 'RESOURCE_EDIT_WATCHER_REMOVE' ,
id : resourceId ,
} ) ;
2020-05-30 18:49:29 +02:00
this . logger ( ) . info ( ` ResourceEditWatcher: Stopped watching ${ item . path } ` ) ;
}
public async stopWatchingAll() {
const promises = [ ] ;
for ( const resourceId in this . watchedItems_ ) {
const item = this . watchedItems_ [ resourceId ] ;
promises . push ( this . stopWatching ( item . resourceId ) ) ;
}
2020-07-22 20:03:31 +02:00
await Promise . all ( promises ) ;
this . dispatch ( {
type : 'RESOURCE_EDIT_WATCHER_CLEAR' ,
} ) ;
2020-05-30 18:49:29 +02:00
}
2020-05-30 14:25:05 +02:00
2020-05-30 18:49:29 +02:00
private watchedItemByResourceId ( resourceId :string ) : WatchedItem {
return this . watchedItems_ [ resourceId ] ;
2020-05-30 14:25:05 +02:00
}
2020-05-30 18:49:29 +02:00
private watchedItemByPath ( path :string ) : WatchedItem {
for ( const resourceId in this . watchedItems_ ) {
const item = this . watchedItems_ [ resourceId ] ;
if ( item . path === path ) return item ;
2020-05-30 14:25:05 +02:00
}
return null ;
}
}