2023-07-27 17:05:56 +02:00
import Logger from '@joplin/utils/Logger' ;
2020-11-16 13:03:44 +02:00
import Setting from '../models/Setting' ;
import shim from '../shim' ;
2022-04-11 17:49:32 +02:00
import { basename , toSystemSlashes } from '../path-utils' ;
2020-11-16 13:03:44 +02:00
import time from '../time' ;
import { NoteEntity } from './database/types' ;
2021-01-22 19:41:11 +02:00
import Note from '../models/Note' ;
2022-04-11 17:49:32 +02:00
import { openFileWithExternalEditor } from './ExternalEditWatcher/utils' ;
2018-06-18 20:56:07 +02:00
const EventEmitter = require ( 'events' ) ;
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 {
2023-06-30 11:30:29 +02:00
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
2020-11-16 13:03:44 +02:00
private dispatch : Function ;
2023-06-30 11:30:29 +02:00
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
2020-11-16 13:03:44 +02:00
private bridge_ : Function ;
private logger_ : Logger = new Logger ( ) ;
2024-04-05 13:16:49 +02:00
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
2020-11-16 13:03:44 +02:00
private watcher_ : any = null ;
2024-04-05 13:16:49 +02:00
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
2020-11-16 13:03:44 +02:00
private eventEmitter_ : any = new EventEmitter ( ) ;
2024-04-05 13:16:49 +02:00
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
2020-11-16 13:03:44 +02:00
private skipNextChangeEvent_ : any = { } ;
2024-04-05 13:16:49 +02:00
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
2020-11-16 13:03:44 +02:00
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_ ;
}
2023-06-30 11:30:29 +02:00
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
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 {
2024-04-05 13:16:49 +02:00
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
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 ) ;
} ,
2024-04-05 13:16:49 +02:00
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
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
} ,
2024-04-05 13:16:49 +02:00
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
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
} ,
} ;
}
2023-03-06 16:22:01 +02:00
public tempDir() {
2019-05-11 12:46:13 +02:00
return Setting . value ( 'profileDir' ) ;
}
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 ) {
2018-06-18 20:56:07 +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 ) {
2018-06-18 20:56:07 +02:00
return this . eventEmitter_ . removeListener ( eventName , callback ) ;
}
2023-03-06 16:22:01 +02:00
public setLogger ( l : Logger ) {
2018-06-18 20:56:07 +02:00
this . logger_ = l ;
}
2023-03-06 16:22:01 +02:00
public logger() {
2018-06-18 20:56:07 +02:00
return this . logger_ ;
}
2023-03-06 16:22:01 +02:00
public 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
}
2022-08-29 16:09:30 +02:00
this . logger ( ) . debug ( 'ExternalEditWatcher: Updating note object.' ) ;
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 } ) ;
2022-08-29 16:09:30 +02:00
} else {
this . logger ( ) . debug ( 'ExternalEditWatcher: Skipping this event.' ) ;
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
}
} ) ;
} else {
this . watcher_ . add ( fileToWatch ) ;
}
return this . watcher_ ;
}
2023-03-06 16:22:01 +02:00
private 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
}
2023-03-06 16:22:01 +02:00
private noteFilePathToId_ ( path : string ) {
2024-04-05 13:16:49 +02:00
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
2020-11-16 13:03:44 +02:00
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 ] ;
}
2023-03-06 16:22:01 +02:00
public watchedFiles() {
2018-06-18 20:56:07 +02:00
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 ;
}
2023-03-06 16:22:01 +02:00
public 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 ;
}
2023-03-06 16:22:01 +02:00
public 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
2022-04-11 17:49:32 +02:00
await openFileWithExternalEditor ( filePath , this . bridge_ ( ) ) ;
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
}
2023-03-06 16:22:01 +02:00
public 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
}
2023-03-06 16:22:01 +02:00
public async stopWatchingAll() {
2018-06-18 20:56:07 +02:00
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' ,
} ) ;
}
2023-03-06 16:22:01 +02:00
public 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
}
2023-03-06 16:22:01 +02:00
private 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 ;
}
}