1
0
mirror of https://github.com/laurent22/joplin.git synced 2024-12-24 10:27:10 +02:00

All: Resolves #1481: New: Allow downloading attachments on demand or automatically (#1527)

* Allow downloading resources automatically, on demand, or when loading note

* Make needToBeFetched calls to return the right number of resources

* All: Improved handling of resource downloading and decryption

* Desktop: Click on resource to download it (and, optionally, to decrypt it)

* Desktop: Better handling of resource state (not downloaded, downloading, encrypted) in front end

* Renamed setting to sync.resourceDownloadMode

* Download resources when changing setting

* tweaks

* removed duplicate cs

* Better report resource download progress

* Make sure resource cache is properly cleared when needed

* Also handle manual download for non-image resources

* More improvements to logic when downloading and decrypting resources
This commit is contained in:
Laurent Cozic 2019-05-22 15:56:07 +01:00 committed by GitHub
parent 6bcbedd6a4
commit 8a6fe20a69
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 470 additions and 108 deletions

View File

@ -865,7 +865,7 @@ describe('Synchronizer', function() {
expect(ls.fetch_status).toBe(Resource.FETCH_STATUS_IDLE); expect(ls.fetch_status).toBe(Resource.FETCH_STATUS_IDLE);
const fetcher = new ResourceFetcher(() => { return synchronizer().api() }); const fetcher = new ResourceFetcher(() => { return synchronizer().api() });
fetcher.queueDownload(resource1_2.id); fetcher.queueDownload_(resource1_2.id);
await fetcher.waitForAllFinished(); await fetcher.waitForAllFinished();
resource1_2 = await Resource.load(resource1.id); resource1_2 = await Resource.load(resource1.id);
@ -894,7 +894,7 @@ describe('Synchronizer', function() {
// Simulate a failed download // Simulate a failed download
get: () => { return new Promise((resolve, reject) => { reject(new Error('did not work')) }); } get: () => { return new Promise((resolve, reject) => { reject(new Error('did not work')) }); }
} }); } });
fetcher.queueDownload(resource1.id); fetcher.queueDownload_(resource1.id);
await fetcher.waitForAllFinished(); await fetcher.waitForAllFinished();
resource1 = await Resource.load(resource1.id); resource1 = await Resource.load(resource1.id);
@ -920,7 +920,7 @@ describe('Synchronizer', function() {
expect(r1.size).toBe(-1); expect(r1.size).toBe(-1);
const fetcher = new ResourceFetcher(() => { return synchronizer().api() }); const fetcher = new ResourceFetcher(() => { return synchronizer().api() });
fetcher.queueDownload(r1.id); fetcher.queueDownload_(r1.id);
await fetcher.waitForAllFinished(); await fetcher.waitForAllFinished();
r1 = await Resource.load(r1.id); r1 = await Resource.load(r1.id);
expect(r1.size).toBe(2720); expect(r1.size).toBe(2720);
@ -974,7 +974,7 @@ describe('Synchronizer', function() {
await encryptionService().loadMasterKeysFromSettings(); await encryptionService().loadMasterKeysFromSettings();
const fetcher = new ResourceFetcher(() => { return synchronizer().api() }); const fetcher = new ResourceFetcher(() => { return synchronizer().api() });
fetcher.queueDownload(resource1.id); fetcher.queueDownload_(resource1.id);
await fetcher.waitForAllFinished(); await fetcher.waitForAllFinished();
let resource1_2 = (await Resource.all())[0]; let resource1_2 = (await Resource.all())[0];
@ -1057,7 +1057,7 @@ describe('Synchronizer', function() {
await encryptionService().loadMasterKeysFromSettings(); await encryptionService().loadMasterKeysFromSettings();
const fetcher = new ResourceFetcher(() => { return synchronizer().api() }); const fetcher = new ResourceFetcher(() => { return synchronizer().api() });
fetcher.queueDownload(resource1.id); fetcher.queueDownload_(resource1.id);
await fetcher.waitForAllFinished(); await fetcher.waitForAllFinished();
await decryptionWorker().start(); await decryptionWorker().start();
@ -1351,4 +1351,38 @@ describe('Synchronizer', function() {
expect((await remoteResources()).length).toBe(1); expect((await remoteResources()).length).toBe(1);
})); }));
it('should decrypt the resource metadata, but not try to decrypt the file, if it is not present', asyncTest(async () => {
const note1 = await Note.save({ title: 'note' });
await shim.attachFileToNote(note1, __dirname + '/../tests/support/photo.jpg');
const masterKey = await loadEncryptionMasterKey();
await encryptionService().enableEncryption(masterKey, '123456');
await encryptionService().loadMasterKeysFromSettings();
await synchronizer().start();
expect(await allSyncTargetItemsEncrypted()).toBe(true);
await switchClient(2);
await synchronizer().start();
Setting.setObjectKey('encryption.passwordCache', masterKey.id, '123456');
await encryptionService().loadMasterKeysFromSettings();
await decryptionWorker().start();
let resource = (await Resource.all())[0];
expect(!!resource.encryption_applied).toBe(false);
expect(!!resource.encryption_blob_encrypted).toBe(true);
const resourceFetcher = new ResourceFetcher(() => { return synchronizer().api() });
await resourceFetcher.start();
await resourceFetcher.waitForAllFinished();
const ls = await Resource.localState(resource);
expect(ls.fetch_status).toBe(Resource.FETCH_STATUS_DONE);
await decryptionWorker().start();
resource = (await Resource.all())[0];
expect(!!resource.encryption_blob_encrypted).toBe(false);
}));
}); });

View File

