diff --git a/src/components/Sidebar.test.ts b/src/components/Sidebar.test.ts index 195c189a..1f5f3444 100644 --- a/src/components/Sidebar.test.ts +++ b/src/components/Sidebar.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { buildSectionGroup } from '../utils/sidebarSections' +import { buildSectionGroup, buildDynamicSections, collectActiveTypes } from '../utils/sidebarSections' import { resolveIcon } from '../utils/iconRegistry' import type { VaultEntry } from '../types' import { GearSix, CookingPot, FileText } from '@phosphor-icons/react' @@ -71,3 +71,71 @@ describe('buildSectionGroup', () => { expect(group.Icon).toBe(GearSix) }) }) + +describe('buildDynamicSections', () => { + it('includes types with 0 notes from typeEntryMap', () => { + const entries: VaultEntry[] = [ + { ...baseEntry, title: 'My Note', isA: 'Note' }, + ] + const typeEntryMap: Record = { + Recipe: { ...baseEntry, title: 'Recipe', isA: 'Type', icon: 'cooking-pot' }, + recipe: { ...baseEntry, title: 'Recipe', isA: 'Type', icon: 'cooking-pot' }, + } + const sections = buildDynamicSections(entries, typeEntryMap) + const types = sections.map((s) => s.type) + expect(types).toContain('Note') + expect(types).toContain('Recipe') + }) + + it('does not duplicate types that have both entries and type definitions', () => { + const entries: VaultEntry[] = [ + { ...baseEntry, title: 'My Project', isA: 'Project' }, + ] + const typeEntryMap: Record = { + Project: { ...baseEntry, title: 'Project', isA: 'Type' }, + project: { ...baseEntry, title: 'Project', isA: 'Type' }, + } + const sections = buildDynamicSections(entries, typeEntryMap) + const projectSections = sections.filter((s) => s.type === 'Project') + expect(projectSections).toHaveLength(1) + }) + + it('excludes trashed type definitions', () => { + const entries: VaultEntry[] = [] + const typeEntryMap: Record = { + Deleted: { ...baseEntry, title: 'Deleted', isA: 'Type', trashed: true }, + } + const sections = buildDynamicSections(entries, typeEntryMap) + expect(sections.map((s) => s.type)).not.toContain('Deleted') + }) + + it('excludes archived type definitions', () => { + const entries: VaultEntry[] = [] + const typeEntryMap: Record = { + Old: { ...baseEntry, title: 'Old', isA: 'Type', archived: true }, + } + const sections = buildDynamicSections(entries, typeEntryMap) + expect(sections.map((s) => s.type)).not.toContain('Old') + }) +}) + +describe('collectActiveTypes', () => { + it('excludes non-markdown entries from type collection', () => { + const entries: VaultEntry[] = [ + { ...baseEntry, title: 'My Note', isA: 'Note', fileKind: 'markdown' }, + { ...baseEntry, title: 'config.yml', isA: null, fileKind: 'text' }, + { ...baseEntry, title: 'photo.png', isA: null, fileKind: 'binary' }, + ] + const types = collectActiveTypes(entries) + expect(types).toContain('Note') + expect(types.size).toBe(1) + }) + + it('treats entries without fileKind as markdown', () => { + const entries: VaultEntry[] = [ + { ...baseEntry, title: 'Legacy Note', isA: 'Note' }, + ] + const types = collectActiveTypes(entries) + expect(types).toContain('Note') + }) +}) diff --git a/src/utils/sidebarSections.ts b/src/utils/sidebarSections.ts index 1ab3e8fe..0cd9d2cd 100644 --- a/src/utils/sidebarSections.ts +++ b/src/utils/sidebarSections.ts @@ -26,31 +26,43 @@ const BUILT_IN_SECTION_GROUPS: SectionGroup[] = [ /** Metadata lookup for well-known types (icon/label only — NOT used to determine which sections to show) */ const BUILT_IN_TYPE_MAP = new Map(BUILT_IN_SECTION_GROUPS.map((sg) => [sg.type, sg])) -/** Collect unique isA values from active (non-trashed, non-archived) entries. Untyped entries count as 'Note'. */ +const isMarkdown = (e: VaultEntry) => e.fileKind === 'markdown' || !e.fileKind +const isActive = (e: VaultEntry) => !e.trashed && !e.archived + +/** Collect unique isA values from active (non-trashed, non-archived) markdown entries. Untyped entries count as 'Note'. */ export function collectActiveTypes(entries: VaultEntry[]): Set { const types = new Set() for (const e of entries) { - if (!e.trashed && !e.archived) types.add(e.isA || 'Note') + if (isActive(e) && isMarkdown(e)) types.add(e.isA || 'Note') } return types } +function resolveLabel(type: string, typeEntry: VaultEntry | undefined, builtIn: SectionGroup | undefined): string { + return typeEntry?.sidebarLabel || builtIn?.label || pluralizeType(type) +} + /** Build a single SectionGroup for a type, using built-in metadata or Type entry for icon/label */ export function buildSectionGroup(type: string, typeEntryMap: Record): SectionGroup { 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)) + const label = resolveLabel(type, typeEntry, builtIn) + const icon = resolveIcon(typeEntry?.icon ?? null) if (builtIn) { - const Icon = typeEntry?.icon ? resolveIcon(typeEntry.icon) : builtIn.Icon - return { ...builtIn, label, Icon, customColor } + return { ...builtIn, label, Icon: typeEntry?.icon ? icon : builtIn.Icon, customColor } } - return { label, type, Icon: resolveIcon(typeEntry?.icon ?? null), customColor } + return { label, type, Icon: icon, customColor } } -/** Build sections dynamically from actual vault entries — only types with ≥1 active note appear */ +/** Build sections dynamically from vault entries and defined types — types with 0 notes still appear */ export function buildDynamicSections(entries: VaultEntry[], typeEntryMap: Record): SectionGroup[] { const activeTypes = collectActiveTypes(entries) + for (const [name, entry] of Object.entries(typeEntryMap)) { + if (name === entry.title && isActive(entry)) { + activeTypes.add(name) + } + } return Array.from(activeTypes, (type) => buildSectionGroup(type, typeEntryMap)) }