2018-06-18 20:56:07 +02:00
const { Logger } = require ( 'lib/logger.js' ) ;
const Note = require ( 'lib/models/Note' ) ;
const Setting = require ( 'lib/models/Setting' ) ;
const { shim } = require ( 'lib/shim' ) ;
const EventEmitter = require ( 'events' ) ;
2018-06-27 22:34:41 +02:00
const { splitCommandString } = require ( 'lib/string-utils' ) ;
2019-05-11 13:08:28 +02:00
const { fileExtension , basename } = require ( 'lib/path-utils' ) ;
2019-07-29 15:43:53 +02:00
const spawn = require ( 'child_process' ) . spawn ;
2019-03-08 19:14:17 +02:00
const chokidar = require ( 'chokidar' ) ;
2019-07-30 09:35:42 +02:00
const { bridge } = require ( 'electron' ) . remote . require ( './bridge' ) ;
2019-10-12 22:51:38 +02:00
const { time } = require ( 'lib/time-utils.js' ) ;
2020-06-20 13:34:05 +02:00
const { ErrorNotFound } = require ( './rest/errors' ) ;
2018-06-18 20:56:07 +02:00
class ExternalEditWatcher {
2018-11-21 21:50:50 +02:00
constructor ( ) {
2018-06-18 20:56:07 +02:00
this . logger _ = new Logger ( ) ;
2019-09-13 00:16:42 +02:00
this . dispatch = ( ) => { } ;
2018-06-18 20:56:07 +02:00
this . watcher _ = null ;
this . eventEmitter _ = new EventEmitter ( ) ;
this . skipNextChangeEvent _ = { } ;
2019-03-08 19:14:17 +02:00
this . chokidar _ = chokidar ;
2018-06-18 20:56:07 +02:00
}
2018-11-21 21:50:50 +02:00
static instance ( ) {
if ( this . instance _ ) return this . instance _ ;
this . instance _ = new ExternalEditWatcher ( ) ;
return this . instance _ ;
}
2020-06-20 03:30:09 +02:00
externalApi ( ) {
2020-06-20 13:34:05 +02:00
const loadNote = async ( noteId ) => {
const note = await Note . load ( noteId ) ;
if ( ! note ) throw new ErrorNotFound ( ` No such note: ${ noteId } ` ) ;
return note ;
} ;
2020-06-20 03:30:09 +02:00
return {
openAndWatch : async ( { noteId } ) => {
2020-06-20 13:34:05 +02:00
const note = await loadNote ( noteId ) ;
2020-06-20 03:30:09 +02:00
return this . openAndWatch ( note ) ;
} ,
stopWatching : async ( { noteId } ) => {
return this . stopWatching ( noteId ) ;
} ,
noteIsWatched : async ( { noteId } ) => {
2020-06-20 13:34:05 +02:00
const note = await loadNote ( noteId ) ;
2020-06-20 13:03:22 +02:00
return this . noteIsWatched ( note ) ;
2020-06-20 03:30:09 +02:00
} ,
} ;
}
2019-05-11 12:46:13 +02:00
tempDir ( ) {
return Setting . value ( 'profileDir' ) ;
}
2018-06-18 20:56:07 +02:00
on ( eventName , callback ) {
return this . eventEmitter _ . on ( eventName , callback ) ;
}
off ( eventName , callback ) {
return this . eventEmitter _ . removeListener ( eventName , callback ) ;
}
setLogger ( l ) {
this . logger _ = l ;
}
logger ( ) {
return this . logger _ ;
}
watch ( fileToWatch ) {
2019-03-01 01:24:28 +02:00
if ( ! this . chokidar _ ) return ;
2018-06-18 20:56:07 +02:00
if ( ! this . watcher _ ) {
2019-03-01 01:24:28 +02:00
this . watcher _ = this . chokidar _ . watch ( fileToWatch ) ;
2018-06-18 20:56:07 +02:00
this . watcher _ . on ( 'all' , async ( event , path ) => {
2019-10-02 20:04:50 +02:00
// For now, to investigate the lost content issue when using an external editor,
// make all the debug statement to info() so that it goes to the log file.
// Those that were previous debug() statements are marked as "was_debug"
/* was_debug */ this . logger ( ) . info ( ` ExternalEditWatcher: Event: ${ event } : ${ path } ` ) ;
2018-06-18 20:56:07 +02:00
if ( event === 'unlink' ) {
2018-09-13 20:29:48 +02:00
// 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);
2018-06-18 20:56:07 +02:00
} else if ( event === 'change' ) {
2019-05-11 13:08:28 +02:00
const id = this . noteFilePathToId _ ( path ) ;
2018-06-18 20:56:07 +02:00
if ( ! this . skipNextChangeEvent _ [ id ] ) {
const note = await Note . load ( id ) ;
2018-11-21 21:50:50 +02:00
if ( ! note ) {
2019-10-12 22:51:38 +02:00
this . logger ( ) . warn ( ` ExternalEditWatcher: Watched note has been deleted: ${ id } ` ) ;
2018-11-21 21:50:50 +02:00
this . stopWatching ( id ) ;
return ;
}
2019-10-12 22:51:38 +02:00
let noteContent = await shim . fsDriver ( ) . readFile ( path , 'utf-8' ) ;
2019-10-12 21:30:38 +02:00
2019-10-12 22:51:38 +02:00
// In some very rare cases, the "change" event is going to be emitted but the file will be empty.
// This is likely to be the editor that first clears the file, then writes the content to it, so if
// the file content is read very quickly after the change event, we'll get empty content.
// Usually, re-reading the content again will fix the issue and give back the file content.
// To replicate on Windows: associate Typora as external editor, and leave Ctrl+S pressed -
// it will keep on saving very fast and the bug should happen at some point.
// Below we re-read the file multiple times until we get the content, but in my tests it always
// work in the first try anyway. The loop is just for extra safety.
2019-10-12 21:30:38 +02:00
// https://github.com/laurent22/joplin/issues/1854
if ( ! noteContent ) {
2019-10-12 22:51:38 +02:00
this . logger ( ) . warn ( ` ExternalEditWatcher: Watched note is empty - this is likely to be a bug and re-reading the note should fix it. Trying again... ${ id } ` ) ;
for ( let i = 0 ; i < 10 ; i ++ ) {
noteContent = await shim . fsDriver ( ) . readFile ( path , 'utf-8' ) ;
if ( noteContent ) {
this . logger ( ) . info ( ` ExternalEditWatcher: Note is now readable: ${ id } ` ) ;
break ;
}
await time . msleep ( 100 ) ;
}
if ( ! noteContent ) this . logger ( ) . warn ( ` ExternalEditWatcher: Could not re-read note - user might have purposely deleted note content: ${ id } ` ) ;
2019-10-12 21:30:38 +02:00
}
2018-06-18 20:56:07 +02:00
const updatedNote = await Note . unserializeForEdit ( noteContent ) ;
updatedNote . id = id ;
2018-11-21 21:50:50 +02:00
updatedNote . parent _id = note . parent _id ;
2018-06-18 20:56:07 +02:00
await Note . save ( updatedNote ) ;
2020-05-02 17:41:07 +02:00
this . eventEmitter _ . emit ( 'noteChange' , { id : updatedNote . id , note : updatedNote } ) ;
2018-06-18 20:56:07 +02:00
}
this . skipNextChangeEvent _ = { } ;
} else if ( event === 'error' ) {
2019-07-30 09:35:42 +02:00
this . logger ( ) . error ( 'ExternalEditWatcher: error' ) ;
2018-06-18 20:56:07 +02:00
}
} ) ;
2019-06-20 01:44:51 +02:00
// Hack to support external watcher on some linux applications (gedit, gvim, etc)
// taken from https://github.com/paulmillr/chokidar/issues/591
2019-07-29 15:43:53 +02:00
this . watcher _ . on ( 'raw' , async ( event , path , { watchedPath } ) => {
2019-06-20 01:44:51 +02:00
if ( event === 'rename' ) {
this . watcher _ . unwatch ( watchedPath ) ;
this . watcher _ . add ( watchedPath ) ;
}
} ) ;
2018-06-18 20:56:07 +02:00
} else {
this . watcher _ . add ( fileToWatch ) ;
}
return this . watcher _ ;
}
2019-05-11 13:08:28 +02:00
noteIdToFilePath _ ( noteId ) {
2019-09-19 23:51:18 +02:00
return ` ${ this . tempDir ( ) } /edit- ${ noteId } .md ` ;
2018-06-18 20:56:07 +02:00
}
2019-05-11 13:08:28 +02:00
noteFilePathToId _ ( path ) {
let id = path . split ( '/' ) ;
2019-09-19 23:51:18 +02:00
if ( ! id . length ) throw new Error ( ` Invalid path: ${ path } ` ) ;
2019-05-11 13:08:28 +02:00
id = id [ id . length - 1 ] ;
id = id . split ( '.' ) ;
id . pop ( ) ;
id = id [ 0 ] . split ( '-' ) ;
return id [ 1 ] ;
}
2018-06-18 20:56:07 +02:00
watchedFiles ( ) {
if ( ! this . watcher _ ) return [ ] ;
const output = [ ] ;
const watchedPaths = this . watcher _ . getWatched ( ) ;
2020-03-14 01:46:14 +02:00
for ( const dirName in watchedPaths ) {
2018-06-18 20:56:07 +02:00
if ( ! watchedPaths . hasOwnProperty ( dirName ) ) continue ;
for ( let i = 0 ; i < watchedPaths [ dirName ] . length ; i ++ ) {
const f = watchedPaths [ dirName ] [ i ] ;
2019-09-19 23:51:18 +02:00
output . push ( ` ${ this . tempDir ( ) } / ${ f } ` ) ;
2018-06-18 20:56:07 +02:00
}
}
return output ;
}
noteIsWatched ( note ) {
if ( ! this . watcher _ ) return false ;
2019-05-11 13:08:28 +02:00
const noteFilename = basename ( this . noteIdToFilePath _ ( note . id ) ) ;
2018-06-18 20:56:07 +02:00
const watchedPaths = this . watcher _ . getWatched ( ) ;
2020-03-14 01:46:14 +02:00
for ( const dirName in watchedPaths ) {
2018-06-18 20:56:07 +02:00
if ( ! watchedPaths . hasOwnProperty ( dirName ) ) continue ;
for ( let i = 0 ; i < watchedPaths [ dirName ] . length ; i ++ ) {
const f = watchedPaths [ dirName ] [ i ] ;
if ( f === noteFilename ) return true ;
}
}
return false ;
}
2018-06-27 22:34:41 +02:00
textEditorCommand ( ) {
const editorCommand = Setting . value ( 'editor' ) ;
if ( ! editorCommand ) return null ;
2019-07-29 15:43:53 +02:00
const s = splitCommandString ( editorCommand , { handleEscape : false } ) ;
2018-06-27 22:34:41 +02:00
const path = s . splice ( 0 , 1 ) ;
2019-09-19 23:51:18 +02:00
if ( ! path . length ) throw new Error ( ` Invalid editor command: ${ editorCommand } ` ) ;
2018-06-27 22:34:41 +02:00
return {
path : path [ 0 ] ,
args : s ,
} ;
}
2019-11-12 19:51:57 +02:00
async spawnCommand ( path , args , options ) {
2018-06-27 22:34:41 +02:00
return new Promise ( ( resolve , reject ) => {
2019-11-12 19:51:57 +02:00
// App bundles need to be opened using the `open` command.
// Additional args can be specified after --args, and the
// -n flag is needed to ensure that the app is always launched
// with the arguments. Without it, if the app is already opened,
// it will just bring it to the foreground without opening the file.
// So the full command is:
//
// open -n /path/to/editor.app --args -app-flag -bla /path/to/file.md
//
if ( shim . isMac ( ) && fileExtension ( path ) === 'app' ) {
args = args . slice ( ) ;
args . splice ( 0 , 0 , '--args' ) ;
args . splice ( 0 , 0 , path ) ;
args . splice ( 0 , 0 , '-n' ) ;
path = 'open' ;
}
2018-06-27 22:34:41 +02:00
2020-05-21 10:14:33 +02:00
const wrapError = error => {
2019-02-06 00:00:25 +02:00
if ( ! error ) return error ;
2020-03-14 01:46:14 +02:00
const msg = error . message ? [ error . message ] : [ ] ;
2019-09-19 23:51:18 +02:00
msg . push ( ` Command was: " ${ path } " ${ args . join ( ' ' ) } ` ) ;
2019-02-06 00:00:25 +02:00
error . message = msg . join ( '\n\n' ) ;
return error ;
2019-07-29 15:43:53 +02:00
} ;
2019-02-06 00:00:25 +02:00
try {
const subProcess = spawn ( path , args , options ) ;
const iid = setInterval ( ( ) => {
if ( subProcess && subProcess . pid ) {
2019-10-02 20:04:50 +02:00
/* was_debug */ this . logger ( ) . info ( ` Started editor with PID ${ subProcess . pid } ` ) ;
2019-02-06 00:00:25 +02:00
clearInterval ( iid ) ;
resolve ( ) ;
}
} , 100 ) ;
2020-05-21 10:14:33 +02:00
subProcess . on ( 'error' , error => {
2019-02-06 00:00:25 +02:00
clearInterval ( iid ) ;
reject ( wrapError ( error ) ) ;
} ) ;
} catch ( error ) {
throw wrapError ( error ) ;
}
2018-06-27 22:34:41 +02:00
} ) ;
}
2018-06-18 20:56:07 +02:00
async openAndWatch ( note ) {
if ( ! note || ! note . id ) {
this . logger ( ) . warn ( 'ExternalEditWatcher: Cannot open note: ' , note ) ;
return ;
}
const filePath = await this . writeNoteToFile _ ( note ) ;
this . watch ( filePath ) ;
2018-06-27 22:34:41 +02:00
const cmd = this . textEditorCommand ( ) ;
if ( ! cmd ) {
2019-11-12 19:51:57 +02:00
bridge ( ) . openExternal ( ` file:// ${ filePath } ` ) ;
2018-06-27 22:34:41 +02:00
} else {
cmd . args . push ( filePath ) ;
2019-11-12 19:51:57 +02:00
await this . spawnCommand ( cmd . path , cmd . args , { detached : true } ) ;
2018-06-27 22:34:41 +02:00
}
2018-06-18 20:56:07 +02:00
this . dispatch ( {
type : 'NOTE_FILE_WATCHER_ADD' ,
id : note . id ,
} ) ;
2019-09-19 23:51:18 +02:00
this . logger ( ) . info ( ` ExternalEditWatcher: Started watching ${ filePath } ` ) ;
2018-06-18 20:56:07 +02:00
}
2018-11-21 21:50:50 +02:00
async stopWatching ( noteId ) {
if ( ! noteId ) return ;
2018-06-18 20:56:07 +02:00
2019-05-11 13:08:28 +02:00
const filePath = this . noteIdToFilePath _ ( noteId ) ;
2018-06-18 20:56:07 +02:00
if ( this . watcher _ ) this . watcher _ . unwatch ( filePath ) ;
await shim . fsDriver ( ) . remove ( filePath ) ;
this . dispatch ( {
type : 'NOTE_FILE_WATCHER_REMOVE' ,
2018-11-21 21:50:50 +02:00
id : noteId ,
2018-06-18 20:56:07 +02:00
} ) ;
2019-09-19 23:51:18 +02:00
this . logger ( ) . info ( ` ExternalEditWatcher: Stopped watching ${ filePath } ` ) ;
2018-06-18 20:56:07 +02:00
}
async stopWatchingAll ( ) {
const filePaths = this . watchedFiles ( ) ;
for ( let i = 0 ; i < filePaths . length ; i ++ ) {
await shim . fsDriver ( ) . remove ( filePaths [ i ] ) ;
}
if ( this . watcher _ ) this . watcher _ . close ( ) ;
this . watcher _ = null ;
this . logger ( ) . info ( 'ExternalEditWatcher: Stopped watching all files' ) ;
this . dispatch ( {
type : 'NOTE_FILE_WATCHER_CLEAR' ,
} ) ;
}
async updateNoteFile ( note ) {
if ( ! this . noteIsWatched ( note ) ) return ;
if ( ! note || ! note . id ) {
this . logger ( ) . warn ( 'ExternalEditWatcher: Cannot update note file: ' , note ) ;
return ;
}
2019-10-02 20:04:50 +02:00
/* was_debug */ this . logger ( ) . info ( ` ExternalEditWatcher: Update note file: ${ note . id } ` ) ;
2018-06-18 20:56:07 +02:00
// When the note file is updated programmatically, we skip the next change event to
// avoid update loops. We only want to listen to file changes made by the user.
this . skipNextChangeEvent _ [ note . id ] = true ;
this . writeNoteToFile _ ( note ) ;
}
async writeNoteToFile _ ( note ) {
if ( ! note || ! note . id ) {
this . logger ( ) . warn ( 'ExternalEditWatcher: Cannot update note file: ' , note ) ;
return ;
2019-07-29 15:43:53 +02:00
}
2018-06-18 20:56:07 +02:00
2019-05-11 13:08:28 +02:00
const filePath = this . noteIdToFilePath _ ( note . id ) ;
2018-06-18 20:56:07 +02:00
const noteContent = await Note . serializeForEdit ( note ) ;
await shim . fsDriver ( ) . writeFile ( filePath , noteContent , 'utf-8' ) ;
return filePath ;
}
}
2019-06-20 01:44:51 +02:00
module . exports = ExternalEditWatcher ;