-
-
Notifications
You must be signed in to change notification settings - Fork 5.2k
/
Copy pathindex.ts
346 lines (290 loc) · 12.6 KB
/
index.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
import AsyncActionQueue from '../../AsyncActionQueue';
import shim from '../../shim';
import { _ } from '../../locale';
import { toSystemSlashes } from '../../path-utils';
import Logger from '@joplin/utils/Logger';
import Setting from '../../models/Setting';
import Resource from '../../models/Resource';
import { ResourceEntity } from '../database/types';
const EventEmitter = require('events');
const chokidar = require('chokidar');
interface WatchedItem {
resourceId: string;
lastFileUpdatedTime: number;
lastResourceUpdatedTime: number;
path: string;
asyncSaveQueue: AsyncActionQueue;
size: number;
}
interface WatchedItems {
[key: string]: WatchedItem;
}
type OpenItemFn = (path: string)=> void;
export default class ResourceEditWatcher {
private static instance_: ResourceEditWatcher;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
private logger_: any;
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
private dispatch: Function;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
private watcher_: any;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
private chokidar_: any;
private watchedItems_: WatchedItems = {};
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
private eventEmitter_: any;
private tempDir_ = '';
private openItem_: OpenItemFn;
public constructor() {
this.logger_ = new Logger();
this.dispatch = () => {};
this.watcher_ = null;
this.chokidar_ = chokidar;
this.eventEmitter_ = new EventEmitter();
}
// eslint-disable-next-line @typescript-eslint/ban-types, @typescript-eslint/no-explicit-any -- Old code before rule was applied, Old code before rule was applied
public initialize(logger: any, dispatch: Function, openItem: OpenItemFn) {
this.logger_ = logger;
this.dispatch = dispatch;
this.openItem_ = openItem;
}
public static instance() {
if (this.instance_) return this.instance_;
this.instance_ = new ResourceEditWatcher();
return this.instance_;
}
private async tempDir() {
if (!this.tempDir_) {
this.tempDir_ = `${Setting.value('tempDir')}/edited_resources`;
await shim.fsDriver().mkdir(this.tempDir_);
}
return this.tempDir_;
}
public logger() {
return this.logger_;
}
// 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 externalApi() {
return {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
openAndWatch: async ({ resourceId }: any) => {
return this.openAndWatch(resourceId);
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
watch: async ({ resourceId }: any) => {
await this.watch(resourceId);
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
stopWatching: async ({ resourceId }: any) => {
return this.stopWatching(resourceId);
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
isWatched: async ({ resourceId }: any) => {
return !!this.watchedItemByResourceId(resourceId);
},
};
}
private watchFile(fileToWatch: string) {
if (!this.chokidar_) return;
const makeSaveAction = (resourceId: string, path: string) => {
return async () => {
this.logger().info(`ResourceEditWatcher: Saving resource ${resourceId}`);
const resource = await Resource.load(resourceId);
const watchedItem = this.watchedItemByResourceId(resourceId);
if (resource.updated_time !== watchedItem.lastResourceUpdatedTime) {
this.logger().info(`ResourceEditWatcher: Conflict was detected (resource was modified from somewhere else, possibly via sync). Conflict note will be created: ${resourceId}`);
// The resource has been modified from elsewhere, for example via sync
// so copy the current version to the Conflict notebook, and overwrite
// the resource content.
await Resource.createConflictResourceNote(resource);
}
const savedResource = await Resource.updateResourceBlobContent(resourceId, path);
watchedItem.lastResourceUpdatedTime = savedResource.updated_time;
this.eventEmitter_.emit('resourceChange', { id: resourceId });
};
};
const handleChangeEvent = async (path: string) => {
this.logger().debug(`ResourceEditWatcher: handleChangeEvent: ${path}`);
const watchedItem = this.watchedItemByPath(path);
if (!watchedItem) {
// The parent directory of the edited resource often gets a change event too
// and ends up here. Print a warning, but most likely it's nothing important.
this.logger().debug(`ResourceEditWatcher: could not find resource ID from path: ${path}`);
return;
}
const resourceId = watchedItem.resourceId;
const stat = await shim.fsDriver().stat(path);
const editedFileUpdatedTime = stat.mtime.getTime();
// To check if the item has really changed we look at the updated time and size, which
// in most cases is sufficient. It could be a problem if the editing tool is making a change
// that neither changes the timestamp nor the file size. The alternative would be to compare
// the files byte for byte but that could be slow and the file might have changed again by
// the time we finished comparing.
if (watchedItem.lastFileUpdatedTime === editedFileUpdatedTime && watchedItem.size === stat.size) {
// chokidar is buggy and emits "change" events even when nothing has changed
// so double-check the modified time and skip processing if there's no change.
// In particular it emits two such events just after the file has been copied
// in openAndWatch().
//
// We also need this because some events are handled twice - once in the "all" event
// handle and once in the "raw" event handler, due to a bug in chokidar. So having
// this check means we don't unnecessarily save the resource twice when the file is
// modified by the user.
this.logger().debug(`ResourceEditWatcher: No timestamp and file size change - skip: ${resourceId}`);
return;
}
this.logger().debug(`ResourceEditWatcher: Queuing save action: ${resourceId}`);
watchedItem.asyncSaveQueue.push(makeSaveAction(resourceId, path));
watchedItem.lastFileUpdatedTime = editedFileUpdatedTime;
watchedItem.size = stat.size;
};
if (!this.watcher_) {
this.watcher_ = this.chokidar_.watch(fileToWatch, {
// Need to turn off fs-events because when it's on Chokidar
// keeps emitting "modified" events (on "raw" handler), several
// times per seconds, even when nothing is changed.
useFsEvents: false,
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
this.watcher_.on('all', (event: any, path: string) => {
path = path ? toSystemSlashes(path, 'linux') : '';
this.logger().info(`ResourceEditWatcher: 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') {
void handleChangeEvent(path);
} else if (event === 'error') {
this.logger().error('ResourceEditWatcher: error');
}
});
// Hack to support external watcher on some linux applications (gedit, gvim, etc)
// taken from https://github.com/paulmillr/chokidar/issues/591
//
// 2020-07-22: It also applies when editing Excel files, which copy the new file
// then rename, so handling the "change" event alone is not enough as sometimes
// that event is not event triggered.
// https://github.com/laurent22/joplin/issues/3407
//
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
this.watcher_.on('raw', (event: string, _path: string, options: any) => {
const watchedPath = options.watchedPath ? toSystemSlashes(options.watchedPath, 'linux') : '';
this.logger().debug(`ResourceEditWatcher: Raw event: ${event}: ${watchedPath}`);
if (event === 'rename') {
this.watcher_.unwatch(watchedPath);
this.watcher_.add(watchedPath);
void handleChangeEvent(watchedPath);
}
});
} else {
this.watcher_.add(fileToWatch);
}
return this.watcher_;
}
private async makeEditPath(resource: ResourceEntity) {
const tempDir = await this.tempDir();
return toSystemSlashes(await shim.fsDriver().findUniqueFilename(`${tempDir}/${Resource.friendlySafeFilename(resource)}`), 'linux');
}
private async copyResourceToEditablePath(resourceId: string) {
const resource = await Resource.load(resourceId);
if (!(await Resource.isReady(resource))) throw new Error(_('This attachment is not downloaded or not decrypted yet'));
const sourceFilePath = Resource.fullPath(resource);
const editFilePath = await this.makeEditPath(resource);
await shim.fsDriver().copy(sourceFilePath, editFilePath);
return { resource, editFilePath };
}
private async watch(resourceId: string): Promise<WatchedItem> {
let watchedItem = this.watchedItemByResourceId(resourceId);
if (!watchedItem) {
// Immediately create and push the item to prevent race conditions
watchedItem = {
resourceId: resourceId,
lastFileUpdatedTime: 0,
lastResourceUpdatedTime: 0,
asyncSaveQueue: new AsyncActionQueue(1000),
path: '',
size: -1,
};
this.watchedItems_[resourceId] = watchedItem;
const { resource, editFilePath } = await this.copyResourceToEditablePath(resourceId);
const stat = await shim.fsDriver().stat(editFilePath);
watchedItem.path = editFilePath;
watchedItem.lastFileUpdatedTime = stat.mtime.getTime();
watchedItem.lastResourceUpdatedTime = resource.updated_time;
watchedItem.size = stat.size;
this.watchFile(editFilePath);
this.dispatch({
type: 'RESOURCE_EDIT_WATCHER_SET',
id: resource.id,
title: resource.title,
});
}
this.logger().info(`ResourceEditWatcher: Started watching ${watchedItem.path}`);
return watchedItem;
}
public async openAndWatch(resourceId: string) {
const watchedItem = await this.watch(resourceId);
this.openItem_(watchedItem.path);
}
// This call simply copies the resource file to a separate path and opens it.
// That way, even if it is changed, the real resource file on drive won't be
// affected.
public async openAsReadOnly(resourceId: string) {
const { editFilePath } = await this.copyResourceToEditablePath(resourceId);
await shim.fsDriver().chmod(editFilePath, 0o0666);
this.openItem_(editFilePath);
}
public async stopWatching(resourceId: string) {
if (!resourceId) return;
const item = this.watchedItemByResourceId(resourceId);
if (!item) {
this.logger().error(`ResourceEditWatcher: Trying to stop watching non-watched resource ${resourceId}`);
return;
}
await item.asyncSaveQueue.waitForAllDone();
try {
if (this.watcher_) this.watcher_.unwatch(item.path);
await shim.fsDriver().remove(item.path);
} catch (error) {
this.logger().warn(`ResourceEditWatcher: There was an error unwatching resource ${resourceId}. Joplin will ignore the file regardless.`, error);
}
delete this.watchedItems_[resourceId];
this.dispatch({
type: 'RESOURCE_EDIT_WATCHER_REMOVE',
id: resourceId,
});
this.logger().info(`ResourceEditWatcher: Stopped watching ${item.path}`);
}
public async stopWatchingAll() {
const promises = [];
for (const resourceId in this.watchedItems_) {
const item = this.watchedItems_[resourceId];
promises.push(this.stopWatching(item.resourceId));
}
await Promise.all(promises);
this.dispatch({
type: 'RESOURCE_EDIT_WATCHER_CLEAR',
});
}
private watchedItemByResourceId(resourceId: string): WatchedItem {
return this.watchedItems_[resourceId];
}
private watchedItemByPath(path: string): WatchedItem {
for (const resourceId in this.watchedItems_) {
const item = this.watchedItems_[resourceId];
if (item.path === path) return item;
}
return null;
}
}