diff --git a/CliClient/tests/models_Folder.js b/CliClient/tests/models_Folder.js index 846fe6d88e..30d2bd8a43 100644 --- a/CliClient/tests/models_Folder.js +++ b/CliClient/tests/models_Folder.js @@ -127,4 +127,27 @@ describe('models_Folder', function() { expect(folders[3].id).toBe(f2.id); })); + it('should add node counts', asyncTest(async () => { + let f1 = await Folder.save({ title: 'folder1' }); + let f2 = await Folder.save({ title: 'folder2', parent_id: f1.id }); + let f3 = await Folder.save({ title: 'folder3', parent_id: f2.id }); + let f4 = await Folder.save({ title: 'folder4' }); + + let n1 = await Note.save({ title: 'note1', parent_id: f3.id }); + let n2 = await Note.save({ title: 'note1', parent_id: f3.id }); + let n3 = await Note.save({ title: 'note1', parent_id: f1.id }); + + const folders = await Folder.all(); + await Folder.addNoteCounts(folders); + + const foldersById = {}; + folders.forEach((f) => { foldersById[f.id] = f; }); + + expect(folders.length).toBe(4); + expect(foldersById[f1.id].note_count).toBe(3); + expect(foldersById[f2.id].note_count).toBe(2); + expect(foldersById[f3.id].note_count).toBe(2); + expect(foldersById[f4.id].note_count).toBe(0); + })); + }); diff --git a/CliClient/tests/models_Tag.js b/CliClient/tests/models_Tag.js index a959d1674a..3a8c5c93ae 100644 --- a/CliClient/tests/models_Tag.js +++ b/CliClient/tests/models_Tag.js @@ -59,4 +59,42 @@ describe('models_Tag', function() { expect(tags.length).toBe(0); })); + it('should return tags with note counts', asyncTest(async () => { + 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 2nd note', parent_id: folder1.id }); + await Tag.setNoteTagsByTitles(note1.id, ['un']); + await Tag.setNoteTagsByTitles(note2.id, ['un']); + + let tags = await Tag.allWithNotes(); + expect(tags.length).toBe(1); + expect(tags[0].note_count).toBe(2); + + await Note.delete(note1.id); + + tags = await Tag.allWithNotes(); + expect(tags.length).toBe(1); + expect(tags[0].note_count).toBe(1); + + await Note.delete(note2.id); + + tags = await Tag.allWithNotes(); + expect(tags.length).toBe(0); + })); + + it('should load individual tags with note count', asyncTest(async () => { + 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 2nd note', parent_id: folder1.id }); + let tag = await Tag.save({ title: 'mytag'}); + await Tag.addNote(tag.id, note1.id); + + let tagWithCount = await Tag.loadWithCount(tag.id); + expect(tagWithCount.note_count).toBe(1); + + await Tag.addNote(tag.id, note2.id); + tagWithCount = await Tag.loadWithCount(tag.id); + expect(tagWithCount.note_count).toBe(2); + })); + }); diff --git a/ElectronClient/app/app.js b/ElectronClient/app/app.js index a9fdb8d787..1cfb9aab75 100644 --- a/ElectronClient/app/app.js +++ b/ElectronClient/app/app.js @@ -939,6 +939,14 @@ class Application extends BaseApplication { label: Setting.settingMetadata('folders.sortOrder.field').label(), screens: ['Main'], submenu: sortFolderItems, + }, { + label: Setting.settingMetadata('showNoteCounts').label(), + type: 'checkbox', + checked: Setting.value('showNoteCounts'), + screens: ['Main'], + click: () => { + Setting.setValue('showNoteCounts', !Setting.value('showNoteCounts')); + }, }, { label: Setting.settingMetadata('uncompletedTodosOnTop').label(), type: 'checkbox', diff --git a/ElectronClient/app/gui/SideBar.jsx b/ElectronClient/app/gui/SideBar.jsx index af64888dab..2393ff93f3 100644 --- a/ElectronClient/app/gui/SideBar.jsx +++ b/ElectronClient/app/gui/SideBar.jsx @@ -190,6 +190,10 @@ class SideBarComponent extends React.Component { marginBottom: 10, wordWrap: 'break-word', }, + noteCount: { + paddingLeft: 5, + opacity: 0.5, + }, }; style.tagItem = Object.assign({}, style.listItem); @@ -260,10 +264,10 @@ class SideBarComponent extends React.Component { } async itemContextMenu(event) { - const itemId = event.target.getAttribute('data-id'); + const itemId = event.currentTarget.getAttribute('data-id'); if (itemId === Folder.conflictFolderId()) return; - const itemType = Number(event.target.getAttribute('data-type')); + const itemType = Number(event.currentTarget.getAttribute('data-type')); if (!itemId || !itemType) throw new Error('No data on element'); let deleteMessage = ''; @@ -431,6 +435,10 @@ class SideBarComponent extends React.Component { return this.anchorItemRefs[type][id]; } + noteCountElement(count) { + return
({count})
; + } + folderItem(folder, selected, hasChildren, depth) { let style = Object.assign({}, this.style().listItem); if (folder.id === Folder.conflictFolderId()) style = Object.assign(style, this.style().conflictFolder); @@ -457,6 +465,7 @@ class SideBarComponent extends React.Component { ); const anchorRef = this.anchorItemRef('folder', folder.id); + const noteCount = folder.note_count ? this.noteCountElement(folder.note_count) : ''; return (
@@ -475,7 +484,7 @@ class SideBarComponent extends React.Component { }} onDoubleClick={this.onFolderToggleClick_} > - {itemTitle} + {itemTitle} {noteCount}
); @@ -486,6 +495,7 @@ class SideBarComponent extends React.Component { if (selected) style = Object.assign(style, this.style().listItemSelected); const anchorRef = this.anchorItemRef('tag', tag.id); + const noteCount = Setting.value('showNoteCounts') ? this.noteCountElement(tag.note_count) : ''; return ( - {Tag.displayTitle(tag)} + {Tag.displayTitle(tag)} {noteCount} ); } diff --git a/ReactNativeClient/lib/BaseApplication.js b/ReactNativeClient/lib/BaseApplication.js index c8fa8a72da..f21845458c 100644 --- a/ReactNativeClient/lib/BaseApplication.js +++ b/ReactNativeClient/lib/BaseApplication.js @@ -440,7 +440,7 @@ class BaseApplication { await this.refreshNotes(newState, refreshNotesUseSelectedNoteId, refreshNotesHash); } - if (action.type === 'NOTE_UPDATE_ONE') { + if (action.type === 'NOTE_UPDATE_ONE' || action.type === 'NOTE_DELETE') { refreshFolders = true; } @@ -448,6 +448,10 @@ class BaseApplication { refreshFolders = 'now'; } + if (this.hasGui() && ((action.type == 'SETTING_UPDATE_ONE' && action.key == 'showNoteCounts') || action.type == 'SETTING_UPDATE_ALL')) { + refreshFolders = 'now'; + } + if (this.hasGui() && action.type === 'SYNC_GOT_ENCRYPTED_ITEM') { DecryptionWorker.instance().scheduleStart(); } diff --git a/ReactNativeClient/lib/components/shared/reduxSharedMiddleware.js b/ReactNativeClient/lib/components/shared/reduxSharedMiddleware.js index 39af260bcc..8b8655dbf0 100644 --- a/ReactNativeClient/lib/components/shared/reduxSharedMiddleware.js +++ b/ReactNativeClient/lib/components/shared/reduxSharedMiddleware.js @@ -20,7 +20,11 @@ const reduxSharedMiddleware = async function(store, next, action) { ResourceFetcher.instance().autoAddResources(); } - if (action.type == 'NOTE_DELETE') { + if (action.type == 'NOTE_DELETE' || + action.type == 'NOTE_UPDATE_ONE' || + action.type == 'NOTE_UPDATE_ALL' || + action.type == 'NOTE_TAG_REMOVE' || + action.type == 'TAG_UPDATE_ONE') { refreshTags = true; } diff --git a/ReactNativeClient/lib/folders-screen-utils.js b/ReactNativeClient/lib/folders-screen-utils.js index cd9cca91e9..eb1728e240 100644 --- a/ReactNativeClient/lib/folders-screen-utils.js +++ b/ReactNativeClient/lib/folders-screen-utils.js @@ -25,6 +25,10 @@ class FoldersScreenUtils { folders = await Folder.orderByLastModified(folders, orderDir); } + if (Setting.value('showNoteCounts')) { + await Folder.addNoteCounts(folders); + } + return folders; } diff --git a/ReactNativeClient/lib/joplin-database.js b/ReactNativeClient/lib/joplin-database.js index 870b7c2b3e..41d94902dc 100644 --- a/ReactNativeClient/lib/joplin-database.js +++ b/ReactNativeClient/lib/joplin-database.js @@ -302,7 +302,7 @@ class JoplinDatabase extends Database { // must be set in the synchronizer too. // 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, 22, 23, 24]; + 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, 23, 24, 25]; let currentVersionIndex = existingDatabaseVersions.indexOf(fromVersion); @@ -639,6 +639,16 @@ class JoplinDatabase extends Database { queries.push('ALTER TABLE notes ADD COLUMN `markup_language` INT NOT NULL DEFAULT 1'); // 1: Markdown, 2: HTML } + if (targetVersion == 25) { + queries.push(`CREATE VIEW tags_with_note_count AS + SELECT tags.id as id, tags.title as title, tags.created_time as created_time, tags.updated_time as updated_time, COUNT(notes.id) as note_count + FROM tags + LEFT JOIN note_tags nt on nt.tag_id = tags.id + LEFT JOIN notes on notes.id = nt.note_id + WHERE notes.id IS NOT NULL + GROUP BY tags.id`); + } + queries.push({ sql: 'UPDATE version SET version = ?', params: [targetVersion] }); try { diff --git a/ReactNativeClient/lib/models/Folder.js b/ReactNativeClient/lib/models/Folder.js index 06e7817b7a..f994224ad1 100644 --- a/ReactNativeClient/lib/models/Folder.js +++ b/ReactNativeClient/lib/models/Folder.js @@ -105,6 +105,28 @@ class Folder extends BaseItem { }; } + // Calculates note counts for all folders and adds the note_count attribute to each folder + // Note: this only calculates the overall number of nodes for this folder and all its descendants + static async addNoteCounts(folders) { + const foldersById = {}; + folders.forEach((f) => { + foldersById[f.id] = f; + }); + + const sql = `SELECT folders.id as folder_id, count(notes.parent_id) as note_count + FROM folders LEFT JOIN notes ON notes.parent_id = folders.id + GROUP BY folders.id`; + const noteCounts = await this.db().selectAll(sql); + noteCounts.forEach((noteCount) => { + let parentId = noteCount.folder_id; + do { + let folder = foldersById[parentId]; + folder.note_count = (folder.note_count || 0) + noteCount.note_count; + parentId = folder.parent_id; + } while (parentId); + }); + } + // Folders that contain notes that have been modified recently go on top. // The remaining folders, that don't contain any notes are sorted by their own user_updated_time static async orderByLastModified(folders, dir = 'DESC') { diff --git a/ReactNativeClient/lib/models/Setting.js b/ReactNativeClient/lib/models/Setting.js index 6967c4d888..11dd0f5fd9 100644 --- a/ReactNativeClient/lib/models/Setting.js +++ b/ReactNativeClient/lib/models/Setting.js @@ -247,6 +247,7 @@ class Setting extends BaseModel { return output; }, }, + showNoteCounts: { value: false, type: Setting.TYPE_BOOL, public: true, appTypes: ['desktop'], label: () => _('Show note counts') }, layoutButtonSequence: { value: Setting.LAYOUT_ALL, type: Setting.TYPE_INT, diff --git a/ReactNativeClient/lib/models/Tag.js b/ReactNativeClient/lib/models/Tag.js index cb44f29a1f..1a0d96acc2 100644 --- a/ReactNativeClient/lib/models/Tag.js +++ b/ReactNativeClient/lib/models/Tag.js @@ -68,7 +68,7 @@ class Tag extends BaseItem { this.dispatch({ type: 'TAG_UPDATE_ONE', - item: await Tag.load(tagId), + item: await Tag.loadWithCount(tagId), }); return output; @@ -86,23 +86,24 @@ class Tag extends BaseItem { }); } + static loadWithCount(tagId) { + let sql = 'SELECT * FROM tags_with_note_count WHERE id = ?'; + return this.modelSelectOne(sql, [tagId]); + } + static async hasNote(tagId, noteId) { let r = await this.db().selectOne('SELECT note_id FROM note_tags WHERE tag_id = ? AND note_id = ? LIMIT 1', [tagId, noteId]); return !!r; } - static tagsWithNotesSql_() { - return 'select distinct tags.id from tags left join note_tags nt on nt.tag_id = tags.id left join notes on notes.id = nt.note_id where notes.id IS NOT NULL'; - } - static async allWithNotes() { - return await Tag.modelSelectAll(`SELECT * FROM tags WHERE id IN (${this.tagsWithNotesSql_()})`); + return await Tag.modelSelectAll('SELECT * FROM tags_with_note_count'); } static async searchAllWithNotes(options) { if (!options) options = {}; if (!options.conditions) options.conditions = []; - options.conditions.push(`id IN (${this.tagsWithNotesSql_()})`); + options.conditions.push('id IN (SELECT distinct id FROM tags_with_note_count)'); return this.search(options); }