[Prototype] Hierarchical Tag Accordion Panel (Evernote-style)

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 View menu.

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)

  1. Description: This plugin addresses the lack of a hierarchical tag overview in Joplin.
  2. The Evernote Legacy: Unlike Evernote, this plugin automatically closes the previous tag group as soon as you open a new one.
  3. 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());
	},
});
1 Like

Thank you for sharing but looks like the code is truncated. Have you considered publishing the plugin?

Thanks for the tip. I’ve adjusted it. The plugin itself works fine for me. However, I’m not really sure whether it’s already publishable and thus suitable (and safe) for others to use. I mainly see it as a stimulus or prototype for the “real” experts.

I migrated from Evernote (more than 60,000 notes and many hundreds of tags) and I’m very pleased with Joplin. Even with this huge archive (almost 20 years of Evernote), Joplin runs very smoothly. Many thanks to the people who designed Joplin and continue to develop it.

With this prototype plugin, the main reason for me to stay with Evernote disappeared. I also think it’s important that this kind of functionality be built into Joplin by default and/or provided through high-quality plugins. It could help others make the switch to Joplin as well.

2 Likes

I second that, considering my case.