diff --git a/.eslintignore b/.eslintignore index 443d34d99..5a24f5684 100644 --- a/.eslintignore +++ b/.eslintignore @@ -96,6 +96,7 @@ ReactNativeClient/lib/joplin-renderer/MdToHtml/rules/fence.js ReactNativeClient/lib/joplin-renderer/MdToHtml/rules/mermaid.js ReactNativeClient/lib/joplin-renderer/MdToHtml/rules/sanitize_html.js ReactNativeClient/lib/JoplinServerApi.js +ReactNativeClient/lib/services/ResourceEditWatcher.js ReactNativeClient/PluginAssetsLoader.js ReactNativeClient/setUpQuickActions.js # AUTO-GENERATED - EXCLUDED TYPESCRIPT BUILD diff --git a/.gitignore b/.gitignore index 75b322edc..2523c4775 100644 --- a/.gitignore +++ b/.gitignore @@ -86,6 +86,7 @@ ReactNativeClient/lib/joplin-renderer/MdToHtml/rules/fence.js ReactNativeClient/lib/joplin-renderer/MdToHtml/rules/mermaid.js ReactNativeClient/lib/joplin-renderer/MdToHtml/rules/sanitize_html.js ReactNativeClient/lib/JoplinServerApi.js +ReactNativeClient/lib/services/ResourceEditWatcher.js ReactNativeClient/PluginAssetsLoader.js ReactNativeClient/setUpQuickActions.js # AUTO-GENERATED - EXCLUDED TYPESCRIPT BUILD diff --git a/ElectronClient/app.js b/ElectronClient/app.js index 3753eb60c..15e8ea4b6 100644 --- a/ElectronClient/app.js +++ b/ElectronClient/app.js @@ -23,6 +23,7 @@ const InteropServiceHelper = require('./InteropServiceHelper.js'); const ResourceService = require('lib/services/ResourceService'); const ClipperServer = require('lib/ClipperServer'); const ExternalEditWatcher = require('lib/services/ExternalEditWatcher'); +const ResourceEditWatcher = require('lib/services/ResourceEditWatcher').default; const { bridge } = require('electron').remote.require('./bridge'); const { shell, webFrame, clipboard } = require('electron'); const Menu = bridge().Menu; @@ -1505,6 +1506,8 @@ class Application extends BaseApplication { ExternalEditWatcher.instance().setLogger(reg.logger()); ExternalEditWatcher.instance().dispatch = this.store().dispatch; + ResourceEditWatcher.instance().initialize(reg.logger(), this.store().dispatch); + RevisionService.instance().runInBackground(); this.updateMenuItemStates(); diff --git a/ElectronClient/gui/NoteEditor/NoteBody/TinyMCE/TinyMCE.tsx b/ElectronClient/gui/NoteEditor/NoteBody/TinyMCE/TinyMCE.tsx index cb73686b1..41830ec4c 100644 --- a/ElectronClient/gui/NoteEditor/NoteBody/TinyMCE/TinyMCE.tsx +++ b/ElectronClient/gui/NoteEditor/NoteBody/TinyMCE/TinyMCE.tsx @@ -158,7 +158,10 @@ const TinyMCE = (props:NoteBodyEditorProps, ref:any) => { const markupToHtml = useRef(null); markupToHtml.current = props.markupToHtml; - const lastOnChangeEventContent = useRef(''); + const lastOnChangeEventInfo = useRef({ + content: null, + resourceInfos: null, + }); const rootIdRef = useRef(`tinymce-${Date.now()}${Math.round(Math.random() * 10000)}`); const editorRef = useRef(null); @@ -761,10 +764,17 @@ const TinyMCE = (props:NoteBodyEditorProps, ref:any) => { let cancelled = false; const loadContent = async () => { - if (lastOnChangeEventContent.current !== props.content) { + if (lastOnChangeEventInfo.current.content !== props.content || lastOnChangeEventInfo.current.resourceInfos !== props.resourceInfos) { + console.info('RELOAD CONTENT'); + const result = await props.markupToHtml(props.contentMarkupLanguage, props.content, markupRenderOptions({ resourceInfos: props.resourceInfos })); if (cancelled) return; - lastOnChangeEventContent.current = props.content; + + lastOnChangeEventInfo.current = { + content: props.content, + resourceInfos: props.resourceInfos, + }; + editor.setContent(result.html); } @@ -859,7 +869,7 @@ const TinyMCE = (props:NoteBodyEditorProps, ref:any) => { if (!editor) return; - lastOnChangeEventContent.current = contentMd; + lastOnChangeEventInfo.current.content = contentMd; props_onChangeRef.current({ changeId: changeId, diff --git a/ElectronClient/gui/NoteEditor/utils/contextMenu.ts b/ElectronClient/gui/NoteEditor/utils/contextMenu.ts index 23f6c1d03..e57a5cf07 100644 --- a/ElectronClient/gui/NoteEditor/utils/contextMenu.ts +++ b/ElectronClient/gui/NoteEditor/utils/contextMenu.ts @@ -7,6 +7,8 @@ const { clipboard } = require('electron'); const { toSystemSlashes } = require('lib/path-utils'); const { _ } = require('lib/locale'); +import ResourceEditWatcher from '../../../lib/services/ResourceEditWatcher'; + export enum ContextMenuItemType { None = '', Image = 'image', @@ -42,9 +44,12 @@ export function menuItems():ContextMenuItems { open: { label: _('Open...'), onAction: async (options:ContextMenuOptions) => { - const { resourcePath } = await resourceInfo(options); - const ok = bridge().openExternal(`file://${resourcePath}`); - if (!ok) bridge().showErrorMessageBox(_('This file could not be opened: %s', resourcePath)); + try { + await ResourceEditWatcher.instance().openAndWatch(options.resourceId); + } catch (error) { + console.error(error); + bridge().showErrorMessageBox(error.message); + } }, isActive: (itemType:ContextMenuItemType) => itemType === ContextMenuItemType.Image || itemType === ContextMenuItemType.Resource, }, diff --git a/ElectronClient/gui/NoteEditor/utils/useFormNote.ts b/ElectronClient/gui/NoteEditor/utils/useFormNote.ts index fdea86a0a..53e100f3a 100644 --- a/ElectronClient/gui/NoteEditor/utils/useFormNote.ts +++ b/ElectronClient/gui/NoteEditor/utils/useFormNote.ts @@ -11,6 +11,7 @@ const Setting = require('lib/models/Setting'); const { reg } = require('lib/registry.js'); const ResourceFetcher = require('lib/services/ResourceFetcher.js'); const DecryptionWorker = require('lib/services/DecryptionWorker.js'); +const ResourceEditWatcher = require('lib/services/ResourceEditWatcher.js').default; export interface OnLoadEvent { formNote: FormNote, @@ -30,12 +31,14 @@ function installResourceChangeHandler(onResourceChangeHandler: Function) { ResourceFetcher.instance().on('downloadComplete', onResourceChangeHandler); ResourceFetcher.instance().on('downloadStarted', onResourceChangeHandler); DecryptionWorker.instance().on('resourceDecrypted', onResourceChangeHandler); + ResourceEditWatcher.instance().on('resourceChange', onResourceChangeHandler); } function uninstallResourceChangeHandler(onResourceChangeHandler: Function) { ResourceFetcher.instance().off('downloadComplete', onResourceChangeHandler); ResourceFetcher.instance().off('downloadStarted', onResourceChangeHandler); DecryptionWorker.instance().off('resourceDecrypted', onResourceChangeHandler); + ResourceEditWatcher.instance().off('resourceChange', onResourceChangeHandler); } export default function useFormNote(dependencies:HookDependencies) { diff --git a/ElectronClient/gui/NoteEditor/utils/useMessageHandler.ts b/ElectronClient/gui/NoteEditor/utils/useMessageHandler.ts index 941ce2fb9..7f4ee9fe9 100644 --- a/ElectronClient/gui/NoteEditor/utils/useMessageHandler.ts +++ b/ElectronClient/gui/NoteEditor/utils/useMessageHandler.ts @@ -10,6 +10,7 @@ const { urlDecode } = require('lib/string-utils'); const urlUtils = require('lib/urlUtils'); const ResourceFetcher = require('lib/services/ResourceFetcher.js'); const { reg } = require('lib/registry.js'); +import ResourceEditWatcher from '../../../lib/services/ResourceEditWatcher'; export default function useMessageHandler(scrollWhenReady:any, setScrollWhenReady:Function, editorRef:any, setLocalSearchResultCount:Function, dispatch:Function, formNote:FormNote) { return useCallback(async (event: any) => { @@ -17,7 +18,7 @@ export default function useMessageHandler(scrollWhenReady:any, setScrollWhenRead const args = event.args; const arg0 = args && args.length >= 1 ? args[0] : null; - if (msg !== 'percentScroll') console.info(`Got ipc-message: ${msg}`, args); + if (msg !== 'percentScroll') console.info(`Got ipc-message: ${msg}`, arg0); if (msg.indexOf('error:') === 0) { const s = msg.split(':'); @@ -60,8 +61,13 @@ export default function useMessageHandler(scrollWhenReady:any, setScrollWhenRead } return; } - const filePath = Resource.fullPath(item); - bridge().openItem(filePath); + + try { + await ResourceEditWatcher.instance().openAndWatch(item.id); + } catch (error) { + console.error(error); + bridge().showErrorMessageBox(error.message); + } } else if (item.type_ === BaseModel.TYPE_NOTE) { dispatch({ type: 'FOLDER_AND_NOTE_SELECT', diff --git a/ReactNativeClient/lib/joplin-renderer/MdToHtml/rules/link_open.js b/ReactNativeClient/lib/joplin-renderer/MdToHtml/rules/link_open.js index 12b7278f3..0e16671c0 100644 --- a/ReactNativeClient/lib/joplin-renderer/MdToHtml/rules/link_open.js +++ b/ReactNativeClient/lib/joplin-renderer/MdToHtml/rules/link_open.js @@ -23,8 +23,9 @@ function installRule(markdownIt, mdOptions, ruleOptions) { let icon = ''; let hrefAttr = '#'; let mime = ''; + let resourceId = ''; if (isResourceUrl) { - const resourceId = resourceHrefInfo.itemId; + resourceId = resourceHrefInfo.itemId; const result = ruleOptions.resources[resourceId]; const resourceStatus = utils.resourceStatus(ruleOptions.ResourceModel, result); @@ -62,7 +63,7 @@ function installRule(markdownIt, mdOptions, ruleOptions) { // https://github.com/laurent22/joplin/issues/2030 href = href.replace(/'/g, '%27'); - let js = `${ruleOptions.postMessageSyntax}(${JSON.stringify(href)}); return false;`; + let js = `${ruleOptions.postMessageSyntax}(${JSON.stringify(href)}, { resourceId: ${JSON.stringify(resourceId)} }); return false;`; if (hrefAttr.indexOf('#') === 0 && href.indexOf('#') === 0) js = ''; // If it's an internal anchor, don't add any JS since the webview is going to handle navigating to the right place if (ruleOptions.plainResourceRendering || pluginOptions.linkRenderingType === 2) { diff --git a/ReactNativeClient/lib/joplin-renderer/utils.js b/ReactNativeClient/lib/joplin-renderer/utils.js index 1460bc2d0..c526ea231 100644 --- a/ReactNativeClient/lib/joplin-renderer/utils.js +++ b/ReactNativeClient/lib/joplin-renderer/utils.js @@ -141,6 +141,7 @@ utils.imageReplacement = function(ResourceModel, src, resources, resourceBaseUrl if (ResourceModel.isSupportedImageMimeType(mime)) { let newSrc = `./${ResourceModel.filename(resource)}`; if (resourceBaseUrl) newSrc = resourceBaseUrl + newSrc; + newSrc += `?t=${resource.updated_time}`; return { 'data-resource-id': resource.id, src: newSrc, diff --git a/ReactNativeClient/lib/models/Note.js b/ReactNativeClient/lib/models/Note.js index e96ba43ff..ee1edeaa1 100644 --- a/ReactNativeClient/lib/models/Note.js +++ b/ReactNativeClient/lib/models/Note.js @@ -149,7 +149,7 @@ class Note extends BaseItem { const id = resourceIds[i]; const resource = await Resource.load(id); if (!resource) continue; - const resourcePath = options.useAbsolutePaths ? `file://${Resource.fullPath(resource)}` : Resource.relativePath(resource); + const resourcePath = options.useAbsolutePaths ? `${`file://${Resource.fullPath(resource)}` + '?t='}${resource.updated_time}` : Resource.relativePath(resource); body = body.replace(new RegExp(`:/${id}`, 'gi'), markdownUtils.escapeLinkUrl(resourcePath)); } @@ -174,7 +174,7 @@ class Note extends BaseItem { this.logger().info('replaceResourceExternalToInternalLinks', 'options:', options, 'pathsToTry:', pathsToTry, 'body:', body); for (const basePath of pathsToTry) { - const reString = `${pregQuote(`${basePath}/`)}[a-zA-Z0-9.]+`; + const reString = `${pregQuote(`${basePath}/`)}[a-zA-Z0-9.]+\\?t=[0-9]+`; const re = new RegExp(reString, 'gi'); body = body.replace(re, match => { const id = Resource.pathToId(match); diff --git a/ReactNativeClient/lib/models/Resource.js b/ReactNativeClient/lib/models/Resource.js index 473438aec..5344442d1 100644 --- a/ReactNativeClient/lib/models/Resource.js +++ b/ReactNativeClient/lib/models/Resource.js @@ -67,6 +67,7 @@ class Resource extends BaseItem { return Resource.fsDriver_; } + // DEPRECATED IN FAVOUR OF friendlySafeFilename() static friendlyFilename(resource) { let output = safeFilename(resource.title); // Make sure not to allow spaces or any special characters as it's not supported in HTTP headers if (!output) output = resource.id; @@ -91,6 +92,15 @@ class Resource extends BaseItem { return resource.id + extension; } + static friendlySafeFilename(resource) { + let ext = resource.extension; + if (!ext) ext = resource.mime ? mime.toFileExtension(resource.mime) : ''; + const safeExt = ext ? pathUtils.safeFileExtension(ext).toLowerCase() : ''; + let title = resource.title ? resource.title : resource.id; + if (safeExt && pathUtils.fileExtension(title).toLowerCase() === safeExt) title = pathUtils.filename(title); + return pathUtils.friendlySafeFilename(title) + (safeExt ? `.${safeExt}` : ''); + } + static relativePath(resource, encryptedBlob = false) { return `${Setting.value('resourceDirName')}/${this.filename(resource, encryptedBlob)}`; } diff --git a/ReactNativeClient/lib/services/ResourceEditWatcher.ts b/ReactNativeClient/lib/services/ResourceEditWatcher.ts new file mode 100644 index 000000000..61d5a0b2b --- /dev/null +++ b/ReactNativeClient/lib/services/ResourceEditWatcher.ts @@ -0,0 +1,160 @@ +const { Logger } = require('lib/logger.js'); +const Setting = require('lib/models/Setting'); +const Resource = require('lib/models/Resource'); +const { shim } = require('lib/shim'); +const EventEmitter = require('events'); +const chokidar = require('chokidar'); +const { bridge } = require('electron').remote.require('./bridge'); +const { _ } = require('lib/locale'); + +interface WatchedItem { + [key: string]: { + resourceId: string, + updatedTime: number, + } +} + +export default class ResourceEditWatcher { + + private static instance_:ResourceEditWatcher; + + private logger_:any; + // private dispatch:Function; + private watcher_:any; + private chokidar_:any; + private watchedItems_:WatchedItem = {}; + private eventEmitter_:any; + + constructor() { + this.logger_ = new Logger(); + // this.dispatch = () => {}; + this.watcher_ = null; + this.chokidar_ = chokidar; + this.eventEmitter_ = new EventEmitter(); + } + + initialize(logger:any/* , dispatch:Function*/) { + this.logger_ = logger; + // this.dispatch = dispatch; + } + + static instance() { + if (this.instance_) return this.instance_; + this.instance_ = new ResourceEditWatcher(); + return this.instance_; + } + + tempDir() { + return Setting.value('tempDir'); + } + + logger() { + return this.logger_; + } + + on(eventName:string, callback:Function) { + return this.eventEmitter_.on(eventName, callback); + } + + off(eventName:string, callback:Function) { + return this.eventEmitter_.removeListener(eventName, callback); + } + + private watch(fileToWatch:string) { + if (!this.chokidar_) return; + + if (!this.watcher_) { + this.watcher_ = this.chokidar_.watch(fileToWatch); + this.watcher_.on('all', async (event:any, path:string) => { + 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') { + const watchedItem = this.watchedItems_[path]; + const stat = await shim.fsDriver().stat(path); + const updatedTime = stat.mtime.getTime(); + + if (watchedItem.updatedTime === updatedTime) { + // 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(). + return; + } + + if (!watchedItem) { + this.logger().error(`ResourceEditWatcher: could not find IDs from path: ${path}`); + return; + } + + const resourceId = watchedItem.resourceId; + + await shim.updateResourceBlob(resourceId, path); + + this.watchedItems_[path] = { + resourceId: resourceId, + updatedTime: updatedTime, + }; + + this.eventEmitter_.emit('resourceChange', { id: resourceId }); + + // TODO: handle race conditions + } 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 + // @ts-ignore Leave unused path variable + this.watcher_.on('raw', async (event:string, path:string, options:any) => { + if (event === 'rename') { + this.watcher_.unwatch(options.watchedPath); + this.watcher_.add(options.watchedPath); + } + }); + } else { + this.watcher_.add(fileToWatch); + } + + return this.watcher_; + } + + public async openAndWatch(resourceId:string) { + let editFilePath = this.resourceIdToPath(resourceId); + + if (!editFilePath) { + 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); + editFilePath = await shim.fsDriver().findUniqueFilename(`${this.tempDir()}/${Resource.friendlySafeFilename(resource)}`); + await shim.fsDriver().copy(sourceFilePath, editFilePath); + const stat = await shim.fsDriver().stat(editFilePath); + + this.watchedItems_[editFilePath] = { + resourceId: resourceId, + updatedTime: stat.mtime.getTime(), + }; + + this.watch(editFilePath); + } + + bridge().openItem(editFilePath); + + this.logger().info(`ResourceEditWatcher: Started watching ${editFilePath}`); + } + + private resourceIdToPath(resourceId:string):string { + for (const path in this.watchedItems_) { + const item = this.watchedItems_[path]; + if (item.resourceId === resourceId) return path; + } + return null; + } + +} diff --git a/ReactNativeClient/lib/shim-init-node.js b/ReactNativeClient/lib/shim-init-node.js index d1e4ffb39..04f252c99 100644 --- a/ReactNativeClient/lib/shim-init-node.js +++ b/ReactNativeClient/lib/shim-init-node.js @@ -144,7 +144,7 @@ function shimInit() { shim.createResourceFromPath = async function(filePath, defaultProps = null, options = null) { options = Object.assign({ - resizeLargeImages: 'always', // 'always' or 'ask' + resizeLargeImages: 'always', // 'always', 'ask' or 'never' }, options); const readChunk = require('read-chunk'); @@ -182,7 +182,7 @@ function shimInit() { const targetPath = Resource.fullPath(resource); - if (['image/jpeg', 'image/jpg', 'image/png'].includes(resource.mime)) { + if (options.resizeLargeImages !== 'never' && ['image/jpeg', 'image/jpg', 'image/png'].includes(resource.mime)) { const ok = await handleResizeImage_(filePath, targetPath, resource.mime, options.resizeLargeImages); if (!ok) return null; } else { @@ -205,6 +205,17 @@ function shimInit() { return Resource.save(resource, { isNew: true }); }; + shim.updateResourceBlob = async function(resourceId, newBlobFilePath) { + const resource = await Resource.load(resourceId); + const fileStat = await shim.fsDriver().stat(newBlobFilePath); + await shim.fsDriver().copy(newBlobFilePath, Resource.fullPath(resource)); + + await Resource.save({ + id: resource.id, + size: fileStat.size, + }); + }; + shim.attachFileToNoteBody = async function(noteBody, filePath, position = null, options = null) { options = Object.assign({}, { createFileURL: false,