2020-11-16 13:03:44 +02:00
import Logger from '../Logger' ;
import Setting from '../models/Setting' ;
import shim from '../shim' ;
import { fileExtension , basename , toSystemSlashes } from '../path-utils' ;
import time from '../time' ;
import { NoteEntity } from './database/types' ;
2021-01-22 19:41:11 +02:00
import Note from '../models/Note' ;
2018-06-18 20:56:07 +02:00
const EventEmitter = require ( 'events' ) ;
2020-11-05 18:58:23 +02:00
const { splitCommandString } = require ( '../string-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' ) ;
2020-11-11 14:51:51 +02:00
const { ErrorNotFound } = require ( './rest/utils/errors' ) ;
2018-06-18 20:56:07 +02:00
2020-11-16 13:03:44 +02:00
export default class ExternalEditWatcher {
private dispatch : Function ;
private bridge_ : Function ;
private logger_ : Logger = new Logger ( ) ;
private watcher_ : any = null ;
private eventEmitter_ : any = new EventEmitter ( ) ;
private skipNextChangeEvent_ : any = { } ;
private chokidar_ : any = chokidar ;
private static instance_ : ExternalEditWatcher ;
2018-06-18 20:56:07 +02:00
2020-11-16 13:03:44 +02:00
public static instance() {
2018-11-21 21:50:50 +02:00
if ( this . instance_ ) return this . instance_ ;
this . instance_ = new ExternalEditWatcher ( ) ;
return this . instance_ ;
}
2020-11-16 13:03:44 +02:00
public initialize ( bridge : Function , dispatch : Function ) {
this . bridge_ = bridge ;
this . dispatch = dispatch ;
}
public externalApi() {
const loadNote = async ( noteId : string ) = > {
2020-06-20 13:34:05 +02:00
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 {
2020-11-16 13:03:44 +02:00
openAndWatch : async ( args : any ) = > {
const note = await loadNote ( args . noteId ) ;
2020-06-20 03:30:09 +02:00
return this . openAndWatch ( note ) ;
} ,
2020-11-16 13:03:44 +02:00
stopWatching : async ( args : any ) = > {
return this . stopWatching ( args . noteId ) ;
2020-06-20 03:30:09 +02:00
} ,
2020-11-16 13:03:44 +02:00
noteIsWatched : async ( args : any ) = > {
const note = await loadNote ( args . 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' ) ;
}
2020-11-16 13:03:44 +02:00
on ( eventName : string , callback : Function ) {
2018-06-18 20:56:07 +02:00
return this . eventEmitter_ . on ( eventName , callback ) ;
}
2020-11-16 13:03:44 +02:00
off ( eventName : string , callback : Function ) {
2018-06-18 20:56:07 +02:00
return this . eventEmitter_ . removeListener ( eventName , callback ) ;
}
2020-11-16 13:03:44 +02:00
setLogger ( l : Logger ) {
2018-06-18 20:56:07 +02:00
this . logger_ = l ;
}
logger() {
return this . logger_ ;
}
2020-11-16 13:03:44 +02:00
watch ( fileToWatch : string ) {
2019-03-01 01:24:28 +02:00
if ( ! this . chokidar_ ) return ;
2018-06-18 20:56:07 +02:00
if ( ! this . watcher_ ) {
2020-11-27 13:08:42 +02:00
this . watcher_ = this . chokidar_ . watch ( fileToWatch , {
useFsEvents : false ,
} ) ;
2020-11-16 13:03:44 +02:00
this . watcher_ . on ( 'all' , async ( event : string , path : string ) = > {
2020-11-16 18:34:05 +02:00
this . logger ( ) . debug ( ` 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 } ` ) ;
2020-11-25 16:40:25 +02:00
void this . stopWatching ( id ) ;
2018-11-21 21:50:50 +02:00
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
2020-11-16 13:03:44 +02:00
this . watcher_ . on ( 'raw' , async ( event : string , _path : string , options : any ) = > {
const watchedPath : string = options . watchedPath ;
2020-11-16 18:34:05 +02:00
this . logger ( ) . debug ( ` ExternalEditWatcher: Raw event: ${ event } : ${ 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_ ;
}
2020-11-16 13:03:44 +02:00
noteIdToFilePath_ ( noteId : string ) {
2019-09-19 23:51:18 +02:00
return ` ${ this . tempDir ( ) } /edit- ${ noteId } .md ` ;
2018-06-18 20:56:07 +02:00
}
2020-11-16 13:03:44 +02:00
noteFilePathToId_ ( path : string ) {
let id : any = toSystemSlashes ( path , 'linux' ) . 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 ;
}
2020-11-16 13:03:44 +02:00
noteIsWatched ( note : NoteEntity ) {
2018-06-18 20:56:07 +02:00
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 ,
} ;
}
2020-11-16 13:03:44 +02:00
async spawnCommand ( path : string , args : string [ ] , options : any ) {
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-11-16 13:03:44 +02:00
const wrapError = ( error : any ) = > {
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 ) ;
2020-10-09 19:35:46 +02:00
const iid = shim . setInterval ( ( ) = > {
2019-02-06 00:00:25 +02:00
if ( subProcess && subProcess . pid ) {
2020-11-16 18:34:05 +02:00
this . logger ( ) . debug ( ` Started editor with PID ${ subProcess . pid } ` ) ;
2020-10-09 19:35:46 +02:00
shim . clearInterval ( iid ) ;
2021-10-01 20:35:27 +02:00
resolve ( null ) ;
2019-02-06 00:00:25 +02:00
}
} , 100 ) ;
2020-11-16 13:03:44 +02:00
subProcess . on ( 'error' , ( error : any ) = > {
2020-10-09 19:35:46 +02:00
shim . clearInterval ( iid ) ;
2019-02-06 00:00:25 +02:00
reject ( wrapError ( error ) ) ;
} ) ;
} catch ( error ) {
throw wrapError ( error ) ;
}
2018-06-27 22:34:41 +02:00
} ) ;
}
2020-11-16 13:03:44 +02:00
async openAndWatch ( note : NoteEntity ) {
2018-06-18 20:56:07 +02:00
if ( ! note || ! note . id ) {
this . logger ( ) . warn ( 'ExternalEditWatcher: Cannot open note: ' , note ) ;
return ;
}
const filePath = await this . writeNoteToFile_ ( note ) ;
2020-11-16 13:03:44 +02:00
if ( ! filePath ) return ;
2018-06-18 20:56:07 +02:00
this . watch ( filePath ) ;
2018-06-27 22:34:41 +02:00
const cmd = this . textEditorCommand ( ) ;
if ( ! cmd ) {
2020-11-16 13:03:44 +02:00
this . 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
}
2020-11-16 13:03:44 +02:00
async stopWatching ( noteId : string ) {
2018-11-21 21:50:50 +02:00
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' ,
} ) ;
}
2020-11-16 13:03:44 +02:00
async updateNoteFile ( note : NoteEntity ) {
2018-06-18 20:56:07 +02:00
if ( ! this . noteIsWatched ( note ) ) return ;
if ( ! note || ! note . id ) {
this . logger ( ) . warn ( 'ExternalEditWatcher: Cannot update note file: ' , note ) ;
return ;
}
2020-11-16 18:34:05 +02:00
this . logger ( ) . debug ( ` 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 ;
2020-11-25 16:40:25 +02:00
await this . writeNoteToFile_ ( note ) ;
2018-06-18 20:56:07 +02:00
}
2020-11-16 13:03:44 +02:00
async writeNoteToFile_ ( note : NoteEntity ) {
2018-06-18 20:56:07 +02:00
if ( ! note || ! note . id ) {
this . logger ( ) . warn ( 'ExternalEditWatcher: Cannot update note file: ' , note ) ;
2020-11-16 13:03:44 +02:00
return null ;
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 ;
}
}