From 5965d5f23f62971b33c1ebb69648fd912d8fd33a Mon Sep 17 00:00:00 2001 From: lucaronin Date: Thu, 7 May 2026 20:44:45 +0200 Subject: [PATCH] fix: clear codacy object injection highs --- src/components/AiActionCard.tsx | 6 +- src/components/CommandPalette.tsx | 2 +- src/components/ConflictResolverModal.tsx | 10 +- src/components/DynamicPropertiesPanel.tsx | 12 +- src/components/EditableValue.tsx | 4 +- src/components/EmojiPicker.tsx | 120 +++++++----- src/components/FeedbackDialog.tsx | 4 +- src/components/FilterBuilder.tsx | 2 +- src/components/FilterFieldCombobox.tsx | 76 +++++--- src/components/IconEditableValue.tsx | 4 +- src/components/NoteAutocomplete.tsx | 184 +++++++++++++----- src/components/NoteItem.tsx | 4 +- src/components/PropertyValueCells.tsx | 2 +- src/components/QuickOpenPalette.tsx | 2 +- src/components/RawEditorFindBar.tsx | 2 +- src/components/SettingsPanel.tsx | 6 +- src/components/Sidebar.tsx | 7 +- src/components/SortDropdown.tsx | 7 +- src/components/StatusDropdown.tsx | 4 +- src/components/TagsDropdown.tsx | 4 +- src/components/TypeSelector.tsx | 25 ++- .../VaultContentSettingsSection.tsx | 2 +- src/components/WelcomeScreen.tsx | 6 +- src/components/blockNoteCursorTarget.ts | 2 +- src/components/blockNoteRenderRecovery.ts | 4 +- src/components/editorModePosition.ts | 23 ++- src/components/folder-tree/folderTreeUtils.ts | 4 +- .../folder-tree/useFolderTreeDisclosure.ts | 4 +- .../inspector/RelationshipsPanel.tsx | 8 +- src/components/mathInputExtension.ts | 2 +- .../note-item/ChangeNoteContent.tsx | 2 +- .../note-item/propertyChipValues.ts | 5 +- src/components/note-item/typeIcon.ts | 2 +- src/components/note-list/FilterPills.tsx | 2 +- src/components/note-list/InboxFilterPills.tsx | 2 +- src/components/note-list/useNoteListModel.tsx | 2 +- .../note-retargeting/RetargetNoteDialog.tsx | 3 +- .../sidebar/SidebarLoadingSections.tsx | 2 +- src/components/sidebar/SidebarSections.tsx | 7 +- src/components/sidebar/sidebarHooks.ts | 5 +- src/components/status-bar/AiAgentsBadge.tsx | 2 +- src/components/status-bar/StatusBarBadges.tsx | 56 +++--- src/components/tableOfContentsModel.ts | 17 +- src/components/tolariaEditorFormatting.tsx | 25 ++- .../useInlineWikilinkSuggestionsState.ts | 2 +- src/constants/appStorage.ts | 12 +- src/hooks/appCommandCatalog.ts | 22 ++- src/hooks/appCommandDispatcher.ts | 3 +- src/hooks/commands/localizeCommands.ts | 2 +- src/hooks/commands/typeCommands.ts | 3 +- src/hooks/commands/viewCommands.ts | 4 +- src/hooks/frontmatterOps.ts | 31 ++- src/hooks/mockFrontmatterHelpers.ts | 11 +- src/hooks/rawEditorEntryState.ts | 4 +- src/hooks/useAutoGit.ts | 4 +- src/hooks/useEntryActions.ts | 6 +- src/hooks/useImageDrop.ts | 5 +- src/hooks/useKeyboardNavigation.ts | 19 +- src/hooks/useLayoutPanels.ts | 18 +- src/hooks/useMultiSelect.ts | 5 +- src/hooks/useNavigationHistory.ts | 76 +++++--- src/hooks/useNoteCreation.ts | 2 +- src/hooks/useNoteListKeyboard.ts | 2 +- src/hooks/useNoteSearch.ts | 2 +- src/hooks/useNoteWidthMode.ts | 11 +- src/hooks/usePropertyPanelState.ts | 10 +- src/hooks/useTheme.ts | 33 ++-- src/lib/aiAgentFileOperations.ts | 2 +- src/lib/aiAgents.ts | 2 +- src/lib/i18n.ts | 30 +-- src/lib/productAnalytics.ts | 6 +- src/lib/telemetry.ts | 2 +- src/mock-tauri/index.ts | 10 +- src/mock-tauri/mock-handlers.ts | 48 +++-- src/mock-tauri/vault-api.ts | 6 +- src/utils/ai-chat.ts | 6 +- src/utils/ai-context.ts | 4 +- src/utils/arrowLigatures.ts | 2 +- src/utils/compact-markdown.ts | 12 +- src/utils/durableMarkdownBlocks.ts | 25 ++- src/utils/filterDates.ts | 117 ++++++----- src/utils/frontmatter.ts | 4 +- src/utils/fuzzyMatch.ts | 5 +- src/utils/mathMarkdown.ts | 22 ++- src/utils/noteListHelpers.ts | 19 +- src/utils/noteOpenPerformance.ts | 13 +- src/utils/propertyTypes.ts | 6 +- src/utils/releaseDownloadPage.ts | 26 ++- src/utils/releaseHistoryPage.ts | 13 +- src/utils/sidebarSections.ts | 5 +- src/utils/statusStyles.ts | 14 +- src/utils/suggestionEnrichment.ts | 2 +- src/utils/tableOfContents.ts | 2 +- src/utils/tagStyles.ts | 13 +- src/utils/typeColors.ts | 16 +- src/utils/vaultMetadataNormalization.ts | 4 +- src/utils/viewFilters.ts | 84 +++++--- src/utils/wikilinks.ts | 30 +-- 98 files changed, 952 insertions(+), 559 deletions(-) diff --git a/src/components/AiActionCard.tsx b/src/components/AiActionCard.tsx index cfec8f56..677eb37c 100644 --- a/src/components/AiActionCard.tsx +++ b/src/components/AiActionCard.tsx @@ -24,6 +24,7 @@ const DEFAULT_ACTION_CARD_BACKGROUND = 'var(--accent-blue-bg)' const TOOL_BACKGROUND_MAP: Record = { open_note: 'var(--accent-blue-light)', } +const TOOL_BACKGROUND_BY_NAME = new Map(Object.entries(TOOL_BACKGROUND_MAP)) type IconRenderer = (size: number) => ReactNode @@ -44,6 +45,7 @@ const TOOL_ICON_MAP: Record = { create_note: (s) => , delete_note: (s) => , } +const TOOL_ICON_BY_NAME = new Map(Object.entries(TOOL_ICON_MAP)) const DEFAULT_ICON: IconRenderer = (s) => @@ -201,7 +203,7 @@ function ActionCardDetails({ export function AiActionCard({ tool, label, path, status, input, output, expanded, onToggle, onOpenNote, }: AiActionCardProps) { - const renderIcon = TOOL_ICON_MAP[tool] ?? DEFAULT_ICON + const renderIcon = TOOL_ICON_BY_NAME.get(tool) ?? DEFAULT_ICON const hasDetails = hasActionDetails(input, output) const directOpenPath = resolveDirectOpenPath({ path, onOpenNote, hasDetails }) @@ -230,7 +232,7 @@ export function AiActionCard({ className="rounded" style={{ fontSize: 12, - background: TOOL_BACKGROUND_MAP[tool] ?? DEFAULT_ACTION_CARD_BACKGROUND, + background: TOOL_BACKGROUND_BY_NAME.get(tool) ?? DEFAULT_ACTION_CARD_BACKGROUND, }} > const BINARY_FILE_EXTENSIONS = [ '.png', @@ -29,11 +30,14 @@ const BINARY_FILE_EXTENSIONS = [ '.eot', ] -const RESOLUTION_LABELS: Record, string> = { +const RESOLUTION_LABELS: Record = { manual: 'Edited manually', ours: 'Keeping mine', theirs: 'Keeping theirs', } +const RESOLUTION_LABELS_BY_VALUE = new Map( + Object.entries(RESOLUTION_LABELS) as Array<[ConflictResolution, string]>, +) const RESOLUTION_SHORTCUTS: Record = { k: 'ours', @@ -65,7 +69,7 @@ function ResolutionLabel({ resolution }: { resolution: ConflictFileState['resolu if (!resolution) return null return ( - {RESOLUTION_LABELS[resolution]} + {RESOLUTION_LABELS_BY_VALUE.get(resolution)} ) } @@ -388,7 +392,7 @@ function ConflictResolverDialogContent({ if (handleNavigationKey(e, moveFocus)) return const focusedIndex = clampFocusIndex(focusIdxRef.current, fileStates.length) - const file = fileStates[focusedIndex] + const file = fileStates.at(focusedIndex) if (handleResolutionShortcut(e, file, onResolveFile)) return if (handleOpenShortcut(e, file, onOpenInEditor)) return handleCommitShortcut({ allResolved, committing, event: e, onCommit }) diff --git a/src/components/DynamicPropertiesPanel.tsx b/src/components/DynamicPropertiesPanel.tsx index 5016d94c..40eaf663 100644 --- a/src/components/DynamicPropertiesPanel.tsx +++ b/src/components/DynamicPropertiesPanel.tsx @@ -110,7 +110,7 @@ const SUGGESTED_PROPERTY_MODES: Record = { } function getSuggestedDisplayMode(key: string): PropertyDisplayMode { - return SUGGESTED_PROPERTY_MODES[key] ?? 'text' + return (Reflect.get(SUGGESTED_PROPERTY_MODES, key) as PropertyDisplayMode | undefined) ?? 'text' } function resolveMissingTypeName(entryIsA: string | null | undefined, availableTypes: string[]): string | null { @@ -124,7 +124,7 @@ function SuggestedPropertySlot({ label, displayMode, onAdd }: { displayMode: PropertyDisplayMode onAdd: () => void }) { - const SuggestedIcon = DISPLAY_MODE_ICONS[displayMode] + const SuggestedIcon = Reflect.get(DISPLAY_MODE_ICONS, displayMode) as typeof Plus return ( + ) +} + +function EmojiSearchResults({ + entries, + onSelect, +}: { + entries: EmojiEntry[] + onSelect: (emoji: string) => void +}) { + if (entries.length === 0) { + return ( +
+ No emojis found +
+ ) + } + + return ( +
+ {entries.map(entry => )} +
+ ) +} + +function EmojiGroupSection({ + group, + onSelect, +}: { + group: string + onSelect: (emoji: string) => void +}) { + const emojis = EMOJIS_BY_GROUP.get(group) + if (!emojis?.length) return null + const label = (Reflect.get(GROUP_SHORT_LABELS, group) as string | undefined) ?? group + + return ( +
+
+ {label} +
+
+ {emojis.map(entry => )} +
+
+ ) +} + +function EmojiGroupedResults({ onSelect }: { onSelect: (emoji: string) => void }) { + return EMOJI_GROUPS.map(group => ( + + )) +} + export function EmojiPicker({ onSelect, onClose }: EmojiPickerProps) { const [search, setSearch] = useState('') const inputRef = useRef(null) @@ -55,10 +122,10 @@ export function EmojiPicker({ onSelect, onClose }: EmojiPickerProps) { data-testid="emoji-picker" >
- setSearch(e.target.value)} @@ -67,52 +134,9 @@ export function EmojiPicker({ onSelect, onClose }: EmojiPickerProps) {
{isSearching ? ( - searchResults.length > 0 ? ( -
- {searchResults.map(entry => ( - - ))} -
- ) : ( -
- No emojis found -
- ) + ) : ( - EMOJI_GROUPS.map(group => { - const emojis = EMOJIS_BY_GROUP.get(group) - if (!emojis?.length) return null - return ( -
-
- {GROUP_SHORT_LABELS[group]} -
-
- {emojis.map(entry => ( - - ))} -
-
- ) - }) + )}
diff --git a/src/components/FeedbackDialog.tsx b/src/components/FeedbackDialog.tsx index 48e23be5..0d8e45d9 100644 --- a/src/components/FeedbackDialog.tsx +++ b/src/components/FeedbackDialog.tsx @@ -163,7 +163,7 @@ function ContributionLinkButton({ className={cn( 'w-full justify-between', accented && 'bg-background text-foreground hover:text-foreground', - accented && CONTRIBUTION_BUTTON_CLASSES[tone], + accented && Reflect.get(CONTRIBUTION_BUTTON_CLASSES, tone), )} autoFocus={autoFocus} onClick={onAction} @@ -188,7 +188,7 @@ function ContributionCard({
- + {title} diff --git a/src/components/FilterBuilder.tsx b/src/components/FilterBuilder.tsx index 271e6e00..da5cd607 100644 --- a/src/components/FilterBuilder.tsx +++ b/src/components/FilterBuilder.tsx @@ -191,7 +191,7 @@ function FilterGroupView({ group, fields, depth, onChange, onRemove }: { const updateChild = (index: number, node: FilterNode) => { const next = [...children] - next[index] = node + next.splice(index, 1, node) onChange(setGroupChildren(mode, next)) } diff --git a/src/components/FilterFieldCombobox.tsx b/src/components/FilterFieldCombobox.tsx index bd5e7a91..a2ef5109 100644 --- a/src/components/FilterFieldCombobox.tsx +++ b/src/components/FilterFieldCombobox.tsx @@ -6,28 +6,43 @@ import { FilterFieldOptionsList } from './filter-builder/FilterFieldOptionsList' const CONTENT_FIELDS = new Set(['body']) +type FilterFieldName = string +type FilterFieldQuery = string +type ListboxId = string + interface FilterFieldComboboxProps { - value: string - fields: string[] - onChange: (value: string) => void + value: FilterFieldName + fields: FilterFieldName[] + onChange: (value: FilterFieldName) => void } interface FieldGroup { key: 'property' | 'content' - label: string - options: string[] + label: 'Properties' | 'Content' + options: FilterFieldName[] } -function normalizeFieldQuery(query: string): string { +interface BuildFieldGroupsInput { + currentValue: FilterFieldName + fields: FilterFieldName[] + query: FilterFieldQuery +} + +interface HighlightIndexInput { + currentValue: FilterFieldName + options: FilterFieldName[] +} + +function normalizeFieldQuery(query: FilterFieldQuery): FilterFieldQuery { return query.trim().toLowerCase() } -function buildFieldGroups(fields: string[], currentValue: string, query: string): FieldGroup[] { +function buildFieldGroups({ fields, currentValue, query }: BuildFieldGroupsInput): FieldGroup[] { const allFields = currentValue !== '' && !fields.includes(currentValue) ? [currentValue, ...fields] : fields const normalized = normalizeFieldQuery(query) - const matches = (field: string) => normalized === '' || field.toLowerCase().includes(normalized) + const matches = (field: FilterFieldName) => normalized === '' || field.toLowerCase().includes(normalized) const propertyOptions = allFields.filter((field) => !CONTENT_FIELDS.has(field) && matches(field)) const contentOptions = allFields.filter((field) => CONTENT_FIELDS.has(field) && matches(field)) const groups: FieldGroup[] = [] @@ -38,11 +53,11 @@ function buildFieldGroups(fields: string[], currentValue: string, query: string) return groups } -function flattenGroups(groups: FieldGroup[]): string[] { +function flattenGroups(groups: FieldGroup[]): FilterFieldName[] { return groups.flatMap((group) => group.options) } -function initialHighlightIndex(options: string[], currentValue: string): number { +function initialHighlightIndex({ options, currentValue }: HighlightIndexInput): number { if (options.length === 0) return -1 const currentIndex = options.indexOf(currentValue) return currentIndex >= 0 ? currentIndex : 0 @@ -64,7 +79,7 @@ function moveHighlightedOption({ }: { event: KeyboardEvent open: boolean - options: string[] + options: FilterFieldName[] direction: 'next' | 'previous' openCombobox: () => void setHighlightedIndex: (updater: number | ((current: number) => number)) => void @@ -87,13 +102,14 @@ function selectHighlightedOption({ }: { event: KeyboardEvent open: boolean - options: string[] + options: FilterFieldName[] highlightedIndex: number - selectOption: (value: string) => void + selectOption: (value: FilterFieldName) => void }) { - if (!open || highlightedIndex < 0 || options[highlightedIndex] === undefined) return + const highlightedOption = options.at(highlightedIndex) + if (!open || highlightedIndex < 0 || highlightedOption === undefined) return event.preventDefault() - selectOption(options[highlightedIndex]) + selectOption(highlightedOption) } function closeOpenCombobox({ @@ -122,11 +138,11 @@ function handleFilterFieldKeyDown({ }: { event: KeyboardEvent open: boolean - options: string[] + options: FilterFieldName[] highlightedIndex: number openCombobox: () => void setHighlightedIndex: (updater: number | ((current: number) => number)) => void - selectOption: (value: string) => void + selectOption: (value: FilterFieldName) => void closeCombobox: () => void }) { switch (event.key) { @@ -160,9 +176,9 @@ function FilterFieldInput({ }: { inputRef: RefObject open: boolean - query: string - value: string - listboxId: string + query: FilterFieldQuery + value: FilterFieldName + listboxId: ListboxId highlightedIndex: number onFocus: () => void onChange: (event: ChangeEvent) => void @@ -205,12 +221,12 @@ function FilterFieldPopoverPanel({ }: { open: boolean contentWidth: number - listboxId: string + listboxId: ListboxId fieldGroups: FieldGroup[] - options: string[] + options: FilterFieldName[] highlightedIndex: number onHighlight: (index: number) => void - onSelect: (value: string) => void + onSelect: (value: FilterFieldName) => void }) { if (!open) return null @@ -254,13 +270,19 @@ export function FilterFieldCombobox({ value, fields, onChange }: FilterFieldComb const inputRef = useRef(null) const listboxId = useId() const effectiveQuery = hasTyped ? query : '' - const fieldGroups = useMemo(() => buildFieldGroups(fields, value, effectiveQuery), [fields, value, effectiveQuery]) + const fieldGroups = useMemo( + () => buildFieldGroups({ fields, currentValue: value, query: effectiveQuery }), + [fields, value, effectiveQuery], + ) const options = useMemo(() => flattenGroups(fieldGroups), [fieldGroups]) const resetToCurrentValue = () => { setQuery(value) setHasTyped(false) - setHighlightedIndex(initialHighlightIndex(flattenGroups(buildFieldGroups(fields, value, '')), value)) + setHighlightedIndex(initialHighlightIndex({ + options: flattenGroups(buildFieldGroups({ fields, currentValue: value, query: '' })), + currentValue: value, + })) } const openCombobox = () => { @@ -274,7 +296,7 @@ export function FilterFieldCombobox({ value, fields, onChange }: FilterFieldComb resetToCurrentValue() } - const selectOption = (nextValue: string) => { + const selectOption = (nextValue: FilterFieldName) => { onChange(nextValue) setQuery(nextValue) setHasTyped(false) @@ -302,7 +324,7 @@ export function FilterFieldCombobox({ value, fields, onChange }: FilterFieldComb const handleInputChange = (event: ChangeEvent) => { const nextQuery = event.target.value - const nextGroups = buildFieldGroups(fields, value, nextQuery) + const nextGroups = buildFieldGroups({ fields, currentValue: value, query: nextQuery }) const nextOptions = flattenGroups(nextGroups) setOpen(true) setQuery(nextQuery) diff --git a/src/components/IconEditableValue.tsx b/src/components/IconEditableValue.tsx index cff34242..be645d96 100644 --- a/src/components/IconEditableValue.tsx +++ b/src/components/IconEditableValue.tsx @@ -84,7 +84,7 @@ function IconEditableInput({ if (event.key === 'Enter') { event.preventDefault() if (shouldSelectIconSuggestion(editValue, filteredIcons.length)) { - selectIcon(filteredIcons[highlightedIndex]?.name ?? editValue) + selectIcon(filteredIcons.at(highlightedIndex)?.name ?? editValue) return } commitTypedValue() @@ -113,7 +113,7 @@ function IconEditableInput({ aria-autocomplete="list" aria-controls={listboxId} aria-expanded - aria-activedescendant={filteredIcons[highlightedIndex] ? `${listboxId}-option-${highlightedIndex}` : undefined} + aria-activedescendant={filteredIcons.at(highlightedIndex) ? `${listboxId}-option-${highlightedIndex}` : undefined} placeholder="Emoji, icon name, or URL" className="h-8 text-[12px]" data-testid="icon-editable-input" diff --git a/src/components/NoteAutocomplete.tsx b/src/components/NoteAutocomplete.tsx index 4588e4a2..268c264a 100644 --- a/src/components/NoteAutocomplete.tsx +++ b/src/components/NoteAutocomplete.tsx @@ -1,4 +1,17 @@ -import { useState, useRef, useCallback, useMemo, useEffect, type ComponentType, type SVGAttributes } from 'react' +import { + useState, + useRef, + useCallback, + useMemo, + useEffect, + type ChangeEvent, + type ComponentType, + type Dispatch, + type KeyboardEvent, + type SetStateAction, + type SVGAttributes, +} from 'react' +import { Input } from '@/components/ui/input' import type { VaultEntry } from '../types' import { getTypeColor, getTypeLightColor } from '../utils/typeColors' import { scrollSelectedHTMLChildIntoView } from '../utils/domScroll' @@ -32,6 +45,17 @@ interface MatchedEntry { TypeIcon?: ComponentType> } +interface OpenAutocompleteKeyContext { + action: AutocompleteKeyAction + matches: MatchedEntry[] + onEscape: (() => void) | undefined + onSelect: (noteTitle: string) => void + selectedIndex: number + setOpen: Dispatch> + setSelectedIndex: Dispatch> + value: string +} + function entryMatchesQuery(entry: VaultEntry, lowerQuery: string): boolean { return entry.title.toLowerCase().includes(lowerQuery) || entry.aliases.some(alias => alias.toLowerCase().includes(lowerQuery)) @@ -39,7 +63,7 @@ function entryMatchesQuery(entry: VaultEntry, lowerQuery: string): boolean { function buildMatchedEntry(entry: VaultEntry, typeEntryMap: Record): MatchedEntry { const isA = entry.isA - const typeEntry = typeEntryMap[isA ?? ''] + const typeEntry = Reflect.get(typeEntryMap, isA ?? '') as VaultEntry | undefined const noteType = isA || undefined return { title: entry.title, @@ -100,6 +124,67 @@ function preventAutocompleteMouseDown(event: React.MouseEvent): void { event.preventDefault() } +function handleOpenAutocompleteKey({ + action, + matches, + onEscape, + onSelect, + selectedIndex, + setOpen, + setSelectedIndex, + value, +}: OpenAutocompleteKeyContext): void { + if (action === 'next') { + setSelectedIndex(i => nextAutocompleteSelectionIndex(i, matches.length)) + return + } + if (action === 'previous') { + setSelectedIndex(i => previousAutocompleteSelectionIndex(i, matches.length)) + return + } + if (action === 'close') { + setOpen(false) + onEscape?.() + return + } + + const match = matches.at(selectedIndex) + onSelect(match?.title ?? value) +} + +function handleAutocompleteKeyDown({ + event, + matches, + onEscape, + onSelect, + open, + selectedIndex, + setOpen, + setSelectedIndex, + value, +}: { + event: KeyboardEvent + matches: MatchedEntry[] + onEscape: (() => void) | undefined + onSelect: (noteTitle: string) => void + open: boolean + selectedIndex: number + setOpen: Dispatch> + setSelectedIndex: Dispatch> + value: string +}): void { + if (!open || matches.length === 0) { + handleClosedAutocompleteKey(event.key, value, onSelect, onEscape) + return + } + + const action = resolveOpenAutocompleteKeyAction(event.key) + if (!action) return + + event.preventDefault() + handleOpenAutocompleteKey({ action, matches, onEscape, onSelect, selectedIndex, setOpen, setSelectedIndex, value }) +} + interface NoteAutocompleteMenuItemProps { item: MatchedEntry selected: boolean @@ -134,6 +219,36 @@ function NoteAutocompleteMenuItem({ ) } +function NoteAutocompleteMenu({ + matches, + menuRef, + selectedIndex, + onHover, + onSelect, +}: { + matches: MatchedEntry[] + menuRef: React.RefObject + selectedIndex: number + onHover: (index: number) => void + onSelect: (title: string) => void +}) { + if (matches.length === 0) return null + + return ( +
+ {matches.map((item, index) => ( + onHover(index)} + /> + ))} +
+ ) +} + export function NoteAutocomplete({ entries, typeEntryMap, value, onChange, onSelect, onEscape, placeholder, autoFocus, testId }: NoteAutocompleteProps) { const [selectedIndex, setSelectedIndex] = useState(-1) const [open, setOpen] = useState(false) @@ -169,47 +284,32 @@ export function NoteAutocomplete({ entries, typeEntryMap, value, onChange, onSel setSelectedIndex(-1) }, [onSelect]) - const handleChange = useCallback((e: React.ChangeEvent) => { + const handleChange = useCallback((e: ChangeEvent) => { onChange(e.target.value) setOpen(true) setSelectedIndex(-1) }, [onChange]) - const handleKeyDown = useCallback((e: React.KeyboardEvent) => { - if (!open || matches.length === 0) { - handleClosedAutocompleteKey(e.key, value, onSelect, onEscape) - return - } - - const action = resolveOpenAutocompleteKeyAction(e.key) - if (!action) return - - e.preventDefault() - if (action === 'next') { - setSelectedIndex(i => nextAutocompleteSelectionIndex(i, matches.length)) - return - } - if (action === 'previous') { - setSelectedIndex(i => previousAutocompleteSelectionIndex(i, matches.length)) - return - } - if (action === 'close') { - setOpen(false) - onEscape?.() - return - } - - const match = matches[selectedIndex] - if (match) handleSelect(match.title) - else onSelect(value) - }, [open, matches, selectedIndex, value, handleSelect, onSelect, onEscape]) + const handleKeyDown = useCallback((event: KeyboardEvent) => { + handleAutocompleteKeyDown({ + event, + matches, + onEscape, + onSelect: handleSelect, + open, + selectedIndex, + setOpen, + setSelectedIndex, + value, + }) + }, [handleSelect, matches, onEscape, open, selectedIndex, value]) return (
- {open && matches.length > 0 && ( -
- {matches.map((item, index) => ( - setSelectedIndex(index)} - /> - ))} -
+ )}
) diff --git a/src/components/NoteItem.tsx b/src/components/NoteItem.tsx index a78654ff..caa9e9d6 100644 --- a/src/components/NoteItem.tsx +++ b/src/components/NoteItem.tsx @@ -28,7 +28,7 @@ const TYPE_ICON_MAP: Record>> // eslint-disable-next-line react-refresh/only-export-components -- utility co-located with component export function getTypeIcon(isA: string | null, customIcon?: string | null): ComponentType> { if (customIcon) return resolveIcon(customIcon) - return (isA && TYPE_ICON_MAP[isA]) || FileText + return (isA && (Reflect.get(TYPE_ICON_MAP, isA) as ComponentType> | undefined)) || FileText } type VisibleNoteStatus = Exclude @@ -45,7 +45,7 @@ function hasStatusDot(noteStatus: NoteStatus): noteStatus is VisibleNoteStatus { } function StatusDot({ noteStatus }: { noteStatus: VisibleNoteStatus }) { - const dot = NOTE_STATUS_DOT[noteStatus] + const dot = Reflect.get(NOTE_STATUS_DOT, noteStatus) as { color: string; testId: string; title: string } return ( (null) - const CurrentIcon = DISPLAY_MODE_ICONS[currentMode] + const CurrentIcon = Reflect.get(DISPLAY_MODE_ICONS, currentMode) as typeof DISPLAY_MODE_ICONS.text const showRelationshipIcon = showsRelationshipPropertyIcon(propKey) const positionMenu = useCallback((node: HTMLDivElement | null) => { diff --git a/src/components/QuickOpenPalette.tsx b/src/components/QuickOpenPalette.tsx index 664b0165..27a2f765 100644 --- a/src/components/QuickOpenPalette.tsx +++ b/src/components/QuickOpenPalette.tsx @@ -37,7 +37,7 @@ export function QuickOpenPalette({ open, entries, isLoading = false, onSelect, o onClose() } else if (e.key === 'Enter') { e.preventDefault() - const selected = results[selectedIndex] + const selected = results.at(selectedIndex) if (selected) { onSelect(selected.entry) onClose() diff --git a/src/components/RawEditorFindBar.tsx b/src/components/RawEditorFindBar.tsx index 90b4b098..86c342dc 100644 --- a/src/components/RawEditorFindBar.tsx +++ b/src/components/RawEditorFindBar.tsx @@ -216,7 +216,7 @@ function useRawEditorFindController({ const options = useMemo(() => ({ caseSensitive, regex }), [caseSensitive, regex]) const result = useMemo(() => findEditorMatches(doc, query, options), [doc, options, query]) const clampedActiveIndex = clampEditorFindIndex(activeIndex, result.matches.length) - const activeMatch = result.matches[clampedActiveIndex] + const activeMatch = result.matches.at(clampedActiveIndex) const status = matchStatusText(locale, result.error, clampedActiveIndex, result.matches.length) const hasMatches = result.matches.length > 0 && !result.error diff --git a/src/components/SettingsPanel.tsx b/src/components/SettingsPanel.tsx index 3c271f56..b5422bfa 100644 --- a/src/components/SettingsPanel.tsx +++ b/src/components/SettingsPanel.tsx @@ -195,7 +195,7 @@ function getSettingsFocusableElements(panel: HTMLElement): HTMLElement[] { function focusSettingsBoundary(focusableElements: HTMLElement[], shiftKey: boolean): void { const targetIndex = shiftKey ? focusableElements.length - 1 : 0 - focusableElements[targetIndex]?.focus() + focusableElements.at(targetIndex)?.focus() } function isSettingsPanelElement(panel: HTMLElement, activeElement: Element | null): activeElement is HTMLElement { @@ -204,7 +204,7 @@ function isSettingsPanelElement(panel: HTMLElement, activeElement: Element | nul function isSettingsFocusBoundary(activeElement: HTMLElement, focusableElements: HTMLElement[], shiftKey: boolean): boolean { const boundaryIndex = shiftKey ? 0 : focusableElements.length - 1 - return activeElement === focusableElements[boundaryIndex] + return activeElement === focusableElements.at(boundaryIndex) } function trapSettingsPanelFocus(event: KeyboardEvent, panel: HTMLElement | null): void { @@ -1216,7 +1216,7 @@ function AiAgentsInstalledSection({ function renderDefaultAiAgentSummary(defaultAiAgent: AiAgentId, aiAgentsStatus: AiAgentsStatus, t: Translate): string { const definition = getAiAgentDefinition(defaultAiAgent) - const status = aiAgentsStatus[defaultAiAgent] + const status = Reflect.get(aiAgentsStatus, defaultAiAgent) as AiAgentsStatus[AiAgentId] if (status.status === 'installed') { return t('settings.aiAgents.ready', { agent: definition.label, diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index c9a8d96d..32899d59 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -501,10 +501,13 @@ function useSidebarRuntime({ onDeleteType, }) - const isSectionVisible = useCallback((type: string) => typeEntryMap[type]?.visible !== false, [typeEntryMap]) + const isSectionVisible = useCallback((type: string) => ( + (Reflect.get(typeEntryMap, type) as VaultEntry | undefined)?.visible !== false + ), [typeEntryMap]) const toggleVisibility = useCallback((type: string) => onToggleTypeVisibility?.(type), [onToggleTypeVisibility]) const selectTypeNote = useCallback((type: string) => { - const typeEntry = typeEntryMap[type] ?? typeEntryMap[type.toLowerCase()] + const typeEntry = (Reflect.get(typeEntryMap, type) as VaultEntry | undefined) + ?? (Reflect.get(typeEntryMap, type.toLowerCase()) as VaultEntry | undefined) if (typeEntry) onSelectNote?.(typeEntry) }, [onSelectNote, typeEntryMap]) diff --git a/src/components/SortDropdown.tsx b/src/components/SortDropdown.tsx index d5af26f8..195cb78d 100644 --- a/src/components/SortDropdown.tsx +++ b/src/components/SortDropdown.tsx @@ -15,6 +15,7 @@ const SORT_LABEL_KEYS = { title: 'noteList.sort.title', status: 'noteList.sort.status', } satisfies Record +const SORT_LABEL_KEYS_BY_OPTION = new Map(Object.entries(SORT_LABEL_KEYS)) type SortMenuAction = | { type: 'close' } @@ -22,7 +23,7 @@ type SortMenuAction = function getLocalizedSortOptionLabel(option: SortOption, locale: AppLocale): string { if (option.startsWith('property:')) return option.slice('property:'.length) - return translate(locale, SORT_LABEL_KEYS[option as keyof typeof SORT_LABEL_KEYS]) + return translate(locale, SORT_LABEL_KEYS_BY_OPTION.get(option) ?? 'noteList.sort.modified') } function buildSortItems(locale: AppLocale, customProperties?: string[]): SortItem[] { @@ -47,7 +48,7 @@ function resolveFocusedIndex(groupLabel: string, current: SortOption, sortItems: } function focusSortItem(sortButtonRefs: React.MutableRefObject>, index: number) { - sortButtonRefs.current[index]?.focus() + sortButtonRefs.current.at(index)?.focus() } function resolveSortMenuAction(key: string, focusIndex: number, itemCount: number): SortMenuAction | null { @@ -243,7 +244,7 @@ function SortDropdownMenu({ current={current} direction={direction} buttonRef={(node) => { - sortButtonRefs.current[index] = node + Reflect.set(sortButtonRefs.current, index, node) }} showSeparator={hasCustom && index === builtInOptionCount} locale={locale} diff --git a/src/components/StatusDropdown.tsx b/src/components/StatusDropdown.tsx index 365350bd..afd48bb3 100644 --- a/src/components/StatusDropdown.tsx +++ b/src/components/StatusDropdown.tsx @@ -186,7 +186,7 @@ function getStatusValueToSave({ query, }: StatusSelectionOptions) { const trimmed = query.trim() - if (highlightIndex >= 0 && highlightIndex < allFiltered.length) return allFiltered[highlightIndex] + if (highlightIndex >= 0 && highlightIndex < allFiltered.length) return allFiltered.at(highlightIndex) if (showCreateOption && highlightIndex === allFiltered.length) return trimmed return trimmed || null } @@ -199,7 +199,7 @@ function useStatusKeyboard(opts: KeyboardNavOptions) { const list = listRef.current if (!list) return const items = list.querySelectorAll('[data-testid^="status-option-"], [data-testid="status-create-option"]') - items[index]?.scrollIntoView({ block: 'nearest' }) + items.item(index)?.scrollIntoView({ block: 'nearest' }) }, [listRef]) const moveHighlight = useCallback((nextIndex: number) => { diff --git a/src/components/TagsDropdown.tsx b/src/components/TagsDropdown.tsx index f4441d13..05dd32dc 100644 --- a/src/components/TagsDropdown.tsx +++ b/src/components/TagsDropdown.tsx @@ -137,7 +137,7 @@ function getTagValueToToggle({ selectedTags, }: TagSelectionOptions) { const trimmed = query.trim() - if (highlightIndex >= 0 && highlightIndex < filtered.length) return filtered[highlightIndex] + if (highlightIndex >= 0 && highlightIndex < filtered.length) return filtered.at(highlightIndex) if (showCreateOption && highlightIndex === filtered.length && trimmed) return trimmed if (trimmed && !selectedTags.has(trimmed)) return trimmed return null @@ -156,7 +156,7 @@ function useTagKeyboard(opts: { const list = listRef.current if (!list) return const items = list.querySelectorAll('[data-testid^="tag-option-"], [data-testid="tag-create-option"]') - items[index]?.scrollIntoView({ block: 'nearest' }) + items.item(index)?.scrollIntoView({ block: 'nearest' }) }, [listRef]) const moveHighlight = useCallback((nextIndex: number) => { diff --git a/src/components/TypeSelector.tsx b/src/components/TypeSelector.tsx index 80de2637..da4e09e6 100644 --- a/src/components/TypeSelector.tsx +++ b/src/components/TypeSelector.tsx @@ -69,9 +69,21 @@ function shouldOpenCombobox(event: KeyboardEvent) { return OPEN_COMBOBOX_KEYS.has(event.key) } +function typeMetadataValue({ + type, + metadata, +}: { + type: string | null | undefined + metadata: Record +}): string | null { + if (!type) return null + const value = Reflect.get(metadata, type) + return typeof value === 'string' ? value : null +} + function TypeSelectorItem({ type, typeColorKeys, typeIconKeys }: TypeSelectorItemProps) { - const Icon = getTypeIcon(type, typeIconKeys[type]) - const color = getTypeColor(type, typeColorKeys[type]) + const Icon = getTypeIcon(type, typeMetadataValue({ type, metadata: typeIconKeys })) + const color = getTypeColor(type, typeMetadataValue({ type, metadata: typeColorKeys })) return ( <> {/* eslint-disable-next-line react-hooks/static-components -- icon from static map lookup */} @@ -272,8 +284,9 @@ function EditableTypeSelector({ onUpdateProperty: (key: string, value: FrontmatterValue) => void }) { const currentValue = isA ?? TYPE_NONE - const typeColor = isA ? getTypeColor(isA, typeColorKeys[isA] ?? customColorKey) : undefined - const typeLightColor = isA ? getTypeLightColor(isA, typeColorKeys[isA] ?? customColorKey) : undefined + const currentTypeColorKey = typeMetadataValue({ type: isA, metadata: typeColorKeys }) ?? customColorKey + const typeColor = isA ? getTypeColor(isA, currentTypeColorKey) : undefined + const typeLightColor = isA ? getTypeLightColor(isA, currentTypeColorKey) : undefined const [open, setOpen] = useState(false) const [query, setQuery] = useState('') const [highlightedIndex, setHighlightedIndex] = useState(-1) @@ -372,9 +385,9 @@ function EditableTypeSelector({ moveHighlight('previous') return case 'Enter': - if (highlightedIndex < 0 || options[highlightedIndex] === undefined) return + if (highlightedIndex < 0 || options.at(highlightedIndex) === undefined) return event.preventDefault() - selectType(options[highlightedIndex]) + selectType(options.at(highlightedIndex) ?? TYPE_NONE) return case 'Escape': event.preventDefault() diff --git a/src/components/VaultContentSettingsSection.tsx b/src/components/VaultContentSettingsSection.tsx index 81b1678c..61211421 100644 --- a/src/components/VaultContentSettingsSection.tsx +++ b/src/components/VaultContentSettingsSection.tsx @@ -34,7 +34,7 @@ const NOTE_WIDTH_LABEL_KEYS: Record = { function buildNoteWidthOptions(t: Translate): Array<{ value: NoteWidthMode; label: string }> { return NOTE_WIDTH_OPTIONS.map((value) => ({ value, - label: t(NOTE_WIDTH_LABEL_KEYS[value]), + label: t(Reflect.get(NOTE_WIDTH_LABEL_KEYS, value) as Parameters[0]), })) } diff --git a/src/components/WelcomeScreen.tsx b/src/components/WelcomeScreen.tsx index 452db171..4c478913 100644 --- a/src/components/WelcomeScreen.tsx +++ b/src/components/WelcomeScreen.tsx @@ -74,15 +74,15 @@ function focusWelcomeAction( actionButtonRefs: WelcomeActionButtonRef[], actionIndex: number, ): void { - actionButtonRefs[actionIndex]?.current?.focus() + actionButtonRefs.at(actionIndex)?.current?.focus() } function triggerWelcomeAction( actionIndex: number, actions: WelcomeAction[], ): void { - const action = actions[actionIndex] - if (!action?.disabled) action.run() + const action = actions.at(actionIndex) + if (action && !action.disabled) action.run() } const CARD_STYLE: React.CSSProperties = { diff --git a/src/components/blockNoteCursorTarget.ts b/src/components/blockNoteCursorTarget.ts index 1cb00a62..c35a900b 100644 --- a/src/components/blockNoteCursorTarget.ts +++ b/src/components/blockNoteCursorTarget.ts @@ -14,7 +14,7 @@ export function findNearestTextCursorBlock( if (blocks.length === 0) return null const clampedTargetIndex = Math.min(Math.max(targetIndex, 0), blocks.length - 1) - const targetBlock = blocks[clampedTargetIndex] + const targetBlock = blocks.at(clampedTargetIndex) if (blockSupportsTextCursor(targetBlock)) { return targetBlock } diff --git a/src/components/blockNoteRenderRecovery.ts b/src/components/blockNoteRenderRecovery.ts index e902ec26..9501ecfe 100644 --- a/src/components/blockNoteRenderRecovery.ts +++ b/src/components/blockNoteRenderRecovery.ts @@ -8,7 +8,7 @@ type MarkedRecoveredBlockNoteRenderError = Error & { function hasRecoveredRenderErrorMark(error: unknown): boolean { if (!(error instanceof Error)) return false - return (error as MarkedRecoveredBlockNoteRenderError)[RECOVERED_BLOCKNOTE_RENDER_ERROR_MARK] === true + return Reflect.get(error as MarkedRecoveredBlockNoteRenderError, RECOVERED_BLOCKNOTE_RENDER_ERROR_MARK) === true } export function isRecoverableBlockNoteRenderError(error: unknown): boolean { @@ -18,7 +18,7 @@ export function isRecoverableBlockNoteRenderError(error: unknown): boolean { export function markRecoveredBlockNoteRenderError(error: unknown): void { if (!isRecoverableBlockNoteRenderError(error)) return const markedError = error as MarkedRecoveredBlockNoteRenderError - markedError[RECOVERED_BLOCKNOTE_RENDER_ERROR_MARK] = true + Reflect.set(markedError, RECOVERED_BLOCKNOTE_RENDER_ERROR_MARK, true) } export function isRecoveredBlockNoteRenderError( diff --git a/src/components/editorModePosition.ts b/src/components/editorModePosition.ts index c14e3fe3..64e53491 100644 --- a/src/components/editorModePosition.ts +++ b/src/components/editorModePosition.ts @@ -98,7 +98,7 @@ function getLineStartOffset({ text, lineIndex }: { text: string; lineIndex: numb let currentLine = 0 for (let index = 0; index < text.length; index++) { - if (text[index] !== '\n') continue + if (text.charAt(index) !== '\n') continue currentLine += 1 if (currentLine === lineIndex) { return index + 1 @@ -211,8 +211,12 @@ function getSelectionIndexes(editor: BlockNotePositionEditor): [number, number] const selectedBlocks = selection?.blocks ?? [] if (selectedBlocks.length === 0) return null - const startIndex = editor.document.findIndex(block => block.id === selectedBlocks[0].id) - const endIndex = editor.document.findIndex(block => block.id === selectedBlocks[selectedBlocks.length - 1].id) + const startBlock = selectedBlocks.at(0) + const endBlock = selectedBlocks.at(-1) + if (!startBlock || !endBlock) return null + + const startIndex = editor.document.findIndex(block => block.id === startBlock.id) + const endIndex = editor.document.findIndex(block => block.id === endBlock.id) if (startIndex === -1 || endIndex === -1) return null return [startIndex, endIndex] @@ -241,13 +245,17 @@ function buildBlockNoteRestoreState( const headIndex = findNearestBlockIndex({ ranges, targetLine: headLine }) const startIndex = Math.min(anchorIndex, headIndex) const endIndex = Math.max(anchorIndex, headIndex) + const startBlock = editor.document.at(startIndex) + const endBlock = editor.document.at(endIndex) + if (!startBlock || !endBlock) return null + const startBlockId = findNearestTextCursorBlockById( editor.document, - editor.document[startIndex].id, + startBlock.id, )?.id const endBlockId = findNearestTextCursorBlockById( editor.document, - editor.document[endIndex].id, + endBlock.id, )?.id if (!startBlockId || !endBlockId) return null @@ -290,8 +298,9 @@ export function buildCodeMirrorRestoreState( const ranges = buildBlockLineRanges({ body, editor }) if (ranges.length === 0) return null - const anchorRange = ranges[clamp(snapshot.anchorBlockIndex, 0, ranges.length - 1)] - const headRange = ranges[clamp(snapshot.headBlockIndex, 0, ranges.length - 1)] + const anchorRange = ranges.at(clamp(snapshot.anchorBlockIndex, 0, ranges.length - 1)) + const headRange = ranges.at(clamp(snapshot.headBlockIndex, 0, ranges.length - 1)) + if (!anchorRange || !headRange) return null const anchorBodyOffset = getLineStartOffset({ text: body, lineIndex: anchorRange.startLine }) const headBodyOffset = getLineEndOffset({ text: body, lineIndex: headRange.endLine }) diff --git a/src/components/folder-tree/folderTreeUtils.ts b/src/components/folder-tree/folderTreeUtils.ts index 2380c5a8..535a4d13 100644 --- a/src/components/folder-tree/folderTreeUtils.ts +++ b/src/components/folder-tree/folderTreeUtils.ts @@ -14,8 +14,8 @@ export function mergeExpandedPaths( let changed = false const nextExpanded = { ...current } for (const path of paths) { - if (nextExpanded[path]) continue - nextExpanded[path] = true + if (Reflect.get(nextExpanded, path)) continue + Reflect.set(nextExpanded, path, true) changed = true } return changed ? nextExpanded : current diff --git a/src/components/folder-tree/useFolderTreeDisclosure.ts b/src/components/folder-tree/useFolderTreeDisclosure.ts index 117225b5..0971558f 100644 --- a/src/components/folder-tree/useFolderTreeDisclosure.ts +++ b/src/components/folder-tree/useFolderTreeDisclosure.ts @@ -26,7 +26,9 @@ function useExpandedFolders(selection: SidebarSelection, renamingFolderPath?: st const toggleFolder = useCallback((path: string) => { setManualExpanded((current) => { const defaultExpanded = path === '' - return { ...current, [path]: !(current[path] ?? defaultExpanded) } + const next = { ...current } + Reflect.set(next, path, !((Reflect.get(current, path) as boolean | undefined) ?? defaultExpanded)) + return next }) }, []) diff --git a/src/components/inspector/RelationshipsPanel.tsx b/src/components/inspector/RelationshipsPanel.tsx index 12959d4b..7b57066a 100644 --- a/src/components/inspector/RelationshipsPanel.tsx +++ b/src/components/inspector/RelationshipsPanel.tsx @@ -109,8 +109,8 @@ function inferVaultPath(entries: VaultEntry[]): string { const prefix: string[] = [] const maxDepth = Math.min(...segments.map((parts) => parts.length)) for (let i = 0; i < maxDepth; i += 1) { - const segment = segments[0][i] - if (segments.every((parts) => parts[i] === segment)) prefix.push(segment) + const segment = segments.at(0)?.at(i) + if (segment !== undefined && segments.every((parts) => parts.at(i) === segment)) prefix.push(segment) else break } return prefix.join('/') @@ -341,7 +341,7 @@ function useInlineAddNoteState( selectedIndex: search.selectedIndex, createIndex, trimmed, - selectedEntry: search.selectedEntry, + selectedEntry: search.selectedEntry ?? undefined, onCreate: handleCreateAndOpen, onSelectEntry: selectEntryAndClose, onFallback: handleFallback, @@ -572,7 +572,7 @@ function NoteTargetInput({ entries, value, locale, onChange, onSubmit, onCancel, selectedIndex: search.selectedIndex, createIndex, trimmed, - selectedEntry: search.selectedEntry, + selectedEntry: search.selectedEntry ?? undefined, onCreate: onSubmitWithCreate, onSelectEntry: selectEntry, onFallback: onSubmit, diff --git a/src/components/mathInputExtension.ts b/src/components/mathInputExtension.ts index 05a1d8ea..e5364f30 100644 --- a/src/components/mathInputExtension.ts +++ b/src/components/mathInputExtension.ts @@ -70,7 +70,7 @@ function replaceCompletedInlineMath( trailingText?: string, ): EditorViewLike['state']['tr'] | null { const replacement = readInlineMathReplacement(view) - const mathNodeType = view.state.schema.nodes[MATH_INLINE_TYPE] + const mathNodeType = Reflect.get(view.state.schema.nodes, MATH_INLINE_TYPE) as EditorViewLike['state']['schema']['nodes'][string] | undefined if (!replacement || !mathNodeType) return null const mathNode = mathNodeType.createChecked({ latex: replacement.latex }) diff --git a/src/components/note-item/ChangeNoteContent.tsx b/src/components/note-item/ChangeNoteContent.tsx index 117db19d..8eda9eea 100644 --- a/src/components/note-item/ChangeNoteContent.tsx +++ b/src/components/note-item/ChangeNoteContent.tsx @@ -28,7 +28,7 @@ function readChangeStats(entry: VaultEntry): Required resolveRelationshipChip(ref, allEntries, typeEntryMap)) .filter((chip): chip is PropertyChipValue => chip !== null) } @@ -149,7 +150,7 @@ function resolveScalarChipValues(entry: VaultEntry, propName: string): PropertyC const propertyKey = findMatchingKey(entry.properties, propName) if (!propertyKey) return [] - const rawValue = entry.properties[propertyKey] + const rawValue = Reflect.get(entry.properties, propertyKey) as unknown const values = Array.isArray(rawValue) ? rawValue : [rawValue] return values .map((value) => resolvePropertyValueChip(propertyKey, value)) diff --git a/src/components/note-item/typeIcon.ts b/src/components/note-item/typeIcon.ts index 40d28d89..942cb989 100644 --- a/src/components/note-item/typeIcon.ts +++ b/src/components/note-item/typeIcon.ts @@ -33,5 +33,5 @@ const TYPE_ICON_MAP: Record>> export function getTypeIcon(isA: string | null, customIcon?: string | null): ComponentType> { if (customIcon) return resolveIcon(customIcon) - return (isA && TYPE_ICON_MAP[isA]) || FileText + return (isA && (Reflect.get(TYPE_ICON_MAP, isA) as ComponentType> | undefined)) || FileText } diff --git a/src/components/note-list/FilterPills.tsx b/src/components/note-list/FilterPills.tsx index c443beb0..8dea739f 100644 --- a/src/components/note-list/FilterPills.tsx +++ b/src/components/note-list/FilterPills.tsx @@ -43,7 +43,7 @@ function FilterPillsInner({ active, counts, onChange, position = 'top', locale = > {translate(locale, labelKey)} - {counts[value]} + {Reflect.get(counts, value)} ))} diff --git a/src/components/note-list/InboxFilterPills.tsx b/src/components/note-list/InboxFilterPills.tsx index 22855757..828c7e3a 100644 --- a/src/components/note-list/InboxFilterPills.tsx +++ b/src/components/note-list/InboxFilterPills.tsx @@ -44,7 +44,7 @@ function InboxFilterPillsInner({ active, counts, onChange, position = 'top', loc > {translate(locale, labelKey)} - {counts[value]} + {Reflect.get(counts, value)} ))} diff --git a/src/components/note-list/useNoteListModel.tsx b/src/components/note-list/useNoteListModel.tsx index a6fd5ecf..39163078 100644 --- a/src/components/note-list/useNoteListModel.tsx +++ b/src/components/note-list/useNoteListModel.tsx @@ -79,7 +79,7 @@ function useLikelyNextPreload(entries: VaultEntry[], selectedNotePath: string | let candidateIndex = 0 const startTimer = window.setTimeout(() => { const preloadNext = () => { - const entry = candidates[candidateIndex] + const entry = candidates.at(candidateIndex) if (!entry) return candidateIndex += 1 prefetchNoteContent(entry) diff --git a/src/components/note-retargeting/RetargetNoteDialog.tsx b/src/components/note-retargeting/RetargetNoteDialog.tsx index be74d221..d53cb9be 100644 --- a/src/components/note-retargeting/RetargetNoteDialog.tsx +++ b/src/components/note-retargeting/RetargetNoteDialog.tsx @@ -101,7 +101,8 @@ export function RetargetNoteDialog({ } if (event.key === 'Enter' && effectiveHighlightedIndex >= 0) { event.preventDefault() - void submitSelection(filteredOptions[effectiveHighlightedIndex].id) + const highlightedOption = filteredOptions.at(effectiveHighlightedIndex) + if (highlightedOption) void submitSelection(highlightedOption.id) } } diff --git a/src/components/sidebar/SidebarLoadingSections.tsx b/src/components/sidebar/SidebarLoadingSections.tsx index 6a0969cc..72403470 100644 --- a/src/components/sidebar/SidebarLoadingSections.tsx +++ b/src/components/sidebar/SidebarLoadingSections.tsx @@ -209,7 +209,7 @@ export function SidebarCreatableLoadingSection({ onCreate, ...props }: ConfiguredCreatableLoadingSectionProps) { - const config = CREATABLE_LOADING_SECTIONS[kind] + const config = Reflect.get(CREATABLE_LOADING_SECTIONS, kind) as typeof CREATABLE_LOADING_SECTIONS[CreatableLoadingSectionKind] const locale = props.locale ?? 'en' return ( onCustomize('icon', icon)} onChangeColor={(color) => onCustomize('color', color)} onChangeTemplate={onChangeTemplate} diff --git a/src/components/sidebar/sidebarHooks.ts b/src/components/sidebar/sidebarHooks.ts index 39dfb652..0632f546 100644 --- a/src/components/sidebar/sidebarHooks.ts +++ b/src/components/sidebar/sidebarHooks.ts @@ -186,7 +186,8 @@ export function useSidebarCollapsed() { const toggle = useCallback((key: SidebarGroupKey) => { setCollapsed((prev) => { - const next = { ...prev, [key]: !prev[key] } + const next = { ...prev } + Reflect.set(next, key, !(Reflect.get(prev, key) as boolean)) localStorage.setItem(APP_STORAGE_KEYS.sidebarCollapsed, JSON.stringify(next)) localStorage.removeItem(LEGACY_APP_STORAGE_KEYS.sidebarCollapsed) return next @@ -231,7 +232,7 @@ export function applyCustomization( value: string, ): void { if (!target || !onCustomizeType) return - const typeEntry = typeEntryMap[target] + const typeEntry = Reflect.get(typeEntryMap, target) as VaultEntry | undefined const [icon, color] = typeEntry ? buildCustomizeArgs(typeEntry, prop, value) : [prop === 'icon' ? value : 'file-text', prop === 'color' ? value : 'blue'] diff --git a/src/components/status-bar/AiAgentsBadge.tsx b/src/components/status-bar/AiAgentsBadge.tsx index 73d45442..5065900a 100644 --- a/src/components/status-bar/AiAgentsBadge.tsx +++ b/src/components/status-bar/AiAgentsBadge.tsx @@ -66,7 +66,7 @@ function badgeTooltip( if (!isAiAgentInstalled(statuses, defaultAgent)) { return translate(locale, 'status.ai.selectedMissing', { agent: definition.label }) } - const version = statuses[defaultAgent].version + const version = (Reflect.get(statuses, defaultAgent) as AiAgentsStatus[AiAgentId]).version const base = translate(locale, 'status.ai.defaultAgent', { agent: definition.label, version: version ? ` ${version}` : '' }) if (!guidanceSummary) return base if (vaultAiGuidanceNeedsRestore(guidanceStatus!)) { diff --git a/src/components/status-bar/StatusBarBadges.tsx b/src/components/status-bar/StatusBarBadges.tsx index 004ae80b..d7d7021d 100644 --- a/src/components/status-bar/StatusBarBadges.tsx +++ b/src/components/status-bar/StatusBarBadges.tsx @@ -21,28 +21,22 @@ import { openExternalUrl } from '../../utils/url' import { useDismissibleLayer } from './useDismissibleLayer' import { ICON_STYLE, SEP_STYLE } from './styles' -const SYNC_ICON_MAP: Record = { - syncing: Loader2, - conflict: AlertTriangle, - pull_required: ArrowDown, -} +const SYNC_LABEL_KEYS = new Map([ + ['syncing', 'status.sync.syncing'], + ['conflict', 'status.sync.conflict'], + ['error', 'status.sync.failed'], + ['pull_required', 'status.sync.pullRequired'], +]) -const SYNC_LABEL_KEYS: Partial> = { - syncing: 'status.sync.syncing', - conflict: 'status.sync.conflict', - error: 'status.sync.failed', - pull_required: 'status.sync.pullRequired', -} +const SYNC_COLORS = new Map([ + ['conflict', 'var(--accent-orange)'], + ['error', 'var(--muted-foreground)'], + ['pull_required', 'var(--accent-orange)'], +]) -const SYNC_COLORS: Record = { - conflict: 'var(--accent-orange)', - error: 'var(--muted-foreground)', - pull_required: 'var(--accent-orange)', -} - -const MCP_TOOLTIP_KEYS: Partial> = { - not_installed: 'status.mcp.notConnected', -} +const MCP_TOOLTIP_KEYS = new Map([ + ['not_installed', 'status.mcp.notConnected'], +]) const CLAUDE_INSTALL_URL = 'https://docs.anthropic.com/en/docs/claude-code' @@ -55,12 +49,25 @@ function formatElapsedSync(locale: AppLocale, lastSyncTime: number | null): stri } function formatSyncLabel(locale: AppLocale, status: SyncStatus, lastSyncTime: number | null): string { - const labelKey = SYNC_LABEL_KEYS[status] + const labelKey = SYNC_LABEL_KEYS.get(status) return labelKey ? translate(locale, labelKey) : formatElapsedSync(locale, lastSyncTime) } function syncIconColor(status: SyncStatus): string { - return SYNC_COLORS[status] ?? 'var(--accent-green)' + return SYNC_COLORS.get(status) ?? 'var(--accent-green)' +} + +function SyncStatusIcon({ status, color, spinning }: { status: SyncStatus; color: string; spinning: boolean }) { + const iconProps = { + className: spinning ? 'animate-spin' : '', + size: 13, + style: { color }, + } + + if (status === 'syncing') return + if (status === 'conflict') return + if (status === 'pull_required') return + return } function syncBadgeTooltipCopy(locale: AppLocale, status: SyncStatus): ActionTooltipCopy { @@ -101,7 +108,7 @@ function getMcpBadgeConfig(locale: AppLocale, status: McpStatus, onInstall?: () const clickable = status === 'not_installed' && Boolean(onInstall) return { clickable, - tooltip: translate(locale, MCP_TOOLTIP_KEYS[status] ?? 'status.mcp.unknown'), + tooltip: translate(locale, MCP_TOOLTIP_KEYS.get(status) ?? 'status.mcp.unknown'), onClick: clickable ? onInstall : undefined, } } @@ -655,7 +662,6 @@ export function SyncBadge({ }) { const [showPopup, setShowPopup] = useState(false) const popupRef = useRef(null) - const SyncIcon = SYNC_ICON_MAP[status] ?? RefreshCw const isSyncing = status === 'syncing' useDismissibleLayer(showPopup, popupRef, () => setShowPopup(false)) @@ -678,7 +684,7 @@ export function SyncBadge({
- + {compact ? null : formatSyncLabel(locale, status, lastSyncTime)} diff --git a/src/components/tableOfContentsModel.ts b/src/components/tableOfContentsModel.ts index 675b97bd..d8231c3a 100644 --- a/src/components/tableOfContentsModel.ts +++ b/src/components/tableOfContentsModel.ts @@ -84,16 +84,16 @@ function tocLevelForBlock(block: TocBlock): TocLevel | null { function nearestParent(stack: TocItem[], level: TocLevel): TocItem { for (let index = stack.length - 1; index >= 0; index -= 1) { - const item = stack[index] + const item = stack.at(index) if (item && item.level < level) return item } - return stack[0] + return stack.at(0)! } function appendTocHeading(stack: TocItem[], item: TocItem) { const parent = nearestParent(stack, item.level) parent.children.push(item) - stack[item.level] = item + Reflect.set(stack, item.level, item) stack.length = item.level + 1 } @@ -175,8 +175,8 @@ function parseMarkdownHeadings({ markdown }: { markdown: string }): MarkdownHead .map((line) => line.match(/^(#{1,3})\s+(.+?)\s*#*\s*$/)) .filter((match): match is RegExpMatchArray => match !== null) .map((match) => ({ - level: match[1].length as TocLevel, - title: stripInlineMarkdown({ text: match[2] }), + level: match.at(1)!.length as TocLevel, + title: stripInlineMarkdown({ text: match.at(2)! }), })) .filter((heading) => heading.title.length > 0) } @@ -202,7 +202,8 @@ function blockIdsForMatchingHeadings({ blocks, entryTitle, headings }: HeadingBl if (visibleBlocks.headings.length !== visibleMarkdown.headings.length) return visibleMarkdown const blockIds = visibleBlocks.headings.map((blockHeading, index) => { - const heading = visibleMarkdown.headings[index] + const heading = visibleMarkdown.headings.at(index) + if (!heading) return undefined if (blockHeading.level !== heading.level) return undefined if (!sameHeadingTitle({ entryTitle: blockHeading.title, title: heading.title })) return undefined return blockHeading.blockId @@ -212,7 +213,7 @@ function blockIdsForMatchingHeadings({ blocks, entryTitle, headings }: HeadingBl return { headings: visibleMarkdown.headings.map((heading, index) => ({ ...heading, - blockId: blockIds[index], + blockId: blockIds.at(index), })), titleBlockId: visibleBlocks.titleBlockId, } @@ -225,7 +226,7 @@ function tocItemMatchesHeading(item: TocItem, heading: MarkdownHeading | undefin } function indexedHeadingForTocItem(item: TocItem, headings: MarkdownHeading[]): MarkdownHeading | undefined { - return item.matchIndex === undefined ? undefined : headings[item.matchIndex] + return item.matchIndex === undefined ? undefined : headings.at(item.matchIndex) } function matchingHeadingForTocItem(item: TocItem, headings: MarkdownHeading[]): MarkdownHeading | undefined { diff --git a/src/components/tolariaEditorFormatting.tsx b/src/components/tolariaEditorFormatting.tsx index 5a48159a..9d8a7bc8 100644 --- a/src/components/tolariaEditorFormatting.tsx +++ b/src/components/tolariaEditorFormatting.tsx @@ -230,10 +230,14 @@ function editorSupportsTextStyle( style: TolariaBasicTextStyle, editor: BlockNoteEditor, ) { + const styleSchema = Reflect.get(editor.schema.styleSchema, style) as { + type?: string + propSchema?: unknown + } | undefined return ( style in editor.schema.styleSchema && - editor.schema.styleSchema[style].type === style && - editor.schema.styleSchema[style].propSchema === 'boolean' + styleSchema?.type === style && + styleSchema.propSchema === 'boolean' ) } @@ -300,7 +304,7 @@ function isSelectedBlockTypeItem( return Object.entries(item.props || {}).every( ([propName, propValue]) => - propValue === firstSelectedBlock.props[propName], + propValue === Reflect.get(firstSelectedBlock.props, propName), ) } @@ -331,7 +335,7 @@ function getTolariaBlockTypeSelectOptions( function getFormattingToolbarBridgeBlockId( editor: BlockNoteEditor, ) { - const selectedBlock = getSelectedBlocksSafely(editor)[0] + const selectedBlock = getSelectedBlocksSafely(editor).at(0) if (!selectedBlock) return null return FORMATTING_TOOLBAR_FILE_BLOCK_TYPES.has(selectedBlock.type) @@ -345,7 +349,8 @@ function getSelectedFileBlockState( const selectedBlocks = getSelectedBlocksSafely(editor) if (selectedBlocks.length !== 1) return null - const block = selectedBlocks[0] + const block = selectedBlocks.at(0) + if (!block) return null if (!FORMATTING_TOOLBAR_FILE_BLOCK_TYPES.has(block.type)) return null const url = (block.props as Record).url @@ -363,7 +368,7 @@ function fileDownloadTooltip(dict: unknown, blockType: string): string { } }).formatting_toolbar?.file_download?.tooltip - return tooltip?.[blockType] ?? tooltip?.file ?? 'Download file' + return (tooltip ? Reflect.get(tooltip, blockType) as string | undefined : undefined) ?? tooltip?.file ?? 'Download file' } function getFormattingToolbarAnchorElement( @@ -412,8 +417,12 @@ function TolariaBasicTextStyleButton({ if (buttonState === undefined) return null - const Icon = TOLARIA_BASIC_TEXT_STYLE_ICONS[basicTextStyle] - const copy = TOLARIA_BASIC_TEXT_STYLE_TOOLTIPS[basicTextStyle] + const Icon = Reflect.get(TOLARIA_BASIC_TEXT_STYLE_ICONS, basicTextStyle) as LucideIcon + const copy = Reflect.get(TOLARIA_BASIC_TEXT_STYLE_TOOLTIPS, basicTextStyle) as { + label: string + mainTooltip: string + secondaryTooltip: string + } return ( [ name, references.map(reference => 'command' in reference - ? APP_COMMAND_MANIFEST_COMMANDS[reference.command].id + ? (Reflect.get(APP_COMMAND_MANIFEST_COMMANDS, reference.command) as AppCommandManifestDefinition).id : reference.id), ]), ) as Record @@ -308,12 +308,14 @@ function normalizeShortcutKey(key: string): string { for (const [id, definition] of Object.entries(APP_COMMAND_DEFINITIONS) as Array<[AppCommandId, AppCommandDefinition]>) { const shortcut = definition.shortcut if (!shortcut) continue - shortcutKeyMaps[shortcut.combo].set(normalizeShortcutKey(shortcut.key), id) + const shortcutKeyMap = Reflect.get(shortcutKeyMaps, shortcut.combo) as Map + shortcutKeyMap.set(normalizeShortcutKey(shortcut.key), id) for (const alias of shortcut.aliases ?? []) { - shortcutKeyMaps[shortcut.combo].set(normalizeShortcutKey(alias), id) + shortcutKeyMap.set(normalizeShortcutKey(alias), id) } if (shortcut.code) { - shortcutCodeMaps[shortcut.combo].set(shortcut.code, id) + const shortcutCodeMap = Reflect.get(shortcutCodeMaps, shortcut.combo) as Map + shortcutCodeMap.set(shortcut.code, id) } } @@ -328,7 +330,7 @@ export function isNativeMenuCommandId(value: string): value is AppCommandId { export function getDeterministicShortcutQaDefinition( id: AppCommandId, ): AppCommandDeterministicQaDefinition | null { - const definition = APP_COMMAND_DEFINITIONS[id] + const definition = Reflect.get(APP_COMMAND_DEFINITIONS, id) as AppCommandDefinition if (!definition.shortcut) return null return { @@ -345,7 +347,7 @@ export function getShortcutEventInit( id: AppCommandId, options: AppCommandShortcutEventOptions = {}, ): AppCommandShortcutEventInit | null { - const shortcut = APP_COMMAND_DEFINITIONS[id].shortcut + const shortcut = (Reflect.get(APP_COMMAND_DEFINITIONS, id) as AppCommandDefinition).shortcut if (!shortcut) return null const useControl = options.preferControl ?? false @@ -382,10 +384,10 @@ export function findShortcutCommandId( code?: string, ): AppCommandId | null { if (code) { - const codeMatch = shortcutCodeMaps[combo].get(code) + const codeMatch = (Reflect.get(shortcutCodeMaps, combo) as Map).get(code) if (codeMatch) return codeMatch } - return shortcutKeyMaps[combo].get(normalizeShortcutKey(key)) ?? null + return (Reflect.get(shortcutKeyMaps, combo) as Map).get(normalizeShortcutKey(key)) ?? null } export function findShortcutCommandIdForEvent(event: ShortcutEventLike): AppCommandId | null { @@ -412,6 +414,6 @@ export function formatShortcutDisplay( } export function getAppCommandShortcutDisplay(id: AppCommandId): string | undefined { - const shortcut = APP_COMMAND_DEFINITIONS[id].shortcut + const shortcut = (Reflect.get(APP_COMMAND_DEFINITIONS, id) as AppCommandDefinition).shortcut return shortcut ? formatShortcutDisplay(shortcut) : undefined } diff --git a/src/hooks/appCommandDispatcher.ts b/src/hooks/appCommandDispatcher.ts index 44ad9e38..528b5ff1 100644 --- a/src/hooks/appCommandDispatcher.ts +++ b/src/hooks/appCommandDispatcher.ts @@ -300,7 +300,8 @@ export function executeAppCommand( return false } - const dispatched = dispatchDefinition(APP_COMMAND_DEFINITIONS[id], handlers) + const definition = Reflect.get(APP_COMMAND_DEFINITIONS, id) as AppCommandDefinition + const dispatched = dispatchDefinition(definition, handlers) if (dispatched) { lastCommandDispatch = { id, source, timestamp } } diff --git a/src/hooks/commands/localizeCommands.ts b/src/hooks/commands/localizeCommands.ts index 7a0c0982..8ce0049d 100644 --- a/src/hooks/commands/localizeCommands.ts +++ b/src/hooks/commands/localizeCommands.ts @@ -172,7 +172,7 @@ function localizeTypeCommand(command: CommandAction, t: Translate): string | nul } export function localizeCommandGroup(group: CommandGroup, locale: AppLocale = 'en'): string { - return createTranslator(locale)(GROUP_LABEL_KEYS[group]) + return createTranslator(locale)(Reflect.get(GROUP_LABEL_KEYS, group) as keyof ReturnType extends never ? never : Parameters>[0]) } export function localizeCommandActions(commands: CommandAction[], locale: AppLocale = 'en'): CommandAction[] { diff --git a/src/hooks/commands/typeCommands.ts b/src/hooks/commands/typeCommands.ts index 4d1dcddf..53ac5433 100644 --- a/src/hooks/commands/typeCommands.ts +++ b/src/hooks/commands/typeCommands.ts @@ -8,7 +8,8 @@ const PLURAL_OVERRIDES: Record = { } export function pluralizeType(type: string): string { - if (PLURAL_OVERRIDES[type]) return PLURAL_OVERRIDES[type] + const override = Reflect.get(PLURAL_OVERRIDES, type) as string | undefined + if (override) return override if (type.endsWith('s') || type.endsWith('x') || type.endsWith('ch') || type.endsWith('sh')) return `${type}es` if (type.endsWith('y') && !/[aeiou]y$/i.test(type)) return `${type.slice(0, -1)}ies` return `${type}s` diff --git a/src/hooks/commands/viewCommands.ts b/src/hooks/commands/viewCommands.ts index 11e120a4..9c5c0d15 100644 --- a/src/hooks/commands/viewCommands.ts +++ b/src/hooks/commands/viewCommands.ts @@ -52,7 +52,7 @@ function buildSetNoteWidthCommand( ): CommandAction { return { id: `set-note-width-${mode}`, - label: NOTE_WIDTH_COMMAND_LABELS[mode], + label: Reflect.get(NOTE_WIDTH_COMMAND_LABELS, mode) as string, group: 'View', keywords: ['layout', 'note', 'column', 'width', mode, 'reading'], enabled: hasActiveNote && Boolean(onSetNoteWidth) && activeMode !== mode, @@ -67,7 +67,7 @@ function buildSetDefaultNoteWidthCommand( ): CommandAction { return { id: `set-default-note-width-${mode}`, - label: DEFAULT_NOTE_WIDTH_COMMAND_LABELS[mode], + label: Reflect.get(DEFAULT_NOTE_WIDTH_COMMAND_LABELS, mode) as string, group: 'View', keywords: ['layout', 'note', 'column', 'width', mode, 'default', 'reading'], enabled: Boolean(onSetDefaultNoteWidth) && defaultMode !== mode, diff --git a/src/hooks/frontmatterOps.ts b/src/hooks/frontmatterOps.ts index 057efc7a..585bfdd1 100644 --- a/src/hooks/frontmatterOps.ts +++ b/src/hooks/frontmatterOps.ts @@ -66,14 +66,18 @@ interface FrontmatterPatchInput { value?: FrontmatterValue } +function singleEntryRecord({ key, value }: { key: FrontmatterKey; value: T }): Record { + return Object.fromEntries([[key, value]]) +} + function applyRecordPatch( existing: Record, patch: Record, ): Record { const merged = { ...existing } for (const [key, value] of Object.entries(patch)) { - if (value === null) delete merged[key] - else merged[key] = value + if (value === null) Reflect.deleteProperty(merged, key) + else Reflect.set(merged, key, value) } return merged } @@ -122,9 +126,14 @@ function knownFrontmatterUpdates(value: FrontmatterValue | undefined): Record | undefined) ?? {}, + relationshipPatch, + propertiesPatch, + } } function relationshipUpdatePatch( @@ -133,7 +142,7 @@ function relationshipUpdatePatch( value: FrontmatterValue | undefined, ): RelationshipPatch | null { const wikilinks = value != null ? extractWikilinks(value) : [] - return !systemMetadataKey && wikilinks.length > 0 ? { [key]: wikilinks } : null + return !systemMetadataKey && wikilinks.length > 0 ? singleEntryRecord({ key, value: wikilinks }) : null } function propertiesUpdatePatch({ @@ -143,14 +152,14 @@ function propertiesUpdatePatch({ value, }: FrontmatterPatchInput): PropertiesPatch | null { const knownUpdates = knownFrontmatterUpdates(value) - if (systemMetadataKey || lookupKey in knownUpdates || value == null) return null - return { [key]: scalarPropertyValue(value) } + if (systemMetadataKey || Object.hasOwn(knownUpdates, lookupKey) || value == null) return null + return singleEntryRecord({ key, value: scalarPropertyValue(value) }) } function updateEntryPatch(input: FrontmatterPatchInput): EntryPatchResult { const updates = knownFrontmatterUpdates(input.value) return { - patch: updates[input.lookupKey] ?? {}, + patch: (Reflect.get(updates, input.lookupKey) as Partial | undefined) ?? {}, relationshipPatch: relationshipUpdatePatch(input.key, input.systemMetadataKey, input.value), propertiesPatch: propertiesUpdatePatch(input), } @@ -192,7 +201,9 @@ async function loadMockContent(path: VaultPath): Promise { try { return await mockInvoke('get_note_content', { path }) } catch { - return typeof window === 'undefined' ? '' : window.__mockContent?.[path] ?? '' + return typeof window === 'undefined' + ? '' + : (window.__mockContent ? Reflect.get(window.__mockContent, path) as string | undefined : undefined) ?? '' } } diff --git a/src/hooks/mockFrontmatterHelpers.ts b/src/hooks/mockFrontmatterHelpers.ts index 41d6349b..2f79808d 100644 --- a/src/hooks/mockFrontmatterHelpers.ts +++ b/src/hooks/mockFrontmatterHelpers.ts @@ -99,7 +99,7 @@ function isArrayItemLine(line: YamlLine): boolean { function skipArrayItemLines(lines: YamlLine[], start: number): number { let next = start - while (next < lines.length && isArrayItemLine(lines[next])) next++ + while (next < lines.length && isArrayItemLine(lines.at(next) ?? '')) next++ return next } @@ -123,19 +123,20 @@ function processKeyInLines(lines: YamlLine[], key: FrontmatterKey, replacement: const newLines: YamlLine[] = [] let i = 0 while (i < lines.length) { - if (lineMatchesKey(lines[i], key)) { + const line = lines.at(i) ?? '' + if (lineMatchesKey(line, key)) { i = skipArrayItemLines(lines, i + 1) appendReplacement(newLines, replacement) continue } - newLines.push(lines[i]) + newLines.push(line) i++ } return newLines } export function updateMockFrontmatter(path: VaultPath, key: FrontmatterKey, value: FrontmatterValue): MarkdownContent { - const content = window.__mockContent?.[path] || '' + const content = (window.__mockContent ? Reflect.get(window.__mockContent, path) as string | undefined : undefined) || '' const writeKey = canonicalWriteKey(key) const yamlKey = formatYamlKey(writeKey) const yamlValue = formatYamlValue(value) @@ -159,7 +160,7 @@ export function updateMockFrontmatter(path: VaultPath, key: FrontmatterKey, valu } export function deleteMockFrontmatterProperty(path: VaultPath, key: FrontmatterKey): MarkdownContent { - const content = window.__mockContent?.[path] || '' + const content = (window.__mockContent ? Reflect.get(window.__mockContent, path) as string | undefined : undefined) || '' const parsed = parseFrontmatter(content) if (!parsed) return content diff --git a/src/hooks/rawEditorEntryState.ts b/src/hooks/rawEditorEntryState.ts index d919a5a0..aa3690db 100644 --- a/src/hooks/rawEditorEntryState.ts +++ b/src/hooks/rawEditorEntryState.ts @@ -30,7 +30,7 @@ function createRawEditorEntryState(): Partial { function mergeRelationships(target: Record, source: Record | null): void { if (!source) return for (const [key, value] of Object.entries(source)) { - if (Array.isArray(value) && value.length > 0) target[key] = value + if (Array.isArray(value) && value.length > 0) Reflect.set(target, key, value) } } @@ -40,7 +40,7 @@ function mergeProperties( ): void { if (!source) return for (const [key, value] of Object.entries(source)) { - if (value !== null) target[key] = value + if (value !== null) Reflect.set(target, key, value) } } diff --git a/src/hooks/useAutoGit.ts b/src/hooks/useAutoGit.ts index a0589231..c71cefe1 100644 --- a/src/hooks/useAutoGit.ts +++ b/src/hooks/useAutoGit.ts @@ -59,7 +59,7 @@ function markTriggerAsHandled( trigger: AutoGitTrigger, activityAt: number, ): void { - target[trigger] = activityAt + Reflect.set(target, trigger, activityAt) } function shouldTriggerCheckpoint({ @@ -117,7 +117,7 @@ export function useAutoGit({ if (!shouldTriggerCheckpoint({ eligibility, trigger, - lastTriggeredAt: lastTriggeredRef.current[trigger], + lastTriggeredAt: Reflect.get(lastTriggeredRef.current, trigger) as number | null, lastActivityAt, idleThresholdSeconds, inactiveThresholdSeconds, diff --git a/src/hooks/useEntryActions.ts b/src/hooks/useEntryActions.ts index daacd577..e4fcd6d5 100644 --- a/src/hooks/useEntryActions.ts +++ b/src/hooks/useEntryActions.ts @@ -282,8 +282,10 @@ function useOrganizedAction({ function useReorderFavoritesAction({ updateEntry, handleUpdateFrontmatter, onFrontmatterPersisted }: ReorderFavoritesDeps) { return useCallback(async (orderedPaths: string[]) => { for (let i = 0; i < orderedPaths.length; i++) { - updateEntry(orderedPaths[i], { favoriteIndex: i }) - await handleUpdateFrontmatter(orderedPaths[i], '_favorite_index', i, { silent: true }) + const orderedPath = orderedPaths.at(i) + if (!orderedPath) continue + updateEntry(orderedPath, { favoriteIndex: i }) + await handleUpdateFrontmatter(orderedPath, '_favorite_index', i, { silent: true }) } onFrontmatterPersisted?.() }, [updateEntry, handleUpdateFrontmatter, onFrontmatterPersisted]) diff --git a/src/hooks/useImageDrop.ts b/src/hooks/useImageDrop.ts index ae8869e7..7b6ae887 100644 --- a/src/hooks/useImageDrop.ts +++ b/src/hooks/useImageDrop.ts @@ -24,7 +24,8 @@ type DroppedImagesRequest = { function hasImageFiles(dt: DataTransfer): boolean { for (let i = 0; i < dt.items.length; i++) { - if (dt.items[i].kind === 'file' && IMAGE_MIME_TYPES.includes(dt.items[i].type)) return true + const item = Reflect.get(dt.items, i) as DataTransferItem | undefined + if (item?.kind === 'file' && IMAGE_MIME_TYPES.includes(item.type)) return true } return false } @@ -40,7 +41,7 @@ export async function uploadImageFile(file: File, vaultPath?: string): Promise('save_image', { vaultPath, diff --git a/src/hooks/useKeyboardNavigation.ts b/src/hooks/useKeyboardNavigation.ts index 5fff2795..91bd8ba1 100644 --- a/src/hooks/useKeyboardNavigation.ts +++ b/src/hooks/useKeyboardNavigation.ts @@ -28,7 +28,8 @@ function navigateNote( // Clamp to list bounds — don't wrap around if (nextIndex < 0 || nextIndex >= notes.length) return - const nextNote = notes[nextIndex] + const nextNote = notes.at(nextIndex) + if (!nextNote) return if (currentPath) { onReplace.current!(nextNote) } else { @@ -36,6 +37,13 @@ function navigateNote( } } +function noteNavigationDirection(event: KeyboardEvent): 1 | -1 | null { + if (!event.altKey || event.shiftKey) return null + if (event.key === 'ArrowDown') return 1 + if (event.key === 'ArrowUp') return -1 + return null +} + function useLatestRef(value: T): React.RefObject { const ref = useRef(value) useEffect(() => { ref.current = value }) @@ -55,11 +63,10 @@ export function useKeyboardNavigation({ const mod = e.metaKey || e.ctrlKey if (!mod) return // Cmd+Alt+ArrowUp/Down: navigate notes in the current list - if (e.altKey && !e.shiftKey && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) { - e.preventDefault() - const direction: 1 | -1 = e.key === 'ArrowDown' ? 1 : -1 - navigateNote(visibleNotesRef, activeTabPathRef, onReplaceRef, onSelectNoteRef, direction) - } + const direction = noteNavigationDirection(e) + if (direction === null) return + e.preventDefault() + navigateNote(visibleNotesRef, activeTabPathRef, onReplaceRef, onSelectNoteRef, direction) } window.addEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown) diff --git a/src/hooks/useLayoutPanels.ts b/src/hooks/useLayoutPanels.ts index 5d040704..805aa520 100644 --- a/src/hooks/useLayoutPanels.ts +++ b/src/hooks/useLayoutPanels.ts @@ -28,7 +28,9 @@ function defaultPanelWidths(): PanelWidths { } function clampPanelWidth(key: PanelWidthKey, value: number): number { - return Math.max(COLUMN_MIN_WIDTHS[key], Math.min(COLUMN_MAX_WIDTHS[key], value)) + const minWidth = Reflect.get(COLUMN_MIN_WIDTHS, key) as number + const maxWidth = Reflect.get(COLUMN_MAX_WIDTHS, key) as number + return Math.max(minWidth, Math.min(maxWidth, value)) } function isPanelWidthRecord(value: unknown): value is Partial> { @@ -36,10 +38,10 @@ function isPanelWidthRecord(value: unknown): value is Partial>, key: PanelWidthKey): number { - const value = source[key] + const value = Reflect.get(source, key) return typeof value === 'number' && Number.isFinite(value) ? clampPanelWidth(key, value) - : DEFAULT_PANEL_WIDTHS[key] + : Reflect.get(DEFAULT_PANEL_WIDTHS, key) as number } function normalizePanelWidths(value: unknown): PanelWidths { @@ -80,10 +82,12 @@ export function useLayoutPanels(options?: { initialInspectorCollapsed?: boolean }, [panelWidths]) const resizePanel = useCallback((key: PanelWidthKey, delta: number) => { - setPanelWidths((widths) => ({ - ...widths, - [key]: clampPanelWidth(key, widths[key] + delta), - })) + setPanelWidths((widths) => { + const nextWidths = { ...widths } + const currentWidth = Reflect.get(widths, key) as number + Reflect.set(nextWidths, key, clampPanelWidth(key, currentWidth + delta)) + return nextWidths + }) }, []) const handleSidebarResize = useCallback((delta: number) => resizePanel('sidebar', delta), [resizePanel]) diff --git a/src/hooks/useMultiSelect.ts b/src/hooks/useMultiSelect.ts index 970e7ba2..97f269e9 100644 --- a/src/hooks/useMultiSelect.ts +++ b/src/hooks/useMultiSelect.ts @@ -42,7 +42,10 @@ export function useMultiSelect(visibleEntries: VaultEntry[], activePath: string const end = Math.max(fromIdx, toIdx) setSelectedPaths((prev) => { const next = new Set(prev) - for (let i = start; i <= end; i++) next.add(paths[i]) + for (let i = start; i <= end; i++) { + const path = paths.at(i) + if (path) next.add(path) + } return next }) lastClickedRef.current = toPath diff --git a/src/hooks/useNavigationHistory.ts b/src/hooks/useNavigationHistory.ts index 74cc0f31..c0ac6eb6 100644 --- a/src/hooks/useNavigationHistory.ts +++ b/src/hooks/useNavigationHistory.ts @@ -7,6 +7,46 @@ interface HistoryState { const EMPTY: HistoryState = { stack: [], cursor: -1 } const MAX_HISTORY = 200 +const BACKWARD = -1 +const FORWARD = 1 + +interface HistoryTarget { + cursor: number + path: string +} + +function currentPath({ stack, cursor }: HistoryState): string | undefined { + return stack.at(cursor) +} + +function appendHistoryPath(prev: HistoryState, path: string): HistoryState { + const truncated = prev.stack.slice(0, prev.cursor + 1) + const stack = truncated.length >= MAX_HISTORY + ? [...truncated.slice(truncated.length - MAX_HISTORY + 1), path] + : [...truncated, path] + return { stack, cursor: stack.length - 1 } +} + +function findHistoryTarget( + { stack, cursor }: HistoryState, + direction: typeof BACKWARD | typeof FORWARD, + isValid?: (path: string) => boolean, +): HistoryTarget | null { + const limit = direction === BACKWARD ? -1 : stack.length + for (let i = cursor + direction; i !== limit; i += direction) { + const path = stack.at(i) + if (path === undefined) continue + if (isValid && !isValid(path)) continue + return { cursor: i, path } + } + return null +} + +function cursorAfterRemoval(prev: HistoryState, removedIndex: number, nextStackLength: number): number { + if (prev.cursor > removedIndex) return prev.cursor - 1 + if (prev.cursor === removedIndex) return Math.min(prev.cursor, nextStackLength - 1) + return prev.cursor +} /** * Manages a browser-style back/forward navigation stack of note paths. @@ -22,12 +62,8 @@ export function useNavigationHistory() { const push = useCallback((path: string) => { setState((prev) => { - if (prev.cursor >= 0 && prev.stack[prev.cursor] === path) return prev - const truncated = prev.stack.slice(0, prev.cursor + 1) - const stack = truncated.length >= MAX_HISTORY - ? [...truncated.slice(truncated.length - MAX_HISTORY + 1), path] - : [...truncated, path] - return { stack, cursor: stack.length - 1 } + if (prev.cursor >= 0 && currentPath(prev) === path) return prev + return appendHistoryPath(prev, path) }) }, []) @@ -35,25 +71,17 @@ export function useNavigationHistory() { const canGoForward = state.cursor < state.stack.length - 1 const goBack = useCallback((isValid?: (path: string) => boolean): string | null => { - const { stack, cursor } = stateRef.current - for (let i = cursor - 1; i >= 0; i--) { - if (!isValid || isValid(stack[i])) { - setState({ stack, cursor: i }) - return stack[i] - } - } - return null + const target = findHistoryTarget(stateRef.current, BACKWARD, isValid) + if (!target) return null + setState({ stack: stateRef.current.stack, cursor: target.cursor }) + return target.path }, []) const goForward = useCallback((isValid?: (path: string) => boolean): string | null => { - const { stack, cursor } = stateRef.current - for (let i = cursor + 1; i < stack.length; i++) { - if (!isValid || isValid(stack[i])) { - setState({ stack, cursor: i }) - return stack[i] - } - } - return null + const target = findHistoryTarget(stateRef.current, FORWARD, isValid) + if (!target) return null + setState({ stack: stateRef.current.stack, cursor: target.cursor }) + return target.path }, []) /** Remove a path from history (e.g. when a tab is closed). */ @@ -62,9 +90,7 @@ export function useNavigationHistory() { const idx = prev.stack.indexOf(path) if (idx === -1) return prev const stack = prev.stack.filter((p) => p !== path) - const cursor = prev.cursor > idx ? prev.cursor - 1 - : prev.cursor === idx ? Math.min(prev.cursor, stack.length - 1) - : prev.cursor + const cursor = cursorAfterRemoval(prev, idx, stack.length) return { stack, cursor: Math.max(cursor, stack.length > 0 ? 0 : -1) } }) }, []) diff --git a/src/hooks/useNoteCreation.ts b/src/hooks/useNoteCreation.ts index 727cf648..10196daa 100644 --- a/src/hooks/useNoteCreation.ts +++ b/src/hooks/useNoteCreation.ts @@ -81,7 +81,7 @@ export interface TemplateLookupParams { export function resolveTemplate({ entries, typeName }: TemplateLookupParams): string | null { const typeEntry = entries.find((entry) => entry.isA === 'Type' && entry.title === typeName) - return typeEntry?.template ?? DEFAULT_TEMPLATES[typeName] ?? null + return typeEntry?.template ?? (Reflect.get(DEFAULT_TEMPLATES, typeName) as string | undefined) ?? null } export interface NoteContentParams { diff --git a/src/hooks/useNoteListKeyboard.ts b/src/hooks/useNoteListKeyboard.ts index c8c090c2..80f0bcf0 100644 --- a/src/hooks/useNoteListKeyboard.ts +++ b/src/hooks/useNoteListKeyboard.ts @@ -259,7 +259,7 @@ function useMoveHighlight({ const currentIndex = resolveCurrentIndex(items, highlightedPathRef.current, selectedNotePath) const nextIndex = moveHighlightIndex(currentIndex, direction, items.length) const currentPath = highlightedPathRef.current ?? selectedNotePath - const nextItem = items[nextIndex] + const nextItem = items.at(nextIndex) if (!nextItem || nextItem.path === currentPath) return syncHighlightedPath(nextItem.path) diff --git a/src/hooks/useNoteSearch.ts b/src/hooks/useNoteSearch.ts index 824bc754..5e1397a1 100644 --- a/src/hooks/useNoteSearch.ts +++ b/src/hooks/useNoteSearch.ts @@ -61,7 +61,7 @@ export function useNoteSearch(entries: VaultEntry[], query: string, maxResults = setSelectedIndex(0) // eslint-disable-line react-hooks/set-state-in-effect -- reset on query change }, [query]) - const selectedEntry = results[selectedIndex]?.entry ?? null + const selectedEntry = results.at(selectedIndex)?.entry ?? null const handleKeyDown = useCallback( (e: React.KeyboardEvent | KeyboardEvent) => { diff --git a/src/hooks/useNoteWidthMode.ts b/src/hooks/useNoteWidthMode.ts index fd561798..ee40ba37 100644 --- a/src/hooks/useNoteWidthMode.ts +++ b/src/hooks/useNoteWidthMode.ts @@ -65,7 +65,7 @@ function resolveCurrentWidth({ }: CurrentWidthRequest): NoteWidthMode { const path = activeTab?.entry.path if (!path) return defaultNoteWidth - return resolveNoteWidthMode(transientNoteWidths[path] ?? activeTab.entry.noteWidth, defaultNoteWidth) + return resolveNoteWidthMode((Reflect.get(transientNoteWidths, path) as NoteWidthMode | undefined) ?? activeTab.entry.noteWidth, defaultNoteWidth) } async function readNoteContentForWidthPersistence({ @@ -133,9 +133,12 @@ export function useNoteWidthMode({ }, [activeTab, defaultNoteWidth, transientNoteWidths]) const rememberTransientWidth = useCallback((path: VaultPath, mode: NoteWidthMode) => { - setTransientNoteWidths((previous) => ( - previous[path] === mode ? previous : { ...previous, [path]: mode } - )) + setTransientNoteWidths((previous) => { + if (Reflect.get(previous, path) === mode) return previous + const next = { ...previous } + Reflect.set(next, path, mode) + return next + }) }, []) const setNoteWidth = useCallback((mode: NoteWidthMode) => persistOrRememberNoteWidth({ diff --git a/src/hooks/usePropertyPanelState.ts b/src/hooks/usePropertyPanelState.ts index 16725318..c321572f 100644 --- a/src/hooks/usePropertyPanelState.ts +++ b/src/hooks/usePropertyPanelState.ts @@ -56,12 +56,12 @@ function deriveTypeInfo(entries: VaultEntry[] | undefined, entryIsA: string | nu const typeColorKeys: Record = {} const typeIconKeys: Record = {} for (const e of typeEntries) { - typeColorKeys[e.title] = e.color ?? null - typeIconKeys[e.title] = e.icon ?? null + Reflect.set(typeColorKeys, e.title, e.color ?? null) + Reflect.set(typeIconKeys, e.title, e.icon ?? null) } return { availableTypes: typeEntries.map(e => e.title).sort((a, b) => a.localeCompare(b)), - customColorKey: entryIsA ? (typeColorKeys[entryIsA] ?? null) : null, + customColorKey: entryIsA ? ((Reflect.get(typeColorKeys, entryIsA) as string | null | undefined) ?? null) : null, typeColorKeys, typeIconKeys, } @@ -164,7 +164,7 @@ function addTagValues(tagsByKey: Map>, key: string, value: u function toSortedTagRecord(tagsByKey: Map>): Record { const result: Record = {} for (const [key, set] of tagsByKey) { - result[key] = Array.from(set).sort((a, b) => a.localeCompare(b)) + Reflect.set(result, key, Array.from(set).sort((a, b) => a.localeCompare(b))) } return result } @@ -205,7 +205,7 @@ function saveScalarProperty({ onUpdateProperty: (key: string, value: FrontmatterValue) => void onDeleteProperty?: (key: string) => void }) { - const currentValue = frontmatter[key] ?? null + const currentValue = (Reflect.get(frontmatter, key) as FrontmatterValue | undefined) ?? null const displayMode = getEffectiveDisplayMode(key, currentValue, displayOverrides) if (displayMode !== 'number') { onUpdateProperty(key, coerceValue(newValue)) diff --git a/src/hooks/useTheme.ts b/src/hooks/useTheme.ts index c2877279..8cf91d2c 100644 --- a/src/hooks/useTheme.ts +++ b/src/hooks/useTheme.ts @@ -3,6 +3,21 @@ import themeConfig from '../theme.json' type ThemeValue = string | number | Record | unknown[] +function isThemeBranch(value: ThemeValue): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function isUnitlessThemeNumber(key: string, cssKey: string): boolean { + return /weight|lineHeight|opacity/i.test(key) + || cssKey.includes('line-height') + || cssKey.includes('font-weight') +} + +function themeCssValue(key: string, cssKey: string, value: string | number): string { + if (typeof value !== 'number') return String(value) + return isUnitlessThemeNumber(key, cssKey) ? String(value) : `${value}px` +} + /** Convert a nested theme config object into a flat map of CSS custom properties */ function flattenTheme( obj: Record, @@ -16,19 +31,13 @@ function flattenTheme( if (value === null || value === undefined) continue if (Array.isArray(value)) continue // skip arrays (e.g. nestedBulletSymbols) - if (typeof value === 'object') { - Object.assign(result, flattenTheme(value as Record, `${cssKey}-`)) - } else if (typeof value === 'number') { - // Numbers that look like px values get 'px' suffix; ratios/weights don't - // These are unitless values; everything else gets 'px' - const isUnitless = /weight|lineHeight|opacity/i.test(key) || - cssKey.includes('line-height') || - cssKey.includes('font-weight') - const needsPx = !isUnitless - result[cssKey] = needsPx ? `${value}px` : String(value) - } else { - result[cssKey] = String(value) + if (isThemeBranch(value)) { + Object.assign(result, flattenTheme(value, `${cssKey}-`)) + continue } + + if (typeof value !== 'string' && typeof value !== 'number') continue + Reflect.set(result, cssKey, themeCssValue(key, cssKey, value)) } return result diff --git a/src/lib/aiAgentFileOperations.ts b/src/lib/aiAgentFileOperations.ts index a25fb76c..6dc566f8 100644 --- a/src/lib/aiAgentFileOperations.ts +++ b/src/lib/aiAgentFileOperations.ts @@ -124,7 +124,7 @@ function isRecord(value: unknown): value is Record { function stringField(record: Record, keys: readonly string[]): string | null { for (const key of keys) { - const value = record[key] + const value = Reflect.get(record, key) if (typeof value === 'string') return value } return null diff --git a/src/lib/aiAgents.ts b/src/lib/aiAgents.ts index 1ffd11f7..df304bff 100644 --- a/src/lib/aiAgents.ts +++ b/src/lib/aiAgents.ts @@ -112,7 +112,7 @@ export function isAiAgentsStatusChecking(statuses: AiAgentsStatus): boolean { } export function isAiAgentInstalled(statuses: AiAgentsStatus, agent: AiAgentId): boolean { - return statuses[agent].status === 'installed' + return (Reflect.get(statuses, agent) as AiAgentAvailability).status === 'installed' } export function hasAnyInstalledAiAgent(statuses: AiAgentsStatus): boolean { diff --git a/src/lib/i18n.ts b/src/lib/i18n.ts index c2fa811c..f1e2a732 100644 --- a/src/lib/i18n.ts +++ b/src/lib/i18n.ts @@ -166,9 +166,12 @@ const LOCALE_DEFINITIONS: Record = { } const APP_LOCALE_SET = new Set(APP_LOCALES) +const LOCALE_DEFINITION_LOOKUP = new Map( + Object.values(LOCALE_DEFINITIONS).map((definition) => [definition.code, definition]), +) const NORMALIZED_LOCALE_LOOKUP = new Map() for (const locale of APP_LOCALES) { - const definition = LOCALE_DEFINITIONS[locale] + const definition = getLocaleDefinition(locale) NORMALIZED_LOCALE_LOOKUP.set(locale.toLowerCase(), locale) for (const alias of definition.aliases) { NORMALIZED_LOCALE_LOOKUP.set(alias, locale) @@ -178,7 +181,7 @@ for (const locale of APP_LOCALES) { const LOCALE_MODULES = import.meta.glob('./locales/*.json', { eager: true, import: 'default' }) as Record const TRANSLATIONS: Partial>>> = buildTranslations() -export const APP_LOCALE_DEFINITIONS = APP_LOCALES.map((locale) => LOCALE_DEFINITIONS[locale]) +export const APP_LOCALE_DEFINITIONS = APP_LOCALES.map((locale) => getLocaleDefinition(locale)) export { EN_TRANSLATIONS } function buildTranslations() { @@ -193,7 +196,7 @@ function buildTranslations() { const locale = normalizeLocaleCode(match[1]) if (!locale || locale === 'en') continue - translations[locale] = catalog + Reflect.set(translations, locale, catalog) } return translations @@ -204,16 +207,19 @@ function isAppLocale(value: string): value is AppLocale { } export function getLocaleDefinition(locale: AppLocale): LocaleDefinition { - return LOCALE_DEFINITIONS[locale] + const definition = LOCALE_DEFINITION_LOOKUP.get(locale) + if (definition) return definition + throw new Error(`Unknown locale: ${locale}`) } export function getLocaleDateLocale(locale: AppLocale): string { - return LOCALE_DEFINITIONS[locale].dateLocale + return getLocaleDefinition(locale).dateLocale } export function interpolate(template: string, values: TranslationValues = {}): string { + const interpolationValues = new Map(Object.entries(values)) return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = values[key] + const value = interpolationValues.get(key) return value === undefined ? match : String(value) }) } @@ -224,8 +230,10 @@ function localizedInterpolationValues(locale: AppLocale, values?: TranslationVal } export function translate(locale: AppLocale, key: TranslationKey, values?: TranslationValues): string { - const template = TRANSLATIONS[locale]?.[key] ?? EN_TRANSLATIONS[key] - return interpolate(template, localizedInterpolationValues(locale, values)) + const catalog = Reflect.get(TRANSLATIONS, locale) as Partial> | undefined + const template = Reflect.get(catalog ?? {}, key) as string | undefined + const fallbackTemplate = Reflect.get(EN_TRANSLATIONS, key) as string + return interpolate(template ?? fallbackTemplate, localizedInterpolationValues(locale, values)) } export function createTranslator(locale: AppLocale = DEFAULT_APP_LOCALE) { @@ -283,15 +291,15 @@ export function resolveEffectiveLocale( } export function localeDisplayName(locale: AppLocale, displayLocale: AppLocale = locale): string { - return translate(displayLocale, LOCALE_DEFINITIONS[locale].labelKey) + return translate(displayLocale, getLocaleDefinition(locale).labelKey) } export function localeSearchKeywords(locale: AppLocale): readonly string[] { - return LOCALE_DEFINITIONS[locale].searchKeywords + return getLocaleDefinition(locale).searchKeywords } export function hasLocaleCatalog(locale: AppLocale): boolean { - return locale === 'en' || !!TRANSLATIONS[locale] + return locale === 'en' || Boolean(Reflect.get(TRANSLATIONS, locale)) } export function localeCatalogLocales(): AppLocale[] { diff --git a/src/lib/productAnalytics.ts b/src/lib/productAnalytics.ts index 51cd2251..6b83fc91 100644 --- a/src/lib/productAnalytics.ts +++ b/src/lib/productAnalytics.ts @@ -46,10 +46,12 @@ export function trackAllNotesVisibilityChanged( next: AllNotesFileVisibility, ): void { for (const category of ALL_NOTES_VISIBILITY_CATEGORIES) { - if (previous[category] === next[category]) continue + const previousValue = Reflect.get(previous, category) as boolean + const nextValue = Reflect.get(next, category) as boolean + if (previousValue === nextValue) continue trackEvent('all_notes_visibility_changed', { category, - enabled: numericFlag(next[category]), + enabled: numericFlag(nextValue), }) } } diff --git a/src/lib/telemetry.ts b/src/lib/telemetry.ts index 7f6e462c..90995bff 100644 --- a/src/lib/telemetry.ts +++ b/src/lib/telemetry.ts @@ -92,7 +92,7 @@ export function setReleaseChannel(channel: string): void { export function isFeatureEnabled(flagKey: string): boolean { if (currentReleaseChannel === 'alpha') return true - return posthogInstance?.isFeatureEnabled(flagKey) ?? FEATURE_DEFAULTS[flagKey] ?? false + return posthogInstance?.isFeatureEnabled(flagKey) ?? (Reflect.get(FEATURE_DEFAULTS, flagKey) as boolean | undefined) ?? false } export function trackEvent(name: string, properties?: Record): void { diff --git a/src/mock-tauri/index.ts b/src/mock-tauri/index.ts index e966f7b3..36ab8dee 100644 --- a/src/mock-tauri/index.ts +++ b/src/mock-tauri/index.ts @@ -10,6 +10,8 @@ import { tryVaultApi } from './vault-api' export { addMockEntry, updateMockContent, trackMockChange } +type MockHandler = (args: Record | undefined) => unknown + export function isTauri(): boolean { if (typeof globalThis !== 'undefined' && typeof (globalThis as { isTauri?: unknown }).isTauri === 'boolean') { return Boolean((globalThis as { isTauri?: unknown }).isTauri) @@ -25,10 +27,10 @@ if (typeof window !== 'undefined') { } function resolveMockHandler(command: string) { - if (typeof window !== 'undefined' && window.__mockHandlers?.[command]) { - return window.__mockHandlers[command] - } - return mockHandlers[command] + const windowHandler = typeof window === 'undefined' || !window.__mockHandlers + ? undefined + : Reflect.get(window.__mockHandlers, command) as MockHandler | undefined + return windowHandler ?? Reflect.get(mockHandlers, command) as MockHandler | undefined } export async function mockInvoke(cmd: string, args?: Record): Promise { diff --git a/src/mock-tauri/mock-handlers.ts b/src/mock-tauri/mock-handlers.ts index 376607e8..884df9e7 100644 --- a/src/mock-tauri/mock-handlers.ts +++ b/src/mock-tauri/mock-handlers.ts @@ -125,9 +125,7 @@ const DEFAULT_MOCK_VAULT = { } let mockLastVaultPath: string | null = DEFAULT_MOCK_VAULT_PATH -const mockRemoteStateByVault: Record = { - [DEFAULT_MOCK_VAULT_PATH]: true, -} +const mockRemoteStateByVault = new Map([[DEFAULT_MOCK_VAULT_PATH, true]]) let mockVaultList: { vaults: Array<{ label: string; path: string }>; active_vault: string | null } = { vaults: [DEFAULT_MOCK_VAULT], @@ -149,13 +147,29 @@ function normalizeMockVaultPath(path: string | null | undefined): string | null function setMockRemoteState(path: string | null | undefined, hasRemote: boolean): void { const normalizedPath = normalizeMockVaultPath(path) if (!normalizedPath) return - mockRemoteStateByVault[normalizedPath] = hasRemote + mockRemoteStateByVault.set(normalizedPath, hasRemote) } function getMockRemoteState(path: string | null | undefined): boolean { const normalizedPath = normalizeMockVaultPath(path) if (!normalizedPath) return true - return mockRemoteStateByVault[normalizedPath] ?? true + return mockRemoteStateByVault.get(normalizedPath) ?? true +} + +type MockContentPath = { path: string } +type MockContentWrite = MockContentPath & { content: string } + +function readMockContent({ path }: MockContentPath): string { + const content = Reflect.get(MOCK_CONTENT, path) + return typeof content === 'string' ? content : '' +} + +function writeMockContent({ path, content }: MockContentWrite): void { + Reflect.set(MOCK_CONTENT, path, content) +} + +function deleteMockContent({ path }: MockContentPath): void { + Reflect.deleteProperty(MOCK_CONTENT, path) } function relativePathStem({ path, vaultPath }: { path: string; vaultPath: string }) { @@ -207,7 +221,7 @@ function updateMockRenameReferences({ newPath, newPathStem, oldTargets }: { if (path === newPath) continue const replaced = replaceRenamedWikilinks({ content, oldTargets, newPathStem }) if (replaced === content) continue - MOCK_CONTENT[path] = replaced + writeMockContent({ path, content: replaced }) updatedFiles += 1 } return updatedFiles @@ -216,7 +230,7 @@ function updateMockRenameReferences({ newPath, newPathStem, oldTargets }: { function handleRenameNote(args: { vault_path: string; old_path: string; new_title: string; old_title?: string | null }) { const oldEntry = MOCK_ENTRIES.find(e => e.path === args.old_path) const oldTitle = args.old_title ?? oldEntry?.title ?? '' - const oldContent = MOCK_CONTENT[args.old_path] ?? '' + const oldContent = readMockContent({ path: args.old_path }) const newPath = buildRenamedMockPath({ oldPath: args.old_path, newTitle: args.new_title }) const oldPathStem = relativePathStem({ path: args.old_path, vaultPath: args.vault_path }) const newPathStem = relativePathStem({ path: newPath, vaultPath: args.vault_path }) @@ -226,8 +240,8 @@ function handleRenameNote(args: { vault_path: string; old_path: string; new_titl } const newContent = replaceMockTitleFrontmatter({ content: oldContent, newTitle: args.new_title }) - delete MOCK_CONTENT[args.old_path] - MOCK_CONTENT[newPath] = newContent + deleteMockContent({ path: args.old_path }) + writeMockContent({ path: newPath, content: newContent }) const oldTargets = canonicalRenameTargets({ oldTitle, oldPathStem }) const updatedFiles = updateMockRenameReferences({ newPath, newPathStem, oldTargets }) @@ -241,7 +255,7 @@ function handleRenameNoteFilename(args: { new_filename_stem: string }) { const oldEntry = MOCK_ENTRIES.find(e => e.path === args.old_path) - const oldContent = MOCK_CONTENT[args.old_path] ?? '' + const oldContent = readMockContent({ path: args.old_path }) const oldTitle = oldEntry?.title ?? '' const normalizedStem = args.new_filename_stem.trim().replace(/\.md$/, '') const oldFilename = args.old_path.split('/').pop() ?? '' @@ -260,8 +274,8 @@ function handleRenameNoteFilename(args: { throw new Error('A note with that name already exists') } - delete MOCK_CONTENT[args.old_path] - MOCK_CONTENT[newPath] = oldContent + deleteMockContent({ path: args.old_path }) + writeMockContent({ path: newPath, content: oldContent }) const oldPathStem = relativePathStem({ path: args.old_path, vaultPath: args.vault_path }) const newPathStem = relativePathStem({ path: newPath, vaultPath: args.vault_path }) @@ -278,7 +292,7 @@ function handleMoveNoteToFolder(args: { folder_path: string }) { const oldEntry = MOCK_ENTRIES.find(e => e.path === args.old_path) - const oldContent = MOCK_CONTENT[args.old_path] ?? '' + const oldContent = readMockContent({ path: args.old_path }) const oldTitle = oldEntry?.title ?? '' const oldFilename = args.old_path.split('/').pop() ?? '' const normalizedFolderPath = args.folder_path.trim().replace(/^\/+|\/+$/g, '') @@ -296,8 +310,8 @@ function handleMoveNoteToFolder(args: { throw new Error('A note with that name already exists') } - delete MOCK_CONTENT[args.old_path] - MOCK_CONTENT[newPath] = oldContent + deleteMockContent({ path: args.old_path }) + writeMockContent({ path: newPath, content: oldContent }) const oldPathStem = relativePathStem({ path: args.old_path, vaultPath: args.vault_path }) const newPathStem = relativePathStem({ path: newPath, vaultPath: args.vault_path }) @@ -521,12 +535,12 @@ export const mockHandlers: Record any> = { } export function addMockEntry(_entry: VaultEntry, content: string): void { - MOCK_CONTENT[_entry.path] = content + writeMockContent({ path: _entry.path, content }) syncWindowContent() } export function updateMockContent(path: string, content: string): void { - MOCK_CONTENT[path] = content + writeMockContent({ path, content }) syncWindowContent() } diff --git a/src/mock-tauri/vault-api.ts b/src/mock-tauri/vault-api.ts index 54deafde..183a74fd 100644 --- a/src/mock-tauri/vault-api.ts +++ b/src/mock-tauri/vault-api.ts @@ -73,7 +73,7 @@ const POST_REQUEST_BUILDERS: Record = { } function argText(args: Record, key: string): string | null { - const value = args[key] + const value = Reflect.get(args, key) return value ? String(value) : null } @@ -112,8 +112,8 @@ function buildVaultApiRequest(cmd: string, args?: Record): Vaul if (cmd === 'list_vault') return buildListRequest(args, false) if (cmd === 'reload_vault') return buildListRequest(args, true) if (cmd === 'search_vault') return buildSearchRequest(args) - if (cmd in PATH_QUERY_ENDPOINTS) return buildPathQueryRequest(args, PATH_QUERY_ENDPOINTS[cmd as PathQueryCommand]) - return POST_REQUEST_BUILDERS[cmd]?.(args) ?? null + if (cmd in PATH_QUERY_ENDPOINTS) return buildPathQueryRequest(args, Reflect.get(PATH_QUERY_ENDPOINTS, cmd) as string) + return (Reflect.get(POST_REQUEST_BUILDERS, cmd) as ((args: Record) => VaultApiRequest | null) | undefined)?.(args) ?? null } function buildFetchOptions(request: VaultApiRequest): RequestInit { diff --git a/src/utils/ai-chat.ts b/src/utils/ai-chat.ts index 4a4cd81f..958f8f85 100644 --- a/src/utils/ai-chat.ts +++ b/src/utils/ai-chat.ts @@ -73,9 +73,11 @@ export function trimHistory(history: ChatMessage[], maxTokens: number): ChatMess let tokenCount = 0 const result: ChatMessage[] = [] for (let i = history.length - 1; i >= 0; i--) { - const tokens = estimateTokens(history[i].content) + const message = history.at(i) + if (!message) continue + const tokens = estimateTokens(message.content) if (tokenCount + tokens > maxTokens) break - result.unshift(history[i]) + result.unshift(message) tokenCount += tokens } return result diff --git a/src/utils/ai-context.ts b/src/utils/ai-context.ts index f21785b5..0bd9021e 100644 --- a/src/utils/ai-context.ts +++ b/src/utils/ai-context.ts @@ -103,12 +103,12 @@ function isPresentValue(value: unknown): boolean { } function assignIfPresent(target: Record, key: string, value: unknown): void { - if (isPresentValue(value)) target[key] = value + if (isPresentValue(value)) Reflect.set(target, key, value) } function assignIfNonEmpty(target: Record, key: string, values: unknown[]): void { if (values.length > 0) { - target[key] = values + Reflect.set(target, key, values) } } diff --git a/src/utils/arrowLigatures.ts b/src/utils/arrowLigatures.ts index 45d90279..c8b29456 100644 --- a/src/utils/arrowLigatures.ts +++ b/src/utils/arrowLigatures.ts @@ -84,7 +84,7 @@ function resolveMatchingRule( beforeText: string, inputText: string, ) { - const rules = INPUT_RULES[inputText] + const rules = Reflect.get(INPUT_RULES, inputText) as ArrowLigatureRule[] | undefined return rules?.find((rule) => beforeText.endsWith(rule.prefix)) ?? null } diff --git a/src/utils/compact-markdown.ts b/src/utils/compact-markdown.ts index dd3f2569..42b8ec2f 100644 --- a/src/utils/compact-markdown.ts +++ b/src/utils/compact-markdown.ts @@ -57,7 +57,7 @@ interface ProcessMarkdownLineArgs extends LinePosition { function processMarkdownLine( { doc, idx, inCodeBlock }: ProcessMarkdownLineArgs, ): { inCodeBlock: boolean; line: string | null } { - const rawLine = doc.lines[idx] + const rawLine = doc.lines.at(idx) ?? '' if (isFenceDelimiter({ line: rawLine })) { return { inCodeBlock: !inCodeBlock, line: rawLine } @@ -99,27 +99,27 @@ function isBlankBetweenListItems({ doc, idx }: LinePosition): boolean { const prev = findPrevNonBlank({ doc, idx }) const next = findNextNonBlank({ doc, idx }) if (prev === null || next === null) return false - return LIST_RE.test(doc.lines[prev]) && LIST_RE.test(doc.lines[next]) + return LIST_RE.test(doc.lines.at(prev) ?? '') && LIST_RE.test(doc.lines.at(next) ?? '') } /** True if this blank line is part of a run of 2+ consecutive blank lines * (i.e. would create 3+ newlines in a row — collapse to just one blank line) */ function isExcessiveBlankLine({ doc, idx }: LinePosition): boolean { // Keep the first blank line in a run, skip subsequent ones - if (idx > 0 && doc.lines[idx - 1].trim() === '') return true + if (idx > 0 && (doc.lines.at(idx - 1) ?? '').trim() === '') return true return false } function findPrevNonBlank({ doc, idx }: LinePosition): number | null { for (let i = idx - 1; i >= 0; i--) { - if (doc.lines[i].trim() !== '') return i + if ((doc.lines.at(i) ?? '').trim() !== '') return i } return null } function findNextNonBlank({ doc, idx }: LinePosition): number | null { for (let i = idx + 1; i < doc.lines.length; i++) { - if (doc.lines[i].trim() !== '') return i + if ((doc.lines.at(i) ?? '').trim() !== '') return i } return null } @@ -130,7 +130,7 @@ function isRedundantHardBreakLine({ doc, idx, line }: NormalizedLinePosition): b const prev = findPrevNonBlank({ doc, idx }) if (prev === null) return false - const prevLine = normalizeMarkdownLine({ line: doc.lines[prev] }) + const prevLine = normalizeMarkdownLine({ line: doc.lines.at(prev) ?? '' }) return isHardBreakOnlyLine({ line: prevLine }) || endsWithHardBreakMarker({ line: prevLine }) } diff --git a/src/utils/durableMarkdownBlocks.ts b/src/utils/durableMarkdownBlocks.ts index 3d374370..1daed1d7 100644 --- a/src/utils/durableMarkdownBlocks.ts +++ b/src/utils/durableMarkdownBlocks.ts @@ -107,12 +107,13 @@ function readFenceOpening(line: string, codec: DurableBlockCodec): FenceOpening const match = /^( {0,3})(`{3,}|~{3,})[ \t]*(.*)$/.exec(line) if (!match) return null - const metadata = codec.readFenceMetadata(match[3]) + const metadata = codec.readFenceMetadata(match.at(3) ?? '') if (metadata === null) return null - const fence = match[2] + const fence = match.at(2) + if (!fence) return null return { - character: fence[0] as FenceCharacter, + character: fence.charAt(0) as FenceCharacter, length: fence.length, metadata, } @@ -130,13 +131,15 @@ function isClosingFence({ line, opening }: MarkdownLine & { opening: FenceOpenin const match = /^( {0,3})(`{3,}|~{3,})[ \t]*$/.exec(line) if (!match) return false - const fence = match[2] - return fence[0] === opening.character && fence.length >= opening.length + const fence = match.at(2) + if (!fence) return false + return fence.charAt(0) === opening.character && fence.length >= opening.length } function findClosingFence({ lines, start, opening }: FenceSearch): number { for (let index = start + 1; index < lines.length; index++) { - if (isClosingFence({ line: lineText({ line: lines[index] }), opening })) return index + const line = lines.at(index) + if (line !== undefined && isClosingFence({ line: lineText({ line }), opening })) return index } return -1 @@ -153,15 +156,17 @@ export function preProcessDurableMarkdownBlocks({ const result: string[] = [] for (let index = 0; index < lines.length; index++) { - const matched = readMatchedFenceOpening(lineText({ line: lines[index] }), codecs) + const line = lines.at(index) + if (line === undefined) continue + const matched = readMatchedFenceOpening(lineText({ line }), codecs) if (!matched) { - result.push(lines[index]) + result.push(line) continue } const closingIndex = findClosingFence({ lines, start: index, opening: matched.opening }) if (closingIndex === -1) { - result.push(lines[index]) + result.push(line) continue } @@ -171,7 +176,7 @@ export function preProcessDurableMarkdownBlocks({ end: closingIndex, metadata: matched.opening.metadata, }) - result.push(`${durableToken(matched.codec, payload)}${lineEnding({ line: lines[closingIndex] })}`) + result.push(`${durableToken(matched.codec, payload)}${lineEnding({ line: lines.at(closingIndex) ?? '' })}`) index = closingIndex } diff --git a/src/utils/filterDates.ts b/src/utils/filterDates.ts index 31dd3fca..10465829 100644 --- a/src/utils/filterDates.ts +++ b/src/utils/filterDates.ts @@ -13,75 +13,94 @@ import { } from 'date-fns' type RelativeUnit = 'day' | 'week' | 'month' | 'year' +type DateFilterInput = string +type RelativeToken = string +type RelativePattern = { amountToken: RelativeToken; future: boolean; unitToken: RelativeToken } -const NUMBER_WORDS: Record = { - a: 1, - an: 1, - one: 1, - two: 2, - three: 3, - four: 4, - five: 5, - six: 6, - seven: 7, - eight: 8, - nine: 9, - ten: 10, - eleven: 11, - twelve: 12, -} +const NUMBER_WORDS = new Map([ + ['a', 1], + ['an', 1], + ['one', 1], + ['two', 2], + ['three', 3], + ['four', 4], + ['five', 5], + ['six', 6], + ['seven', 7], + ['eight', 8], + ['nine', 9], + ['ten', 10], + ['eleven', 11], + ['twelve', 12], +]) -function parseRelativeAmount(token: string): number | null { +const RELATIVE_UNITS = new Set(['day', 'week', 'month', 'year']) +const NAMED_RELATIVE_DAY_OFFSETS = new Map([ + ['today', 0], + ['yesterday', -1], + ['tomorrow', 1], +]) +const RELATIVE_SHIFT = new Map([ + ['day', { future: addDays, past: subDays }], + ['week', { future: addWeeks, past: subWeeks }], + ['month', { future: addMonths, past: subMonths }], + ['year', { future: addYears, past: subYears }], +]) + +function parseRelativeAmount(token: RelativeToken): number | null { if (/^\d+$/.test(token)) return Number(token) - return NUMBER_WORDS[token] ?? null + return NUMBER_WORDS.get(token) ?? null } -function normalizeRelativeUnit(token: string): RelativeUnit | null { +function normalizeRelativeUnit(token: RelativeToken): RelativeUnit | null { const unit = token.toLowerCase().replace(/s$/, '') - if (unit === 'day' || unit === 'week' || unit === 'month' || unit === 'year') return unit - return null + return RELATIVE_UNITS.has(unit as RelativeUnit) ? unit as RelativeUnit : null } function shiftRelativeDate(reference: Date, unit: RelativeUnit, amount: number, future: boolean): Date { - if (unit === 'day') return future ? addDays(reference, amount) : subDays(reference, amount) - if (unit === 'week') return future ? addWeeks(reference, amount) : subWeeks(reference, amount) - if (unit === 'month') return future ? addMonths(reference, amount) : subMonths(reference, amount) - return future ? addYears(reference, amount) : subYears(reference, amount) + const shift = RELATIVE_SHIFT.get(unit)! + return (future ? shift.future : shift.past)(reference, amount) } -function parseRelativeDateInput(value: string, reference: Date): Date | null { +function parseNamedRelativeDate(normalized: DateFilterInput, base: Date): Date | null { + const offsetDays = NAMED_RELATIVE_DAY_OFFSETS.get(normalized) + return offsetDays === undefined ? null : addDays(base, offsetDays) +} + +function futureRelativePattern(tokens: RelativeToken[]): RelativePattern | null { + if (tokens.length !== 3 || tokens.at(0) !== 'in') return null + return { amountToken: tokens.at(1) ?? '', future: true, unitToken: tokens.at(2) ?? '' } +} + +function pastRelativePattern(tokens: RelativeToken[]): RelativePattern | null { + if (tokens.length !== 3 || tokens.at(2) !== 'ago') return null + return { amountToken: tokens.at(0) ?? '', future: false, unitToken: tokens.at(1) ?? '' } +} + +function parseRelativeTokenPattern(tokens: RelativeToken[]): RelativePattern | null { + if (tokens.length !== 3) return null + return futureRelativePattern(tokens) ?? pastRelativePattern(tokens) +} + +function parseRelativeDateInput(value: DateFilterInput, reference: Date): Date | null { const normalized = value.trim().toLowerCase() if (!normalized) return null const base = startOfDay(reference) - if (normalized === 'today') return base - if (normalized === 'yesterday') return subDays(base, 1) - if (normalized === 'tomorrow') return addDays(base, 1) + const namedDate = parseNamedRelativeDate(normalized, base) + if (namedDate) return namedDate - const tokens = normalized.split(/\s+/) - let future = false - let amountToken = '' - let unitToken = '' + const tokenPattern = parseRelativeTokenPattern(normalized.split(/\s+/)) + if (!tokenPattern) return null - if (tokens.length === 3 && tokens[0] === 'in') { - future = true - amountToken = tokens[1] - unitToken = tokens[2] - } else if (tokens.length === 3 && tokens[2] === 'ago') { - amountToken = tokens[0] - unitToken = tokens[1] - } else { - return null - } - - const amount = parseRelativeAmount(amountToken) - const unit = normalizeRelativeUnit(unitToken) + const amount = parseRelativeAmount(tokenPattern.amountToken) + const unit = normalizeRelativeUnit(tokenPattern.unitToken) if (amount == null || unit == null) return null - return shiftRelativeDate(base, unit, amount, future) + return shiftRelativeDate(base, unit, amount, tokenPattern.future) } -export function parseDateFilterInput(value: string, reference = new Date()): Date | null { +export function parseDateFilterInput(value: DateFilterInput, reference = new Date()): Date | null { const trimmed = value.trim() if (!trimmed) return null @@ -98,7 +117,7 @@ export function parseDateFilterInput(value: string, reference = new Date()): Dat return new Date(timestamp) } -export function toDateFilterTimestamp(value: string, reference = new Date()): number | null { +export function toDateFilterTimestamp(value: DateFilterInput, reference = new Date()): number | null { const parsed = parseDateFilterInput(value, reference) return parsed ? parsed.getTime() : null } diff --git a/src/utils/frontmatter.ts b/src/utils/frontmatter.ts index f740d105..ed219663 100644 --- a/src/utils/frontmatter.ts +++ b/src/utils/frontmatter.ts @@ -133,9 +133,9 @@ function assignFrontmatterValue( ) { const collisionKey = frontmatterCollisionKey(key) const previousKey = collisionKeys.get(collisionKey) - if (previousKey && previousKey !== key) delete result[previousKey] + if (previousKey && previousKey !== key) Reflect.deleteProperty(result, previousKey) collisionKeys.set(collisionKey, key) - result[key] = value + Reflect.set(result, key, value) } function parseFrontmatterValue(value: FrontmatterText): FrontmatterValue | undefined { diff --git a/src/utils/fuzzyMatch.ts b/src/utils/fuzzyMatch.ts index 3706be00..c8da8263 100644 --- a/src/utils/fuzzyMatch.ts +++ b/src/utils/fuzzyMatch.ts @@ -45,9 +45,10 @@ export function fuzzyMatch(query: string, target: string): { match: boolean; sco let lastMatchIndex = -1 for (let ti = 0; ti < t.length && qi < q.length; ti++) { - if (t[ti] === q[qi]) { + if (t.charAt(ti) === q.charAt(qi)) { if (ti === lastMatchIndex + 1) score += 2 - if (ti === 0 || t[ti - 1] === ' ' || t[ti - 1] === '-') score += 3 + const previousChar = t.charAt(ti - 1) + if (ti === 0 || previousChar === ' ' || previousChar === '-') score += 3 score += 1 lastMatchIndex = ti qi++ diff --git a/src/utils/mathMarkdown.ts b/src/utils/mathMarkdown.ts index 55b767b3..59839a3f 100644 --- a/src/utils/mathMarkdown.ts +++ b/src/utils/mathMarkdown.ts @@ -120,7 +120,7 @@ function readMathToken({ text, prefix }: TokenReadRequest): string | null { function isEscaped({ text, index }: TextPosition): boolean { let slashCount = 0 - for (let i = index - 1; i >= 0 && text[i] === '\\'; i--) { + for (let i = index - 1; i >= 0 && text.charAt(i) === '\\'; i--) { slashCount++ } return slashCount % 2 === 1 @@ -132,7 +132,7 @@ function isCodeFence({ text: line }: { text: string }): boolean { } function isSingleDollar({ text, index }: TextPosition): boolean { - return text[index] === '$' && text[index - 1] !== '$' && text[index + 1] !== '$' + return text.charAt(index) === '$' && text.charAt(index - 1) !== '$' && text.charAt(index + 1) !== '$' } function isInlineMathEnd(position: TextPosition): boolean { @@ -168,7 +168,7 @@ function isCompletedInlineMathEnd({ text, index }: TextPosition): boolean { function findCompletedInlineMathStart({ text, index: end }: TextPosition): number { for (let i = end - 1; i >= 0; i--) { - if (isInlineMathEnd({ text, index: i }) && text[i - 1] !== '$') { + if (isInlineMathEnd({ text, index: i }) && text.charAt(i - 1) !== '$') { return i } } @@ -192,7 +192,7 @@ function replaceInlineMath({ line }: MarkdownLine): string { let inCodeSpan = false while (index < line.length) { - const char = line[index] + const char = line.charAt(index) if (char === '`') { inCodeSpan = !inCodeSpan result += char @@ -215,21 +215,24 @@ function replaceInlineMath({ line }: MarkdownLine): string { function readSingleLineDisplayMath({ line }: MarkdownLine): InlineMathMatch | null { const match = line.trim().match(/^\$\$(.+)\$\$$/) - const latex = match?.[1]?.trim() + const latex = match?.at(1)?.trim() return latex ? { latex, end: 0 } : null } function readMultilineDisplayMath({ lines, start }: MarkdownLines): InlineMathMatch | null { - if (lines[start].trim() !== '$$') return null + const startLine = lines.at(start) + if (startLine?.trim() !== '$$') return null const end = lines.findIndex((line, index) => index > start && line.trim() === '$$') return end === -1 ? null : { latex: lines.slice(start + 1, end).join('\n'), end } } function readDisplayMath({ lines, start }: MarkdownLines): InlineMathMatch | null { - const trimmed = lines[start].trim() + const line = lines.at(start) + if (line === undefined) return null + const trimmed = line.trim() const displayMath = trimmed === '$$' ? readMultilineDisplayMath({ lines, start }) - : readSingleLineDisplayMath({ line: lines[start] }) + : readSingleLineDisplayMath({ line }) return displayMath && displayMath.end === 0 ? { ...displayMath, end: start } : displayMath @@ -241,7 +244,8 @@ export function preProcessMathMarkdown({ markdown }: MarkdownSource): string { let inFence = false for (let i = 0; i < lines.length; i++) { - const line = lines[i] + const line = lines.at(i) + if (line === undefined) continue if (isCodeFence({ text: line })) { inFence = !inFence result.push(line) diff --git a/src/utils/noteListHelpers.ts b/src/utils/noteListHelpers.ts index 730a0101..f3c14c1a 100644 --- a/src/utils/noteListHelpers.ts +++ b/src/utils/noteListHelpers.ts @@ -150,6 +150,7 @@ export function extractSortableProperties(entries: VaultEntry[]): string[] { const STATUS_ORDER: Record = { Active: 0, Paused: 1, Done: 2, Finished: 3, } +const STATUS_ORDER_LOOKUP = new Map(Object.entries(STATUS_ORDER)) const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}/ @@ -178,8 +179,8 @@ function comparePropertyValues(a: unknown, b: unknown): number { function makePropertyComparator(key: string, flip: number): (a: VaultEntry, b: VaultEntry) => number { return (a, b) => { - const va = a.properties?.[key] ?? null - const vb = b.properties?.[key] ?? null + const va = (a.properties ? Reflect.get(a.properties, key) : null) ?? null + const vb = (b.properties ? Reflect.get(b.properties, key) : null) ?? null if (va == null && vb == null) return 0 if (va == null) return 1 if (vb == null) return -1 @@ -191,8 +192,8 @@ function makeBuiltinComparator(option: string, flip: number): (a: VaultEntry, b: if (option === 'title') return (a, b) => flip * a.title.localeCompare(b.title) if (option === 'created') return (a, b) => flip * ((a.createdAt ?? a.modifiedAt ?? 0) - (b.createdAt ?? b.modifiedAt ?? 0)) if (option === 'status') return (a, b) => { - const sa = STATUS_ORDER[a.status ?? ''] ?? 999 - const sb = STATUS_ORDER[b.status ?? ''] ?? 999 + const sa = STATUS_ORDER_LOOKUP.get(a.status ?? '') ?? 999 + const sb = STATUS_ORDER_LOOKUP.get(b.status ?? '') ?? 999 if (sa !== sb) return flip * (sa - sb) return (getDisplayDate(b) ?? 0) - (getDisplayDate(a) ?? 0) } @@ -238,9 +239,9 @@ export function loadSortPreferences(): Record { if (typeof value === 'string') { // Migrate old format: bare SortOption string → SortConfig const opt = value as SortOption - result[key] = { option: opt, direction: getDefaultDirection(opt) } + Reflect.set(result, key, { option: opt, direction: getDefaultDirection(opt) }) } else { - result[key] = value as SortConfig + Reflect.set(result, key, value as SortConfig) } } return result @@ -418,7 +419,7 @@ export function buildRelationshipGroups( Object.keys(rels) .filter((k) => k.toLowerCase() !== 'type') .sort((a, b) => a.localeCompare(b)) - .forEach((key) => b.addFromRefs(key, rels[key] ?? [])) + .forEach((key) => b.addFromRefs(key, (Reflect.get(rels, key) as string[] | undefined) ?? [])) collectInverseRelationshipGroups(entity, allEntries).forEach((group) => b.add(group.label, group.entries)) b.add('Backlinks', findBacklinks(entity, allEntries).sort(sortByModified)) @@ -575,11 +576,13 @@ export function isInboxEntry(entry: VaultEntry): boolean { const INBOX_PERIOD_DAYS: Record = { week: 7, month: 30, quarter: 90, all: Infinity, } +const INBOX_PERIOD_DAYS_LOOKUP = new Map(Object.entries(INBOX_PERIOD_DAYS) as Array<[InboxPeriod, number]>) /** Filter entries for the Inbox view: not organized, within the given time period, sorted by createdAt desc. */ export function filterInboxEntries(entries: VaultEntry[], period: InboxPeriod): VaultEntry[] { const now = Math.floor(Date.now() / 1000) - const cutoff = period === 'all' ? 0 : now - INBOX_PERIOD_DAYS[period] * 86400 + const periodDays = INBOX_PERIOD_DAYS_LOOKUP.get(period) ?? Infinity + const cutoff = period === 'all' ? 0 : now - periodDays * 86400 return entries .filter((e) => isInboxEntry(e) && (e.createdAt ?? 0) >= cutoff) diff --git a/src/utils/noteOpenPerformance.ts b/src/utils/noteOpenPerformance.ts index 904a657e..7f46f314 100644 --- a/src/utils/noteOpenPerformance.ts +++ b/src/utils/noteOpenPerformance.ts @@ -33,8 +33,8 @@ function measureDuration( start: keyof NoteOpenTrace['marks'] | 'startedAt', end: keyof NoteOpenTrace['marks'], ): number | null { - const startTime = start === 'startedAt' ? trace.startedAt : trace.marks[start] - const endTime = trace.marks[end] + const startTime = start === 'startedAt' ? trace.startedAt : Reflect.get(trace.marks, start) as number | undefined + const endTime = Reflect.get(trace.marks, end) as number | undefined if (startTime === undefined || endTime === undefined) return null return endTime - startTime } @@ -61,7 +61,7 @@ export function markNoteOpenTrace(path: string, stage: NoteOpenStage): void { if (!canMeasurePerformance()) return const trace = inFlightNoteOpens.get(path) if (!trace) return - trace.marks[stage] = performance.now() + Reflect.set(trace.marks, stage, performance.now()) } export function finishNoteOpenTrace(path: string): void { @@ -69,8 +69,9 @@ export function finishNoteOpenTrace(path: string): void { const trace = inFlightNoteOpens.get(path) if (!trace) return - trace.marks.editorSwapped = performance.now() - const total = trace.marks.editorSwapped - trace.startedAt + Reflect.set(trace.marks, 'editorSwapped', performance.now()) + const editorSwappedAt = Reflect.get(trace.marks, 'editorSwapped') as number + const total = editorSwappedAt - trace.startedAt const beforeNavigate = measureDuration(trace, 'beforeNavigateStart', 'beforeNavigateEnd') const freshnessCheck = measureDuration(trace, 'freshnessCheckStart', 'freshnessCheckEnd') const contentLoad = measureDuration(trace, 'contentLoadStart', 'contentLoadEnd') @@ -85,7 +86,7 @@ export function finishNoteOpenTrace(path: string): void { + `freshnessCheck=${formatDuration(freshnessCheck)} ` + `contentLoad=${formatDuration(contentLoad)} ` + `editorSwap=${formatDuration(editorSwap)} ` - + `cache=${trace.marks.cacheReady !== undefined ? 'hit' : 'miss'}`, + + `cache=${Reflect.get(trace.marks, 'cacheReady') !== undefined ? 'hit' : 'miss'}`, ) inFlightNoteOpens.delete(path) } diff --git a/src/utils/propertyTypes.ts b/src/utils/propertyTypes.ts index a8c6a0c4..8733bb68 100644 --- a/src/utils/propertyTypes.ts +++ b/src/utils/propertyTypes.ts @@ -97,13 +97,13 @@ function persistDisplayModeOverrides(overrides: DisplayModeOverrides): void { export function saveDisplayModeOverride(propertyName: PropertyKey, mode: PropertyDisplayMode): void { const overrides = loadDisplayModeOverrides() - overrides[propertyName] = mode + Reflect.set(overrides, propertyName, mode) persistDisplayModeOverrides(overrides) } export function removeDisplayModeOverride(propertyName: PropertyKey): void { const overrides = loadDisplayModeOverrides() - delete overrides[propertyName] + Reflect.deleteProperty(overrides, propertyName) persistDisplayModeOverrides(overrides) } @@ -112,7 +112,7 @@ export function getEffectiveDisplayMode( value: FrontmatterValue, overrides: DisplayModeOverrides, ): PropertyDisplayMode { - return overrides[key] ?? detectPropertyType(key, value) + return (Reflect.get(overrides, key) as PropertyDisplayMode | undefined) ?? detectPropertyType(key, value) } function parseISODateParts(value: PropertyValueText): DateParts | null { diff --git a/src/utils/releaseDownloadPage.ts b/src/utils/releaseDownloadPage.ts index fa0f6e15..b554fdde 100644 --- a/src/utils/releaseDownloadPage.ts +++ b/src/utils/releaseDownloadPage.ts @@ -62,6 +62,9 @@ const PLATFORM_METADATA: Record( + Object.entries(PLATFORM_METADATA) as Array<[StablePlatformKey, { buttonLabel: string; label: string }]>, +) const PLATFORM_ORDER: StablePlatformKey[] = [ 'darwin-aarch64', @@ -192,8 +195,11 @@ function buildStableDownloadTarget( platform: StablePlatformKey, url: string, ): StableDownloadTarget { + const metadata = PLATFORM_METADATA_BY_KEY.get(platform) + if (!metadata) throw new Error(`Unsupported stable platform: ${platform}`) + return { - ...PLATFORM_METADATA[platform], + ...metadata, url, } } @@ -231,8 +237,9 @@ export function extractStableDownloadTargets(payload: unknown): StableDownloadTa const downloads: StableDownloadTargets = {} for (const platform of PLATFORM_ORDER) { - const url = extractPlatformDownloadUrl(platform, platforms[platform]) - if (url) downloads[platform] = buildStableDownloadTarget(platform, url) + const platformPayload = Reflect.get(platforms, platform) as PlatformPayload | undefined + const url = extractPlatformDownloadUrl(platform, platformPayload) + if (url) Reflect.set(downloads, platform, buildStableDownloadTarget(platform, url)) } return downloads @@ -297,11 +304,11 @@ function updateDownloadPreference( state: ReleaseAssetSelectionState, selection: ReleaseAssetSelection, ) { - const currentPreference = state.preferences[selection.platform] ?? Number.NEGATIVE_INFINITY + const currentPreference = (Reflect.get(state.preferences, selection.platform) as number | undefined) ?? Number.NEGATIVE_INFINITY if (selection.preference < currentPreference) return - state.preferences[selection.platform] = selection.preference - state.downloads[selection.platform] = buildStableDownloadTarget(selection.platform, selection.url) + Reflect.set(state.preferences, selection.platform, selection.preference) + Reflect.set(state.downloads, selection.platform, buildStableDownloadTarget(selection.platform, selection.url)) } function selectReleaseAsset(asset: ReleaseAssetPayload): ReleaseAssetSelection | null { @@ -390,14 +397,17 @@ function buildStableDownloadPageContent( function buildDownloadsMarkup(downloads: StableDownloadTargets): string { const targets = PLATFORM_ORDER - .map((platform) => downloads[platform]) + .map((platform) => Reflect.get(downloads, platform) as StableDownloadTarget | undefined) .filter((target): target is StableDownloadTarget => Boolean(target)) if (targets.length === 0) { return `` } - const primaryTarget = targets[0] + const primaryTarget = targets.at(0) + if (!primaryTarget) { + return `` + } const secondaryLinks = targets .map((target) => ( `${escapeHtml(target.label)}` diff --git a/src/utils/releaseHistoryPage.ts b/src/utils/releaseHistoryPage.ts index 8282b158..3bcc76e3 100644 --- a/src/utils/releaseHistoryPage.ts +++ b/src/utils/releaseHistoryPage.ts @@ -513,6 +513,9 @@ const RELEASE_CHANNEL_LABELS: Record = { alpha: 'Alpha', stable: 'Stable', } +const RELEASE_CHANNEL_LABELS_BY_CHANNEL = new Map( + Object.entries(RELEASE_CHANNEL_LABELS) as Array<[ReleaseChannel, string]>, +) function escapeMarkupText(value: string): string { return value @@ -526,7 +529,7 @@ function releasePayloadValue( release: GitHubReleasePayload, field: K, ): GitHubReleasePayload[K] { - return release[field] + return Reflect.get(release, field) as GitHubReleasePayload[K] } function normalizeText(value: unknown): string | null { @@ -658,7 +661,8 @@ function appendReleaseSection( normalizedRelease: [ReleaseChannel, ReleaseEntry], ): void { const [channel, release] = normalizedRelease - sections[channel].push(release) + const section = Reflect.get(sections, channel) as ReleaseEntry[] + section.push(release) } function collectReleaseSections(payload: unknown): ReleaseSections { @@ -675,14 +679,15 @@ function collectReleaseSections(payload: unknown): ReleaseSections { } for (const channel of ['stable', 'alpha'] as const) { - sections[channel].sort((left, right) => right.publishedTimestamp - left.publishedTimestamp) + const section = Reflect.get(sections, channel) as ReleaseEntry[] + section.sort((left, right) => right.publishedTimestamp - left.publishedTimestamp) } return sections } function buildTabMarkup(channel: ReleaseChannel, count: number, selected: boolean): string { - const label = RELEASE_CHANNEL_LABELS[channel] + const label = RELEASE_CHANNEL_LABELS_BY_CHANNEL.get(channel) ?? channel return `