diff --git a/CliClient/tests/services_ResourceService.js b/CliClient/tests/services_ResourceService.js index 8a9929829..163230292 100644 --- a/CliClient/tests/services_ResourceService.js +++ b/CliClient/tests/services_ResourceService.js @@ -8,6 +8,7 @@ const Note = require('lib/models/Note.js'); const Tag = require('lib/models/Tag.js'); const NoteTag = require('lib/models/NoteTag.js'); const Resource = require('lib/models/Resource.js'); +const NoteResource = require('lib/models/NoteResource.js'); const ResourceService = require('lib/services/ResourceService.js'); const fs = require('fs-extra'); const ArrayUtils = require('lib/ArrayUtils'); @@ -67,6 +68,32 @@ describe('services_ResourceService', function() { expect(!!(await Resource.load(resource1.id))).toBe(false); expect(await shim.fsDriver().exists(resourcePath)).toBe(false); + expect(!(await NoteResource.all()).length).toBe(true); + })); + + it('should not delete resource if still associated with at least one note', asyncTest(async () => { + const service = new ResourceService(); + + let folder1 = await Folder.save({ title: "folder1" }); + let note1 = await Note.save({ title: 'ma note', parent_id: folder1.id }); + let note2 = await Note.save({ title: 'ma deuxième note', parent_id: folder1.id }); + note1 = await shim.attachFileToNote(note1, __dirname + '/../tests/support/photo.jpg'); + let resource1 = (await Resource.all())[0]; + const resourcePath = Resource.fullPath(resource1); + + await service.indexNoteResources(); + + await Note.delete(note1.id); + + await service.indexNoteResources(); + + await Note.save({ id: note2.id, body: Resource.markdownTag(resource1) }); + + await service.indexNoteResources(); + + await service.deleteOrphanResources(0); + + expect(!!(await Resource.load(resource1.id))).toBe(true); })); }); \ No newline at end of file diff --git a/README.md b/README.md index 91cb007c0..3964f2e25 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Joplin is a free, open source note taking and to-do application, which can handl Notes exported from Evernote via .enex files [can be imported](#importing) into Joplin, including the formatted content (which is converted to Markdown), resources (images, attachments, etc.) and complete metadata (geolocation, updated time, created time, etc.). Plain Markdown files can also be imported. -The notes can be [synchronised](#synchronisation) with various targets including [Nextcloud](https://nextcloud.com/), the file system (for example with a network directory) or with Microsoft OneDrive. When synchronising the notes, notebooks, tags and other metadata are saved to plain text files which can be easily inspected, backed up and moved around. +The notes can be [synchronised](#synchronisation) with various cloud services including [Nextcloud](https://nextcloud.com/), the file system (for example with a network directory) or with Microsoft OneDrive. When synchronising the notes, notebooks, tags and other metadata are saved to plain text files which can be easily inspected, backed up and moved around. The UI of the terminal client is built on top of the great [terminal-kit](https://github.com/cronvel/terminal-kit) library, the desktop client using [Electron](https://electronjs.org/), and the Android client front end is done using [React Native](https://facebook.github.io/react-native/). diff --git a/ReactNativeClient/lib/database.js b/ReactNativeClient/lib/database.js index 10567a0d2..b67118006 100644 --- a/ReactNativeClient/lib/database.js +++ b/ReactNativeClient/lib/database.js @@ -3,14 +3,13 @@ const { promiseChain } = require('lib/promise-utils.js'); const { Logger } = require('lib/logger.js'); const { time } = require('lib/time-utils.js'); const { sprintf } = require('sprintf-js'); +const Mutex = require('async-mutex').Mutex; class Database { constructor(driver) { this.debugMode_ = false; this.driver_ = driver; - this.inTransaction_ = false; - this.logger_ = new Logger(); this.logExcludedQueryTypes_ = []; } @@ -113,29 +112,20 @@ class Database { return; } - // There can be only one transaction running at a time so queue - // any new transaction here. - if (this.inTransaction_) { - while (true) { - await time.msleep(100); - if (!this.inTransaction_) { - this.inTransaction_ = true; - break; - } + // There can be only one transaction running at a time so use a mutex + const release = await Database.batchTransactionMutex_.acquire(); + + try { + queries.splice(0, 0, 'BEGIN TRANSACTION'); + queries.push('COMMIT'); // Note: ROLLBACK is currently not supported + + for (let i = 0; i < queries.length; i++) { + let query = this.wrapQuery(queries[i]); + await this.exec(query.sql, query.params); } + } finally { + release(); } - - this.inTransaction_ = true; - - queries.splice(0, 0, 'BEGIN TRANSACTION'); - queries.push('COMMIT'); // Note: ROLLBACK is currently not supported - - for (let i = 0; i < queries.length; i++) { - let query = this.wrapQuery(queries[i]); - await this.exec(query.sql, query.params); - } - - this.inTransaction_ = false; } static enumId(type, s) { @@ -310,6 +300,8 @@ class Database { } +Database.batchTransactionMutex_ = new Mutex(); + Database.TYPE_UNKNOWN = 0; Database.TYPE_INT = 1; Database.TYPE_TEXT = 2; diff --git a/ReactNativeClient/lib/joplin-database.js b/ReactNativeClient/lib/joplin-database.js index 4a13347cf..227e1d0d2 100644 --- a/ReactNativeClient/lib/joplin-database.js +++ b/ReactNativeClient/lib/joplin-database.js @@ -336,6 +336,9 @@ class JoplinDatabase extends Database { } if (targetVersion == 11) { + // This trick was needed because Electron Builder incorrectly released a dev branch containing v10 as it was + // still being developed, and the db schema was not final at that time. So this v11 was created to + // make sure any invalid db schema that was accidentally created was deleted and recreated. queries.push('DROP TABLE item_changes'); queries.push('DROP TABLE note_resources'); upgradeVersion10(); diff --git a/ReactNativeClient/lib/models/NoteResource.js b/ReactNativeClient/lib/models/NoteResource.js index 2ac92e74f..47938b64c 100644 --- a/ReactNativeClient/lib/models/NoteResource.js +++ b/ReactNativeClient/lib/models/NoteResource.js @@ -31,7 +31,17 @@ class NoteResource extends BaseModel { queries.push({ sql: 'INSERT INTO note_resources (note_id, resource_id, is_associated, last_seen_time) VALUES (?, ?, ?, ?)', params: [noteId, notProcessedResourceIds[i], 1, Date.now()] }); } - await this.db().transactionExecBatch(queries); + await this.db().transactionExecBatch(queries); + } + + static async addOrphanedResources() { + const missingResources = await this.db().selectAll('SELECT id FROM resources WHERE id NOT IN (SELECT DISTINCT resource_id FROM note_resources)'); + const queries = []; + for (let i = 0; i < missingResources.length; i++) { + const id = missingResources[i]; + queries.push({ sql: 'INSERT INTO note_resources (note_id, resource_id, is_associated, last_seen_time) VALUES (?, ?, ?, ?)', params: ["", id, 0, Date.now()] }); + } + await this.db().transactionExecBatch(queries); } static async remove(noteId) { @@ -41,10 +51,20 @@ class NoteResource extends BaseModel { static async orphanResources(expiryDelay = null) { if (expiryDelay === null) expiryDelay = 1000 * 60 * 60 * 24; const cutOffTime = Date.now() - expiryDelay; - const output = await this.modelSelectAll('SELECT DISTINCT resource_id FROM note_resources WHERE is_associated = 0 AND last_seen_time < ?', [cutOffTime]); + const output = await this.modelSelectAll(` + SELECT resource_id, sum(is_associated) + FROM note_resources + GROUP BY resource_id + HAVING sum(is_associated) <= 0 + AND last_seen_time < ? + `, [cutOffTime]); return output.map(r => r.resource_id); } + static async deleteByResource(resourceId) { + await this.db().exec('DELETE FROM note_resources WHERE resource_id = ?', [resourceId]); + } + } module.exports = NoteResource; \ No newline at end of file diff --git a/ReactNativeClient/lib/models/Resource.js b/ReactNativeClient/lib/models/Resource.js index 303b91791..bb11ba615 100644 --- a/ReactNativeClient/lib/models/Resource.js +++ b/ReactNativeClient/lib/models/Resource.js @@ -1,5 +1,6 @@ const BaseModel = require('lib/BaseModel.js'); const BaseItem = require('lib/models/BaseItem.js'); +const NoteResource = require('lib/models/NoteResource.js'); const Setting = require('lib/models/Setting.js'); const ArrayUtils = require('lib/ArrayUtils.js'); const pathUtils = require('lib/path-utils.js'); @@ -152,7 +153,8 @@ class Resource extends BaseItem { const resource = await Resource.load(id); const path = Resource.fullPath(resource); await this.fsDriver().remove(path); - await super.batchDelete([id], options) + await super.batchDelete([id], options); + await NoteResource.deleteByResource(id); // Clean up note/resource relationships } } diff --git a/ReactNativeClient/lib/services/ResourceService.js b/ReactNativeClient/lib/services/ResourceService.js index 3caf9495f..042853a0c 100644 --- a/ReactNativeClient/lib/services/ResourceService.js +++ b/ReactNativeClient/lib/services/ResourceService.js @@ -62,6 +62,8 @@ class ResourceService extends BaseService { await ItemChange.db().exec('DELETE FROM item_changes WHERE id <= ?', [lastId]); } + await NoteResource.addOrphanedResources(); + this.logger().info('ResourceService::indexNoteResources: Completed'); } diff --git a/docs/index.html b/docs/index.html index b2f7d3fe6..182cebbde 100644 --- a/docs/index.html +++ b/docs/index.html @@ -205,7 +205,7 @@

Joplin is a free, open source note taking and to-do application, which can handle a large number of notes organised into notebooks. The notes are searchable, can be copied, tagged and modified either from the applications directly or from your own text editor. The notes are in Markdown format.

Notes exported from Evernote via .enex files can be imported into Joplin, including the formatted content (which is converted to Markdown), resources (images, attachments, etc.) and complete metadata (geolocation, updated time, created time, etc.). Plain Markdown files can also be imported.

-

The notes can be synchronised with various targets including Nextcloud, the file system (for example with a network directory) or with Microsoft OneDrive. When synchronising the notes, notebooks, tags and other metadata are saved to plain text files which can be easily inspected, backed up and moved around.

+

The notes can be synchronised with various cloud services including Nextcloud, the file system (for example with a network directory) or with Microsoft OneDrive. When synchronising the notes, notebooks, tags and other metadata are saved to plain text files which can be easily inspected, backed up and moved around.

The UI of the terminal client is built on top of the great terminal-kit library, the desktop client using Electron, and the Android client front end is done using React Native.

@@ -409,14 +409,14 @@ $$ Croatian hr_HR -Hrvoje Mandić trbuhom@net.hr +Hrvoje Mandić trbuhom@net.hr 64% Deutsch de_DE -Tobias Grasse mail@tobias-grasse.net +Tobias Grasse mail@tobias-grasse.net 99% @@ -430,7 +430,7 @@ $$ Español es_ES -Fernando Martín f@mrtn.es +Fernando Martín f@mrtn.es 99% @@ -458,21 +458,21 @@ $$ Português (Brasil) pt_BR -Renato Nunes Bastos rnbastos@gmail.com +Renato Nunes Bastos rnbastos@gmail.com 98% Русский ru_RU -Artyom Karlov artyom.karlov@gmail.com +Artyom Karlov artyom.karlov@gmail.com 99% 中文 (简体) zh_CN -RCJacH RCJacH@outlook.com +RCJacH RCJacH@outlook.com 66%