diff --git a/src/components/Sidebar.test.tsx b/src/components/Sidebar.test.tsx index fb9a738d..4e9a657d 100644 --- a/src/components/Sidebar.test.tsx +++ b/src/components/Sidebar.test.tsx @@ -874,6 +874,88 @@ describe('Sidebar', () => { expect(topNav.children[0].textContent).toContain('All Notes') }) + it('excludes attachments-folder markdown from top-nav note totals', () => { + const entries: VaultEntry[] = [ + { + path: '/vault/note/real-note.md', + filename: 'real-note.md', + title: 'Real Note', + isA: 'Note', + aliases: [], belongsTo: [], relatedTo: [], + status: null, owner: null, cadence: null, + archived: false, + modifiedAt: 1700000000, createdAt: null, + fileSize: 310, snippet: '', wordCount: 120, + relationships: {}, icon: null, color: null, order: null, + sidebarLabel: null, template: null, sort: null, view: null, + outgoingLinks: [], properties: {}, + }, + { + path: '/vault/attachments/reference.md', + filename: 'reference.md', + title: 'Attachment Markdown', + isA: 'Note', + aliases: [], belongsTo: [], relatedTo: [], + status: null, owner: null, cadence: null, + archived: false, + modifiedAt: 1700000000, createdAt: null, + fileSize: 220, snippet: '', wordCount: 50, + relationships: {}, icon: null, color: null, order: null, + sidebarLabel: null, template: null, sort: null, view: null, + outgoingLinks: [], properties: {}, + }, + { + path: '/vault/attachments/nested/archive.md', + filename: 'archive.md', + title: 'Attachment Archive', + isA: 'Note', + aliases: [], belongsTo: [], relatedTo: [], + status: null, owner: null, cadence: null, + archived: true, + modifiedAt: 1700000000, createdAt: null, + fileSize: 180, snippet: '', wordCount: 25, + relationships: {}, icon: null, color: null, order: null, + sidebarLabel: null, template: null, sort: null, view: null, + outgoingLinks: [], properties: {}, + }, + { + path: '/vault/archive/real-archive.md', + filename: 'real-archive.md', + title: 'Real Archive', + isA: 'Note', + aliases: [], belongsTo: [], relatedTo: [], + status: null, owner: null, cadence: null, + archived: true, + modifiedAt: 1700000000, createdAt: null, + fileSize: 280, snippet: '', wordCount: 90, + relationships: {}, icon: null, color: null, order: null, + sidebarLabel: null, template: null, sort: null, view: null, + outgoingLinks: [], properties: {}, + }, + { + path: '/vault/attachments/image.png', + filename: 'image.png', + title: 'image.png', + isA: null, + aliases: [], belongsTo: [], relatedTo: [], + status: null, owner: null, cadence: null, + archived: false, + modifiedAt: 1700000000, createdAt: null, + fileSize: 1024, snippet: '', wordCount: 0, + relationships: {}, icon: null, color: null, order: null, + sidebarLabel: null, template: null, sort: null, view: null, + fileKind: 'binary', + outgoingLinks: [], properties: {}, + }, + ] + + render( {}} />) + + const topNav = screen.getByTestId('sidebar-top-nav') + expect(topNav.children[1].textContent).toContain('All Notes1') + expect(topNav.children[2].textContent).toContain('Archive1') + }) + it('does not show inline entries — no child items in type sections', () => { const entriesWithEmoji: VaultEntry[] = [ { diff --git a/src/components/note-list/useNoteListModel.tsx b/src/components/note-list/useNoteListModel.tsx index 53f816b2..ecf049e4 100644 --- a/src/components/note-list/useNoteListModel.tsx +++ b/src/components/note-list/useNoteListModel.tsx @@ -8,7 +8,7 @@ import type { ViewFile, } from '../../types' import type { NoteListFilter } from '../../utils/noteListHelpers' -import { countByFilter, countAllByFilter } from '../../utils/noteListHelpers' +import { countByFilter, countAllByFilter, countAllNotesByFilter } from '../../utils/noteListHelpers' import { NoteItem } from '../NoteItem' import type { MultiSelectState } from '../../hooks/useMultiSelect' import { resolveHeaderTitle, type DeletedNoteEntry } from './noteListUtils' @@ -76,7 +76,7 @@ function useFilterCounts(entries: VaultEntry[], selection: SidebarSelection) { return useMemo(() => { if (selection.kind === 'sectionGroup') return countByFilter(entries, selection.type) if (selection.kind === 'folder') return countAllByFilter(entries) - if (selection.kind === 'filter' && selection.filter === 'all') return countAllByFilter(entries) + if (selection.kind === 'filter' && selection.filter === 'all') return countAllNotesByFilter(entries) return { open: 0, archived: 0 } }, [entries, selection]) } diff --git a/src/components/sidebar/sidebarHooks.ts b/src/components/sidebar/sidebarHooks.ts index a9a3ba6f..c210ef54 100644 --- a/src/components/sidebar/sidebarHooks.ts +++ b/src/components/sidebar/sidebarHooks.ts @@ -2,6 +2,7 @@ import { useState, useMemo, useEffect, useCallback, type RefObject } from 'react import type { VaultEntry } from '../../types' import { APP_STORAGE_KEYS, LEGACY_APP_STORAGE_KEYS, getAppStorageItem } from '../../constants/appStorage' import { buildTypeEntryMap } from '../../utils/typeColors' +import { countAllNotesByFilter } from '../../utils/noteListHelpers' import { buildDynamicSections, sortSections } from '../../utils/sidebarSections' export type SidebarGroupKey = 'favorites' | 'views' | 'sections' | 'folders' @@ -58,13 +59,8 @@ export function useSidebarCollapsed() { export function useEntryCounts(entries: VaultEntry[]) { return useMemo(() => { - let active = 0 - let archived = 0 - for (const entry of entries) { - if (entry.archived) archived++ - else active++ - } - return { activeCount: active, archivedCount: archived } + const counts = countAllNotesByFilter(entries) + return { activeCount: counts.open, archivedCount: counts.archived } }, [entries]) } diff --git a/src/utils/noteListHelpers.test.ts b/src/utils/noteListHelpers.test.ts index b367d705..a4bf80f3 100644 --- a/src/utils/noteListHelpers.test.ts +++ b/src/utils/noteListHelpers.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { countAllByFilter, countByFilter, filterEntries } from './noteListHelpers' +import { countAllByFilter, countAllNotesByFilter, countByFilter, filterEntries } from './noteListHelpers' import { allSelection, makeEntry, mockEntries } from '../test-utils/noteListTestUtils' describe('filterEntries', () => { @@ -62,6 +62,17 @@ describe('filterEntries', () => { const result = filterEntries(entries, allSelection, 'archived') expect(result.map((entry) => entry.title)).toEqual(['Archived']) }) + + it('excludes attachments-folder markdown from the All Notes view', () => { + const entries = [ + makeEntry({ path: '/vault/note/real-note.md', title: 'Real Note', isA: 'Note' }), + makeEntry({ path: '/vault/attachments/reference.md', title: 'Attachment Markdown', isA: 'Note' }), + makeEntry({ path: '/vault/attachments/nested/diagram.md', title: 'Nested Attachment Markdown', isA: 'Note' }), + ] + + const result = filterEntries(entries, allSelection, 'open') + expect(result.map((entry) => entry.title)).toEqual(['Real Note']) + }) }) describe('countByFilter', () => { @@ -102,3 +113,17 @@ describe('countAllByFilter', () => { expect(countAllByFilter(entries)).toEqual({ open: 1, archived: 0 }) }) }) + +describe('countAllNotesByFilter', () => { + it('excludes attachments-folder files from All Notes totals', () => { + const entries = [ + makeEntry({ path: '/vault/note/real-note.md', isA: 'Note' }), + makeEntry({ path: '/vault/attachments/reference.md', isA: 'Note' }), + makeEntry({ path: '/vault/attachments/archive.md', isA: 'Note', archived: true }), + makeEntry({ path: '/vault/attachments/image.png', fileKind: 'binary' }), + makeEntry({ path: '/vault/archive/real-archive.md', isA: 'Note', archived: true }), + ] + + expect(countAllNotesByFilter(entries)).toEqual({ open: 1, archived: 1 }) + }) +}) diff --git a/src/utils/noteListHelpers.ts b/src/utils/noteListHelpers.ts index b4e72e2d..6937a449 100644 --- a/src/utils/noteListHelpers.ts +++ b/src/utils/noteListHelpers.ts @@ -310,6 +310,7 @@ export function buildRelationshipGroups( const isActive = (e: VaultEntry) => !e.archived const isMarkdown = (e: VaultEntry) => e.fileKind === 'markdown' || !e.fileKind +const ATTACHMENTS_FOLDER = 'attachments' function applySubFilter(entries: VaultEntry[], subFilter: NoteListFilter): VaultEntry[] { if (subFilter === 'archived') return entries.filter((e) => e.archived) @@ -321,26 +322,46 @@ function isInFolder(entryPath: string, folderRelPath: string): boolean { return entryPath.includes(needle) || entryPath.startsWith(folderRelPath + '/') } +export function isAllNotesEntry(entry: VaultEntry): boolean { + return isMarkdown(entry) && !isInFolder(entry.path, ATTACHMENTS_FOLDER) +} + +function filterViewEntries(entries: VaultEntry[], filename: string, views?: ViewFile[]): VaultEntry[] { + const view = views?.find((candidate) => candidate.filename === filename) + if (!view) return [] + return evaluateView(view.definition, entries.filter(isMarkdown)) +} + +function filterFolderEntries(entries: VaultEntry[], folderPath: string, subFilter?: NoteListFilter): VaultEntry[] { + // Folder view shows ALL files (text + binary), not just markdown + const folderEntries = entries.filter((entry) => isInFolder(entry.path, folderPath)) + return subFilter ? applySubFilter(folderEntries, subFilter) : folderEntries.filter(isActive) +} + +function filterSectionGroupEntries(entries: VaultEntry[], type: string, subFilter?: NoteListFilter): VaultEntry[] { + const typeEntries = entries.filter((entry) => isMarkdown(entry) && entry.isA === type) + return subFilter ? applySubFilter(typeEntries, subFilter) : typeEntries.filter(isActive) +} + +function filterTopLevelEntries( + entries: VaultEntry[], + selection: Extract, + subFilter?: NoteListFilter, +): VaultEntry[] { + const filterableEntries = selection.filter === 'all' + ? entries.filter(isAllNotesEntry) + : entries.filter(isMarkdown) + if (selection.filter === 'all' && subFilter) return applySubFilter(filterableEntries, subFilter) + return filterByFilterType(filterableEntries, selection.filter) +} + function filterByKind(entries: VaultEntry[], selection: SidebarSelection, subFilter?: NoteListFilter, views?: ViewFile[]): VaultEntry[] { if (selection.kind === 'entity') return [] - if (selection.kind === 'view') { - const view = views?.find((v) => v.filename === selection.filename) - if (!view) return [] - return evaluateView(view.definition, entries.filter(isMarkdown)) - } - if (selection.kind === 'folder') { - // Folder view shows ALL files (text + binary), not just markdown - const folderEntries = entries.filter((e) => isInFolder(e.path, selection.path)) - return subFilter ? applySubFilter(folderEntries, subFilter) : folderEntries.filter(isActive) - } - if (selection.kind === 'sectionGroup') { - const typeEntries = entries.filter((e) => isMarkdown(e) && e.isA === selection.type) - return subFilter ? applySubFilter(typeEntries, subFilter) : typeEntries.filter(isActive) - } - // Non-folder views: only markdown files - const mdEntries = entries.filter(isMarkdown) - if (selection.filter === 'all' && subFilter) return applySubFilter(mdEntries, subFilter) - return filterByFilterType(mdEntries, selection.filter) + if (selection.kind === 'view') return filterViewEntries(entries, selection.filename, views) + if (selection.kind === 'folder') return filterFolderEntries(entries, selection.path, subFilter) + if (selection.kind === 'sectionGroup') return filterSectionGroupEntries(entries, selection.type, subFilter) + if (selection.kind === 'filter') return filterTopLevelEntries(entries, selection, subFilter) + return [] } function filterByFilterType(entries: VaultEntry[], filter: string): VaultEntry[] { @@ -366,17 +387,25 @@ export function countByFilter(entries: VaultEntry[], type: string): Record { +function countEntriesByArchiveStatus(entries: VaultEntry[]): Record { let open = 0, archived = 0 - for (const e of entries) { - if (!isMarkdown(e)) continue - if (e.archived) archived++ + for (const entry of entries) { + if (entry.archived) archived++ else open++ } return { open, archived } } +/** Count notes per sub-filter across all entries (no type filter). */ +export function countAllByFilter(entries: VaultEntry[]): Record { + return countEntriesByArchiveStatus(entries.filter(isMarkdown)) +} + +/** Count All Notes-eligible documents per sub-filter, excluding files under attachments/. */ +export function countAllNotesByFilter(entries: VaultEntry[]): Record { + return countEntriesByArchiveStatus(entries.filter(isAllNotesEntry)) +} + // --- Inbox --- /** Check if entry belongs in the Inbox (markdown only, not organized, not archived, not a Type). */