I recently migrated from Evernote to Joplin. With an archive of over 60,000 notes and hundreds of tags, the transition was a significant undertaking. The main thing holding me back was the lack of a collapsible tag hierarchy within Joplin.
In the default interface, the tag list is simply a long vertical column; extremely impractical for heavy tag users. While Joplin is excellent in many ways, Evernote is simply superior in this specific regard.
I created this prototype (with AI help) to bring back that functionality with an added "Accordion" feature.
How it works:
- Custom Panel: The plugin creates a dedicated tag panel (usually on the right) to keep your main sidebar uncluttered.
- Smart Nesting: It uses the
/character for nesting (e.g.,Project/Task/Tagetc). - Accordion Effect: When you open one tag group, others close automatically, maintaining a clean workspace.
- Navigation: Simply click on a tag within this panel to instantly filter and display the related notes in your list.
- Toggle Visibility: You can easily show or hide this panel via the
Viewmenu.
Note: This is a prototype (which, for the record, works flawlessly on my end). I hope it inspires the Joplin team or expert plugin developers (who have far more expertise than I do) to further develop this concept and integrate it professionally.I recently migrated from Evernote to Joplin. With an archive of over 60,000 notes and hundreds of tags, the transition was a significant undertaking. The main thing holding me back was the lack of a collapsible tag hierarchy within Joplin.
DOSSIER: JOPLIN TAG GROUPS PLUGIN (Version 1.3.0)
- Description: This plugin addresses the lack of a hierarchical tag overview in Joplin.
- The Evernote Legacy: Unlike Evernote, this plugin automatically closes the previous tag group as soon as you open a new one.
- Installation: Run
npm run dist, then go to Tools > Options > Plugins > Install from file in Joplin.
SOURCE CODE (src/index.ts):
import joplin from 'api';
joplin.plugins.register({
onStart: async function() {
const panel = await joplin.views.panels.create('tag_group_panel');
const style = `
body {
font-family: -apple-system, sans-serif;
background: var(--joplin-background-color);
color: var(--joplin-color);
padding: 0;
margin: 0;
}
.container {
height: 100vh;
overflow-y: auto;
padding: 15px;
box-sizing: border-box;
}
details { margin-left: 12px; margin-top: 2px; }
.top-level { margin-left: 0; }
summary {
cursor: pointer;
padding: 6px 0;
outline: none;
list-style: none;
border-bottom: 1px solid var(--joplin-divider-color);
font-size: 13px;
}
summary::-webkit-details-marker { display: none; }
summary:before { content: '▸ '; opacity: 0.4; margin-right: 8px; }
details[open] > summary:before { content: '▾ '; }
.top-level > summary {
font-weight: bold;
font-size: 14px;
color: var(--joplin-color);
}
.tag-item {
display: block;
padding: 5px 10px;
cursor: pointer;
font-size: 13px;
color: var(--joplin-color);
border-radius: 3px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tag-item:hover {
background: var(--joplin-background-color-hover);
}
`;
function buildHierarchy(tags) {
const root = { _tags: [], _sub: {} };
tags.forEach(tag => {
const parts = tag.title.split('/');
let current = root;
for (let i = 0; i < parts.length - 1; i++) {
const folderName = parts.slice(0, i + 1).join('/') + '/';
if (!current._sub[folderName]) {
current._sub[folderName] = { _tags: [], _sub: {} };
}
current = current._sub[folderName];
}
current._tags.push(tag);
});
return root;
}
function renderHierarchy(node, isRoot = false) {
let html = "";
const subKeys = Object.keys(node._sub).sort();
subKeys.forEach(key => {
if (isRoot) {
html += '<details class="top-level" name="tag-groups"><summary>' + key + '</summary>';
} else {
html += '<details><summary>' + key + '</summary>';
}
html += renderHierarchy(node._sub[key], false);
html += </details>';
});
const sortedTags = node._tags.sort((a, b) => a.title.localeCompare(b.title));
sortedTags.forEach(tag => {
const safe = tag.title.replace(/'/g, "\\'");
html += '<div class="tag-item" onclick="webviewApi.postMessage(\'' + safe + '\')">' + tag.title + '</div>';
});
return html;
}
async function updateTagPanel() {
let allTags = [];
let page = 1;
let hasMore = true;
while (hasMore) {
const response = await joplin.data.get(['tags'], { fields: ['id', 'title'], page: page, limit: 100 });
allTags.push(...response.items);
hasMore = response.has_more;
page++;
}
const tree = buildHierarchy(allTags);
let html = '<style>' + style + '</style><div class="container">';
html += renderHierarchy(tree, true);
html += '</div>';
await joplin.views.panels.setHtml(panel, html);
}
await joplin.views.panels.onMessage(panel, async (tagTitle: string) => {
let page = 1;
let foundTagId = null;
let hasMore = true;
while (hasMore && !foundTagId) {
const response = await joplin.data.get(['tags'], { fields: ['id', 'title'], page: page, limit: 100 });
const match = response.items.find(t => t.title === tagTitle);
if (match) foundTagId = match.id;
hasMore = response.has_more;
page++;
}
if (foundTagId) {
await joplin.commands.execute('openTag', foundTagId);
await joplin.commands.execute('focusElementNoteList');
}
});
updateTagPanel();
joplin.workspace.onNoteSelectionChange(() => updateTagPanel());
},
});