@ -36,6 +36,7 @@ const ResourceFetcher = require('lib/services/ResourceFetcher');
const { toSystemSlashes, safeFilename } = require('lib/path-utils'); const { toSystemSlashes, safeFilename } = require('lib/path-utils');
const { clipboard } = require('electron'); const { clipboard } = require('electron');
const SearchEngine = require('lib/services/SearchEngine'); const SearchEngine = require('lib/services/SearchEngine');
const DecryptionWorker = require('lib/services/DecryptionWorker');
const ModelCache = require('lib/services/ModelCache'); const ModelCache = require('lib/services/ModelCache');
const NoteTextViewer = require('./NoteTextViewer.min'); const NoteTextViewer = require('./NoteTextViewer.min');
const NoteRevisionViewer = require('./NoteRevisionViewer.min'); const NoteRevisionViewer = require('./NoteRevisionViewer.min');
@ -226,14 +227,13 @@ class NoteTextComponent extends React.Component {
} }
} }
this.resourceFetcher_downloadComplete = async (resource) => { this.refreshResource = async (event) => {
if (!this.state.note || !this.state.note.body) return; if (!this.state.note || !this.state.note.body) return;
const resourceIds = await Note.linkedResourceIds(this.state.note.body); const resourceIds = await Note.linkedResourceIds(this.state.note.body);
if (resourceIds.indexOf(resource.id) >= 0) { if (resourceIds.indexOf(event.id) >= 0) {
// this.mdToHtml().clearCache(); shared.clearResourceCache();
this.lastSetHtml_ = ''; this.lastSetHtml_ = '';
this.scheduleHtmlUpdate(); this.scheduleHtmlUpdate();
//this.updateHtml(this.state.note.body);
} }
} }
@ -363,7 +363,8 @@ class NoteTextComponent extends React.Component {
eventManager.on('noteTypeToggle', this.onNoteTypeToggle_); eventManager.on('noteTypeToggle', this.onNoteTypeToggle_);
eventManager.on('todoToggle', this.onTodoToggle_); eventManager.on('todoToggle', this.onTodoToggle_);
ResourceFetcher.instance().on('downloadComplete', this.resourceFetcher_downloadComplete); shared.installResourceHandling(this.refreshResource);
ExternalEditWatcher.instance().on('noteChange', this.externalEditWatcher_noteChange); ExternalEditWatcher.instance().on('noteChange', this.externalEditWatcher_noteChange);
} }
@ -376,7 +377,8 @@ class NoteTextComponent extends React.Component {
eventManager.removeListener('noteTypeToggle', this.onNoteTypeToggle_); eventManager.removeListener('noteTypeToggle', this.onNoteTypeToggle_);
eventManager.removeListener('todoToggle', this.onTodoToggle_); eventManager.removeListener('todoToggle', this.onTodoToggle_);
ResourceFetcher.instance().off('downloadComplete', this.resourceFetcher_downloadComplete); shared.uninstallResourceHandling(this.refreshResource);
ExternalEditWatcher.instance().off('noteChange', this.externalEditWatcher_noteChange); ExternalEditWatcher.instance().off('noteChange', this.externalEditWatcher_noteChange);
} }
@ -474,6 +476,8 @@ class NoteTextComponent extends React.Component {
// Scroll back to top when loading new note // Scroll back to top when loading new note
if (loadingNewNote) { if (loadingNewNote) {
shared.clearResourceCache();
this.editorMaxScrollTop_ = 0; this.editorMaxScrollTop_ = 0;
// HACK: To go around a bug in Ace editor, we first set the scroll position to 1 // HACK: To go around a bug in Ace editor, we first set the scroll position to 1
@ -521,6 +525,11 @@ class NoteTextComponent extends React.Component {
this.setViewerPercentScroll(scrollPercent ? scrollPercent : 0); this.setViewerPercentScroll(scrollPercent ? scrollPercent : 0);
}, 10); }, 10);
} }
if (note && note.body && Setting.value('sync.resourceDownloadMode') === 'auto') {
const resourceIds = await Note.linkedResourceIds(note.body);
await ResourceFetcher.instance().markForDownload(resourceIds);
}
} }
if (note) { if (note) {
@ -666,6 +675,10 @@ class NoteTextComponent extends React.Component {
const ls = Object.assign({}, this.state.localSearch); const ls = Object.assign({}, this.state.localSearch);
ls.resultCount = arg0; ls.resultCount = arg0;
this.setState({ localSearch: ls }); this.setState({ localSearch: ls });
} else if (msg.indexOf('markForDownload:') === 0) {
const s = msg.split(':');
if (s.length < 2) throw new Error('Invalid message: ' + msg);
ResourceFetcher.instance().markForDownload(s[1]);
} else if (msg === 'percentScroll') { } else if (msg === 'percentScroll') {
this.ignoreNextEditorScroll_ = true; this.ignoreNextEditorScroll_ = true;
this.setEditorPercentScroll(arg0); this.setEditorPercentScroll(arg0);
@ -1741,6 +1754,7 @@ class NoteTextComponent extends React.Component {
if (htmlHasChanged) { if (htmlHasChanged) {
let options = { let options = {
cssFiles: this.state.lastRenderCssFiles, cssFiles: this.state.lastRenderCssFiles,
downloadResources: Setting.value('sync.resourceDownloadMode'),
}; };
this.webviewRef_.current.wrappedInstance.send('setHtml', html, options); this.webviewRef_.current.wrappedInstance.send('setHtml', html, options);
this.lastSetHtml_ = html; this.lastSetHtml_ = html;

View File

@ -714,7 +714,7 @@ class SideBarComponent extends React.Component {
let resourceFetcherText = ''; let resourceFetcherText = '';
if (this.props.resourceFetcher && this.props.resourceFetcher.toFetchCount) { if (this.props.resourceFetcher && this.props.resourceFetcher.toFetchCount) {
resourceFetcherText = _('Fetching resources: %d', this.props.resourceFetcher.toFetchCount); resourceFetcherText = _('Fetching resources: %d/%d', this.props.resourceFetcher.fetchingCount, this.props.resourceFetcher.toFetchCount);
} }
let lines = Synchronizer.reportToLines(this.props.syncReport); let lines = Synchronizer.reportToLines(this.props.syncReport);

View File

@ -123,6 +123,10 @@
} }
loadCssFiles(event.options.cssFiles); loadCssFiles(event.options.cssFiles);
if (event.options.downloadResources === 'manual') {
webviewLib.setupResourceManualDownload();
}
} }
let ignoreNextScrollEvent = false; let ignoreNextScrollEvent = false;

View File

@ -279,6 +279,12 @@ class BaseApplication {
} }
} }
resourceFetcher_downloadComplete(event) {
if (event.encrypted) {
DecryptionWorker.instance().scheduleStart();
}
}
reducerActionToString(action) { reducerActionToString(action) {
let o = [action.type]; let o = [action.type];
if ('id' in action) o.push(action.id); if ('id' in action) o.push(action.id);
@ -426,7 +432,7 @@ class BaseApplication {
} }
if (this.hasGui() && action.type === 'SYNC_CREATED_RESOURCE') { if (this.hasGui() && action.type === 'SYNC_CREATED_RESOURCE') {
ResourceFetcher.instance().queueDownload(action.id); ResourceFetcher.instance().autoAddResources();
} }
if (refreshFolders) { if (refreshFolders) {
@ -603,6 +609,7 @@ class BaseApplication {
ResourceFetcher.instance().setFileApi(() => { return reg.syncTarget().fileApi() }); ResourceFetcher.instance().setFileApi(() => { return reg.syncTarget().fileApi() });
ResourceFetcher.instance().setLogger(this.logger_); ResourceFetcher.instance().setLogger(this.logger_);
ResourceFetcher.instance().on('downloadComplete', this.resourceFetcher_downloadComplete);
ResourceFetcher.instance().start(); ResourceFetcher.instance().start();
SearchEngine.instance().setDb(reg.db()); SearchEngine.instance().setDb(reg.db());

View File

@ -161,6 +161,21 @@ module.exports = function(style, options) {
color: black; color: black;
} }
.not-loaded-resource img {
width: 1.15em;
height: 1.15em;
}
a.not-loaded-resource img {
margin-right: .2em;
}
a.not-loaded-resource {
display: flex;
flex-direction: row;
align-items: center;
}
.checkbox-label-checked { .checkbox-label-checked {
opacity: 0.5; opacity: 0.5;
} }

View File

@ -5,8 +5,14 @@ const utils = require('../utils');
function renderImageHtml(before, src, after, ruleOptions) { function renderImageHtml(before, src, after, ruleOptions) {
const resourceId = Resource.urlToId(src); const resourceId = Resource.urlToId(src);
const resource = ruleOptions.resources[resourceId]; const result = ruleOptions.resources[resourceId];
if (!resource) return '<div>' + utils.loaderImage() + '</div>'; const resource = result ? result.item : null;
const resourceStatus = utils.resourceStatus(result);
if (resourceStatus !== 'ready') {
const icon = utils.resourceStatusImage(resourceStatus);
return '<div class="not-loaded-resource resource-status-' + resourceStatus + '" data-resource-id="' + resourceId + '">' + '<img src="data:image/svg+xml;utf8,' + htmlentities(icon) + '"/>' + '</div>';
}
const mime = resource.mime ? resource.mime.toLowerCase() : ''; const mime = resource.mime ? resource.mime.toLowerCase() : '';
if (Resource.isSupportedImageMimeType(mime)) { if (Resource.isSupportedImageMimeType(mime)) {

View File

@ -14,8 +14,14 @@ function installRule(markdownIt, mdOptions, ruleOptions) {
if (!Resource.isResourceUrl(src)) return defaultRender(tokens, idx, options, env, self); if (!Resource.isResourceUrl(src)) return defaultRender(tokens, idx, options, env, self);
const resourceId = Resource.urlToId(src); const resourceId = Resource.urlToId(src);
const resource = ruleOptions.resources[resourceId]; const result = ruleOptions.resources[resourceId];
if (!resource) return '<div>' + utils.loaderImage() + '</div>'; const resource = result ? result.item : null;
const resourceStatus = utils.resourceStatus(result);
if (resourceStatus !== 'ready') {
const icon = utils.resourceStatusImage(resourceStatus);
return '<div class="not-loaded-resource resource-status-' + resourceStatus + '" data-resource-id="' + resourceId + '">' + '<img src="data:image/svg+xml;utf8,' + htmlentities(icon) + '"/>' + '</div>';
}
const mime = resource.mime ? resource.mime.toLowerCase() : ''; const mime = resource.mime ? resource.mime.toLowerCase() : '';
if (Resource.isSupportedImageMimeType(mime)) { if (Resource.isSupportedImageMimeType(mime)) {

View File

@ -18,9 +18,19 @@ function installRule(markdownIt, mdOptions, ruleOptions) {
let hrefAttr = '#'; let hrefAttr = '#';
if (isResourceUrl) { if (isResourceUrl) {
const resourceId = Resource.pathToId(href); const resourceId = Resource.pathToId(href);
href = "joplin://" + resourceId;
resourceIdAttr = "data-resource-id='" + resourceId + "'"; const result = ruleOptions.resources[resourceId];
icon = '<span class="resource-icon"></span>'; const resource = result ? result.item : null;
const resourceStatus = utils.resourceStatus(result);
if (resourceStatus !== 'ready') {
const icon = utils.resourceStatusFile(resourceStatus);
return '<a class="not-loaded-resource resource-status-' + resourceStatus + '" data-resource-id="' + resourceId + '">' + '<img src="data:image/svg+xml;utf8,' + htmlentities(icon) + '"/>';
} else {
href = "joplin://" + resourceId;
resourceIdAttr = "data-resource-id='" + resourceId + "'";
icon = '<span class="resource-icon"></span>';
}
} else { } else {
// If the link is a plain URL (as opposed to a resource link), set the href to the actual // If the link is a plain URL (as opposed to a resource link), set the href to the actual
// link. This allows the link to be exported too when exporting to PDF. // link. This allows the link to be exported too when exporting to PDF.
@ -29,7 +39,6 @@ function installRule(markdownIt, mdOptions, ruleOptions) {
let js = ruleOptions.postMessageSyntax + "(" + JSON.stringify(href) + "); return false;"; let js = ruleOptions.postMessageSyntax + "(" + JSON.stringify(href) + "); 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 (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 (js) hrefAttr = '#';
return "<a data-from-md " + resourceIdAttr + " title='" + htmlentities(title) + "' href='" + hrefAttr + "' onclick='" + js + "'>" + icon; return "<a data-from-md " + resourceIdAttr + " title='" + htmlentities(title) + "' href='" + hrefAttr + "' onclick='" + js + "'>" + icon;
}; };
} }

View File

@ -1,14 +1,89 @@
module.exports = { const Resource = require('lib/models/Resource.js');
getAttr: function(attrs, name, defaultValue = null) { const utils = {};
for (let i = 0; i < attrs.length; i++) {
if (attrs[i][0] === name) return attrs[i].length > 1 ? attrs[i][1] : null; utils.getAttr = function(attrs, name, defaultValue = null) {
for (let i = 0; i < attrs.length; i++) {
if (attrs[i][0] === name) return attrs[i].length > 1 ? attrs[i][1] : null;
}
return defaultValue;
}
utils.notDownloadedResource = function() {
return `
<svg width="1700" height="1536" xmlns="http://www.w3.org/2000/svg">
<path d="M1280 1344c0-35-29-64-64-64s-64 29-64 64 29 64 64 64 64-29 64-64zm256 0c0-35-29-64-64-64s-64 29-64 64 29 64 64 64 64-29 64-64zm128-224v320c0 53-43 96-96 96H96c-53 0-96-43-96-96v-320c0-53 43-96 96-96h465l135 136c37 36 85 56 136 56s99-20 136-56l136-136h464c53 0 96 43 96 96zm-325-569c10 24 5 52-14 70l-448 448c-12 13-29 19-45 19s-33-6-45-19L339 621c-19-18-24-46-14-70 10-23 33-39 59-39h256V64c0-35 29-64 64-64h256c35 0 64 29 64 64v448h256c26 0 49 16 59 39z"/>
</svg>
`;
}
utils.notDownloadedImage = function() {
// https://github.com/ForkAwesome/Fork-Awesome/blob/master/src/icons/svg/file-image-o.svg
// Height changed to 1795
return `
<svg width="1925" height="1792" xmlns="http://www.w3.org/2000/svg">
<path d="M640 576c0 106-86 192-192 192s-192-86-192-192 86-192 192-192 192 86 192 192zm1024 384v448H256v-192l320-320 160 160 512-512zm96-704H160c-17 0-32 15-32 32v1216c0 17 15 32 32 32h1600c17 0 32-15 32-32V288c0-17-15-32-32-32zm160 32v1216c0 88-72 160-160 160H160c-88 0-160-72-160-160V288c0-88 72-160 160-160h1600c88 0 160 72 160 160z"/>
</svg>
`;
}
utils.notDownloadedFile = function() {
// https://github.com/ForkAwesome/Fork-Awesome/blob/master/src/icons/svg/file-o.svg
return `
<svg width="1925" height="1792" xmlns="http://www.w3.org/2000/svg">
<path d="M1468 380c37 37 68 111 68 164v1152c0 53-43 96-96 96H96c-53 0-96-43-96-96V96C0 43 43 0 96 0h896c53 0 127 31 164 68zm-444-244v376h376c-6-17-15-34-22-41l-313-313c-7-7-24-16-41-22zm384 1528V640H992c-53 0-96-43-96-96V128H128v1536h1280z"/>
</svg>
`;
}
utils.errorImage = function() {
// https://github.com/ForkAwesome/Fork-Awesome/blob/master/src/icons/svg/times-circle.svg
return `
<svg width="1795" height="1795" xmlns="http://www.w3.org/2000/svg">
<path d="M1149 1122c0-17-7-33-19-45L949 896l181-181c12-12 19-28 19-45s-7-34-19-46l-90-90c-12-12-29-19-46-19s-33 7-45 19L768 715 587 534c-12-12-28-19-45-19s-34 7-46 19l-90 90c-12 12-19 29-19 46s7 33 19 45l181 181-181 181c-12 12-19 28-19 45s7 34 19 46l90 90c12 12 29 19 46 19s33-7 45-19l181-181 181 181c12 12 28 19 45 19s34-7 46-19l90-90c12-12 19-29 19-46zm387-226c0 424-344 768-768 768S0 1320 0 896s344-768 768-768 768 344 768 768z"/>
</svg>
`;
}
utils.loaderImage = function() {
return '<?xml version="1.0" encoding="UTF-8" standalone="no"?><svg xmlns:svg="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.0" width="16px" height="16px" viewBox="0 0 128 128" xml:space="preserve"><g><circle cx="16" cy="64" r="16" fill="#000000" fill-opacity="1"/><circle cx="16" cy="64" r="16" fill="#555555" fill-opacity="0.67" transform="rotate(45,64,64)"/><circle cx="16" cy="64" r="16" fill="#949494" fill-opacity="0.42" transform="rotate(90,64,64)"/><circle cx="16" cy="64" r="16" fill="#cccccc" fill-opacity="0.2" transform="rotate(135,64,64)"/><circle cx="16" cy="64" r="16" fill="#e1e1e1" fill-opacity="0.12" transform="rotate(180,64,64)"/><circle cx="16" cy="64" r="16" fill="#e1e1e1" fill-opacity="0.12" transform="rotate(225,64,64)"/><circle cx="16" cy="64" r="16" fill="#e1e1e1" fill-opacity="0.12" transform="rotate(270,64,64)"/><circle cx="16" cy="64" r="16" fill="#e1e1e1" fill-opacity="0.12" transform="rotate(315,64,64)"/><animateTransform attributeName="transform" type="rotate" values="0 64 64;315 64 64;270 64 64;225 64 64;180 64 64;135 64 64;90 64 64;45 64 64" calcMode="discrete" dur="720ms" repeatCount="indefinite"></animateTransform></g></svg>';
}
utils.resourceStatusImage = function(state) {
if (state === 'notDownloaded') return utils.notDownloadedResource();
return utils.resourceStatusFile(state);
}
utils.resourceStatusFile = function(state) {
if (state === 'notDownloaded') return utils.notDownloadedResource();
if (state === 'downloading') return utils.loaderImage();
if (state === 'encrypted') return utils.loaderImage();
if (state === 'error') return utils.errorImage();
throw new Error('Unknown state: ' + state);
}
utils.resourceStatus = function(resourceInfo, localState) {
let resourceStatus = 'ready';
if (resourceInfo) {
const resource = resourceInfo.item;
const localState = resourceInfo.localState;
if (localState.fetch_status === Resource.FETCH_STATUS_IDLE) {
resourceStatus = 'notDownloaded';
} else if (localState.fetch_status === Resource.FETCH_STATUS_STARTED) {
resourceStatus = 'downloading';
} else if (localState.fetch_status === Resource.FETCH_STATUS_DONE) {
if (resource.encryption_blob_encrypted || resource.encryption_applied) {
resourceStatus = 'encrypted';
}
} }
return defaultValue; } else {
}, resourceStatus = 'notDownloaded';
}
loaderImage: function() { return resourceStatus;
return '<?xml version="1.0" encoding="UTF-8" standalone="no"?><svg xmlns:svg="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.0" width="16px" height="16px" viewBox="0 0 128 128" xml:space="preserve"><g><circle cx="16" cy="64" r="16" fill="#000000" fill-opacity="1"/><circle cx="16" cy="64" r="16" fill="#555555" fill-opacity="0.67" transform="rotate(45,64,64)"/><circle cx="16" cy="64" r="16" fill="#949494" fill-opacity="0.42" transform="rotate(90,64,64)"/><circle cx="16" cy="64" r="16" fill="#cccccc" fill-opacity="0.2" transform="rotate(135,64,64)"/><circle cx="16" cy="64" r="16" fill="#e1e1e1" fill-opacity="0.12" transform="rotate(180,64,64)"/><circle cx="16" cy="64" r="16" fill="#e1e1e1" fill-opacity="0.12" transform="rotate(225,64,64)"/><circle cx="16" cy="64" r="16" fill="#e1e1e1" fill-opacity="0.12" transform="rotate(270,64,64)"/><circle cx="16" cy="64" r="16" fill="#e1e1e1" fill-opacity="0.12" transform="rotate(315,64,64)"/><animateTransform attributeName="transform" type="rotate" values="0 64 64;315 64 64;270 64 64;225 64 64;180 64 64;135 64 64;90 64 64;45 64 64" calcMode="discrete" dur="720ms" repeatCount="indefinite"></animateTransform></g></svg>'; }
},
}; module.exports = utils;

View File

@ -1,7 +1,33 @@
const webviewLib = {}; const webviewLib = {};
let manualDownloadResourceElements = [];
webviewLib.onUnloadedResourceClick = function(event) {
const resourceId = event.currentTarget.getAttribute('data-resource-id');
webviewLib.options_.postMessage('markForDownload:' + resourceId);
}
webviewLib.setupResourceManualDownload = function() {
for (const element of manualDownloadResourceElements) {
element.style.cursor = 'default';
element.removeEventListener('click', webviewLib.onUnloadedResourceClick);
}
manualDownloadResourceElements = [];
const elements = document.getElementsByClassName('resource-status-notDownloaded');
for (const element of elements) {
element.style.cursor = 'pointer';
element.addEventListener('click', webviewLib.onUnloadedResourceClick);
manualDownloadResourceElements.push(element);
}
}
webviewLib.handleInternalLink = function(event, anchorNode) { webviewLib.handleInternalLink = function(event, anchorNode) {
const href = anchorNode.getAttribute('href'); const href = anchorNode.getAttribute('href');
if (!href) return false;
if (href.indexOf('#') === 0) { if (href.indexOf('#') === 0) {
event.preventDefault(); event.preventDefault();
let old_hash = location.hash; let old_hash = location.hash;
@ -18,6 +44,7 @@ webviewLib.handleInternalLink = function(event, anchorNode) {
setTimeout(function() { location.hash = old_hash; }, 10); setTimeout(function() { location.hash = old_hash; }, 10);
return true; return true;
} }
return false; return false;
} }
@ -52,9 +79,8 @@ document.addEventListener('click', function(event) {
// as Katex. // as Katex.
if (!anchor.hasAttribute('data-from-md')) { if (!anchor.hasAttribute('data-from-md')) {
if (webviewLib.handleInternalLink(event, anchor)) return; if (webviewLib.handleInternalLink(event, anchor)) return;
event.preventDefault(); event.preventDefault();
webviewLib.options_.postMessage(anchor.getAttribute('href')); if (anchor.getAttribute('href')) webviewLib.options_.postMessage(anchor.getAttribute('href'));
return; return;
} }
@ -63,3 +89,4 @@ document.addEventListener('click', function(event) {
if (webviewLib.handleInternalLink(event, anchor)) return; if (webviewLib.handleInternalLink(event, anchor)) return;
} }
}); });

View File

@ -104,11 +104,19 @@ class NoteBodyViewer extends Component {
let result = this.mdToHtml_.render(bodyToRender, this.props.webViewStyle, mdOptions); let result = this.mdToHtml_.render(bodyToRender, this.props.webViewStyle, mdOptions);
let html = result.html; let html = result.html;
const resourceDownloadMode = Setting.value('sync.resourceDownloadMode');
const injectedJs = [this.mdToHtml_.injectedJavaScript()]; const injectedJs = [this.mdToHtml_.injectedJavaScript()];
injectedJs.push(shim.injectedJs('webviewLib')); injectedJs.push(shim.injectedJs('webviewLib'));
injectedJs.push('webviewLib.initialize({ postMessage: postMessage });'); injectedJs.push('webviewLib.initialize({ postMessage: postMessage });');
injectedJs.push(`
console.info(injectedJs); const readyStateCheckInterval = setInterval(function() {
if (document.readyState === "complete") {
clearInterval(readyStateCheckInterval);
if ("${resourceDownloadMode}" === "manual") webviewLib.setupResourceManualDownload();
}
}, 10);
`);
html = ` html = `
<!DOCTYPE html> <!DOCTYPE html>
@ -172,9 +180,10 @@ class NoteBodyViewer extends Component {
if (msg.indexOf('checkboxclick:') === 0) { if (msg.indexOf('checkboxclick:') === 0) {
const newBody = shared.toggleCheckbox(msg, this.props.note.body); const newBody = shared.toggleCheckbox(msg, this.props.note.body);
if (this.props.onCheckboxChange) this.props.onCheckboxChange(newBody); if (this.props.onCheckboxChange) this.props.onCheckboxChange(newBody);
} else if (msg.indexOf('bodyscroll:') === 0) { } else if (msg.indexOf('markForDownload:') === 0) {
//msg = msg.split(':'); msg = msg.split(':');
//this.bodyScrollTop_ = Number(msg[1]); const resourceId = msg[1];
if (this.props.onMarkForDownload) this.props.onMarkForDownload({ resourceId: resourceId });
} else { } else {
this.props.onJoplinLinkClick(msg); this.props.onJoplinLinkClick(msg);
} }

View File

@ -164,10 +164,11 @@ class NoteScreenComponent extends BaseScreenComponent {
} }
} }
this.resourceFetcher_downloadComplete = async (resource) => { this.refreshResource = async (resource) => {
if (!this.state.note || !this.state.note.body) return; if (!this.state.note || !this.state.note.body) return;
const resourceIds = await Note.linkedResourceIds(this.state.note.body); const resourceIds = await Note.linkedResourceIds(this.state.note.body);
if (resourceIds.indexOf(resource.id) >= 0 && this.refs.noteBodyViewer) { if (resourceIds.indexOf(resource.id) >= 0 && this.refs.noteBodyViewer) {
shared.clearResourceCache();
const attachedResources = await shared.attachedResources(this.state.note.body); const attachedResources = await shared.attachedResources(this.state.note.body);
this.setState({ noteResources: attachedResources }, () => { this.setState({ noteResources: attachedResources }, () => {
this.refs.noteBodyViewer.rebuildMd(); this.refs.noteBodyViewer.rebuildMd();
@ -178,6 +179,7 @@ class NoteScreenComponent extends BaseScreenComponent {
this.takePhoto_onPress = this.takePhoto_onPress.bind(this); this.takePhoto_onPress = this.takePhoto_onPress.bind(this);
this.cameraView_onPhoto = this.cameraView_onPhoto.bind(this); this.cameraView_onPhoto = this.cameraView_onPhoto.bind(this);
this.cameraView_onCancel = this.cameraView_onCancel.bind(this); this.cameraView_onCancel = this.cameraView_onCancel.bind(this);
this.onMarkForDownload = this.onMarkForDownload.bind(this);
} }
styles() { styles() {
@ -235,13 +237,23 @@ class NoteScreenComponent extends BaseScreenComponent {
BackButtonService.addHandler(this.backHandler); BackButtonService.addHandler(this.backHandler);
NavService.addHandler(this.navHandler); NavService.addHandler(this.navHandler);
ResourceFetcher.instance().on('downloadComplete', this.resourceFetcher_downloadComplete); shared.clearResourceCache();
shared.installResourceHandling(this.refreshResource);
await shared.initState(this); await shared.initState(this);
if (this.state.note && this.state.note.body && Setting.value('sync.resourceDownloadMode') === 'auto') {
const resourceIds = await Note.linkedResourceIds(this.state.note.body);
await ResourceFetcher.instance().markForDownload(resourceIds);
}
this.refreshNoteMetadata(); this.refreshNoteMetadata();
} }
onMarkForDownload(event) {
ResourceFetcher.instance().markForDownload(event.resourceId);
}
refreshNoteMetadata(force = null) { refreshNoteMetadata(force = null) {
return shared.refreshNoteMetadata(this, force); return shared.refreshNoteMetadata(this, force);
} }
@ -250,7 +262,7 @@ class NoteScreenComponent extends BaseScreenComponent {
BackButtonService.removeHandler(this.backHandler); BackButtonService.removeHandler(this.backHandler);
NavService.removeHandler(this.navHandler); NavService.removeHandler(this.navHandler);
ResourceFetcher.instance().off('downloadComplete', this.resourceFetcher_downloadComplete); shared.uninstallResourceHandling(this.refreshResource);
if (Platform.OS !== 'ios' && this.state.fromShare) { if (Platform.OS !== 'ios' && this.state.fromShare) {
ShareExtension.close(); ShareExtension.close();
@ -626,6 +638,7 @@ class NoteScreenComponent extends BaseScreenComponent {
highlightedKeywords={keywords} highlightedKeywords={keywords}
theme={this.props.theme} theme={this.props.theme}
onCheckboxChange={(newBody) => { onCheckboxChange(newBody) }} onCheckboxChange={(newBody) => { onCheckboxChange(newBody) }}
onMarkForDownload={this.onMarkForDownload}
onLoadEnd={() => { onLoadEnd={() => {
setTimeout(() => { setTimeout(() => {
this.setState({ HACK_webviewLoadingState: 1 }); this.setState({ HACK_webviewLoadingState: 1 });

View File

@ -3,6 +3,8 @@ const Folder = require('lib/models/Folder.js');
const BaseModel = require('lib/BaseModel.js'); const BaseModel = require('lib/BaseModel.js');
const Note = require('lib/models/Note.js'); const Note = require('lib/models/Note.js');
const Resource = require('lib/models/Resource.js'); const Resource = require('lib/models/Resource.js');
const ResourceFetcher = require('lib/services/ResourceFetcher.js');
const DecryptionWorker = require('lib/services/DecryptionWorker.js');
const Setting = require('lib/models/Setting.js'); const Setting = require('lib/models/Setting.js');
const Mutex = require('async-mutex').Mutex; const Mutex = require('async-mutex').Mutex;
@ -154,7 +156,11 @@ shared.noteComponent_change = function(comp, propName, propValue) {
comp.setState(newState); comp.setState(newState);
} }
const resourceCache_ = {}; let resourceCache_ = {};
shared.clearResourceCache = function() {
resourceCache_ = {};
}
shared.attachedResources = async function(noteBody) { shared.attachedResources = async function(noteBody) {
if (!noteBody) return {}; if (!noteBody) return {};
@ -163,14 +169,20 @@ shared.attachedResources = async function(noteBody) {
const output = {}; const output = {};
for (let i = 0; i < resourceIds.length; i++) { for (let i = 0; i < resourceIds.length; i++) {
const id = resourceIds[i]; const id = resourceIds[i];
if (resourceCache_[id]) { if (resourceCache_[id]) {
output[id] = resourceCache_[id]; output[id] = resourceCache_[id];
} else { } else {
const resource = await Resource.load(id); const resource = await Resource.load(id);
const isReady = await Resource.isReady(resource); const localState = await Resource.localState(resource);
if (!isReady) continue;
resourceCache_[id] = resource; const o = {
output[id] = resource; item: resource,
localState: localState,
};
resourceCache_[id] = o;
output[id] = o;
} }
} }
@ -269,4 +281,16 @@ shared.toggleCheckbox = function(ipcMessage, noteBody) {
return newBody.join('\n') return newBody.join('\n')
} }
shared.installResourceHandling = function(refreshResourceHandler) {
ResourceFetcher.instance().on('downloadComplete', refreshResourceHandler);
ResourceFetcher.instance().on('downloadStarted', refreshResourceHandler);
DecryptionWorker.instance().on('resourceDecrypted', refreshResourceHandler);
}
shared.uninstallResourceHandling = function(refreshResourceHandler) {
ResourceFetcher.instance().off('downloadComplete', refreshResourceHandler);
ResourceFetcher.instance().off('downloadStarted', refreshResourceHandler);
DecryptionWorker.instance().off('resourceDecrypted', refreshResourceHandler);
}
module.exports = shared; module.exports = shared;

View File

@ -1,6 +1,7 @@
const Setting = require('lib/models/Setting'); const Setting = require('lib/models/Setting');
const Tag = require('lib/models/Tag'); const Tag = require('lib/models/Tag');
const { reg } = require('lib/registry.js'); const { reg } = require('lib/registry.js');
const ResourceFetcher = require('lib/services/ResourceFetcher');
const reduxSharedMiddleware = async function(store, next, action) { const reduxSharedMiddleware = async function(store, next, action) {
const newState = store.getState(); const newState = store.getState();
@ -15,6 +16,10 @@ const reduxSharedMiddleware = async function(store, next, action) {
reg.resetSyncTarget(); reg.resetSyncTarget();
} }
if (action.type === 'SETTING_UPDATE_ONE' && action.key === 'sync.resourceDownloadMode') {
ResourceFetcher.instance().autoAddResources();
}
if (action.type == 'NOTE_DELETE') { if (action.type == 'NOTE_DELETE') {
refreshTags = true; refreshTags = true;
} }

View File

@ -207,13 +207,13 @@ class SideMenuContentComponent extends Component {
let resourceFetcherText = ''; let resourceFetcherText = '';
if (this.props.resourceFetcher && this.props.resourceFetcher.toFetchCount) { if (this.props.resourceFetcher && this.props.resourceFetcher.toFetchCount) {
resourceFetcherText = _('Fetching resources: %d', this.props.resourceFetcher.toFetchCount); resourceFetcherText = _('Fetching resources: %d/%d', this.props.resourceFetcher.fetchingCount, this.props.resourceFetcher.toFetchCount);
} }
let fullReport = []; let fullReport = [];
if (syncReportText) fullReport.push(syncReportText); if (syncReportText) fullReport.push(syncReportText);
// if (fullReport.length) fullReport.push(''); // if (fullReport.length) fullReport.push('');
if (resourceFetcherText) lines.push(resourceFetcherText); if (resourceFetcherText) fullReport.push(resourceFetcherText);
if (decryptionReportText) fullReport.push(decryptionReportText); if (decryptionReportText) fullReport.push(decryptionReportText);
while (fullReport.length < 12) fullReport.push(''); // Add blank lines so that height of report text is fixed and doesn't affect scrolling while (fullReport.length < 12) fullReport.push(''); // Add blank lines so that height of report text is fixed and doesn't affect scrolling

View File

@ -263,7 +263,7 @@ class JoplinDatabase extends Database {
// must be set in the synchronizer too. // must be set in the synchronizer too.
// Note: v16 and v17 don't do anything. They were used to debug an issue. // Note: v16 and v17 don't do anything. They were used to debug an issue.
const existingDatabaseVersions = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21]; const existingDatabaseVersions = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22];
let currentVersionIndex = existingDatabaseVersions.indexOf(fromVersion); let currentVersionIndex = existingDatabaseVersions.indexOf(fromVersion);
@ -564,6 +564,21 @@ class JoplinDatabase extends Database {
queries.push('ALTER TABLE sync_items ADD COLUMN item_location INT NOT NULL DEFAULT 1'); queries.push('ALTER TABLE sync_items ADD COLUMN item_location INT NOT NULL DEFAULT 1');
} }
if (targetVersion == 22) {
const newTableSql = `
CREATE TABLE resources_to_download (
id INTEGER PRIMARY KEY,
resource_id TEXT NOT NULL,
updated_time INT NOT NULL,
created_time INT NOT NULL
);
`;
queries.push(this.sqlStringToLines(newTableSql)[0]);
queries.push('CREATE INDEX resources_to_download_resource_id ON resources_to_download (resource_id)');
queries.push('CREATE INDEX resources_to_download_updated_time ON resources_to_download (updated_time)');
}
queries.push({ sql: 'UPDATE version SET version = ?', params: [targetVersion] }); queries.push({ sql: 'UPDATE version SET version = ?', params: [targetVersion] });
try { try {

View File

@ -458,7 +458,13 @@ class BaseItem extends BaseModel {
const className = classNames[i]; const className = classNames[i];
const ItemClass = this.getClass(className); const ItemClass = this.getClass(className);
const whereSql = className === 'Resource' ? ['(encryption_blob_encrypted = 1 OR encryption_applied = 1)'] : ['encryption_applied = 1']; let whereSql = ['encryption_applied = 1'];
if (className === 'Resource') {
const blobDownloadedButEncryptedSql = 'encryption_blob_encrypted = 1 AND id IN (SELECT resource_id FROM resource_local_states WHERE fetch_status = 2))';
whereSql = ['(encryption_applied = 1 OR (' + blobDownloadedButEncryptedSql + ')'];
}
if (exclusions.length) whereSql.push('id NOT IN ("' + exclusions.join('","') + '")'); if (exclusions.length) whereSql.push('id NOT IN ("' + exclusions.join('","') + '")');
const sql = sprintf(` const sql = sprintf(`

View File

@ -32,15 +32,14 @@ class Resource extends BaseItem {
return imageMimeTypes.indexOf(type.toLowerCase()) >= 0; return imageMimeTypes.indexOf(type.toLowerCase()) >= 0;
} }
static needToBeFetched(limit = null) { static needToBeFetched(resourceDownloadMode = null, limit = null) {
let sql = 'SELECT * FROM resources WHERE id IN (SELECT resource_id FROM resource_local_states WHERE fetch_status = ?) ORDER BY updated_time DESC'; let sql = ['SELECT * FROM resources WHERE encryption_applied = 0 AND id IN (SELECT resource_id FROM resource_local_states WHERE fetch_status = ?)'];
if (limit !== null) sql += ' LIMIT ' + limit; if (resourceDownloadMode !== 'always') {
return this.modelSelectAll(sql, [Resource.FETCH_STATUS_IDLE]); sql.push('AND resources.id IN (SELECT resource_id FROM resources_to_download)');
} }
sql.push('ORDER BY updated_time DESC');
static async needToBeFetchedCount() { if (limit !== null) sql.push('LIMIT ' + limit);
const r = await this.db().selectOne('SELECT count(*) as total FROM resource_local_states WHERE fetch_status = ?', [Resource.FETCH_STATUS_IDLE]); return this.modelSelectAll(sql.join(' '), [Resource.FETCH_STATUS_IDLE]);
return r ? r['total'] : 0;
} }
static async resetStartedFetchStatus() { static async resetStartedFetchStatus() {
@ -52,13 +51,6 @@ class Resource extends BaseItem {
return Resource.fsDriver_; return Resource.fsDriver_;
} }
static filename(resource, encryptedBlob = false) {
let extension = encryptedBlob ? 'crypted' : resource.file_extension;
if (!extension) extension = resource.mime ? mime.toFileExtension(resource.mime) : '';
extension = extension ? ('.' + extension) : '';
return resource.id + extension;
}
static friendlyFilename(resource) { 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 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; if (!output) output = resource.id;
@ -76,6 +68,13 @@ class Resource extends BaseItem {
return Setting.value('resourceDirName'); return Setting.value('resourceDirName');
} }
static filename(resource, encryptedBlob = false) {
let extension = encryptedBlob ? 'crypted' : resource.file_extension;
if (!extension) extension = resource.mime ? mime.toFileExtension(resource.mime) : '';
extension = extension ? ('.' + extension) : '';
return resource.id + extension;
}
static relativePath(resource, encryptedBlob = false) { static relativePath(resource, encryptedBlob = false) {
return Setting.value('resourceDirName') + '/' + this.filename(resource, encryptedBlob); return Setting.value('resourceDirName') + '/' + this.filename(resource, encryptedBlob);
} }
@ -96,6 +95,13 @@ class Resource extends BaseItem {
const decryptedItem = item.encryption_cipher_text ? await super.decrypt(item) : Object.assign({}, item); const decryptedItem = item.encryption_cipher_text ? await super.decrypt(item) : Object.assign({}, item);
if (!decryptedItem.encryption_blob_encrypted) return decryptedItem; if (!decryptedItem.encryption_blob_encrypted) return decryptedItem;
const localState = await this.localState(item);
if (localState.fetch_status !== Resource.FETCH_STATUS_DONE) {
// Not an error - it means the blob has not been downloaded yet.
// It will be decrypted later on, once downloaded.
return decryptedItem;
}
const plainTextPath = this.fullPath(decryptedItem); const plainTextPath = this.fullPath(decryptedItem);
const encryptedPath = this.fullPath(decryptedItem, true); const encryptedPath = this.fullPath(decryptedItem, true);
const noExtPath = pathUtils.dirname(encryptedPath) + '/' + pathUtils.filename(encryptedPath); const noExtPath = pathUtils.dirname(encryptedPath) + '/' + pathUtils.filename(encryptedPath);
@ -109,12 +115,7 @@ class Resource extends BaseItem {
} }
try { try {
// const stat = await this.fsDriver().stat(encryptedPath); await this.encryptionService().decryptFile(encryptedPath, plainTextPath);
await this.encryptionService().decryptFile(encryptedPath, plainTextPath, {
// onProgress: (progress) => {
// console.info('Decryption: ', progress.doneSize / stat.size);
// },
});
} catch (error) { } catch (error) {
if (error.code === 'invalidIdentifier') { if (error.code === 'invalidIdentifier') {
// As the identifier is invalid it most likely means that this is not encrypted data // As the identifier is invalid it most likely means that this is not encrypted data
@ -149,12 +150,7 @@ class Resource extends BaseItem {
if (resource.encryption_blob_encrypted) return { path: encryptedPath, resource: resource }; if (resource.encryption_blob_encrypted) return { path: encryptedPath, resource: resource };
try { try {
// const stat = await this.fsDriver().stat(plainTextPath); await this.encryptionService().encryptFile(plainTextPath, encryptedPath);
await this.encryptionService().encryptFile(plainTextPath, encryptedPath, {
// onProgress: (progress) => {
// console.info(progress.doneSize / stat.size);
// },
});
} catch (error) { } catch (error) {
if (error.code === 'ENOENT') throw new JoplinError('File not found:' + error.toString(), 'fileNotFound'); if (error.code === 'ENOENT') throw new JoplinError('File not found:' + error.toString(), 'fileNotFound');
throw error; throw error;
@ -244,6 +240,20 @@ class Resource extends BaseItem {
await ResourceLocalState.batchDelete(ids); await ResourceLocalState.batchDelete(ids);
} }
static async markForDownload(resourceId) {
// Insert the row only if it's not already there
const t = Date.now();
await this.db().exec('INSERT INTO resources_to_download (resource_id, updated_time, created_time) SELECT ?, ?, ? WHERE NOT EXISTS (SELECT 1 FROM resources_to_download WHERE resource_id = ?)', [resourceId, t, t, resourceId]);
}
static async downloadedButEncryptedBlobCount() {
const r = await this.db().selectOne('SELECT count(*) as total FROM resource_local_states WHERE fetch_status = ? AND resource_id IN (SELECT id FROM resources WHERE encryption_blob_encrypted = 1)', [
Resource.FETCH_STATUS_DONE,
]);
return r ? r.total : 0;
}
} }
Resource.IMAGE_MAX_DIMENSION = 1920; Resource.IMAGE_MAX_DIMENSION = 1920;

View File

@ -155,6 +155,14 @@ class Setting extends BaseModel {
return SyncTargetRegistry.idAndLabelPlainObject(); return SyncTargetRegistry.idAndLabelPlainObject();
}}, }},
'sync.resourceDownloadMode': { value: 'always', type: Setting.TYPE_STRING, section: 'sync', public: true, isEnum: true, appTypes: ['mobile', 'desktop'], label: () => _('Attachment download behaviour'), description: () => _('In "Manual" mode, attachments are downloaded only when you click on them. In "Auto", they are downloaded when you open the note. In "Always", all the attachments are downloaded whether you open the note or not.'), options: () => {
return {
'always': _('Always'),
'manual': _('Manual'),
'auto': _('Auto'),
};
}},
'sync.2.path': { value: '', type: Setting.TYPE_STRING, section:'sync', show: (settings) => { 'sync.2.path': { value: '', type: Setting.TYPE_STRING, section:'sync', show: (settings) => {
try { try {
return settings['sync.target'] == SyncTargetRegistry.nameToId('filesystem') return settings['sync.target'] == SyncTargetRegistry.nameToId('filesystem')

View File

@ -2,6 +2,7 @@ const BaseItem = require('lib/models/BaseItem');
const Resource = require('lib/models/Resource'); const Resource = require('lib/models/Resource');
const ResourceService = require('lib/services/ResourceService'); const ResourceService = require('lib/services/ResourceService');
const { Logger } = require('lib/logger.js'); const { Logger } = require('lib/logger.js');
const EventEmitter = require('events');
class DecryptionWorker { class DecryptionWorker {
@ -14,6 +15,7 @@ class DecryptionWorker {
}; };
this.scheduleId_ = null; this.scheduleId_ = null;
this.eventEmitter_ = new EventEmitter();
} }
setLogger(l) { setLogger(l) {
@ -24,6 +26,14 @@ class DecryptionWorker {
return this.logger_; return this.logger_;
} }
on(eventName, callback) {
return this.eventEmitter_.on(eventName, callback);
}
off(eventName, callback) {
return this.eventEmitter_.removeListener(eventName, callback);
}
static instance() { static instance() {
if (this.instance_) return this.instance_; if (this.instance_) return this.instance_;
this.instance_ = new DecryptionWorker(); this.instance_ = new DecryptionWorker();
@ -85,14 +95,6 @@ class DecryptionWorker {
const ItemClass = BaseItem.itemClass(item); const ItemClass = BaseItem.itemClass(item);
if (item.type_ === Resource.modelType()) {
const ls = await Resource.localState(item);
if (ls.fetch_status !== Resource.FETCH_STATUS_DONE) {
excludedIds.push(item.id);
continue;
}
}
this.dispatchReport({ this.dispatchReport({
itemIndex: i, itemIndex: i,
itemCount: items.length, itemCount: items.length,
@ -101,7 +103,18 @@ class DecryptionWorker {
// Don't log in production as it results in many messages when importing many items // Don't log in production as it results in many messages when importing many items
// this.logger().info('DecryptionWorker: decrypting: ' + item.id + ' (' + ItemClass.tableName() + ')'); // this.logger().info('DecryptionWorker: decrypting: ' + item.id + ' (' + ItemClass.tableName() + ')');
try { try {
await ItemClass.decrypt(item); const decryptedItem = await ItemClass.decrypt(item);
if (decryptedItem.type_ === Resource.modelType() && !!decryptedItem.encryption_blob_encrypted) {
// itemsThatNeedDecryption() will return the resource again if the blob has not been decrypted,
// but that will result in an infinite loop if the blob simply has not been downloaded yet.
// So skip the ID for now, and the service will try to decrypt the blob again the next time.
excludedIds.push(decryptedItem.id);
}
if (decryptedItem.type_ === Resource.modelType() && !decryptedItem.encryption_blob_encrypted) {
this.eventEmitter_.emit('resourceDecrypted', { id: decryptedItem.id });
}
} catch (error) { } catch (error) {
excludedIds.push(item.id); excludedIds.push(item.id);
@ -139,9 +152,16 @@ class DecryptionWorker {
this.logger().info('DecryptionWorker: completed decryption.'); this.logger().info('DecryptionWorker: completed decryption.');
const downloadedButEncryptedBlobCount = await Resource.downloadedButEncryptedBlobCount();
this.dispatchReport({ state: 'idle' }); this.dispatchReport({ state: 'idle' });
this.state_ = 'idle'; this.state_ = 'idle';
if (downloadedButEncryptedBlobCount) {
this.logger().info('DecryptionWorker: Some resources have been downloaded but are not decrypted yet. Scheduling another decryption. Resource count: ' + downloadedButEncryptedBlobCount);
this.scheduleStart();
}
} }
} }

View File

@ -1,4 +1,5 @@
const Resource = require('lib/models/Resource'); const Resource = require('lib/models/Resource');
const Setting = require('lib/models/Setting');
const BaseService = require('lib/services/BaseService'); const BaseService = require('lib/services/BaseService');
const ResourceService = require('lib/services/ResourceService'); const ResourceService = require('lib/services/ResourceService');
const BaseSyncTarget = require('lib/BaseSyncTarget'); const BaseSyncTarget = require('lib/BaseSyncTarget');
@ -63,23 +64,32 @@ class ResourceFetcher extends BaseService {
} }
updateReport() { updateReport() {
if (this.updateReportIID_) return; const fetchingCount = Object.keys(this.fetchingItems_).length;
this.dispatch({
this.updateReportIID_ = setTimeout(async () => { type: 'RESOURCE_FETCHER_SET',
const toFetchCount = await Resource.needToBeFetchedCount(); fetchingCount: fetchingCount,
this.dispatch({ toFetchCount: fetchingCount + this.queue_.length,
type: 'RESOURCE_FETCHER_SET', });
toFetchCount: toFetchCount,
});
this.updateReportIID_ = null;
}, 2000);
} }
queueDownload(resourceId, priority = null) { async markForDownload(resourceIds) {
if (!Array.isArray(resourceIds)) resourceIds = [resourceIds];
for (const id of resourceIds) {
await Resource.markForDownload(id);
}
for (const id of resourceIds) {
this.queueDownload_(id, 'high');
}
}
queueDownload_(resourceId, priority = null) {
if (priority === null) priority = 'normal'; if (priority === null) priority = 'normal';
const index = this.queuedItemIndex_(resourceId); const index = this.queuedItemIndex_(resourceId);
if (index >= 0) return false; if (index >= 0) return false;
if (this.fetchingItems_[resourceId]) return false;
const item = { id: resourceId }; const item = { id: resourceId };
@ -99,6 +109,8 @@ class ResourceFetcher extends BaseService {
if (this.fetchingItems_[resourceId]) return; if (this.fetchingItems_[resourceId]) return;
this.fetchingItems_[resourceId] = true; this.fetchingItems_[resourceId] = true;
this.updateReport();
const resource = await Resource.load(resourceId); const resource = await Resource.load(resourceId);
const localState = await Resource.localState(resource); const localState = await Resource.localState(resource);
@ -118,7 +130,7 @@ class ResourceFetcher extends BaseService {
// might still be encrypted and the caller usually can't do much with this. In particular // might still be encrypted and the caller usually can't do much with this. In particular
// the note being displayed will refresh the resource images but since they are still // the note being displayed will refresh the resource images but since they are still
// encrypted it's not useful. Probably, the views should listen to DecryptionWorker events instead. // encrypted it's not useful. Probably, the views should listen to DecryptionWorker events instead.
if (emitDownloadComplete) this.eventEmitter_.emit('downloadComplete', { id: resource.id }); if (resource && emitDownloadComplete) this.eventEmitter_.emit('downloadComplete', { id: resource.id, encrypted: !!resource.encryption_blob_encrypted });
this.updateReport(); this.updateReport();
} }
@ -137,7 +149,7 @@ class ResourceFetcher extends BaseService {
this.fetchingItems_[resourceId] = resource; this.fetchingItems_[resourceId] = resource;
const localResourceContentPath = Resource.fullPath(resource); const localResourceContentPath = Resource.fullPath(resource, !!resource.encryption_blob_encrypted);
const remoteResourceContentPath = this.resourceDirName_ + "/" + resource.id; const remoteResourceContentPath = this.resourceDirName_ + "/" + resource.id;
await Resource.setLocalState(resource, { fetch_status: Resource.FETCH_STATUS_STARTED }); await Resource.setLocalState(resource, { fetch_status: Resource.FETCH_STATUS_STARTED });
@ -146,6 +158,8 @@ class ResourceFetcher extends BaseService {
this.logger().debug('ResourceFetcher: Downloading resource: ' + resource.id); this.logger().debug('ResourceFetcher: Downloading resource: ' + resource.id);
this.eventEmitter_.emit('downloadStarted', { id: resource.id })
fileApi.get(remoteResourceContentPath, { path: localResourceContentPath, target: "file" }).then(async () => { fileApi.get(remoteResourceContentPath, { path: localResourceContentPath, target: "file" }).then(async () => {
await Resource.setLocalState(resource, { fetch_status: Resource.FETCH_STATUS_DONE }); await Resource.setLocalState(resource, { fetch_status: Resource.FETCH_STATUS_DONE });
this.logger().debug('ResourceFetcher: Resource downloaded: ' + resource.id); this.logger().debug('ResourceFetcher: Resource downloaded: ' + resource.id);
@ -184,10 +198,12 @@ class ResourceFetcher extends BaseService {
if (this.addingResources_) return; if (this.addingResources_) return;
this.addingResources_ = true; this.addingResources_ = true;
this.logger().info('ResourceFetcher: Auto-add resources: Mode: ' + Setting.value('sync.resourceDownloadMode'));
let count = 0; let count = 0;
const resources = await Resource.needToBeFetched(limit); const resources = await Resource.needToBeFetched(Setting.value('sync.resourceDownloadMode'), limit);
for (let i = 0; i < resources.length; i++) { for (let i = 0; i < resources.length; i++) {
const added = this.queueDownload(resources[i].id); const added = this.queueDownload_(resources[i].id);
if (added) count++; if (added) count++;
} }

View File

@ -146,7 +146,7 @@ const generalMiddleware = store => next => async (action) => {
} }
if (action.type === 'SYNC_CREATED_RESOURCE') { if (action.type === 'SYNC_CREATED_RESOURCE') {
ResourceFetcher.instance().queueDownload(action.id); ResourceFetcher.instance().autoAddResources();
} }
return result; return result;
@ -333,6 +333,12 @@ const appReducer = (state = appDefaultState, action) => {
let store = createStore(appReducer, applyMiddleware(generalMiddleware)); let store = createStore(appReducer, applyMiddleware(generalMiddleware));
storeDispatch = store.dispatch; storeDispatch = store.dispatch;
function resourceFetcher_downloadComplete(event) {
if (event.encrypted) {
DecryptionWorker.instance().scheduleStart();
}
}
async function initialize(dispatch) { async function initialize(dispatch) {
shimInit(); shimInit();
@ -402,7 +408,34 @@ async function initialize(dispatch) {
if (Setting.value('env') == 'prod') { if (Setting.value('env') == 'prod') {
await db.open({ name: 'joplin.sqlite' }) await db.open({ name: 'joplin.sqlite' })
} else { } else {
await db.open({ name: 'joplin-68.sqlite' }) await db.open({ name: 'joplin-68.sqlite' });
const tableNames = [
'notes',
'folders',
'resources',
'tags',
'note_tags',
// 'master_keys',
'item_changes',
'note_resources',
// 'settings',
'deleted_items',
'sync_items',
'notes_normalized',
'revisions',
'resources_to_download',
];
const queries = [];
for (const n of tableNames) {
queries.push('DELETE FROM ' + n);
queries.push('DELETE FROM sqlite_sequence WHERE name="' + n + '"'); // Reset autoincremented IDs
}
queries.push('DELETE FROM settings WHERE key="sync.7.context"');
// await db.transactionExecBatch(queries);
} }
reg.logger().info('Database is ready.'); reg.logger().info('Database is ready.');
@ -423,6 +456,10 @@ async function initialize(dispatch) {
reg.logger().info('db.ftsEnabled = ', Setting.value('db.ftsEnabled')); reg.logger().info('db.ftsEnabled = ', Setting.value('db.ftsEnabled'));
} }
if (Setting.value('env') === 'dev') {
Setting.setValue('welcome.enabled', false);
}
BaseItem.revisionService_ = RevisionService.instance(); BaseItem.revisionService_ = RevisionService.instance();
// Note: for now we hard-code the folder sort order as we need to // Note: for now we hard-code the folder sort order as we need to
@ -505,6 +542,8 @@ async function initialize(dispatch) {
ResourceFetcher.instance().setFileApi(() => { return reg.syncTarget().fileApi() }); ResourceFetcher.instance().setFileApi(() => { return reg.syncTarget().fileApi() });
ResourceFetcher.instance().setLogger(reg.logger()); ResourceFetcher.instance().setLogger(reg.logger());
ResourceFetcher.instance().dispatch = dispatch;
ResourceFetcher.instance().on('downloadComplete', resourceFetcher_downloadComplete);
ResourceFetcher.instance().start(); ResourceFetcher.instance().start();
SearchEngine.instance().setDb(reg.db()); SearchEngine.instance().setDb(reg.db());