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

308 lines
10 KiB
TypeScript

import Logger from '@joplin/utils/Logger';
import Setting from '../models/Setting';
import shim from '../shim';
import { basename, toSystemSlashes } from '../path-utils';
import time from '../time';
import { NoteEntity } from './database/types';
import Note from '../models/Note';
import { openFileWithExternalEditor } from './ExternalEditWatcher/utils';
const EventEmitter = require('events');
const chokidar = require('chokidar');
const { ErrorNotFound } = require('./rest/utils/errors');
export default class ExternalEditWatcher {
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
private dispatch: Function;
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
private bridge_: Function;
private logger_: Logger = new Logger();
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
private watcher_: any = null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
private eventEmitter_: any = new EventEmitter();
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
private skipNextChangeEvent_: any = {};
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
private chokidar_: any = chokidar;
private static instance_: ExternalEditWatcher;
public static instance() {
if (this.instance_) return this.instance_;
this.instance_ = new ExternalEditWatcher();
return this.instance_;
}
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
public initialize(bridge: Function, dispatch: Function) {
this.bridge_ = bridge;
this.dispatch = dispatch;
}
public externalApi() {
const loadNote = async (noteId: string) => {
const note = await Note.load(noteId);
if (!note) throw new ErrorNotFound(`No such note: ${noteId}`);
return note;
};
return {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
openAndWatch: async (args: any) => {
const note = await loadNote(args.noteId);
return this.openAndWatch(note);
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
stopWatching: async (args: any) => {
return this.stopWatching(args.noteId);
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
noteIsWatched: async (args: any) => {
const note = await loadNote(args.noteId);
return this.noteIsWatched(note);
},
};
}
public tempDir() {
return Setting.value('profileDir');
}
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
public on(eventName: string, callback: Function) {
return this.eventEmitter_.on(eventName, callback);
}
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
public off(eventName: string, callback: Function) {
return this.eventEmitter_.removeListener(eventName, callback);
}
public setLogger(l: Logger) {
this.logger_ = l;
}
public logger() {
return this.logger_;
}
public watch(fileToWatch: string) {
if (!this.chokidar_) return;
if (!this.watcher_) {
this.watcher_ = this.chokidar_.watch(fileToWatch, {
useFsEvents: false,
});
this.watcher_.on('all', async (event: string, path: string) => {
this.logger().debug(`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}`);
void 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}`);
}
this.logger().debug('ExternalEditWatcher: Updating note object.');
const updatedNote = await Note.unserializeForEdit(noteContent);
updatedNote.id = id;
updatedNote.parent_id = note.parent_id;
await Note.save(updatedNote);
this.eventEmitter_.emit('noteChange', { id: updatedNote.id, note: updatedNote });
} else {
this.logger().debug('ExternalEditWatcher: Skipping this event.');
}
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
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
this.watcher_.on('raw', async (event: string, _path: string, options: any) => {
const watchedPath: string = options.watchedPath;
this.logger().debug(`ExternalEditWatcher: Raw event: ${event}: ${watchedPath}`);
if (event === 'rename') {
this.watcher_.unwatch(watchedPath);
this.watcher_.add(watchedPath);
}
});
} else {
this.watcher_.add(fileToWatch);
}
return this.watcher_;
}
private noteIdToFilePath_(noteId: string) {
return `${this.tempDir()}/edit-${noteId}.md`;
}
private noteFilePathToId_(path: string) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
let id: any = toSystemSlashes(path, 'linux').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];
}
public 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];
output.push(`${this.tempDir()}/${f}`);
}
}
return output;
}
public noteIsWatched(note: NoteEntity) {
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;
}
public async openAndWatch(note: NoteEntity) {
if (!note || !note.id) {
this.logger().warn('ExternalEditWatcher: Cannot open note: ', note);
return;
}
const filePath = await this.writeNoteToFile_(note);
if (!filePath) return;
this.watch(filePath);
await openFileWithExternalEditor(filePath, this.bridge_());
this.dispatch({
type: 'NOTE_FILE_WATCHER_ADD',
id: note.id,
});
this.logger().info(`ExternalEditWatcher: Started watching ${filePath}`);
}
public async stopWatching(noteId: string) {
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,
});
this.logger().info(`ExternalEditWatcher: Stopped watching ${filePath}`);
}
public 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',
});
}
public async updateNoteFile(note: NoteEntity) {
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;
await this.writeNoteToFile_(note);
}
private async writeNoteToFile_(note: NoteEntity) {
if (!note || !note.id) {
this.logger().warn('ExternalEditWatcher: Cannot update note file: ', note);
return null;
}
const filePath = this.noteIdToFilePath_(note.id);
const noteContent = await Note.serializeForEdit(note);
await shim.fsDriver().writeFile(filePath, noteContent, 'utf-8');
return filePath;
}
}