Files
tolaria/src/components/note-list/noteListHooks.ts
Test 6946aad139 feat: add custom views frontend — types, sidebar section, filter evaluation
Add ViewDefinition/ViewFile types, extend SidebarSelection with 'view'
kind. Client-side filter evaluation in viewFilters.ts with full operator
support. VIEWS section in sidebar (hidden when empty). Wire view loading
through useVaultLoader, NoteList, and filterEntries pipeline.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 16:09:43 +02:00

225 lines
10 KiB
TypeScript

import { useState, useMemo, useCallback, useEffect, useRef } from 'react'
import type { VaultEntry, SidebarSelection, ModifiedFile, NoteStatus, ViewFile } from '../../types'
import {
type SortOption, type SortDirection, type SortConfig, type NoteListFilter,
getSortComparator, extractSortableProperties,
buildRelationshipGroups, filterEntries, filterInboxEntries,
loadSortPreferences, saveSortPreferences,
parseSortConfig, serializeSortConfig, clearListSortFromLocalStorage,
} from '../../utils/noteListHelpers'
import type { InboxPeriod } from '../../types'
import { buildTypeEntryMap } from '../../utils/typeColors'
import { filterByQuery, filterGroupsByQuery, countExpiredTrash, createNoteStatusResolver, isModifiedEntry } from './noteListUtils'
import type { MultiSelectState } from '../../hooks/useMultiSelect'
// --- useTypeEntryMap ---
export function useTypeEntryMap(entries: VaultEntry[]) {
return useMemo(() => buildTypeEntryMap(entries), [entries])
}
// --- useFilteredEntries ---
export function useFilteredEntries(entries: VaultEntry[], selection: SidebarSelection, modifiedPathSet: Set<string>, modifiedSuffixes: string[], subFilter?: NoteListFilter, inboxPeriod?: InboxPeriod, views?: ViewFile[]) {
const isEntityView = selection.kind === 'entity'
const isChangesView = selection.kind === 'filter' && selection.filter === 'changes'
const isInboxView = selection.kind === 'filter' && selection.filter === 'inbox'
return useMemo(() => {
if (isEntityView) return []
if (isChangesView) return entries.filter((e) => isModifiedEntry(e.path, modifiedPathSet, modifiedSuffixes))
if (isInboxView) return filterInboxEntries(entries, inboxPeriod ?? 'month')
return filterEntries(entries, selection, subFilter, views)
}, [entries, selection, isEntityView, isChangesView, isInboxView, modifiedPathSet, modifiedSuffixes, subFilter, inboxPeriod, views])
}
// --- useNoteListData ---
interface NoteListDataParams {
entries: VaultEntry[]; selection: SidebarSelection
query: string; listSort: SortOption; listDirection: SortDirection
modifiedPathSet: Set<string>; modifiedSuffixes: string[]
subFilter?: NoteListFilter
inboxPeriod?: InboxPeriod
views?: ViewFile[]
}
export function useNoteListData({ entries, selection, query, listSort, listDirection, modifiedPathSet, modifiedSuffixes, subFilter, inboxPeriod, views }: NoteListDataParams) {
const isEntityView = selection.kind === 'entity'
const isTrashView = (selection.kind === 'filter' && selection.filter === 'trash') || subFilter === 'trashed'
const isArchivedView = (selection.kind === 'filter' && selection.filter === 'archived') || subFilter === 'archived'
const filteredEntries = useFilteredEntries(entries, selection, modifiedPathSet, modifiedSuffixes, subFilter, inboxPeriod, views)
const searched = useMemo(() => {
const sorted = [...filteredEntries].sort(getSortComparator(listSort, listDirection))
return filterByQuery(sorted, query)
}, [filteredEntries, listSort, listDirection, query])
const searchedGroups = useMemo(() => {
if (!isEntityView) return []
// Look up the fresh entry from the entries array to pick up relationship
// updates that happened after the selection was captured.
const freshEntry = entries.find((e) => e.path === selection.entry.path) ?? selection.entry
const groups = buildRelationshipGroups(freshEntry, entries)
return filterGroupsByQuery(groups, query)
}, [isEntityView, selection, entries, query])
const expiredTrashCount = useMemo(
() => isTrashView ? countExpiredTrash(searched) : 0,
[isTrashView, searched],
)
return { isEntityView, isTrashView, isArchivedView, searched, searchedGroups, expiredTrashCount }
}
// --- useNoteListSearch ---
export function useNoteListSearch() {
const [search, setSearch] = useState('')
const [searchVisible, setSearchVisible] = useState(false)
const query = search.trim().toLowerCase()
const toggleSearch = useCallback(() => {
setSearchVisible((v) => { if (v) setSearch(''); return !v })
}, [])
return { search, setSearch, query, searchVisible, toggleSearch }
}
// --- useNoteListSort ---
const DEFAULT_LIST_CONFIG: SortConfig = { option: 'modified', direction: 'desc' }
function resolveListSortConfig(typeDocument: VaultEntry | null, sortPrefs: Record<string, SortConfig>): SortConfig {
if (typeDocument?.sort) {
const parsed = parseSortConfig(typeDocument.sort)
if (parsed) return parsed
}
return sortPrefs['__list__'] ?? DEFAULT_LIST_CONFIG
}
interface SortPersistence {
onUpdateTypeSort: (path: string, key: string, value: string) => void
updateEntry: (path: string, patch: Partial<VaultEntry>) => void
}
function persistSortToType(path: string, config: SortConfig, persistence: SortPersistence) {
const serialized = serializeSortConfig(config)
persistence.onUpdateTypeSort(path, 'sort', serialized)
persistence.updateEntry(path, { sort: serialized })
clearListSortFromLocalStorage()
}
function migrateListSortToType(typeDoc: VaultEntry, sortPrefs: Record<string, SortConfig>, migrationDone: Set<string>, persistence: SortPersistence) {
if (typeDoc.sort || migrationDone.has(typeDoc.path)) return
const lsConfig = sortPrefs['__list__']
if (!lsConfig) return
migrationDone.add(typeDoc.path)
persistSortToType(typeDoc.path, lsConfig, persistence)
}
function saveGroupSort(groupLabel: string, option: SortOption, direction: SortDirection, setSortPrefs: React.Dispatch<React.SetStateAction<Record<string, SortConfig>>>) {
setSortPrefs((prev) => { const next = { ...prev, [groupLabel]: { option, direction } }; saveSortPreferences(next); return next })
}
function deriveEffectiveSort(configOption: SortOption, customProperties: string[]): SortOption {
if (!configOption.startsWith('property:')) return configOption
return customProperties.includes(configOption.slice('property:'.length)) ? configOption : 'modified'
}
export interface UseNoteListSortParams {
entries: VaultEntry[]
selection: SidebarSelection
modifiedPathSet: Set<string>
modifiedSuffixes: string[]
subFilter?: NoteListFilter
inboxPeriod?: InboxPeriod
onUpdateTypeSort?: (path: string, key: string, value: string | number | boolean | string[] | null) => void
updateEntry?: (path: string, patch: Partial<VaultEntry>) => void
}
export function useNoteListSort({ entries, selection, modifiedPathSet, modifiedSuffixes, subFilter, inboxPeriod, onUpdateTypeSort, updateEntry }: UseNoteListSortParams) {
const [sortPrefs, setSortPrefs] = useState<Record<string, SortConfig>>(loadSortPreferences)
const typeDocument = useMemo(() => {
if (selection.kind !== 'sectionGroup') return null
return entries.find((e) => e.isA === 'Type' && e.title === selection.type) ?? null
}, [selection, entries])
const listConfig = resolveListSortConfig(typeDocument, sortPrefs)
const persistence = useMemo<SortPersistence | null>(
() => (onUpdateTypeSort && updateEntry) ? { onUpdateTypeSort, updateEntry } : null,
[onUpdateTypeSort, updateEntry],
)
const migrationDoneRef = useRef<Set<string>>(new Set())
useEffect(() => {
if (!typeDocument || !persistence) return
migrateListSortToType(typeDocument, sortPrefs, migrationDoneRef.current, persistence)
}, [typeDocument, sortPrefs, persistence])
const handleSortChange = useCallback((groupLabel: string, option: SortOption, direction: SortDirection) => {
if (groupLabel === '__list__' && typeDocument && persistence) {
persistSortToType(typeDocument.path, { option, direction }, persistence)
} else {
saveGroupSort(groupLabel, option, direction, setSortPrefs)
}
}, [typeDocument, persistence])
const filteredEntries = useFilteredEntries(entries, selection, modifiedPathSet, modifiedSuffixes, subFilter, inboxPeriod)
const customProperties = useMemo(() => extractSortableProperties(filteredEntries), [filteredEntries])
const listSort = useMemo<SortOption>(() => deriveEffectiveSort(listConfig.option, customProperties), [listConfig.option, customProperties])
const listDirection = listSort === listConfig.option ? listConfig.direction : 'desc'
return { listSort, listDirection, customProperties, handleSortChange, sortPrefs, typeDocument }
}
// --- useMultiSelectKeyboard ---
function isInputFocused(): boolean {
const el = document.activeElement
return el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement || !!(el as HTMLElement)?.isContentEditable
}
function handleEscapeKey(e: KeyboardEvent, multiSelect: MultiSelectState) {
if (e.key !== 'Escape' || !multiSelect.isMultiSelecting) return
e.preventDefault()
multiSelect.clear()
}
function handleSelectAllKey(e: KeyboardEvent, multiSelect: MultiSelectState, isEntityView: boolean) {
if (e.key !== 'a' || !(e.metaKey || e.ctrlKey) || isEntityView || isInputFocused()) return
e.preventDefault()
multiSelect.selectAll()
}
function handleBulkActionKey(e: KeyboardEvent, multiSelect: MultiSelectState, onArchive: () => void, onTrash: () => void) {
if (!multiSelect.isMultiSelecting || !(e.metaKey || e.ctrlKey)) return
if (e.key === 'e') { e.preventDefault(); e.stopPropagation(); onArchive() }
if (e.key === 'Backspace' || e.key === 'Delete') { e.preventDefault(); e.stopPropagation(); onTrash() }
}
export function useMultiSelectKeyboard(multiSelect: MultiSelectState, isEntityView: boolean, onBulkArchive: () => void, onBulkTrash: () => void) {
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
handleEscapeKey(e, multiSelect)
handleSelectAllKey(e, multiSelect, isEntityView)
handleBulkActionKey(e, multiSelect, onBulkArchive, onBulkTrash)
}
window.addEventListener('keydown', handleKeyDown, true)
return () => window.removeEventListener('keydown', handleKeyDown, true)
}, [multiSelect, isEntityView, onBulkArchive, onBulkTrash])
}
// --- useModifiedFilesState ---
export function useModifiedFilesState(modifiedFiles: ModifiedFile[] | undefined, getNoteStatus: ((path: string) => NoteStatus) | undefined) {
const modifiedPathSet = useMemo(() => new Set((modifiedFiles ?? []).map((f) => f.path)), [modifiedFiles])
const modifiedSuffixes = useMemo(() => (modifiedFiles ?? []).map((f) => '/' + f.relativePath), [modifiedFiles])
const resolvedGetNoteStatus = useMemo<(path: string) => NoteStatus>(
() => createNoteStatusResolver(getNoteStatus, modifiedFiles, modifiedPathSet),
[getNoteStatus, modifiedFiles, modifiedPathSet],
)
return { modifiedPathSet, modifiedSuffixes, resolvedGetNoteStatus }
}