feat: allow custom sidebar label for type sections (#165)

* feat: allow custom sidebar label for type sections

- Adds sidebarLabel field to vault type config (Rust + frontend)
- Overrides auto-pluralization with user-defined label in sidebar
- Tests updated to cover custom label display

* fix: add missing sidebarLabel field to buildNewEntry and bulk mock generator

---------

Co-authored-by: Test <test@test.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Luca Rossi
2026-03-02 02:01:21 +01:00
committed by GitHub
parent 568f0aef4f
commit ee6972113a
6 changed files with 138 additions and 8 deletions

View File

@@ -62,6 +62,9 @@ pub struct VaultEntry {
pub color: Option<String>,
/// Display order for Type entries in sidebar (lower = higher). None = use default order.
pub order: Option<i64>,
/// Custom sidebar section label for Type entries, overriding auto-pluralization.
#[serde(rename = "sidebarLabel")]
pub sidebar_label: Option<String>,
/// Word count of the note body (excludes frontmatter and H1 title).
#[serde(rename = "wordCount")]
pub word_count: u32,
@@ -104,6 +107,8 @@ struct Frontmatter {
color: Option<String>,
#[serde(default)]
order: Option<i64>,
#[serde(rename = "sidebar label", default)]
sidebar_label: Option<String>,
}
/// Handles YAML fields that can be either a single string or a list of strings.
@@ -147,6 +152,7 @@ const SKIP_KEYS: &[&str] = &[
"icon",
"color",
"order",
"sidebar label",
];
/// Extract all wikilink-containing fields from raw YAML frontmatter.
@@ -325,6 +331,7 @@ pub fn parse_md_file(path: &Path) -> Result<VaultEntry, String> {
icon: frontmatter.icon,
color: frontmatter.color,
order: frontmatter.order,
sidebar_label: frontmatter.sidebar_label,
word_count,
outgoing_links,
})
@@ -1055,6 +1062,32 @@ References:
assert_eq!(fs::read_to_string(&path).unwrap(), content);
}
// --- sidebar_label tests ---
#[test]
fn test_parse_sidebar_label_from_type_entry() {
let dir = TempDir::new().unwrap();
let content = "---\ntype: Type\nsidebar label: News\n---\n# News\n";
let entry = parse_test_entry(&dir, "type/news.md", content);
assert_eq!(entry.sidebar_label, Some("News".to_string()));
}
#[test]
fn test_parse_sidebar_label_missing_defaults_to_none() {
let dir = TempDir::new().unwrap();
let content = "---\ntype: Type\n---\n# Project\n";
let entry = parse_test_entry(&dir, "type/project.md", content);
assert_eq!(entry.sidebar_label, None);
}
#[test]
fn test_sidebar_label_not_in_relationships() {
let dir = TempDir::new().unwrap();
let content = "---\ntype: Type\nsidebar label: My Series\n---\n# Series\n";
let entry = parse_test_entry(&dir, "type/series.md", content);
assert!(entry.relationships.get("sidebar label").is_none());
}
// Frontmatter update/delete tests are in frontmatter.rs
// save_image tests are in vault/image.rs
// purge_trash tests are in vault/trash.rs

View File

