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:
parent
c0dd8d0332
commit
9c98fb5312
@ -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);
|
||||
}));
|
||||
|
||||
});
|
||||
|
@ -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);
|
||||
}));
|
||||
|
||||
});
|
||||
|
@ -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',
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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();
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -25,6 +25,10 @@ class FoldersScreenUtils {
|
||||
folders = await Folder.orderByLastModified(folders, orderDir);
|
||||
}
|
||||
|
||||
if (Setting.value('showNoteCounts')) {
|
||||
await Folder.addNoteCounts(folders);
|
||||
}
|
||||
|
||||
return folders;
|
||||
}
|
||||
|
||||
|
@ -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 {
|
||||
|
@ -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') {
|
||||
|
@ -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,
|
||||
|
@ -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);
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user