2020-07-22 20:03:31 +02:00
import AsyncActionQueue from '../../AsyncActionQueue' ;
2020-11-05 18:58:23 +02:00
import shim from '../../shim' ;
import { _ } from '../../locale' ;
2020-11-23 18:38:29 +02:00
import { toSystemSlashes } from '../../path-utils' ;
2023-07-27 17:05:56 +02:00
import Logger from '@joplin/utils/Logger' ;
2021-01-22 19:41:11 +02:00
import Setting from '../../models/Setting' ;
import Resource from '../../models/Resource' ;
2023-07-16 18:42:42 +02:00
import { ResourceEntity } from '../database/types' ;
2020-05-30 14:25:05 +02:00
const EventEmitter = require ( 'events' ) ;
const chokidar = require ( 'chokidar' ) ;
interface WatchedItem {
2020-11-12 21:29:22 +02:00
resourceId : string ;
lastFileUpdatedTime : number ;
lastResourceUpdatedTime : number ;
path : string ;
asyncSaveQueue : AsyncActionQueue ;
size : number ;
2020-05-30 18:49:29 +02:00
}
interface WatchedItems {
2020-11-12 21:29:22 +02:00
[ key : string ] : WatchedItem ;
2020-05-30 14:25:05 +02:00
}
2021-10-01 20:35:27 +02:00
type OpenItemFn = ( path : string ) = > void ;
2020-05-30 14:25:05 +02:00
export default class ResourceEditWatcher {
2020-11-12 21:13:28 +02:00
private static instance_ : ResourceEditWatcher ;
2020-05-30 14:25:05 +02:00
2020-11-12 21:13:28 +02:00
private logger_ : any ;
2023-06-30 11:30:29 +02:00
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
2020-11-12 21:13:28 +02:00
private dispatch : Function ;
private watcher_ : any ;
private chokidar_ : any ;
private watchedItems_ : WatchedItems = { } ;
private eventEmitter_ : any ;
2023-06-30 10:07:03 +02:00
private tempDir_ = '' ;
2021-10-01 20:35:27 +02:00
private openItem_ : OpenItemFn ;
2020-05-30 14:25:05 +02:00
2023-03-06 16:22:01 +02:00
public constructor ( ) {
2020-05-30 14:25:05 +02:00
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 ( ) ;
}
2023-06-30 11:30:29 +02:00
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
2023-03-06 16:22:01 +02:00
public initialize ( logger : any , dispatch : Function , openItem : OpenItemFn ) {
2020-05-30 14:25:05 +02:00
this . logger_ = logger ;
2020-07-22 20:03:31 +02:00
this . dispatch = dispatch ;
2021-10-01 20:35:27 +02:00
this . openItem_ = openItem ;
2020-05-30 14:25:05 +02:00
}
2023-03-06 16:22:01 +02:00
public static instance() {
2020-05-30 14:25:05 +02:00
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
}
2023-03-06 16:22:01 +02:00
public logger() {
2020-05-30 14:25:05 +02:00
return this . logger_ ;
}
2023-06-30 11:30:29 +02:00
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
2023-03-06 16:22:01 +02:00
public on ( eventName : string , callback : Function ) {
2020-05-30 14:25:05 +02:00
return this . eventEmitter_ . on ( eventName , callback ) ;
}
2023-06-30 11:30:29 +02:00
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
2023-03-06 16:22:01 +02:00
public off ( eventName : string , callback : Function ) {
2020-05-30 14:25:05 +02:00
return this . eventEmitter_ . removeListener ( eventName , callback ) ;
}
2023-03-06 16:22:01 +02:00
public externalApi() {
2020-10-29 13:09:18 +02:00
return {
2020-11-12 21:13:28 +02:00
openAndWatch : async ( { resourceId } : any ) = > {
2020-10-29 13:09:18 +02:00
return this . openAndWatch ( resourceId ) ;
} ,
2020-11-12 21:13:28 +02:00
watch : async ( { resourceId } : any ) = > {
2020-11-07 18:32:08 +02:00
await this . watch ( resourceId ) ;
} ,
2020-11-12 21:13:28 +02:00
stopWatching : async ( { resourceId } : any ) = > {
2020-10-29 13:09:18 +02:00
return this . stopWatching ( resourceId ) ;
} ,
2020-11-12 21:13:28 +02:00
isWatched : async ( { resourceId } : any ) = > {
2020-10-29 13:09:18 +02:00
return ! ! this . watchedItemByResourceId ( resourceId ) ;
} ,
} ;
}
2020-11-12 21:13:28 +02:00
private watchFile ( fileToWatch : string ) {
2020-05-30 14:25:05 +02:00
if ( ! this . chokidar_ ) return ;
2020-11-12 21:13:28 +02:00
const makeSaveAction = ( resourceId : string , path : string ) = > {
2020-05-30 18:10:25 +02:00
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-11-12 21:13:28 +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_ ) {
2020-11-27 13:08:42 +02:00
this . watcher_ = this . chokidar_ . watch ( fileToWatch , {
// Need to turn off fs-events because when it's on Chokidar
// keeps emitting "modified" events (on "raw" handler), several
// times per seconds, even when nothing is changed.
useFsEvents : false ,
} ) ;
2020-11-25 16:40:25 +02:00
this . watcher_ . on ( 'all' , ( event : any , path : string ) = > {
2020-11-27 13:08:42 +02:00
path = path ? toSystemSlashes ( path , 'linux' ) : '' ;
2020-11-23 18:38:29 +02:00
2020-05-30 14:25:05 +02:00
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-11-25 16:40:25 +02:00
void 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
//
2023-06-30 11:22:47 +02:00
this . watcher_ . on ( 'raw' , ( event : string , _path : string , options : any ) = > {
2020-11-27 13:08:42 +02:00
const watchedPath = options . watchedPath ? toSystemSlashes ( options . watchedPath , 'linux' ) : '' ;
2020-11-23 18:38:29 +02:00
this . logger ( ) . debug ( ` ResourceEditWatcher: Raw event: ${ event } : ${ watchedPath } ` ) ;
2020-05-30 14:25:05 +02:00
if ( event === 'rename' ) {
2020-11-23 18:38:29 +02:00
this . watcher_ . unwatch ( watchedPath ) ;
this . watcher_ . add ( watchedPath ) ;
2020-11-25 16:40:25 +02:00
void handleChangeEvent ( watchedPath ) ;
2020-05-30 14:25:05 +02:00
}
} ) ;
} else {
this . watcher_ . add ( fileToWatch ) ;
}
return this . watcher_ ;
}
2023-07-16 18:42:42 +02:00
private async makeEditPath ( resource : ResourceEntity ) {
const tempDir = await this . tempDir ( ) ;
return toSystemSlashes ( await shim . fsDriver ( ) . findUniqueFilename ( ` ${ tempDir } / ${ Resource . friendlySafeFilename ( resource ) } ` ) , 'linux' ) ;
}
private async copyResourceToEditablePath ( resourceId : string ) {
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 ) ;
const editFilePath = await this . makeEditPath ( resource ) ;
await shim . fsDriver ( ) . copy ( sourceFilePath , editFilePath ) ;
return { resource , editFilePath } ;
}
2020-11-12 21:13:28 +02:00
private async watch ( resourceId : string ) : Promise < WatchedItem > {
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 ;
2023-07-16 18:42:42 +02:00
const { resource , editFilePath } = await this . copyResourceToEditablePath ( resourceId ) ;
2020-05-30 14:25:05 +02:00
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
2020-11-07 18:32:08 +02:00
this . watchFile ( 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
this . logger ( ) . info ( ` ResourceEditWatcher: Started watching ${ watchedItem . path } ` ) ;
2020-11-07 18:32:08 +02:00
return watchedItem ;
}
2020-11-12 21:13:28 +02:00
public async openAndWatch ( resourceId : string ) {
2020-11-07 18:32:08 +02:00
const watchedItem = await this . watch ( resourceId ) ;
2021-10-01 20:35:27 +02:00
this . openItem_ ( watchedItem . path ) ;
2020-05-30 18:49:29 +02:00
}
2023-07-16 18:42:42 +02:00
// This call simply copies the resource file to a separate path and opens it.
// That way, even if it is changed, the real resource file on drive won't be
// affected.
public async openAsReadOnly ( resourceId : string ) {
const { editFilePath } = await this . copyResourceToEditablePath ( resourceId ) ;
await shim . fsDriver ( ) . chmod ( editFilePath , 0 o0666 ) ;
this . openItem_ ( editFilePath ) ;
}
2023-03-06 16:22:01 +02:00
public async stopWatching ( resourceId : string ) {
2020-05-30 18:49:29 +02:00
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-11-12 21:13:28 +02:00
private watchedItemByResourceId ( resourceId : string ) : WatchedItem {
2020-05-30 18:49:29 +02:00
return this . watchedItems_ [ resourceId ] ;
2020-05-30 14:25:05 +02:00
}
2020-11-12 21:13:28 +02:00
private watchedItemByPath ( path : string ) : WatchedItem {
2020-05-30 18:49:29 +02:00
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 ;
}
}