@@ -39,6 +39,7 @@ const mockEntries: VaultEntry[] = [
icon: null,
color: null,
order: null,
sidebarLabel: null,
outgoingLinks: [],
},
{
@@ -64,6 +65,7 @@ const mockEntries: VaultEntry[] = [
icon: null,
color: null,
order: null,
sidebarLabel: null,
outgoingLinks: [],
},
{
@@ -89,6 +91,7 @@ const mockEntries: VaultEntry[] = [
icon: null,
color: null,
order: null,
sidebarLabel: null,
outgoingLinks: [],
},
{
@@ -114,6 +117,7 @@ const mockEntries: VaultEntry[] = [
icon: null,
color: null,
order: null,
sidebarLabel: null,
outgoingLinks: [],
},
{
@@ -139,6 +143,7 @@ const mockEntries: VaultEntry[] = [
icon: null,
color: null,
order: null,
sidebarLabel: null,
outgoingLinks: [],
},
{
@@ -164,6 +169,7 @@ const mockEntries: VaultEntry[] = [
icon: null,
color: null,
order: null,
sidebarLabel: null,
outgoingLinks: [],
},
{
@@ -189,6 +195,7 @@ const mockEntries: VaultEntry[] = [
icon: null,
color: null,
order: null,
sidebarLabel: null,
outgoingLinks: [],
},
{
@@ -214,6 +221,7 @@ const mockEntries: VaultEntry[] = [
icon: null,
color: null,
order: null,
sidebarLabel: null,
outgoingLinks: [],
},
]
@@ -432,6 +440,7 @@ describe('Sidebar', () => {
icon: null,
color: null,
order: null,
sidebarLabel: null,
outgoingLinks: [],
},
{
@@ -457,6 +466,7 @@ describe('Sidebar', () => {
icon: null,
color: null,
order: null,
sidebarLabel: null,
outgoingLinks: [],
},
{
@@ -553,7 +563,7 @@ describe('Sidebar', () => {
isA: 'Event', aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null,
cadence: null, archived: false, trashed: true, trashedAt: 1700000000,
modifiedAt: 1700000000, createdAt: null, fileSize: 100, snippet: '', wordCount: 0,
relationships: {}, icon: null, color: null, order: null, outgoingLinks: [],
relationships: {}, icon: null, color: null, order: null, sidebarLabel: null, outgoingLinks: [],
},
]
render(<Sidebar entries={entriesWithTrashedOnly} selection={defaultSelection} onSelect={() => {}} />)
@@ -591,6 +601,7 @@ describe('Sidebar', () => {
icon: null,
color: null,
order: null,
sidebarLabel: null,
outgoingLinks: [],
}
render(<Sidebar entries={[...mockEntries, projectTypeEntry]} selection={defaultSelection} onSelect={() => {}} />)
@@ -598,6 +609,52 @@ describe('Sidebar', () => {
const projectLabels = screen.getAllByText('Projects')
expect(projectLabels.length).toBe(1)
})
it('uses sidebarLabel from Type entry instead of auto-pluralization', () => {
const entriesWithLabel: VaultEntry[] = [
...mockEntries,
{
path: '/vault/type/news.md', filename: 'news.md', title: 'News', isA: 'Type',
aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null, cadence: null,
archived: false, trashed: false, trashedAt: null, modifiedAt: 1700000000, createdAt: null,
fileSize: 200, snippet: '', wordCount: 0, relationships: {},
icon: null, color: null, order: null, sidebarLabel: 'News', outgoingLinks: [],
},
{
path: '/vault/news/breaking.md', filename: 'breaking.md', title: 'Breaking Story', isA: 'News',
aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null, cadence: null,
archived: false, trashed: false, trashedAt: null, modifiedAt: 1700000000, createdAt: null,
fileSize: 300, snippet: '', wordCount: 0, relationships: {},
icon: null, color: null, order: null, sidebarLabel: null, outgoingLinks: [],
},
]
render(<Sidebar entries={entriesWithLabel} selection={defaultSelection} onSelect={() => {}} />)
// Should show "News" (custom label), not "Newses" (auto-pluralized)
expect(screen.getByText('News')).toBeInTheDocument()
expect(screen.queryByText('Newses')).not.toBeInTheDocument()
})
it('uses sidebarLabel to override built-in type label', () => {
const entriesWithBuiltInOverride: VaultEntry[] = [
...mockEntries,
{
path: '/vault/type/person.md', filename: 'person.md', title: 'Person', isA: 'Type',
aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null, cadence: null,
archived: false, trashed: false, trashedAt: null, modifiedAt: 1700000000, createdAt: null,
fileSize: 200, snippet: '', wordCount: 0, relationships: {},
icon: null, color: null, order: null, sidebarLabel: 'Contacts', outgoingLinks: [],
},
]
render(<Sidebar entries={entriesWithBuiltInOverride} selection={defaultSelection} onSelect={() => {}} />)
expect(screen.getByText('Contacts')).toBeInTheDocument()
expect(screen.queryByText('People')).not.toBeInTheDocument()
})
it('falls back to auto-pluralization when sidebarLabel is null', () => {
render(<Sidebar entries={entriesWithCustomTypes} selection={defaultSelection} onSelect={() => {}} />)
// Recipe has no sidebarLabel → should auto-pluralize to "Recipes"
expect(screen.getByText('Recipes')).toBeInTheDocument()
})
})
describe('customize section visibility', () => {
@@ -705,21 +762,21 @@ describe('Sidebar', () => {
aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null, cadence: null,
archived: false, trashed: false, trashedAt: null, modifiedAt: 1700000000, createdAt: null, fileSize: 200, snippet: '',
wordCount: 0,
relationships: {}, icon: null, color: null, order: 5, outgoingLinks: [],
relationships: {}, icon: null, color: null, order: 5, sidebarLabel: null, outgoingLinks: [],
},
{
path: '/vault/type/topic.md', filename: 'topic.md', title: 'Topic', isA: 'Type',
aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null, cadence: null,
archived: false, trashed: false, trashedAt: null, modifiedAt: 1700000000, createdAt: null, fileSize: 200, snippet: '',
wordCount: 0,
relationships: {}, icon: null, color: null, order: 0, outgoingLinks: [],
relationships: {}, icon: null, color: null, order: 0, sidebarLabel: null, outgoingLinks: [],
},
{
path: '/vault/type/person.md', filename: 'person.md', title: 'Person', isA: 'Type',
aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null, cadence: null,
archived: false, trashed: false, trashedAt: null, modifiedAt: 1700000000, createdAt: null, fileSize: 200, snippet: '',
wordCount: 0,
relationships: {}, icon: null, color: null, order: 1, outgoingLinks: [],
relationships: {}, icon: null, color: null, order: 1, sidebarLabel: null, outgoingLinks: [],
},
]

View File

@@ -79,11 +79,12 @@ function buildSectionGroup(type: string, typeEntryMap: Record<string, VaultEntry
const builtIn = BUILT_IN_TYPE_MAP.get(type)
const typeEntry = typeEntryMap[type]
const customColor = typeEntry?.color ?? null
const label = typeEntry?.sidebarLabel || (builtIn?.label ?? pluralizeType(type))
if (builtIn) {
const Icon = typeEntry?.icon ? resolveIcon(typeEntry.icon) : builtIn.Icon
return { ...builtIn, Icon, customColor }
return { ...builtIn, label, Icon, customColor }
}
return { label: pluralizeType(type), type, Icon: resolveIcon(typeEntry?.icon ?? null), customColor }
return { label, type, Icon: resolveIcon(typeEntry?.icon ?? null), customColor }
}
/** Build sections dynamically from actual vault entries — only types with ≥1 active note appear */

View File

@@ -70,7 +70,7 @@ export function buildNewEntry({ path, slug, title, type, status }: NewEntryParam
aliases: [], belongsTo: [], relatedTo: [],
status, owner: null, cadence: null, archived: false, trashed: false, trashedAt: null,
modifiedAt: now, createdAt: now, fileSize: 0,
snippet: '', wordCount: 0, relationships: {}, icon: null, color: null, order: null, outgoingLinks: [],
snippet: '', wordCount: 0, relationships: {}, icon: null, color: null, order: null, outgoingLinks: [], sidebarLabel: null,
}
}

View File

@@ -35,6 +35,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
order: null,
sidebarLabel: null,
outgoingLinks: ['quarter/q1-2026', 'topic/software-development', 'person/matteo-cellini', 'person/maria-bianchi', 'person/marco-verdi'],
},
{
@@ -69,6 +70,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
order: null,
sidebarLabel: null,
outgoingLinks: ['essay/on-writing-well', 'essay/engineering-leadership-101', 'essay/ai-agents-primer', 'topic/growth', 'topic/writing'],
},
{
@@ -97,6 +99,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
order: null,
sidebarLabel: null,
outgoingLinks: ['person/matteo-cellini'],
},
{
@@ -125,6 +128,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
order: null,
sidebarLabel: null,
outgoingLinks: ['responsibility/grow-newsletter'],
},
{
@@ -153,6 +157,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
order: null,
sidebarLabel: null,
outgoingLinks: ['responsibility/manage-sponsorships'],
},
{
@@ -182,6 +187,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
order: null,
sidebarLabel: null,
outgoingLinks: ['topic/trading', 'topic/algorithmic-trading', 'data/ema200-backtest-results'],
},
{
@@ -211,6 +217,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
order: null,
sidebarLabel: null,
outgoingLinks: ['project/26q1-laputa-app', 'topic/growth', 'topic/ads'],
},
{
@@ -239,6 +246,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
order: null,
sidebarLabel: null,
outgoingLinks: ['project/26q1-laputa-app'],
},
{
@@ -266,6 +274,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
order: null,
sidebarLabel: null,
outgoingLinks: [],
},
{
@@ -293,6 +302,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
order: null,
sidebarLabel: null,
outgoingLinks: [],
},
{
@@ -320,6 +330,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
order: null,
sidebarLabel: null,
outgoingLinks: [],
},
{
@@ -347,6 +358,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
order: null,
sidebarLabel: null,
outgoingLinks: [],
},
{
@@ -375,6 +387,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
order: null,
sidebarLabel: null,
outgoingLinks: ['project/26q1-laputa-app', 'person/matteo-cellini'],
},
{
@@ -403,6 +416,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
order: null,
sidebarLabel: null,
outgoingLinks: [],
},
{
@@ -431,6 +445,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
order: null,
sidebarLabel: null,
outgoingLinks: [],
},
{
@@ -459,6 +474,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
order: null,
sidebarLabel: null,
outgoingLinks: ['responsibility/grow-newsletter'],
},
{
@@ -488,6 +504,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
order: null,
sidebarLabel: null,
outgoingLinks: ['responsibility/grow-newsletter', 'topic/software-development'],
},
{
@@ -516,6 +533,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
order: null,
sidebarLabel: null,
outgoingLinks: ['responsibility/grow-newsletter'],
},
// --- Type documents ---
@@ -542,6 +560,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
order: 0,
sidebarLabel: null,
outgoingLinks: [],
},
{
@@ -567,6 +586,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
order: 1,
sidebarLabel: null,
outgoingLinks: [],
},
{
@@ -592,6 +612,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
order: 2,
sidebarLabel: null,
outgoingLinks: [],
},
{
@@ -617,6 +638,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
order: 3,
sidebarLabel: null,
outgoingLinks: [],
},
{
@@ -642,6 +664,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
order: 4,
sidebarLabel: null,
outgoingLinks: [],
},
{
@@ -667,6 +690,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
order: 5,
sidebarLabel: null,
outgoingLinks: [],
},
{
@@ -692,6 +716,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
order: 6,
sidebarLabel: null,
outgoingLinks: [],
},
{
@@ -717,6 +742,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
order: 7,
sidebarLabel: null,
outgoingLinks: [],
},
{
@@ -742,6 +768,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
order: 8,
sidebarLabel: null,
outgoingLinks: [],
},
// --- Custom type documents ---
@@ -768,6 +795,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
icon: 'cooking-pot',
color: 'orange',
order: 9,
sidebarLabel: null,
outgoingLinks: [],
},
{
@@ -793,6 +821,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
icon: 'book-open',
color: 'green',
order: 10,
sidebarLabel: null,
outgoingLinks: [],
},
// --- Instances of custom types ---
@@ -821,6 +850,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
order: null,
sidebarLabel: null,
outgoingLinks: [],
},
{
@@ -848,6 +878,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
order: null,
sidebarLabel: null,
outgoingLinks: [],
},
// --- Trashed entries ---
@@ -877,6 +908,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
order: null,
sidebarLabel: null,
outgoingLinks: [],
},
{
@@ -904,6 +936,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
order: null,
sidebarLabel: null,
outgoingLinks: [],
},
{
@@ -932,6 +965,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
order: null,
sidebarLabel: null,
outgoingLinks: [],
},
// --- Archived entries ---
@@ -952,6 +986,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
order: null,
sidebarLabel: null,
outgoingLinks: [],
modifiedAt: now - 86400 * 120,
createdAt: now - 86400 * 200,
@@ -980,6 +1015,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
order: null,
sidebarLabel: null,
outgoingLinks: [],
modifiedAt: now - 86400 * 90,
createdAt: now - 86400 * 150,
@@ -1041,7 +1077,8 @@ function generateBulkEntries(count: number): VaultEntry[] {
icon: null,
color: null,
order: null,
outgoingLinks: Array.from({ length: i % 8 }, (_, j) => `note/link-target-${(i + j) % 50}`),
outgoingLinks: Array.from({ length: i % 8 }, (_j, j) => `note/link-target-${(i + j) % 50}`),
sidebarLabel: null,
})
}
return entries

View File

@@ -25,6 +25,8 @@ export interface VaultEntry {
color: string | null
/** Display order for Type entries in sidebar (lower = higher). null = use default order. */
order: number | null
/** Custom sidebar section label for Type entries, overriding auto-pluralization. */
sidebarLabel: string | null
/** All wikilink targets found in the note content. Extracted from [[target]] patterns. */
outgoingLinks: string[]
}