1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-01-11 18:24:43 +02:00

Desktop: Add new setting to show note counts for folders and tags (#2006)

* Adding node counts for folders and tags

* Add unit tests

* Fix count update when the tag list for a note is updated

* Right align note counts and remove from the settings screen

* Folder note count calculation update to include descendants

* Update Setting.js

* Change count style and fix click on counts

* Fix tag/folder count update on delete/add note

* Review updates
This commit is contained in:
Diego Erdody 2019-11-10 22:14:56 -08:00 committed by Laurent Cozic
parent c0dd8d0332
commit 9c98fb5312
11 changed files with 139 additions and 14 deletions

View File

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

View File

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

View File

@ -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',

View File

@ -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 <div style={this.style().noteCount}>({count})</div>;
}
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 (
<div className="list-item-container" style={containerStyle} key={folder.id} onDragStart={this.onFolderDragStart_} onDragOver={this.onFolderDragOver_} onDrop={this.onFolderDrop_} draggable={true} folderid={folder.id}>
@ -475,7 +484,7 @@ class SideBarComponent extends React.Component {
}}
onDoubleClick={this.onFolderToggleClick_}
>
{itemTitle}
{itemTitle} {noteCount}
</a>
</div>
);
@ -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 (
<a
@ -503,7 +513,7 @@ class SideBarComponent extends React.Component {
this.tagItem_click(tag);
}}
>
{Tag.displayTitle(tag)}
{Tag.displayTitle(tag)} {noteCount}
</a>
);
}

View File

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

View File

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

View File

@ -25,6 +25,10 @@ class FoldersScreenUtils {
folders = await Folder.orderByLastModified(folders, orderDir);
}
if (Setting.value('showNoteCounts')) {
await Folder.addNoteCounts(folders);
}
return folders;
}

View File

@ -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 {

View File

@ -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') {

View File

@ -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,

View File

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