1
0
mirror of https://github.com/laurent22/joplin.git synced 2024-12-12 08:54:00 +02:00
joplin/packages/lib/services/ExternalEditWatcher.js

353 lines
10 KiB
JavaScript
Raw Normal View History

2020-11-05 18:58:23 +02:00
const Logger = require('../Logger').default;
const Note = require('../models/Note');
const Setting = require('../models/Setting').default;
const shim = require('../shim').default;
const EventEmitter = require('events');
2020-11-05 18:58:23 +02:00
const { splitCommandString } = require('../string-utils');
const { fileExtension, basename } = require('../path-utils');
2019-07-29 15:43:53 +02:00
const spawn = require('child_process').spawn;
const chokidar = require('chokidar');
const bridge = require('electron').remote.require('./bridge').default;
2020-11-05 18:58:23 +02:00
const time = require('../time').default;
2020-11-11 14:51:51 +02:00
const { ErrorNotFound } = require('./rest/utils/errors');
class ExternalEditWatcher {
constructor() {
this.logger_ = new Logger();
this.dispatch = () => {};
this.watcher_ = null;
this.eventEmitter_ = new EventEmitter();
this.skipNextChangeEvent_ = {};
this.chokidar_ = chokidar;
}
static instance() {
if (this.instance_) return this.instance_;
this.instance_ = new ExternalEditWatcher();
return this.instance_;
}
externalApi() {
const loadNote = async (noteId) => {
const note = await Note.load(noteId);
if (!note) throw new ErrorNotFound(`No such note: ${noteId}`);
return note;
};
return {
openAndWatch: async ({ noteId }) => {
const note = await loadNote(noteId);
return this.openAndWatch(note);
},
stopWatching: async ({ noteId }) => {
return this.stopWatching(noteId);
},
noteIsWatched: async ({ noteId }) => {
const note = await loadNote(noteId);
return this.noteIsWatched(note);
},
};
}
tempDir() {
return Setting.value('profileDir');
}
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) {
if (!this.chokidar_) return;
if (!this.watcher_) {
this.watcher_ = this.chokidar_.watch(fileToWatch);
this.watcher_.on('all', async (event, path) => {
// 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}`);
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') {
const id = this.noteFilePathToId_(path);
if (!this.skipNextChangeEvent_[id]) {
const note = await Note.load(id);
if (!note) {
this.logger().warn(`ExternalEditWatcher: Watched note has been deleted: ${id}`);
this.stopWatching(id);
return;
}
let noteContent = await shim.fsDriver().readFile(path, 'utf-8');
// 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.
// https://github.com/laurent22/joplin/issues/1854
if (!noteContent) {
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}`);
}
const updatedNote = await Note.unserializeForEdit(noteContent);
updatedNote.id = id;
updatedNote.parent_id = note.parent_id;
await Note.save(updatedNote);
Refactor note editor Refactor note editor using React Hooks and TypeScript and moved editor-specific code to separate files. Moved business logic into more maintainable custom hooks. Squashed commit of the following: commit f243d9bf89bdcfa1849ee26df5c0dd3e33405010 Author: Laurent Cozic <laurent@cozic.net> Date: Sat May 2 16:04:14 2020 +0100 Fixed saving issue commit 055f68d2e8b6cf6f130336c38ac2ab480887583d Author: Laurent Cozic <laurent@cozic.net> Date: Sat May 2 15:43:38 2020 +0100 Fixed HTML notes commit 99a3cf71f58d2fedcdf3001bf4110b6e8e3993da Merge: 9be85c45f2 b16ebbbf7a Author: Laurent Cozic <laurent@cozic.net> Date: Sat May 2 12:54:42 2020 +0100 Merge branch 'master' into refactor_note_text commit 9be85c45f23e5cb1ecd612b0ee631947871ada6f Author: Laurent Cozic <laurent@cozic.net> Date: Sat May 2 12:21:01 2020 +0100 Ident to space commit 848dde1869c010fe5851f493ef7287ada5f2991e Author: Laurent Cozic <laurent@cozic.net> Date: Sat May 2 11:28:50 2020 +0100 Refactor prop types commit 13c3bbe2b4f9a522ea3f8a25e7e5e7bb026dfd4f Author: Laurent Cozic <laurent@cozic.net> Date: Sat May 2 11:15:45 2020 +0100 Fixed resource loading issue commit 50cb38e3f00ef40ea8b6a468eadd66728a3ec332 Author: Laurent Cozic <laurent@cozic.net> Date: Fri May 1 23:46:58 2020 +0100 Fixed resource loading logic commit bc42ed03735f50c8394d597bb9e67312e55752fe Author: Laurent Cozic <laurent@cozic.net> Date: Fri May 1 23:08:41 2020 +0100 Various fixes commit 03c038e6d6cbde03bd474798b96c4eb120fd1647 Author: Laurent Cozic <laurent@cozic.net> Date: Wed Apr 29 23:22:49 2020 +0100 Fixed resource handling commit dc6c15302fac094c4e7dec5a20c9fcc4edb3d132 Author: Laurent Cozic <laurent@cozic.net> Date: Wed Apr 29 22:55:13 2020 +0100 Moved more code to files commit 398d5121e53df34de89b4148ef2cfd3a7bbe4feb Author: Laurent Cozic <laurent@cozic.net> Date: Wed Apr 29 00:22:43 2020 +0000 More fixes commit 3ebbb80147d7d502fd955776c7fedb743400597f Author: Laurent Cozic <laurent@cozic.net> Date: Wed Apr 29 00:12:44 2020 +0000 Various improvements and bug fixes commit 52a65ed3875e0709117ca93ba723e20624577d05 Author: Laurent Cozic <laurent@cozic.net> Date: Tue Apr 28 23:51:07 2020 +0000 Move more code to sub-files commit 33ccf530fb442d7ddae0852cbab2c335efdbbf33 Author: Laurent Cozic <laurent@cozic.net> Date: Tue Apr 28 23:25:12 2020 +0100 Moved code to sub-files commit ba3ad2cf9fcc1d7809df4afe93cd9737585a9960 Merge: 445acdab73 150ee14de6 Author: Laurent Cozic <laurent@cozic.net> Date: Tue Apr 28 22:28:56 2020 +0100 Merge branch 'master' into refactor_note_text commit 445acdab7368345369d7f69b9becd1e77c8383dc Author: Laurent Cozic <laurent@cozic.net> Date: Tue Apr 28 19:01:41 2020 +0100 Imported more code commit 772481d3a3ac7f0b0b00e86394c0f4fd2f3a9fa7 Author: Laurent Cozic <laurent@cozic.net> Date: Mon Apr 27 23:43:17 2020 +0000 Handle save/load state commit b3b92192ae3a1a30e3018810346cebfad47ac5e3 Author: Laurent Cozic <laurent@cozic.net> Date: Mon Apr 27 23:11:11 2020 +0000 Clean up and added back scroll commit 7a19ecfd0cb7fef1d58ece2e024099c7e40986da Author: Laurent Cozic <laurent@cozic.net> Date: Mon Apr 27 22:29:39 2020 +0100 More refactoring commit ac388afd381eaecfa4582b3566d032c9d953c4dc Author: Laurent Cozic <laurent@cozic.net> Date: Sun Apr 26 17:07:01 2020 +0100 Restored print commit 1d2c0ed389a5398dacc584d24922c5ea0dda861a Author: Laurent Cozic <laurent@cozic.net> Date: Sun Apr 26 12:03:15 2020 +0100 Put back search commit c618cb59d43fa3bb507dbd0b757b302ecfe907b3 Author: Laurent Cozic <laurent@cozic.net> Date: Sat Apr 25 18:21:11 2020 +0100 Restore scrolling behaviour commit 324e6ea79ebafab1d2bca246ef030751147a47eb Author: Laurent Cozic <laurent@cozic.net> Date: Sat Apr 25 10:22:31 2020 +0100 Simplified saving notes commit ef089aaf2289193bf275d94c1f2785f6d88657e4 Author: Laurent Cozic <laurent@cozic.net> Date: Sat Apr 25 10:12:16 2020 +0100 More refactoring commit 61b102307d5a98d2c1502d7bf073592da21af720 Author: Laurent Cozic <laurent@cozic.net> Date: Fri Apr 24 18:04:44 2020 +0100 Added back note revisions commit 7d5e3694d0df044b8493d9114e89e2d81c9b69ad Author: Laurent Cozic <laurent@cozic.net> Date: Thu Apr 23 22:51:52 2020 +0000 More note toolbar refactoring commit a56d58e7c80d91f29afadaffaaa004f3254482f7 Author: Laurent Cozic <laurent@cozic.net> Date: Thu Apr 23 20:54:37 2020 +0100 Finished toolbar refactoring commit 6c8ef9f44f880a9569eed5c54c9c47dca2251e5e Author: Laurent Cozic <laurent@cozic.net> Date: Thu Apr 23 19:17:44 2020 +0100 More refactoring commit 7de8057158a9256e2e0dcf948081e10a6a642216 Author: Laurent Cozic <laurent@cozic.net> Date: Wed Apr 22 23:48:42 2020 +0100 Started refactoring commands commit 177263c85e7d17d8ddc01b583738c2ab14b3acd7 Merge: f58f1a06e0 7ceb68d835 Author: Laurent Cozic <laurent@cozic.net> Date: Wed Apr 22 20:26:19 2020 +0100 Merge branch 'master' into refactor_note_text commit f58f1a06e08b3cf80e2ac7a794b15f4b5caf8932 Author: Laurent Cozic <laurent@cozic.net> Date: Wed Apr 22 20:25:43 2020 +0100 Moving Ace Editor to separate component commit a83d3a220515137985c0f334f5848c91b8539138 Author: Laurent Cozic <laurent@cozic.net> Date: Mon Apr 20 20:33:21 2020 +0000 Cleaned up directory structure for note editor commit c6f2e609c9443bac21de5033bbedf86ac6f12cc0 Author: Laurent Cozic <laurent@cozic.net> Date: Mon Apr 20 19:23:06 2020 +0100 Added "note" menu to move note-related items to it commit 1219465318ae5a7a2c777ae2ec15d3357e1499df Author: Laurent Cozic <laurent@cozic.net> Date: Mon Apr 20 19:05:04 2020 +0100 Moved note related toolbar to separate component
2020-05-02 17:41:07 +02:00
this.eventEmitter_.emit('noteChange', { id: updatedNote.id, note: updatedNote });
}
this.skipNextChangeEvent_ = {};
} else if (event === 'error') {
this.logger().error('ExternalEditWatcher: error');
}
});
// 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 }) => {
/* was_debug */ this.logger().info(`ExternalEditWatcher: Raw event: ${event}: ${watchedPath}`);
if (event === 'rename') {
this.watcher_.unwatch(watchedPath);
this.watcher_.add(watchedPath);
}
});
} else {
this.watcher_.add(fileToWatch);
}
return this.watcher_;
}
noteIdToFilePath_(noteId) {
2019-09-19 23:51:18 +02:00
return `${this.tempDir()}/edit-${noteId}.md`;
}
noteFilePathToId_(path) {
let id = path.split('/');
2019-09-19 23:51:18 +02:00
if (!id.length) throw new Error(`Invalid path: ${path}`);
id = id[id.length - 1];
id = id.split('.');
id.pop();
id = id[0].split('-');
return id[1];
}
watchedFiles() {
if (!this.watcher_) return [];
const output = [];
const watchedPaths = this.watcher_.getWatched();
for (const dirName in watchedPaths) {
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}`);
}
}
return output;
}
noteIsWatched(note) {
if (!this.watcher_) return false;
const noteFilename = basename(this.noteIdToFilePath_(note.id));
const watchedPaths = this.watcher_.getWatched();
for (const dirName in watchedPaths) {
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;
}
textEditorCommand() {
const editorCommand = Setting.value('editor');
if (!editorCommand) return null;
2019-07-29 15:43:53 +02:00
const s = splitCommandString(editorCommand, { handleEscape: false });
const path = s.splice(0, 1);
2019-09-19 23:51:18 +02:00
if (!path.length) throw new Error(`Invalid editor command: ${editorCommand}`);
return {
path: path[0],
args: s,
};
}
async spawnCommand(path, args, options) {
return new Promise((resolve, reject) => {
// 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';
}
const wrapError = error => {
if (!error) return error;
const msg = error.message ? [error.message] : [];
2019-09-19 23:51:18 +02:00
msg.push(`Command was: "${path}" ${args.join(' ')}`);
error.message = msg.join('\n\n');
return error;
2019-07-29 15:43:53 +02:00
};
try {
const subProcess = spawn(path, args, options);
const iid = shim.setInterval(() => {
if (subProcess && subProcess.pid) {
/* was_debug */ this.logger().info(`Started editor with PID ${subProcess.pid}`);
shim.clearInterval(iid);
resolve();
}
}, 100);
subProcess.on('error', error => {
shim.clearInterval(iid);
reject(wrapError(error));
});
} catch (error) {
throw wrapError(error);
}
});
}
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);
const cmd = this.textEditorCommand();
if (!cmd) {
bridge().openExternal(`file://${filePath}`);
} else {
cmd.args.push(filePath);
await this.spawnCommand(cmd.path, cmd.args, { detached: true });
}
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}`);
}
async stopWatching(noteId) {
if (!noteId) return;
const filePath = this.noteIdToFilePath_(noteId);
if (this.watcher_) this.watcher_.unwatch(filePath);
await shim.fsDriver().remove(filePath);
this.dispatch({
type: 'NOTE_FILE_WATCHER_REMOVE',
id: noteId,
});
2019-09-19 23:51:18 +02:00
this.logger().info(`ExternalEditWatcher: Stopped watching ${filePath}`);
}
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;
}
/* was_debug */ this.logger().info(`ExternalEditWatcher: Update note file: ${note.id}`);
// 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
}
const filePath = this.noteIdToFilePath_(note.id);
const noteContent = await Note.serializeForEdit(note);
await shim.fsDriver().writeFile(filePath, noteContent, 'utf-8');
return filePath;
}
}
module.exports = ExternalEditWatcher;