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');
|
2018-06-27 22:34:41 +02:00
|
|
|
const spawn = require('child_process').spawn;
|
2019-03-08 19:14:17 +02:00
|
|
|
const chokidar = require('chokidar');
|
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();
|
2018-11-21 21:50:50 +02:00
|
|
|
this.dispatch = (action) => {};
|
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_;
|
|
|
|
}
|
|
|
|
|
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) => {
|
|
|
|
this.logger().debug('ExternalEditWatcher: Event: ' + event + ': ' + path);
|
|
|
|
|
|
|
|
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) {
|
|
|
|
this.logger().warn('Watched note has been deleted: ' + id);
|
|
|
|
this.stopWatching(id);
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2018-06-18 20:56:07 +02:00
|
|
|
const noteContent = await shim.fsDriver().readFile(path, 'utf-8');
|
|
|
|
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);
|
|
|
|
this.eventEmitter_.emit('noteChange', { id: updatedNote.id });
|
|
|
|
}
|
|
|
|
|
|
|
|
this.skipNextChangeEvent_ = {};
|
|
|
|
} else if (event === 'error') {
|
|
|
|
this.logger().error('ExternalEditWatcher:');
|
|
|
|
this.logger().error(error)
|
|
|
|
}
|
|
|
|
});
|
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
|
|
|
|
this.watcher_.on('raw', async (event, path, {watchedPath}) => {
|
|
|
|
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_;
|
|
|
|
}
|
|
|
|
|
|
|
|
static instance() {
|
|
|
|
if (this.instance_) return this.instance_;
|
|
|
|
this.instance_ = new ExternalEditWatcher();
|
|
|
|
return this.instance_;
|
|
|
|
}
|
|
|
|
|
2019-05-11 13:08:28 +02:00
|
|
|
noteIdToFilePath_(noteId) {
|
2019-05-11 12:46:13 +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('/');
|
|
|
|
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];
|
|
|
|
}
|
|
|
|
|
2018-06-18 20:56:07 +02:00
|
|
|
watchedFiles() {
|
|
|
|
if (!this.watcher_) return [];
|
|
|
|
|
|
|
|
const output = [];
|
|
|
|
const watchedPaths = this.watcher_.getWatched();
|
|
|
|
|
|
|
|
for (let dirName in watchedPaths) {
|
|
|
|
if (!watchedPaths.hasOwnProperty(dirName)) continue;
|
|
|
|
|
|
|
|
for (let i = 0; i < watchedPaths[dirName].length; i++) {
|
|
|
|
const f = watchedPaths[dirName][i];
|
2019-05-11 12:46:13 +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();
|
|
|
|
|
|
|
|
for (let 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;
|
|
|
|
}
|
|
|
|
|
2018-06-27 22:34:41 +02:00
|
|
|
textEditorCommand() {
|
|
|
|
const editorCommand = Setting.value('editor');
|
|
|
|
if (!editorCommand) return null;
|
|
|
|
|
2018-11-20 23:46:18 +02:00
|
|
|
const s = splitCommandString(editorCommand, {handleEscape: false});
|
2018-06-27 22:34:41 +02:00
|
|
|
const path = s.splice(0, 1);
|
|
|
|
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) => {
|
|
|
|
|
2019-02-06 00:00:25 +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
|
|
|
|
2019-02-06 00:00:25 +02:00
|
|
|
const wrapError = (error) => {
|
|
|
|
if (!error) return error;
|
|
|
|
let msg = error.message ? [error.message] : [];
|
|
|
|
msg.push('Command was: "' + path + '" ' + args.join(' '));
|
|
|
|
error.message = msg.join('\n\n');
|
|
|
|
return error;
|
|
|
|
}
|
|
|
|
|
|
|
|
try {
|
|
|
|
const subProcess = spawn(path, args, options);
|
|
|
|
|
|
|
|
const iid = setInterval(() => {
|
|
|
|
if (subProcess && subProcess.pid) {
|
|
|
|
this.logger().debug('Started editor with PID ' + subProcess.pid);
|
|
|
|
clearInterval(iid);
|
|
|
|
resolve();
|
|
|
|
}
|
|
|
|
}, 100);
|
|
|
|
|
|
|
|
subProcess.on('error', (error) => {
|
|
|
|
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) {
|
|
|
|
bridge().openExternal('file://' + filePath);
|
|
|
|
} else {
|
|
|
|
cmd.args.push(filePath);
|
|
|
|
await this.spawnCommand(cmd.path, cmd.args, { detached: true });
|
|
|
|
}
|
2018-06-18 20:56:07 +02:00
|
|
|
|
|
|
|
this.dispatch({
|
|
|
|
type: 'NOTE_FILE_WATCHER_ADD',
|
|
|
|
id: note.id,
|
|
|
|
});
|
|
|
|
|
|
|
|
this.logger().info('ExternalEditWatcher: Started watching ' + filePath);
|
|
|
|
}
|
|
|
|
|
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
|
|
|
});
|
|
|
|
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;
|
|
|
|
}
|
|
|
|
|
|
|
|
this.logger().debug('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-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;
|