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);
}