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);
|
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);
|
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(),
|
label: Setting.settingMetadata('folders.sortOrder.field').label(),
|
||||||
screens: ['Main'],
|
screens: ['Main'],
|
||||||
submenu: sortFolderItems,
|
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(),
|
label: Setting.settingMetadata('uncompletedTodosOnTop').label(),
|
||||||
type: 'checkbox',
|
type: 'checkbox',
|
||||||
|
@ -190,6 +190,10 @@ class SideBarComponent extends React.Component {
|
|||||||
marginBottom: 10,
|
marginBottom: 10,
|
||||||
wordWrap: 'break-word',
|
wordWrap: 'break-word',
|
||||||
},
|
},
|
||||||
|
noteCount: {
|
||||||
|
paddingLeft: 5,
|
||||||
|
opacity: 0.5,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
style.tagItem = Object.assign({}, style.listItem);
|
style.tagItem = Object.assign({}, style.listItem);
|
||||||
@ -260,10 +264,10 @@ class SideBarComponent extends React.Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async itemContextMenu(event) {
|
async itemContextMenu(event) {
|
||||||
const itemId = event.target.getAttribute('data-id');
|
const itemId = event.currentTarget.getAttribute('data-id');
|
||||||
if (itemId === Folder.conflictFolderId()) return;
|
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');
|
if (!itemId || !itemType) throw new Error('No data on element');
|
||||||
|
|
||||||
let deleteMessage = '';
|
let deleteMessage = '';
|
||||||
@ -431,6 +435,10 @@ class SideBarComponent extends React.Component {
|
|||||||
return this.anchorItemRefs[type][id];
|
return this.anchorItemRefs[type][id];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
noteCountElement(count) {
|
||||||
|
return <div style={this.style().noteCount}>({count})</div>;
|
||||||
|
}
|
||||||
|
|
||||||
folderItem(folder, selected, hasChildren, depth) {
|
folderItem(folder, selected, hasChildren, depth) {
|
||||||
let style = Object.assign({}, this.style().listItem);
|
let style = Object.assign({}, this.style().listItem);
|
||||||
if (folder.id === Folder.conflictFolderId()) style = Object.assign(style, this.style().conflictFolder);
|
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 anchorRef = this.anchorItemRef('folder', folder.id);
|
||||||
|
const noteCount = folder.note_count ? this.noteCountElement(folder.note_count) : '';
|
||||||
|
|
||||||
return (
|
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}>
|
<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_}
|
onDoubleClick={this.onFolderToggleClick_}
|
||||||
>
|
>
|
||||||
{itemTitle}
|
{itemTitle} {noteCount}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -486,6 +495,7 @@ class SideBarComponent extends React.Component {
|
|||||||
if (selected) style = Object.assign(style, this.style().listItemSelected);
|
if (selected) style = Object.assign(style, this.style().listItemSelected);
|
||||||
|
|
||||||
const anchorRef = this.anchorItemRef('tag', tag.id);
|
const anchorRef = this.anchorItemRef('tag', tag.id);
|
||||||
|
const noteCount = Setting.value('showNoteCounts') ? this.noteCountElement(tag.note_count) : '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
@ -503,7 +513,7 @@ class SideBarComponent extends React.Component {
|
|||||||
this.tagItem_click(tag);
|
this.tagItem_click(tag);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{Tag.displayTitle(tag)}
|
{Tag.displayTitle(tag)} {noteCount}
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -440,7 +440,7 @@ class BaseApplication {
|
|||||||
await this.refreshNotes(newState, refreshNotesUseSelectedNoteId, refreshNotesHash);
|
await this.refreshNotes(newState, refreshNotesUseSelectedNoteId, refreshNotesHash);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action.type === 'NOTE_UPDATE_ONE') {
|
if (action.type === 'NOTE_UPDATE_ONE' || action.type === 'NOTE_DELETE') {
|
||||||
refreshFolders = true;
|
refreshFolders = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -448,6 +448,10 @@ class BaseApplication {
|
|||||||
refreshFolders = 'now';
|
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') {
|
if (this.hasGui() && action.type === 'SYNC_GOT_ENCRYPTED_ITEM') {
|
||||||
DecryptionWorker.instance().scheduleStart();
|
DecryptionWorker.instance().scheduleStart();
|
||||||
}
|
}
|
||||||
|
@ -20,7 +20,11 @@ const reduxSharedMiddleware = async function(store, next, action) {
|
|||||||
ResourceFetcher.instance().autoAddResources();
|
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;
|
refreshTags = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -25,6 +25,10 @@ class FoldersScreenUtils {
|
|||||||
folders = await Folder.orderByLastModified(folders, orderDir);
|
folders = await Folder.orderByLastModified(folders, orderDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (Setting.value('showNoteCounts')) {
|
||||||
|
await Folder.addNoteCounts(folders);
|
||||||
|
}
|
||||||
|
|
||||||
return folders;
|
return folders;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -302,7 +302,7 @@ class JoplinDatabase extends Database {
|
|||||||
// must be set in the synchronizer too.
|
// must be set in the synchronizer too.
|
||||||
|
|
||||||
// Note: v16 and v17 don't do anything. They were used to debug an issue.
|
// 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);
|
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
|
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] });
|
queries.push({ sql: 'UPDATE version SET version = ?', params: [targetVersion] });
|
||||||
|
|
||||||
try {
|
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.
|
// 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
|
// The remaining folders, that don't contain any notes are sorted by their own user_updated_time
|
||||||
static async orderByLastModified(folders, dir = 'DESC') {
|
static async orderByLastModified(folders, dir = 'DESC') {
|
||||||
|
@ -247,6 +247,7 @@ class Setting extends BaseModel {
|
|||||||
return output;
|
return output;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
showNoteCounts: { value: false, type: Setting.TYPE_BOOL, public: true, appTypes: ['desktop'], label: () => _('Show note counts') },
|
||||||
layoutButtonSequence: {
|
layoutButtonSequence: {
|
||||||
value: Setting.LAYOUT_ALL,
|
value: Setting.LAYOUT_ALL,
|
||||||
type: Setting.TYPE_INT,
|
type: Setting.TYPE_INT,
|
||||||
|
@ -68,7 +68,7 @@ class Tag extends BaseItem {
|
|||||||
|
|
||||||
this.dispatch({
|
this.dispatch({
|
||||||
type: 'TAG_UPDATE_ONE',
|
type: 'TAG_UPDATE_ONE',
|
||||||
item: await Tag.load(tagId),
|
item: await Tag.loadWithCount(tagId),
|
||||||
});
|
});
|
||||||
|
|
||||||
return output;
|
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) {
|
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]);
|
let r = await this.db().selectOne('SELECT note_id FROM note_tags WHERE tag_id = ? AND note_id = ? LIMIT 1', [tagId, noteId]);
|
||||||
return !!r;
|
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() {
|
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) {
|
static async searchAllWithNotes(options) {
|
||||||
if (!options) options = {};
|
if (!options) options = {};
|
||||||
if (!options.conditions) options.conditions = [];
|
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);
|
return this.search(options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user