diff --git a/.obsidian/app.json b/.obsidian/app.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/.obsidian/app.json @@ -0,0 +1 @@ +{} diff --git a/.obsidian/appearance.json b/.obsidian/appearance.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/.obsidian/appearance.json @@ -0,0 +1 @@ +{} diff --git a/.obsidian/core-plugins.json b/.obsidian/core-plugins.json new file mode 100644 index 0000000..1b87954 --- /dev/null +++ b/.obsidian/core-plugins.json @@ -0,0 +1,31 @@ +{ + "file-explorer": true, + "global-search": true, + "switcher": true, + "graph": true, + "backlink": true, + "canvas": true, + "outgoing-link": true, + "tag-pane": true, + "properties": false, + "page-preview": true, + "daily-notes": true, + "templates": true, + "note-composer": true, + "command-palette": true, + "slash-command": false, + "editor-status": true, + "bookmarks": true, + "markdown-importer": false, + "zk-prefixer": false, + "random-note": false, + "outline": true, + "word-count": true, + "slides": false, + "audio-recorder": false, + "workspaces": false, + "file-recovery": true, + "publish": false, + "sync": true, + "webviewer": false +} diff --git a/.obsidian/workspace.json b/.obsidian/workspace.json new file mode 100644 index 0000000..d90b985 --- /dev/null +++ b/.obsidian/workspace.json @@ -0,0 +1,191 @@ +{ + "main": { + "id": "886965717aacb6d0", + "type": "split", + "children": [ + { + "id": "9481425cee5d18ea", + "type": "tabs", + "children": [ + { + "id": "1eb2e9c2c530a169", + "type": "leaf", + "state": { + "type": "markdown", + "state": { + "file": "README.md", + "mode": "source", + "source": false + }, + "icon": "lucide-file", + "title": "README" + } + }, + { + "id": "87a5f93795884bb0", + "type": "leaf", + "state": { + "type": "empty", + "state": {}, + "icon": "lucide-file", + "title": "New tab" + } + } + ], + "currentTab": 1 + } + ], + "direction": "vertical" + }, + "left": { + "id": "580f4711e49ff790", + "type": "split", + "children": [ + { + "id": "2b68b0e26714f9db", + "type": "tabs", + "children": [ + { + "id": "9a2de93776a5f52a", + "type": "leaf", + "state": { + "type": "file-explorer", + "state": { + "sortOrder": "alphabetical", + "autoReveal": true + }, + "icon": "lucide-folder-closed", + "title": "Files" + } + }, + { + "id": "c1f9ccb674013288", + "type": "leaf", + "state": { + "type": "search", + "state": { + "query": "", + "matchingCase": false, + "explainSearch": false, + "collapseAll": false, + "extraContext": false, + "sortOrder": "alphabetical" + }, + "icon": "lucide-search", + "title": "Search" + } + }, + { + "id": "1d6b9ea734b0a656", + "type": "leaf", + "state": { + "type": "bookmarks", + "state": {}, + "icon": "lucide-bookmark", + "title": "Bookmarks" + } + } + ] + } + ], + "direction": "horizontal", + "width": 300 + }, + "right": { + "id": "267e362ff570d883", + "type": "split", + "children": [ + { + "id": "beba4aa22886cb5b", + "type": "tabs", + "children": [ + { + "id": "1f3a1273479a0100", + "type": "leaf", + "state": { + "type": "backlink", + "state": { + "collapseAll": false, + "extraContext": false, + "sortOrder": "alphabetical", + "showSearch": false, + "searchQuery": "", + "backlinkCollapsed": false, + "unlinkedCollapsed": true + }, + "icon": "links-coming-in", + "title": "Backlinks" + } + }, + { + "id": "a50a231b68e4c2c8", + "type": "leaf", + "state": { + "type": "outgoing-link", + "state": { + "linksCollapsed": false, + "unlinkedCollapsed": true + }, + "icon": "links-going-out", + "title": "Outgoing links" + } + }, + { + "id": "760ee1f1f13bf340", + "type": "leaf", + "state": { + "type": "tag", + "state": { + "sortOrder": "frequency", + "useHierarchy": true, + "showSearch": false, + "searchQuery": "" + }, + "icon": "lucide-tags", + "title": "Tags" + } + }, + { + "id": "e8291e1c7436943b", + "type": "leaf", + "state": { + "type": "outline", + "state": { + "followCursor": false, + "showSearch": false, + "searchQuery": "" + }, + "icon": "lucide-list", + "title": "Outline" + } + } + ] + } + ], + "direction": "horizontal", + "width": 300 + }, + "left-ribbon": { + "hiddenItems": { + "switcher:Open quick switcher": false, + "graph:Open graph view": false, + "canvas:Create new canvas": false, + "daily-notes:Open today's daily note": false, + "templates:Insert template": false, + "command-palette:Open command palette": false + } + }, + "active": "87a5f93795884bb0", + "lastOpenFiles": [ + "README.md", + "wiki/User Manual.md", + "wiki/Quick Start Guide.md", + "wiki/Performance Tuning.md", + "wiki/Home Page.md", + "wiki/Frontmatter Format.md", + "wiki/Development Setup.md", + "wiki/Configuration Guide.md", + "wiki/Architecture Overview.md", + "wiki" + ] +} diff --git a/components/ChangeStatusModal/ChangeStatusModal.tsx b/components/ChangeStatusModal/ChangeStatusModal.tsx new file mode 100644 index 0000000..f34587f --- /dev/null +++ b/components/ChangeStatusModal/ChangeStatusModal.tsx @@ -0,0 +1,58 @@ +import React from "react"; +import { GroupedStatuses, NoteStatus } from "@/types/noteStatus"; +import { + StatusSelectorGroup, + Props as StatusSelectorGroupProps, +} from "./StatusSelectorGroup"; + +export interface Props { + currentStatuses: GroupedStatuses; + filesQuantity: number; + availableStatuses: NoteStatus[]; + onRemoveStatus: ( + frontmatterTagName: string, + status: NoteStatus, + ) => Promise; + onSelectStatus: ( + frontmatterTagName: string, + status: NoteStatus, + ) => Promise; +} + +export const ChangeStatusModal: React.FC = ({ + currentStatuses: initialStatuses, + filesQuantity, + availableStatuses, + onRemoveStatus, + onSelectStatus, +}) => { + const currentStatuses = Object.entries(initialStatuses); + + const handleSelectedState: StatusSelectorGroupProps["onSelectedState"] = ( + frontmatterTagName, + status, + action, + ) => { + if (action === "select") { + onSelectStatus(frontmatterTagName, status); + } else { + onRemoveStatus(frontmatterTagName, status); + } + }; + + return ( + <> +

Change note status {filesQuantity}

+ + {currentStatuses.map(([frontmatterTagName, statusList]) => ( + + ))} + + ); +}; diff --git a/components/ChangeStatusModal/CurrentStatusChips.tsx b/components/ChangeStatusModal/CurrentStatusChips.tsx new file mode 100644 index 0000000..444e0e1 --- /dev/null +++ b/components/ChangeStatusModal/CurrentStatusChips.tsx @@ -0,0 +1,39 @@ +import React from "react"; +import { NoteStatus } from "@/types/noteStatus"; +import { StatusDisplay } from "../atoms/StatusDisplay"; +import { SettingItem } from "../SettingsUI.tsx/SettingItem"; + +interface Props { + currentStatuses: NoteStatus[]; + onRemoveStatus: (status: NoteStatus) => void; +} + +export const CurrentStatusChips: React.FC = ({ + currentStatuses, + onRemoveStatus, +}) => { + return ( + +
+ {currentStatuses.map((status) => ( + onRemoveStatus(status)} + /> + ))} +
+
+ ); +}; diff --git a/components/ChangeStatusModal/StatusSelectorGroup.tsx b/components/ChangeStatusModal/StatusSelectorGroup.tsx new file mode 100644 index 0000000..5316c26 --- /dev/null +++ b/components/ChangeStatusModal/StatusSelectorGroup.tsx @@ -0,0 +1,102 @@ +import React, { useCallback } from "react"; +import { NoteStatus } from "@/types/noteStatus"; +import { SearchFilter } from "../atoms/SearchFilter"; +import { StatusSelector } from "../atoms/StatusSelector"; +import { SettingItem } from "../SettingsUI.tsx/SettingItem"; +import { CurrentStatusChips } from "./CurrentStatusChips"; +import { useKeyboardNavigation } from "./useKeyboardNavigation"; + +export interface Props { + frontmatterTagName: string; + currentStatuses: NoteStatus[]; + availableStatuses: NoteStatus[]; + onSelectedState: ( + frontmatterTagName: string, + status: NoteStatus, + action: "select" | "unselected", + ) => void; +} + +export const StatusSelectorGroup: React.FC = ({ + currentStatuses, + availableStatuses, + frontmatterTagName, + onSelectedState, +}) => { + const handleRemoveStatus = useCallback( + (status: NoteStatus) => { + onSelectedState(frontmatterTagName, status, "unselected"); + }, + [onSelectedState, frontmatterTagName], + ); + + const handleSelectStatus = useCallback( + (status: NoteStatus) => { + onSelectedState(frontmatterTagName, status, "select"); + }, + [onSelectedState, frontmatterTagName], + ); + + const { + focusedIndex, + searchFilter, + filteredStatuses, + containerRef, + searchRef, + handleKeyDown, + setSearchFilter, + } = useKeyboardNavigation({ + availableStatuses, + currentStatuses, + onSelectStatus: handleSelectStatus, + onRemoveStatus: handleRemoveStatus, + }); + + return ( +
+ + + + {filteredStatuses.length === 0 ? ( +
+ {searchFilter + ? `No statuses match "${searchFilter}"` + : "No statuses found"} +
+ ) : ( + + selected + ? handleSelectStatus(status) + : handleRemoveStatus(status) + } + /> + )} +
+ + +
+ ); +}; diff --git a/components/ChangeStatusModal/useKeyboardNavigation.tsx b/components/ChangeStatusModal/useKeyboardNavigation.tsx new file mode 100644 index 0000000..4d57100 --- /dev/null +++ b/components/ChangeStatusModal/useKeyboardNavigation.tsx @@ -0,0 +1,154 @@ +import { useState, useEffect, useRef, useMemo, useCallback } from "react"; +import { NoteStatus } from "@/types/noteStatus"; + +interface UseKeyboardNavigationProps { + availableStatuses: NoteStatus[]; + currentStatuses: NoteStatus[]; + onSelectStatus: (status: NoteStatus) => void; + onRemoveStatus: (status: NoteStatus) => void; +} + +export const useKeyboardNavigation = ({ + availableStatuses, + currentStatuses, + onSelectStatus, + onRemoveStatus, +}: UseKeyboardNavigationProps) => { + const [focusedIndex, setFocusedIndex] = useState(-1); + const [searchFilter, setSearchFilter] = useState(""); + const containerRef = useRef(null); + const searchRef = useRef(null); + + const filteredStatuses = useMemo( + () => + searchFilter + ? availableStatuses.filter((status) => { + const searchTerm = searchFilter.toLowerCase(); + return ( + status.name.toLowerCase().includes(searchTerm) || + (status.templateId && + status.templateId + .toLowerCase() + .includes(searchTerm)) + ); + }) + : availableStatuses, + [searchFilter, availableStatuses], + ); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + switch (e.key) { + case "ArrowDown": + e.preventDefault(); + if (filteredStatuses.length > 0) { + setFocusedIndex((prev) => + prev < filteredStatuses.length - 1 ? prev + 1 : 0, + ); + } + break; + case "ArrowUp": + e.preventDefault(); + if (filteredStatuses.length > 0) { + setFocusedIndex((prev) => + prev > 0 ? prev - 1 : filteredStatuses.length - 1, + ); + } + break; + case "Tab": + if (!e.shiftKey) { + e.preventDefault(); + if (filteredStatuses.length > 0) { + setFocusedIndex((prev) => + prev < filteredStatuses.length - 1 + ? prev + 1 + : 0, + ); + } + } else { + e.preventDefault(); + if (filteredStatuses.length > 0) { + setFocusedIndex((prev) => + prev > 0 + ? prev - 1 + : filteredStatuses.length - 1, + ); + } + } + break; + case "Enter": + if ( + focusedIndex >= 0 && + focusedIndex < filteredStatuses.length + ) { + e.preventDefault(); + const status = filteredStatuses[focusedIndex]; + const isSelected = currentStatuses.some( + (s) => + s.name === status.name && + s.templateId === status.templateId, + ); + if (isSelected) { + onRemoveStatus(status); + } else { + onSelectStatus(status); + } + } + break; + case "Backspace": + e.preventDefault(); + setSearchFilter((prev) => prev.slice(0, -1)); + if (searchRef.current) { + searchRef.current.focus(); + } + break; + case "Escape": + e.preventDefault(); + setSearchFilter(""); + break; + default: + if ( + e.key.length === 1 && + !e.ctrlKey && + !e.metaKey && + !e.altKey + ) { + e.preventDefault(); + setSearchFilter((prev) => prev + e.key); + if (searchRef.current) { + searchRef.current.focus(); + } + } + break; + } + }, + [ + filteredStatuses, + focusedIndex, + currentStatuses, + onRemoveStatus, + onSelectStatus, + searchRef, + ], + ); + + useEffect(() => { + setFocusedIndex(filteredStatuses.length > 0 ? 0 : -1); + }, [searchFilter, filteredStatuses.length]); + + useEffect(() => { + if (containerRef.current) { + containerRef.current.focus(); + } + }, []); + + return { + focusedIndex, + searchFilter, + filteredStatuses, + containerRef, + searchRef, + handleKeyDown, + setSearchFilter, + }; +}; diff --git a/components/FileExplorer/FileExplorerIcon.tsx b/components/FileExplorer/FileExplorerIcon.tsx new file mode 100644 index 0000000..d1448f7 --- /dev/null +++ b/components/FileExplorer/FileExplorerIcon.tsx @@ -0,0 +1,66 @@ +import { GroupedStatuses } from "@/types/noteStatus"; +import React, { FC, memo } from "react"; + +type Props = { + statuses: GroupedStatuses; + onMouseEnter: (statuses: GroupedStatuses) => void; + onMouseLeave: (statuses: GroupedStatuses) => void; + hideUnknownStatus?: boolean; +}; + +export const FileExplorerIcon: FC = memo( + ({ statuses, onMouseLeave, onMouseEnter, hideUnknownStatus }) => { + const statusEntries = Object.entries(statuses); + const totalStatuses = statusEntries.reduce( + (acc, [, list]) => acc + list.length, + 0, + ); + + if (totalStatuses === 0) { + // If hideUnknownStatus is enabled, don't show anything for files without status + if (hideUnknownStatus) return null; + + // Show "no status" icon + return ( +
+
onMouseEnter({})} + onMouseLeave={() => onMouseLeave({})} + style={ + { + "--primary-color": "var(--text-muted)", + } as React.CSSProperties + } + > + +
+
+ ); + } + + const primaryStatus = statusEntries[0]?.[1]?.[0]; + if (!primaryStatus) return null; + + return ( +
+
onMouseEnter(statuses)} + onMouseLeave={() => onMouseLeave(statuses)} + style={ + { + "--primary-color": + primaryStatus.color || "var(--text-accent)", + } as React.CSSProperties + } + > + {primaryStatus.icon} + {totalStatuses > 1 && ( + {totalStatuses} + )} +
+
+ ); + }, +); diff --git a/components/GroupedStatusView/GroupedStatusView.tsx b/components/GroupedStatusView/GroupedStatusView.tsx new file mode 100644 index 0000000..891528c --- /dev/null +++ b/components/GroupedStatusView/GroupedStatusView.tsx @@ -0,0 +1,169 @@ +import { useState, useCallback, useMemo } from "react"; +import { FilterSection } from "./components/FilterSection"; +import { TagSection } from "./components/TagSection"; +import { LoadingSpinner } from "./components/LoadingSpinner"; +import { + GroupedDataProvider, + useGroupedDataContext, +} from "./context/GroupedDataProvider"; +import { NoteStatus } from "@/types/noteStatus"; + +export type FileItem = { + id: string; + name: string; + path: string; +}; + +export type StatusItem = { + name: string; + color: string; + icon?: string; +}; + +export type FilesByStatus = { + [statusName: string]: FileItem[]; +}; + +export type GroupedByStatus = { + [frontmatterTag: string]: FilesByStatus; +}; + +export type GroupedStatusViewProps = { + getAllFiles: () => FileItem[]; + processFiles: (files: FileItem[]) => GroupedByStatus; + onFileClick: (file: FileItem) => void; + subscribeToEvents: (onDataChange: () => void) => () => void; + getAvailableStatuses: () => StatusItem[]; + getAvailableStatusesWithTemplateInfo: () => NoteStatus[]; +}; + +const GroupedStatusViewContent = () => { + const { + filteredData, + isLoading, + expandedGroups, + expandedFiles, + toggleGroup, + toggleFiles, + onFileClick, + getAvailableStatusesWithTemplateInfo, + getLoadedCount, + loadMoreItems, + handleScroll, + } = useGroupedDataContext(); + + const handleFileClickCallback = useCallback( + (file: FileItem) => { + onFileClick(file); + }, + [onFileClick], + ); + + const statusMap = useMemo(() => { + const map = new Map(); + const noteStatuses = getAvailableStatusesWithTemplateInfo(); + + // Build map using scoped identifiers as keys + noteStatuses.forEach((noteStatus) => { + const statusItem: StatusItem = { + name: noteStatus.name, + color: noteStatus.color || "white", + icon: noteStatus.icon, + }; + + const scopedIdentifier = noteStatus.templateId + ? `${noteStatus.templateId}:${noteStatus.name}` + : noteStatus.name; + map.set(scopedIdentifier, statusItem); + }); + + return map; + }, [getAvailableStatusesWithTemplateInfo]); + + if (isLoading) { + return ; + } + + return ( +
+ {Object.entries(filteredData).map(([tag, statusGroups]) => { + const tagKey = `tag-${tag}`; + const isTagExpanded = expandedGroups.has(tagKey); + + return ( + toggleGroup(tagKey)} + onToggleFiles={toggleFiles} + onFileClick={handleFileClickCallback} + onScroll={handleScroll} + onLoadMore={loadMoreItems} + /> + ); + })} + + {Object.keys(filteredData).length === 0 && ( +
No statuses found.
+ )} +
+ ); +}; + +export const GroupedStatusView = ({ + getAllFiles, + processFiles, + onFileClick, + subscribeToEvents, + getAvailableStatuses, + getAvailableStatusesWithTemplateInfo, +}: GroupedStatusViewProps) => { + const [searchFilter, setSearchFilter] = useState(""); + const [noteNameFilter, setNoteNameFilter] = useState(""); + const [templateFilter, setTemplateFilter] = useState(""); + + // Get available templates from the statuses + const availableTemplates = useMemo(() => { + const templates = new Set(); + getAvailableStatusesWithTemplateInfo().forEach((status) => { + if (status.templateId) { + templates.add(status.templateId); + } + }); + return Array.from(templates).sort(); + }, [getAvailableStatusesWithTemplateInfo]); + + return ( + +
+ + +
+
+ ); +}; diff --git a/components/GroupedStatusView/components/FileList.tsx b/components/GroupedStatusView/components/FileList.tsx new file mode 100644 index 0000000..3acf883 --- /dev/null +++ b/components/GroupedStatusView/components/FileList.tsx @@ -0,0 +1,82 @@ +import React, { useCallback } from "react"; +import { FileItem } from "../GroupedStatusView"; +import { SelectableListItem } from "../../atoms/SelectableListItem"; + +interface FileListProps { + files: FileItem[]; + groupKey: string; + loadedCount: number; + onFileClick: (file: FileItem) => void; + onScroll: ( + e: React.UIEvent, + groupKey: string, + totalItems: number, + ) => void; + onLoadMore: (groupKey: string) => void; +} + +export const FileList = ({ + files, + groupKey, + loadedCount, + onFileClick, + onScroll, + onLoadMore, +}: FileListProps) => { + const visibleFiles = files.slice(0, loadedCount); + const hasMoreItems = files.length > loadedCount; + + const handleFileClick = useCallback( + (file: FileItem) => { + onFileClick(file); + }, + [onFileClick], + ); + + const handleLoadMore = useCallback(() => { + onLoadMore(groupKey); + }, [onLoadMore, groupKey]); + + const handleScroll = useCallback( + (e: React.UIEvent) => { + onScroll(e, groupKey, files.length); + }, + [onScroll, groupKey, files.length], + ); + + return ( +
+
+ {visibleFiles.map((file) => ( + handleFileClick(file)} + className="grouped-status-file-item" + title={file.path} + > +
+ + {file.name} + + + {file.path} + +
+
+ ))} + + {hasMoreItems && ( +
+ +
+ )} +
+
+ ); +}; diff --git a/components/GroupedStatusView/components/FilterSection.tsx b/components/GroupedStatusView/components/FilterSection.tsx new file mode 100644 index 0000000..fadc861 --- /dev/null +++ b/components/GroupedStatusView/components/FilterSection.tsx @@ -0,0 +1,58 @@ +import React from "react"; +import { SearchFilter } from "@/components/atoms/SearchFilter"; +import { Input } from "@/components/atoms/Input"; + +interface FilterSectionProps { + searchFilter: string; + noteNameFilter: string; + templateFilter: string; + availableTemplates: string[]; + onSearchFilterChange: (value: string) => void; + onNoteNameFilterChange: (value: string) => void; + onTemplateFilterChange: (value: string) => void; +} + +export const FilterSection = ({ + searchFilter, + noteNameFilter, + templateFilter, + availableTemplates, + onSearchFilterChange, + onNoteNameFilterChange, + onTemplateFilterChange, +}: FilterSectionProps) => { + return ( +
+

Status Groups

+
+ +
+ +
+
+ +
+
+
+ ); +}; diff --git a/components/GroupedStatusView/components/LoadingSpinner.tsx b/components/GroupedStatusView/components/LoadingSpinner.tsx new file mode 100644 index 0000000..d0aaf1f --- /dev/null +++ b/components/GroupedStatusView/components/LoadingSpinner.tsx @@ -0,0 +1,9 @@ +import React from "react"; + +export const LoadingSpinner = () => { + return ( +
+
Loading statuses...
+
+ ); +}; diff --git a/components/GroupedStatusView/components/StatusGroup.tsx b/components/GroupedStatusView/components/StatusGroup.tsx new file mode 100644 index 0000000..58b6af0 --- /dev/null +++ b/components/GroupedStatusView/components/StatusGroup.tsx @@ -0,0 +1,85 @@ +import React, { useCallback } from "react"; +import { StatusDisplay } from "@/components/atoms/StatusDisplay"; +import { CollapsibleCounter } from "@/components/atoms/CollapsibleCounter"; +import { FileItem, StatusItem } from "../GroupedStatusView"; +import { FileList } from "./FileList"; +import { NoteStatus } from "@/types/noteStatus"; + +interface StatusGroupProps { + statusName: string; + status: StatusItem; + files: FileItem[]; + tag: string; + isExpanded: boolean; + loadedCount: number; + onToggle: () => void; + onFileClick: (file: FileItem) => void; + onScroll: ( + e: React.UIEvent, + groupKey: string, + totalItems: number, + ) => void; + onLoadMore: (groupKey: string) => void; +} + +export const StatusGroup = ({ + statusName, + status, + files, + tag, + isExpanded, + loadedCount, + onToggle, + onFileClick, + onScroll, + onLoadMore, +}: StatusGroupProps) => { + const groupKey = `${tag}-${statusName}`; + + // Extract template ID from scoped identifier + const templateId = statusName.includes(":") + ? statusName.split(":", 2)[0] + : null; + + const handleToggle = useCallback(() => { + onToggle(); + }, [onToggle]); + + return ( +
+
+
+ + {templateId && ( + + {templateId} + + )} +
+
+ +
+
+ + {isExpanded && ( + + )} +
+ ); +}; diff --git a/components/GroupedStatusView/components/TagSection.tsx b/components/GroupedStatusView/components/TagSection.tsx new file mode 100644 index 0000000..aa0d089 --- /dev/null +++ b/components/GroupedStatusView/components/TagSection.tsx @@ -0,0 +1,97 @@ +import React, { useCallback } from "react"; +import { GroupLabel } from "@/components/atoms/GroupLabel"; +import { CollapsibleCounter } from "@/components/atoms/CollapsibleCounter"; +import { FilesByStatus, FileItem, StatusItem } from "../GroupedStatusView"; +import { StatusGroup } from "./StatusGroup"; + +interface TagSectionProps { + tag: string; + statusGroups: FilesByStatus; + isExpanded: boolean; + statusMap: Map; + expandedFiles: Set; + getLoadedCount: (groupKey: string) => number; + onToggle: () => void; + onToggleFiles: (groupKey: string) => void; + onFileClick: (file: FileItem) => void; + onScroll: ( + e: React.UIEvent, + groupKey: string, + totalItems: number, + ) => void; + onLoadMore: (groupKey: string) => void; +} + +export const TagSection = ({ + tag, + statusGroups, + isExpanded, + statusMap, + expandedFiles, + getLoadedCount, + onToggle, + onToggleFiles, + onFileClick, + onScroll, + onLoadMore, +}: TagSectionProps) => { + const totalFilesInTag = Object.values(statusGroups).reduce( + (total, files) => total + files.length, + 0, + ); + + const handleToggle = useCallback(() => { + onToggle(); + }, [onToggle]); + + const handleToggleFiles = useCallback( + (groupKey: string) => { + onToggleFiles(groupKey); + }, + [onToggleFiles], + ); + + return ( +
+
+ + +
+ + {isExpanded && ( +
+ {Object.entries(statusGroups).map(([statusName, files]) => { + if (files.length === 0) return null; + + const status = statusMap.get(statusName); + if (!status) return null; + + const groupKey = `${tag}-${statusName}`; + const filesExpanded = expandedFiles.has(groupKey); + const loadedCount = getLoadedCount(groupKey); + + return ( + handleToggleFiles(groupKey)} + onFileClick={onFileClick} + onScroll={onScroll} + onLoadMore={onLoadMore} + /> + ); + })} +
+ )} +
+ ); +}; diff --git a/components/GroupedStatusView/context/GroupedDataProvider.tsx b/components/GroupedStatusView/context/GroupedDataProvider.tsx new file mode 100644 index 0000000..fc796df --- /dev/null +++ b/components/GroupedStatusView/context/GroupedDataProvider.tsx @@ -0,0 +1,111 @@ +import React, { createContext, useContext, ReactNode } from "react"; +import { FileItem, GroupedByStatus, StatusItem } from "../GroupedStatusView"; +import { NoteStatus } from "@/types/noteStatus"; +import { useGroupedData } from "../hooks/useGroupedData"; +import { usePagination } from "../hooks/usePagination"; +import { useExpandedState } from "../hooks/useExpandedState"; + +type GroupedDataContextType = { + // Data + groupedData: GroupedByStatus; + filteredData: GroupedByStatus; + isLoading: boolean; + loadData: () => void; + + // Pagination + getLoadedCount: (groupKey: string) => number; + loadMoreItems: (groupKey: string) => void; + handleScroll: ( + e: React.UIEvent, + groupKey: string, + totalItems: number, + ) => void; + + // Expanded state + expandedGroups: Set; + expandedFiles: Set; + toggleGroup: (groupKey: string) => void; + toggleFiles: (groupKey: string) => void; + + // Props + onFileClick: (file: FileItem) => void; + getAvailableStatuses: () => StatusItem[]; + getAvailableStatusesWithTemplateInfo: () => NoteStatus[]; +}; + +const GroupedDataContext = createContext( + undefined, +); + +export const useGroupedDataContext = () => { + const context = useContext(GroupedDataContext); + if (!context) { + throw new Error( + "useGroupedDataContext must be used within GroupedDataProvider", + ); + } + return context; +}; + +type GroupedDataProviderProps = { + children: ReactNode; + getAllFiles: () => FileItem[]; + processFiles: (files: FileItem[]) => GroupedByStatus; + onFileClick: (file: FileItem) => void; + subscribeToEvents: (onDataChange: () => void) => () => void; + getAvailableStatuses: () => StatusItem[]; + getAvailableStatusesWithTemplateInfo: () => NoteStatus[]; + searchFilter: string; + noteNameFilter: string; + templateFilter: string; +}; + +export const GroupedDataProvider = ({ + children, + getAllFiles, + processFiles, + onFileClick, + subscribeToEvents, + getAvailableStatuses, + getAvailableStatusesWithTemplateInfo, + searchFilter, + noteNameFilter, + templateFilter, +}: GroupedDataProviderProps) => { + const { groupedData, filteredData, isLoading, loadData } = useGroupedData({ + getAllFiles, + processFiles, + subscribeToEvents, + searchFilter, + noteNameFilter, + templateFilter, + }); + + const { getLoadedCount, loadMoreItems, handleScroll } = usePagination(); + + const { expandedGroups, expandedFiles, toggleGroup, toggleFiles } = + useExpandedState(filteredData); + + const value: GroupedDataContextType = { + groupedData, + filteredData, + isLoading, + loadData, + getLoadedCount, + loadMoreItems, + handleScroll, + expandedGroups, + expandedFiles, + toggleGroup, + toggleFiles, + onFileClick, + getAvailableStatuses, + getAvailableStatusesWithTemplateInfo, + }; + + return ( + + {children} + + ); +}; diff --git a/components/GroupedStatusView/hooks/index.ts b/components/GroupedStatusView/hooks/index.ts new file mode 100644 index 0000000..512fbb4 --- /dev/null +++ b/components/GroupedStatusView/hooks/index.ts @@ -0,0 +1,3 @@ +export { useGroupedData } from "./useGroupedData"; +export { usePagination } from "./usePagination"; +export { useExpandedState } from "./useExpandedState"; diff --git a/components/GroupedStatusView/hooks/useExpandedState.ts b/components/GroupedStatusView/hooks/useExpandedState.ts new file mode 100644 index 0000000..d68138a --- /dev/null +++ b/components/GroupedStatusView/hooks/useExpandedState.ts @@ -0,0 +1,66 @@ +import { useState, useEffect, useCallback } from "react"; +import { GroupedByStatus } from "../GroupedStatusView"; + +export const useExpandedState = (filteredData: GroupedByStatus) => { + const [expandedGroups, setExpandedGroups] = useState>( + new Set(), + ); + const [expandedFiles, setExpandedFiles] = useState>(new Set()); + + // Auto-expand groups when there's only one group + useEffect(() => { + const dataToCheck = filteredData; + const groupKeys = Object.keys(dataToCheck); + + if (groupKeys.length === 1) { + const singleGroupKey = `tag-${groupKeys[0]}`; + setExpandedGroups((prev) => { + const newSet = new Set(prev); + newSet.add(singleGroupKey); + return newSet; + }); + + // Also expand all status groups within the single tag group + const statusGroups = dataToCheck[groupKeys[0]]; + const statusKeys = Object.keys(statusGroups).map( + (statusName) => `${groupKeys[0]}-${statusName}`, + ); + setExpandedFiles((prev) => { + const newSet = new Set(prev); + statusKeys.forEach((key) => newSet.add(key)); + return newSet; + }); + } + }, [filteredData]); + + const toggleGroup = useCallback((groupKey: string) => { + setExpandedGroups((prev) => { + const newSet = new Set(prev); + if (newSet.has(groupKey)) { + newSet.delete(groupKey); + } else { + newSet.add(groupKey); + } + return newSet; + }); + }, []); + + const toggleFiles = useCallback((groupKey: string) => { + setExpandedFiles((prev) => { + const newSet = new Set(prev); + if (newSet.has(groupKey)) { + newSet.delete(groupKey); + } else { + newSet.add(groupKey); + } + return newSet; + }); + }, []); + + return { + expandedGroups, + expandedFiles, + toggleGroup, + toggleFiles, + }; +}; diff --git a/components/GroupedStatusView/hooks/useGroupedData.ts b/components/GroupedStatusView/hooks/useGroupedData.ts new file mode 100644 index 0000000..3978b6a --- /dev/null +++ b/components/GroupedStatusView/hooks/useGroupedData.ts @@ -0,0 +1,109 @@ +import { useState, useEffect, useMemo, useCallback } from "react"; +import { FileItem, GroupedByStatus } from "../GroupedStatusView"; + +export type UseGroupedDataProps = { + getAllFiles: () => FileItem[]; + processFiles: (files: FileItem[]) => GroupedByStatus; + subscribeToEvents: (onDataChange: () => void) => () => void; + searchFilter: string; + noteNameFilter: string; + templateFilter: string; +}; + +export const useGroupedData = ({ + getAllFiles, + processFiles, + subscribeToEvents, + searchFilter, + noteNameFilter, + templateFilter, +}: UseGroupedDataProps) => { + const [groupedData, setGroupedData] = useState({}); + const [isLoading, setIsLoading] = useState(true); + + const loadData = useCallback(async () => { + setIsLoading(true); + try { + const files = getAllFiles(); + const processed = processFiles(files); + setGroupedData(processed); + } finally { + setIsLoading(false); + } + }, [getAllFiles, processFiles]); + + useEffect(() => { + loadData(); + + const handleFileChange = () => { + loadData(); + }; + + const unsubscribe = subscribeToEvents(handleFileChange); + + return unsubscribe; + }, [loadData, subscribeToEvents]); + + const filteredData = useMemo(() => { + if ( + !searchFilter.trim() && + !noteNameFilter.trim() && + !templateFilter.trim() + ) + return groupedData; + + const filtered: GroupedByStatus = {}; + const searchLower = searchFilter.toLowerCase(); + const noteNameLower = noteNameFilter.toLowerCase(); + + Object.entries(groupedData).forEach(([tag, statusGroups]) => { + filtered[tag] = {}; + Object.entries(statusGroups).forEach(([statusName, files]) => { + // Filter by template if templateFilter is provided + if (templateFilter.trim()) { + // Check if statusName contains the template (scoped: "template:status") + const statusTemplate = statusName.includes(":") + ? statusName.split(":", 2)[0] + : ""; + + if (statusTemplate !== templateFilter) { + return; // Skip this status group if it doesn't match the template filter + } + } + + let matchingFiles = files; + + // Filter by status/tag if searchFilter is provided + if (searchFilter.trim()) { + matchingFiles = matchingFiles.filter( + (file) => + statusName.toLowerCase().includes(searchLower) || + tag.toLowerCase().includes(searchLower), + ); + } + + // Filter by note name if noteNameFilter is provided + if (noteNameFilter.trim()) { + matchingFiles = matchingFiles.filter( + (file) => + file.name.toLowerCase().includes(noteNameLower) || + file.path.toLowerCase().includes(noteNameLower), + ); + } + + if (matchingFiles.length > 0) { + filtered[tag][statusName] = matchingFiles; + } + }); + }); + + return filtered; + }, [groupedData, searchFilter, noteNameFilter, templateFilter]); + + return { + groupedData, + filteredData, + isLoading, + loadData, + }; +}; diff --git a/components/GroupedStatusView/hooks/usePagination.ts b/components/GroupedStatusView/hooks/usePagination.ts new file mode 100644 index 0000000..0cc63b6 --- /dev/null +++ b/components/GroupedStatusView/hooks/usePagination.ts @@ -0,0 +1,47 @@ +import { useState, useCallback } from "react"; + +const ITEMS_PER_LOAD = 20; + +export const usePagination = () => { + const [loadedItems, setLoadedItems] = useState>({}); + + const getLoadedCount = useCallback( + (groupKey: string) => { + return loadedItems[groupKey] || ITEMS_PER_LOAD; + }, + [loadedItems], + ); + + const loadMoreItems = useCallback((groupKey: string) => { + setLoadedItems((prev) => ({ + ...prev, + [groupKey]: (prev[groupKey] || ITEMS_PER_LOAD) + ITEMS_PER_LOAD, + })); + }, []); + + const handleScroll = useCallback( + ( + e: React.UIEvent, + groupKey: string, + totalItems: number, + ) => { + const { scrollTop, scrollHeight, clientHeight } = e.currentTarget; + const loadedCount = getLoadedCount(groupKey); + + if ( + scrollHeight - scrollTop <= clientHeight + 100 && + loadedCount < totalItems + ) { + loadMoreItems(groupKey); + } + }, + [getLoadedCount, loadMoreItems], + ); + + return { + loadedItems, + getLoadedCount, + loadMoreItems, + handleScroll, + }; +}; diff --git a/components/SettingsUI.tsx/BehaviourSettings.tsx b/components/SettingsUI.tsx/BehaviourSettings.tsx new file mode 100644 index 0000000..313c39a --- /dev/null +++ b/components/SettingsUI.tsx/BehaviourSettings.tsx @@ -0,0 +1,57 @@ +import { PluginSettings } from "@/types/pluginSettings"; +import React from "react"; +import { SettingItem } from "./SettingItem"; + +export type Props = { + settings: PluginSettings; + onChange: (key: keyof PluginSettings, value: unknown) => void; +}; + +export const BehaviourSettings: React.FC = ({ settings, onChange }) => { + return ( +
+

Status tag

+ + + + onChange("useMultipleStatuses", e.target.checked) + } + /> + + + + { + if (e.target.value.trim()) { + onChange("tagPrefix", e.target.value.trim()); + } + }} + /> + + + + + onChange("strictStatuses", e.target.checked) + } + /> + +
+ ); +}; diff --git a/components/SettingsUI.tsx/CustomStatusItem.tsx b/components/SettingsUI.tsx/CustomStatusItem.tsx new file mode 100644 index 0000000..a189789 --- /dev/null +++ b/components/SettingsUI.tsx/CustomStatusItem.tsx @@ -0,0 +1,137 @@ +import { NoteStatus } from "@/types/noteStatus"; +import { PluginSettings } from "@/types/pluginSettings"; +import React from "react"; +import { Input } from "@/components/atoms/Input"; + +export type Props = { + status: NoteStatus; + index: number; + settings: PluginSettings; + onCustomStatusChange: ( + index: number, + column: "name" | "icon" | "color" | "description", + value: string, + ) => void; + onCustomStatusRemove: (index: number) => void; +}; + +export const CustomStatusItem: React.FC = ({ + status, + index, + onCustomStatusChange, + onCustomStatusRemove, +}) => { + const isValid = status.name.trim().length > 0; + const displayIcon = status.icon.trim() || "📝"; + + return ( +
+ {/* Simple horizontal layout with all inputs in a row */} +
+ {/* Icon field - simple text input */} +
+ + + onCustomStatusChange(index, "icon", value || "") + } + placeholder="📝" + className="custom-status-item__input custom-status-item__input--icon" + /> +
+ + {/* Name field */} +
+ + + onCustomStatusChange(index, "name", value || "") + } + placeholder="e.g. In Progress" + className={`custom-status-item__input custom-status-item__input--name ${ + !isValid ? "custom-status-item__input--invalid" : "" + }`} + /> +
+ + {/* Description field */} +
+ + + onCustomStatusChange(index, "description", value) + } + placeholder="Optional description" + className="custom-status-item__input custom-status-item__input--description" + /> +
+ + {/* Color picker */} +
+ + + onCustomStatusChange(index, "color", value) + } + className="custom-status-item__input custom-status-item__input--color" + /> +
+ + {/* Remove button */} +
+ +
+
+ + {/* Preview row - shows how the status will look */} +
+ + {displayIcon} + + + {status.name || "Status name"} + + {status.description && ( + + {status.description} + + )} +
+ + {/* Validation message */} + {!isValid && ( +
+ Please enter a status name +
+ )} +
+ ); +}; diff --git a/components/SettingsUI.tsx/CustomStatusSettings.tsx b/components/SettingsUI.tsx/CustomStatusSettings.tsx new file mode 100644 index 0000000..5168fd8 --- /dev/null +++ b/components/SettingsUI.tsx/CustomStatusSettings.tsx @@ -0,0 +1,103 @@ +import { PluginSettings } from "@/types/pluginSettings"; +import React from "react"; +import { + CustomStatusItem, + Props as CustomStatusItemProps, +} from "./CustomStatusItem"; +import { SettingItem } from "./SettingItem"; + +export type Props = { + settings: PluginSettings; + onChange: (key: keyof PluginSettings, value: unknown) => void; +}; + +export const CustomStatusSettings: React.FC = ({ + settings, + onChange, +}) => { + const addNewCustomStatus = () => { + const currentStatuses = [...settings.customStatuses]; + currentStatuses.push({ + name: "", + icon: "", + }); + onChange("customStatuses", currentStatuses); + }; + const removeCustomStatus: CustomStatusItemProps["onCustomStatusRemove"] = ( + index, + ) => { + const currentStatuses = [...settings.customStatuses]; + currentStatuses.splice(index, 1); + onChange("customStatuses", currentStatuses); + }; + + const updateCustomStatus: CustomStatusItemProps["onCustomStatusChange"] = ( + index, + column, + value, + ) => { + const currentStatuses = [...settings.customStatuses]; + + const target = currentStatuses[index]; + if (target) { + target[column] = value; + onChange("customStatuses", currentStatuses); + } + }; + + return ( +
+

Custom Statuses

+ + + + onChange("useCustomStatusesOnly", e.target.checked) + } + /> + + + +
+ {settings.customStatuses.length === 0 ? ( +
+

+ No custom statuses yet. Click "Add Status" below + to create your first one. +

+
+ ) : ( + settings.customStatuses.map((status, index) => ( + + )) + )} +
+
+ + + + +
+ ); +}; diff --git a/components/SettingsUI.tsx/NoStatusesMessage.tsx b/components/SettingsUI.tsx/NoStatusesMessage.tsx new file mode 100644 index 0000000..becb1ae --- /dev/null +++ b/components/SettingsUI.tsx/NoStatusesMessage.tsx @@ -0,0 +1,7 @@ +import React from "react"; + +export const NoStatusesMessage: React.FC = () => ( +
+ No statuses available. Enable templates or add custom statuses first. +
+); diff --git a/components/SettingsUI.tsx/QuickCommandsSettings.tsx b/components/SettingsUI.tsx/QuickCommandsSettings.tsx new file mode 100644 index 0000000..af3fd1d --- /dev/null +++ b/components/SettingsUI.tsx/QuickCommandsSettings.tsx @@ -0,0 +1,105 @@ +import { PluginSettings, StatusTemplate } from "@/types/pluginSettings"; +import React, { useCallback, useMemo } from "react"; +import { StatusGroup } from "./StatusGroup"; +import { NoStatusesMessage } from "./NoStatusesMessage"; + +export type Props = { + settings: PluginSettings; + onChange: (key: keyof PluginSettings, value: unknown) => void; + templates: StatusTemplate[]; +}; + +type GroupedStatuses = { + customStatuses: PluginSettings["customStatuses"]; + templateGroups: Array<{ + template: StatusTemplate; + statuses: PluginSettings["customStatuses"]; + }>; +}; + +export const QuickCommandsSettings: React.FC = ({ + settings, + onChange, + templates, +}) => { + const groupedStatuses = useMemo((): GroupedStatuses => { + const customStatuses = settings.customStatuses.filter( + (s) => s.name !== "unknown", + ); + const templateGroups = []; + if (!settings.useCustomStatusesOnly) { + for (const templateId of settings.enabledTemplates) { + const template = templates.find((t) => t.id === templateId); + if (template) { + const statuses = template.statuses.filter( + (s) => s.name !== "unknown", + ); + if (statuses.length > 0) { + templateGroups.push({ template, statuses }); + } + } + } + } + return { customStatuses, templateGroups }; + }, [settings, templates]); + + const currentQuickCommands = settings.quickStatusCommands || []; + + const handleQuickCommandToggle = useCallback( + (statusName: string, enabled: boolean) => { + const updatedCommands = enabled + ? [ + ...currentQuickCommands.filter( + (cmd) => cmd !== statusName, + ), + statusName, + ] + : currentQuickCommands.filter((cmd) => cmd !== statusName); + onChange("quickStatusCommands", updatedCommands); + }, + [currentQuickCommands, onChange], + ); + + const hasAnyStatuses = + groupedStatuses.customStatuses.length > 0 || + groupedStatuses.templateGroups.length > 0; + + return ( +
+

Quick status commands

+

+ Select which statuses should have dedicated commands in the + command palette. These can be assigned hotkeys for quick access. +

+
+ {!hasAnyStatuses ? ( + + ) : ( + <> + {groupedStatuses.customStatuses.length > 0 && ( + + )} + {groupedStatuses.templateGroups.map( + ({ template, statuses }) => ( + + ), + )} + + )} +
+
+ ); +}; diff --git a/components/SettingsUI.tsx/SettingItem.tsx b/components/SettingsUI.tsx/SettingItem.tsx new file mode 100644 index 0000000..5837a38 --- /dev/null +++ b/components/SettingsUI.tsx/SettingItem.tsx @@ -0,0 +1,29 @@ +import React from "react"; + +interface SettingItemProps { + name: string; + description?: string; + vertical?: boolean; + children: React.ReactNode; +} + +export const SettingItem: React.FC = ({ + name, + description, + children, + vertical, +}) => ( +
+
+
{name}
+ {description && ( +
{description}
+ )} +
+
+ {children} +
+
+); diff --git a/components/SettingsUI.tsx/StatusGroup.tsx b/components/SettingsUI.tsx/StatusGroup.tsx new file mode 100644 index 0000000..cdee693 --- /dev/null +++ b/components/SettingsUI.tsx/StatusGroup.tsx @@ -0,0 +1,45 @@ +import React from "react"; +import { PluginSettings } from "@/types/pluginSettings"; +import { SettingItem } from "./SettingItem"; + +export type StatusGroupProps = { + statuses: PluginSettings["customStatuses"]; + title: string; + description?: string; + currentQuickCommands: string[]; + onToggle: (statusName: string, enabled: boolean) => void; +}; + +export const StatusGroup: React.FC = ({ + statuses, + title, + description, + currentQuickCommands, + onToggle, +}) => ( +
+
+
{title}
+ {description && ( +
{description}
+ )} +
+
+ {statuses.map((status) => ( + + + onToggle(status.name, e.target.checked) + } + /> + + ))} +
+
+); diff --git a/components/SettingsUI.tsx/TemplateItem.tsx b/components/SettingsUI.tsx/TemplateItem.tsx new file mode 100644 index 0000000..194a100 --- /dev/null +++ b/components/SettingsUI.tsx/TemplateItem.tsx @@ -0,0 +1,48 @@ +import React from "react"; +import { StatusTemplate } from "@/types/pluginSettings"; +import { StatusDisplay } from "../atoms/StatusDisplay"; +import { SelectableListItem } from "../atoms/SelectableListItem"; + +interface TemplateItemProps { + template: StatusTemplate; + isEnabled: boolean; + onToggle: (templateId: string, enabled: boolean) => void; +} + +export const TemplateItem: React.FC = ({ + template, + isEnabled, + onToggle, +}) => ( + onToggle(template.id, !isEnabled)} + className={`template-item ${isEnabled ? "enabled" : ""}`} + icon={ + + } + > +
+
+ {template.name} +
+
+ {template.description}: +
+
+ {template.statuses.map((status, index) => ( + + ))} +
+
+
+); diff --git a/components/SettingsUI.tsx/TemplateSettings.tsx b/components/SettingsUI.tsx/TemplateSettings.tsx new file mode 100644 index 0000000..67d6756 --- /dev/null +++ b/components/SettingsUI.tsx/TemplateSettings.tsx @@ -0,0 +1,54 @@ +import React, { useCallback } from "react"; +import { PluginSettings, StatusTemplate } from "@/types/pluginSettings"; +import { TemplateItem } from "./TemplateItem"; + +interface TemplateSettingsProps { + settings: PluginSettings; + onChange: (key: keyof PluginSettings, value: unknown) => void; + templates: StatusTemplate[]; +} + +export const TemplateSettings: React.FC = ({ + settings, + onChange, + templates, +}) => { + const handleTemplateToggle = useCallback( + (templateId: string, enabled: boolean) => { + let newEnabledTemplated = [...settings.enabledTemplates]; + if (enabled) { + if (!newEnabledTemplated.includes(templateId)) { + newEnabledTemplated.push(templateId); + } + } else { + newEnabledTemplated = newEnabledTemplated.filter( + (id: string) => id !== templateId, + ); + } + onChange("enabledTemplates", newEnabledTemplated); + }, + [onChange, settings.enabledTemplates], + ); + + return ( +
+

Status templates

+

+ Enable predefined templates to quickly add common status + workflows +

+
+ {templates.map((template) => ( + + ))} +
+
+ ); +}; diff --git a/components/SettingsUI.tsx/UISettings.tsx b/components/SettingsUI.tsx/UISettings.tsx new file mode 100644 index 0000000..d4b370b --- /dev/null +++ b/components/SettingsUI.tsx/UISettings.tsx @@ -0,0 +1,95 @@ +import { PluginSettings } from "@/types/pluginSettings"; +import React from "react"; +import { Select } from "../atoms/Select"; +import { SettingItem } from "./SettingItem"; + +export type Props = { + settings: PluginSettings; + onChange: (key: keyof PluginSettings, value: unknown) => void; +}; + +export const UISettings: React.FC = ({ settings, onChange }) => { + const handleChange = + (key: keyof PluginSettings) => + (e: React.ChangeEvent) => { + onChange(key, e.target.checked); + }; + + return ( +
+

User interface

+ + + + + + + + + + + + + + + + + + + + +
+ ); +}; diff --git a/components/SettingsUI.tsx/index.tsx b/components/SettingsUI.tsx/index.tsx new file mode 100644 index 0000000..b59b7ee --- /dev/null +++ b/components/SettingsUI.tsx/index.tsx @@ -0,0 +1,45 @@ +import { useState, useEffect } from "react"; +import { PluginSettings } from "@/types/pluginSettings"; +import { UISettings } from "./UISettings"; +import { TemplateSettings } from "./TemplateSettings"; +import { BehaviourSettings } from "./BehaviourSettings"; +import { CustomStatusSettings } from "./CustomStatusSettings"; +import { QuickCommandsSettings } from "./QuickCommandsSettings"; +import { PREDEFINED_TEMPLATES } from "@/constants/predefinedTemplates"; + +export type Props = { + settings: PluginSettings; + onChange: (key: keyof PluginSettings, value: unknown) => void; +}; + +const SettingsUI: React.FC = ({ settings, onChange }) => { + const [localSettings, setLocalSettings] = useState(settings); + + useEffect(() => { + setLocalSettings(settings); + }, [settings]); + + const handleChange = (key: keyof PluginSettings, value: unknown) => { + setLocalSettings((prev) => ({ ...prev, [key]: value })); + onChange(key, value); + }; + + return ( +
+ + + + + +
+ ); +}; +export default SettingsUI; diff --git a/components/StatusBar/StatusBar.tsx b/components/StatusBar/StatusBar.tsx new file mode 100644 index 0000000..45ff688 --- /dev/null +++ b/components/StatusBar/StatusBar.tsx @@ -0,0 +1,55 @@ +import { GroupedStatuses } from "@/types/noteStatus"; +import { FC } from "react"; +import { + StatusBarProvider, + Props as StatusBarContextProps, +} from "./StatusBarContext"; +import { StatusBarGroup } from "./StatusBarGroup"; + +export type Props = { + statuses: GroupedStatuses; + templates?: { [key: string]: { description: string } }; + hideIfNotStatuses?: boolean; + onStatusClick: StatusBarContextProps["onStatusClick"]; +}; + +export const StatusBar: FC = ({ + statuses, + hideIfNotStatuses, + onStatusClick, +}) => { + const statusEntries = Object.entries(statuses); + const hasStatuses = statusEntries.flatMap((s) => s[1]).length > 0; + + if (!hasStatuses) { + if (hideIfNotStatuses) return null; + return ( + onStatusClick({ name: "", icon: "" })} + style={{ cursor: "pointer" }} + > + No status + + ); + } + + return ( + +
+ {statusEntries.map(([frontmatterTagName, statusList]) => ( + + ))} +
+
+ ); +}; diff --git a/components/StatusBar/StatusBarContext.tsx b/components/StatusBar/StatusBarContext.tsx new file mode 100644 index 0000000..2b10f8c --- /dev/null +++ b/components/StatusBar/StatusBarContext.tsx @@ -0,0 +1,34 @@ +import { createContext, useContext, ReactNode } from "react"; +import { NoteStatus } from "@/types/noteStatus"; + +interface StatusBarContextValue { + onStatusClick: (status: NoteStatus) => void; +} + +const StatusBarContext = createContext({ + onStatusClick: () => {}, +}); + +export const useStatusBarContext = () => useContext(StatusBarContext); + +export type Props = { + children: ReactNode; + onStatusClick: StatusBarContextValue["onStatusClick"]; +}; + +export const StatusBarProvider: React.FC = ({ + children, + onStatusClick, +}) => { + const value = { + onStatusClick: (status: NoteStatus) => { + onStatusClick(status); + }, + }; + + return ( + + {children} + + ); +}; diff --git a/components/StatusBar/StatusBarEnableButton.tsx b/components/StatusBar/StatusBarEnableButton.tsx new file mode 100644 index 0000000..b3a34ef --- /dev/null +++ b/components/StatusBar/StatusBarEnableButton.tsx @@ -0,0 +1,17 @@ +import { FC } from "react"; + +export type Props = { + onEnableClick: () => void; +}; + +export const StatusBarEnableButton: FC = ({ onEnableClick }) => { + return ( + + 📊 + + ); +}; diff --git a/components/StatusBar/StatusBarGroup.tsx b/components/StatusBar/StatusBarGroup.tsx new file mode 100644 index 0000000..c2e94e0 --- /dev/null +++ b/components/StatusBar/StatusBarGroup.tsx @@ -0,0 +1,61 @@ +import { FC, useState } from "react"; +import { NoteStatus } from "@/types/noteStatus"; +import { StatusTemplate } from "@/types/pluginSettings"; +import { GroupLabel } from "../atoms/GroupLabel"; +import { CollapsibleCounter } from "../atoms/CollapsibleCounter"; +import { StatusDisplay } from "../atoms/StatusDisplay"; +import { useStatusBarContext } from "./StatusBarContext"; + +export interface StatusBarGroupProps { + statuses: NoteStatus[]; + template: StatusTemplate; + maxVisible?: number; +} + +export const StatusBarGroup: FC = ({ + statuses, + template, + maxVisible = 3, +}) => { + const [isUncollapsed, setIsUncollapsed] = useState(false); + const visibleStatuses = isUncollapsed + ? statuses + : statuses.slice(0, maxVisible); + const hiddenCount = Math.max(0, statuses.length - maxVisible); + const { onStatusClick } = useStatusBarContext(); + + return ( + <> + +
+ {visibleStatuses.map((status, i) => ( +
= maxVisible + ? `status-slide-in var(--anim-duration-fast) ease-out ${i * 0.05}s both` + : "none", + }} + title={status.description} + > + { + onStatusClick(status); + }} + /> +
+ ))} + {hiddenCount > 0 && ( + setIsUncollapsed(!isUncollapsed)} + /> + )} +
+ + ); +}; diff --git a/components/StatusDashboard/CurrentNoteSection.tsx b/components/StatusDashboard/CurrentNoteSection.tsx new file mode 100644 index 0000000..66b9994 --- /dev/null +++ b/components/StatusDashboard/CurrentNoteSection.tsx @@ -0,0 +1,78 @@ +import { TFile } from "obsidian"; +import { NoteStatus } from "@/types/noteStatus"; +import { StatusDisplay } from "@/components/atoms/StatusDisplay"; +import { GroupLabel } from "@/components/atoms/GroupLabel"; + +interface CurrentNoteInfo { + file: TFile | null; + statuses: Record; + lastModified: number; +} + +interface CurrentNoteSectionProps { + currentNote: CurrentNoteInfo; +} + +export const CurrentNoteSection = ({ + currentNote, +}: CurrentNoteSectionProps) => { + return ( +
+
+

Current Note

+
+
+ {currentNote.file ? ( + <> +
+
+ {currentNote.file.basename} +
+
+ {currentNote.file.path} +
+
+ Last modified:{" "} + {new Date( + currentNote.lastModified, + ).toLocaleString()} +
+
+ +
+ {Object.entries(currentNote.statuses).map( + ([tag, statuses]) => ( +
+ +
+ {statuses.length > 0 ? ( + statuses.map((status) => ( + + )) + ) : ( + + No status assigned + + )} +
+
+ ), + )} +
+ + ) : ( +
+ No note currently active +
+ )} +
+
+ ); +}; diff --git a/components/StatusDashboard/QuickActionsPanel.tsx b/components/StatusDashboard/QuickActionsPanel.tsx new file mode 100644 index 0000000..f7286bd --- /dev/null +++ b/components/StatusDashboard/QuickActionsPanel.tsx @@ -0,0 +1,160 @@ +export type DashboardAction = + | "refresh" + | "open-grouped-view" + | "find-unassigned" + | "change-status" + | "cycle-status" + | "clear-status" + | "copy-status" + | "paste-status" + | "search-by-status" + | "search-by-specific-status" + | "toggle-multiple-mode" + | "set-quick-status"; + +interface QuickActionsPanelProps { + hasCurrentFile: boolean; + hasCurrentNoteStatuses: boolean; + useMultipleStatuses: boolean; + quickStatusCommands: string[]; + onAction: (action: DashboardAction, value?: string) => void; +} + +export const QuickActionsPanel = ({ + hasCurrentFile, + hasCurrentNoteStatuses, + useMultipleStatuses, + quickStatusCommands, + onAction, +}: QuickActionsPanelProps) => { + return ( +
+
+

Quick Actions

+
+
+ {/* Views Group */} +
+
📊 Views
+ + +
+ + {/* Quick Status Group - Only show when not in multi-status mode and has quick statuses */} + {!useMultipleStatuses && quickStatusCommands.length > 0 && ( +
+
+ ⚡ Quick Status +
+ {quickStatusCommands.map((statusName) => ( + + ))} +
+ )} + + {/* Current Note Group */} +
+
+ 📝 Current Note +
+ + + +
+ + {/* Clipboard Group */} +
+
+ 📋 Clipboard +
+ + +
+ + {/* Tools Group */} +
+
⚙️ Tools
+ + + +
+
+
+ ); +}; diff --git a/components/StatusDashboard/StatusDashboard.tsx b/components/StatusDashboard/StatusDashboard.tsx new file mode 100644 index 0000000..24b6922 --- /dev/null +++ b/components/StatusDashboard/StatusDashboard.tsx @@ -0,0 +1,83 @@ +import { VaultStatsCard } from "./VaultStatsCard"; +import { CurrentNoteSection } from "./CurrentNoteSection"; +import { StatusDistributionChart } from "./StatusDistributionChart"; +import { QuickActionsPanel, DashboardAction } from "./QuickActionsPanel"; +import { VaultStats } from "./useVaultStats"; +import { PluginSettings } from "@/types/pluginSettings"; +import { TFile } from "obsidian"; +import { NoteStatus } from "@/types/noteStatus"; + +interface CurrentNoteInfo { + file: TFile | null; + statuses: Record; + lastModified: number; +} + +interface StatusDashboardProps { + onAction: (action: DashboardAction, value?: string) => void; + settings: PluginSettings; + vaultStats: VaultStats; + currentNote: CurrentNoteInfo; + isLoading: boolean; + availableStatuses: NoteStatus[]; +} + +export const StatusDashboard = ({ + onAction, + settings, + vaultStats, + currentNote, + isLoading, + availableStatuses, +}: StatusDashboardProps) => { + if (isLoading) { + return ( +
+
+ Loading dashboard... +
+
+ ); + } + + // Check if current note has any statuses + const hasCurrentNoteStatuses = + currentNote.file && + Object.values(currentNote.statuses).some( + (statuses) => statuses.length > 0, + ); + + return ( +
+
+

Status Dashboard

+ +
+ +
+ + + + onAction("search-by-specific-status", statusName) + } + /> + +
+
+ ); +}; diff --git a/components/StatusDashboard/StatusDistributionChart.tsx b/components/StatusDashboard/StatusDistributionChart.tsx new file mode 100644 index 0000000..590a8cb --- /dev/null +++ b/components/StatusDashboard/StatusDistributionChart.tsx @@ -0,0 +1,126 @@ +import { useMemo } from "react"; +import { StatusDisplay } from "@/components/atoms/StatusDisplay"; +import { VaultStats } from "./useVaultStats"; +import { NoteStatus } from "@/types/noteStatus"; + +interface StatusDistributionChartProps { + vaultStats: VaultStats; + availableStatuses: NoteStatus[]; + onStatusClick?: (statusName: string) => void; +} + +export const StatusDistributionChart = ({ + vaultStats, + availableStatuses, + onStatusClick, +}: StatusDistributionChartProps) => { + const statusChart = useMemo(() => { + const total = Object.values(vaultStats.statusDistribution).reduce( + (sum, count) => sum + count, + 0, + ); + if (total === 0) return []; + + return Object.entries(vaultStats.statusDistribution) + .filter(([_, count]) => count > 0) + .map(([statusName, count]) => ({ + name: statusName, + count, + percentage: Math.round((count / total) * 100), + })) + .sort((a, b) => b.count - a.count); + }, [vaultStats.statusDistribution]); + + const statusMap = useMemo(() => { + const map = new Map(); + availableStatuses.forEach((s) => { + const scopedIdentifier = s.templateId + ? `${s.templateId}:${s.name}` + : s.name; + map.set(scopedIdentifier, s); + }); + + // Also handle the case where we need to map legacy statuses to their first template + Object.keys(vaultStats.statusDistribution).forEach( + (statusIdentifier) => { + if (!map.has(statusIdentifier)) { + // This might be a legacy status that got assigned to a template + if (statusIdentifier.includes(":")) { + const [templateId, statusName] = + statusIdentifier.split(":"); + const templateStatus = availableStatuses.find( + (s) => + s.templateId === templateId && + s.name === statusName, + ); + if (templateStatus) { + map.set(statusIdentifier, templateStatus); + } + } + } + }, + ); + + return map; + }, [availableStatuses, vaultStats.statusDistribution]); + + return ( +
+
+

Status Distribution

+
+
+ {statusChart.length > 0 ? ( +
+ {statusChart.map(({ name, count, percentage }) => { + const status = statusMap.get(name); + return ( +
onStatusClick?.(name)} + title={ + onStatusClick + ? `Click to search for notes with status: ${name}` + : undefined + } + > +
+ {status ? ( + + ) : ( + + {name} + + )} + + {count} notes ({percentage}%) + +
+
+
+
+
+ ); + })} +
+ ) : ( +
+ No status data available +
+ )} +
+
+ ); +}; diff --git a/components/StatusDashboard/VaultStatsCard.tsx b/components/StatusDashboard/VaultStatsCard.tsx new file mode 100644 index 0000000..a9ece36 --- /dev/null +++ b/components/StatusDashboard/VaultStatsCard.tsx @@ -0,0 +1,54 @@ +import { VaultStats } from "./useVaultStats"; + +interface VaultStatsCardProps { + vaultStats: VaultStats; +} + +export const VaultStatsCard = ({ vaultStats }: VaultStatsCardProps) => { + return ( +
+
+

Vault Overview

+
+
+
+
+
+ {vaultStats.totalNotes} +
+
Total Notes
+
+
+
+ {vaultStats.notesWithStatus} +
+
+ Notes with Status +
+
+
+
+ {vaultStats.totalNotes > 0 + ? Math.round( + (vaultStats.notesWithStatus / + vaultStats.totalNotes) * + 100, + ) + : 0} + % +
+
Coverage
+
+
+
+ {Object.values( + vaultStats.statusDistribution, + ).reduce((sum, count) => sum + count, 0)} +
+
Total Statuses
+
+
+
+
+ ); +}; diff --git a/components/StatusDashboard/useCurrentNote.tsx b/components/StatusDashboard/useCurrentNote.tsx new file mode 100644 index 0000000..805db41 --- /dev/null +++ b/components/StatusDashboard/useCurrentNote.tsx @@ -0,0 +1,44 @@ +import { useState, useCallback } from "react"; +import { TFile } from "obsidian"; +import { NoteStatus } from "@/types/noteStatus"; +import { + BaseNoteStatusService, + NoteStatusService, +} from "@/core/noteStatusService"; + +interface CurrentNoteInfo { + file: TFile | null; + statuses: Record; + lastModified: number; +} + +export const useCurrentNote = () => { + const [currentNote, setCurrentNote] = useState({ + file: null, + statuses: {}, + lastModified: 0, + }); + + const updateCurrentNote = useCallback(() => { + const activeFile = BaseNoteStatusService.app.workspace.getActiveFile(); + + if (!activeFile) { + setCurrentNote({ file: null, statuses: {}, lastModified: 0 }); + return; + } + + const noteStatusService = new NoteStatusService(activeFile); + noteStatusService.populateStatuses(); + + setCurrentNote({ + file: activeFile, + statuses: noteStatusService.statuses, + lastModified: activeFile.stat.mtime, + }); + }, []); + + return { + currentNote, + updateCurrentNote, + }; +}; diff --git a/components/StatusDashboard/useVaultStats.tsx b/components/StatusDashboard/useVaultStats.tsx new file mode 100644 index 0000000..70c49cf --- /dev/null +++ b/components/StatusDashboard/useVaultStats.tsx @@ -0,0 +1,121 @@ +import { useState, useCallback } from "react"; +import { TFile } from "obsidian"; +import { NoteStatus } from "@/types/noteStatus"; +import { BaseNoteStatusService } from "@/core/noteStatusService"; +import settingsService from "@/core/settingsService"; + +export interface VaultStats { + totalNotes: number; + notesWithStatus: number; + statusDistribution: Record; + tagDistribution: Record; + recentChanges: Array<{ + file: TFile; + status: NoteStatus; + timestamp: number; + action: "added" | "removed"; + }>; +} + +export const useVaultStats = () => { + const [vaultStats, setVaultStats] = useState({ + totalNotes: 0, + notesWithStatus: 0, + statusDistribution: {}, + tagDistribution: {}, + recentChanges: [], + }); + + const calculateVaultStats = useCallback((): VaultStats => { + const files = BaseNoteStatusService.app.vault.getMarkdownFiles(); + const availableStatuses = + BaseNoteStatusService.getAllAvailableStatuses(); + const statusMetadataKeys = [settingsService.settings.tagPrefix]; + + let notesWithStatus = 0; + const statusDistribution: Record = {}; + const tagDistribution: Record = {}; + + // Initialize distribution using full scoped identifiers + availableStatuses.forEach((status) => { + const scopedIdentifier = status.templateId + ? `${status.templateId}:${status.name}` + : status.name; + statusDistribution[scopedIdentifier] = 0; + }); + + statusMetadataKeys.forEach((tag) => { + tagDistribution[tag] = 0; + }); + + files.forEach((file) => { + const cachedMetadata = + BaseNoteStatusService.app.metadataCache.getFileCache(file); + const frontmatter = cachedMetadata?.frontmatter; + + if (!frontmatter) return; + + let hasAnyStatus = false; + + statusMetadataKeys.forEach((key) => { + const value = frontmatter[key]; + if (value) { + hasAnyStatus = true; + tagDistribution[key]++; + + const statusNames = Array.isArray(value) ? value : [value]; + statusNames.forEach((statusName) => { + const statusStr = statusName.toString(); + + // Determine the scoped identifier to use + let scopedIdentifier: string; + + if (statusStr.includes(":")) { + // Already scoped + scopedIdentifier = statusStr; + } else { + // Legacy status - find first template that has this status + const firstTemplateWithStatus = + availableStatuses.find( + (s) => s.name === statusStr && s.templateId, + ); + scopedIdentifier = firstTemplateWithStatus + ? `${firstTemplateWithStatus.templateId}:${statusStr}` + : statusStr; // Fallback to unscoped if no template found + } + + // Initialize status if not already present + if ( + !statusDistribution.hasOwnProperty(scopedIdentifier) + ) { + statusDistribution[scopedIdentifier] = 0; + } + statusDistribution[scopedIdentifier]++; + }); + } + }); + + if (hasAnyStatus) { + notesWithStatus++; + } + }); + + return { + totalNotes: files.length, + notesWithStatus, + statusDistribution, + tagDistribution, + recentChanges: [], + }; + }, []); + + const updateVaultStats = useCallback(() => { + const stats = calculateVaultStats(); + setVaultStats(stats); + }, [calculateVaultStats]); + + return { + vaultStats, + updateVaultStats, + }; +}; diff --git a/components/StatusFileInfoPopup/StatusFileInfoPopup.tsx b/components/StatusFileInfoPopup/StatusFileInfoPopup.tsx new file mode 100644 index 0000000..6cd42c7 --- /dev/null +++ b/components/StatusFileInfoPopup/StatusFileInfoPopup.tsx @@ -0,0 +1,67 @@ +import React from "react"; +import { GroupedStatuses } from "@/types/noteStatus"; +import { StatusDisplay } from "../atoms/StatusDisplay"; + +export interface Props { + statuses: GroupedStatuses; + onClose?: () => void; +} + +export const StatusFileInfoPopup: React.FC = ({ statuses }) => { + const statusEntries = Object.entries(statuses); + + if (statusEntries.length === 0) { + return ( +
+ 📋 + No statuses found +
+ ); + } + + return ( +
e.stopPropagation()}> +
+ 📊 + Status Overview +
+ +
+ {statusEntries.map(([groupName, statusList]) => ( +
+
+ + {groupName.toLowerCase()} + + + {statusList.length} + +
+ +
+ {statusList.map((status, index) => ( +
+ + {status.description && ( +
+ {status.description} +
+ )} +
+ ))} +
+
+ ))} +
+
+ ); +}; diff --git a/components/atoms/CollapsibleCounter.tsx b/components/atoms/CollapsibleCounter.tsx new file mode 100644 index 0000000..b5519d8 --- /dev/null +++ b/components/atoms/CollapsibleCounter.tsx @@ -0,0 +1,25 @@ +import { FC } from "react"; + +export interface CollapsibleCounterProps { + count: number; + isCollapsed: boolean; + onToggle: React.MouseEventHandler; +} + +export const CollapsibleCounter: FC = ({ + count, + isCollapsed, + onToggle, +}) => { + if (count <= 0) return null; + + return ( + + {isCollapsed ? "+" : "-"} + {count} + + ); +}; diff --git a/components/atoms/GroupLabel.tsx b/components/atoms/GroupLabel.tsx new file mode 100644 index 0000000..aa8f727 --- /dev/null +++ b/components/atoms/GroupLabel.tsx @@ -0,0 +1,42 @@ +import { FC } from "react"; + +export interface GroupLabelProps { + name: string; + isHighlighted?: boolean; +} + +export const GroupLabel: FC = ({ + name, + isHighlighted = false, +}) => { + if (!name) return null; + + return ( + + + + + {name} + + ); +}; diff --git a/components/atoms/Input.tsx b/components/atoms/Input.tsx new file mode 100644 index 0000000..184af6e --- /dev/null +++ b/components/atoms/Input.tsx @@ -0,0 +1,94 @@ +import React from "react"; + +export type InputVariant = "text" | "color" | "search"; + +interface BaseInputProps { + variant: InputVariant; + value: string; + onChange: (value: string) => void; + placeholder?: string; + className?: string; + style?: React.CSSProperties; + onFocus?: () => void; + onBlur?: () => void; +} + +interface TextInputProps extends BaseInputProps { + variant: "text"; +} + +interface ColorInputProps extends BaseInputProps { + variant: "color"; +} + +interface SearchInputProps extends BaseInputProps { + variant: "search"; +} + +export type InputProps = TextInputProps | ColorInputProps | SearchInputProps; + +const getInputStyles = (variant: InputVariant): React.CSSProperties => { + const baseStyles: React.CSSProperties = { + border: "1px solid var(--background-modifier-border)", + borderRadius: "4px", + backgroundColor: "var(--background-primary)", + color: "var(--text-normal)", + fontSize: "14px", + outline: "none", + }; + + switch (variant) { + case "text": + case "search": + return { + ...baseStyles, + width: "100%", + padding: "8px 12px", + }; + case "color": + return { + ...baseStyles, + width: "32px", + height: "32px", + padding: "2px", + cursor: "pointer", + }; + default: + return baseStyles; + } +}; + +export const Input = React.forwardRef( + ( + { + variant, + value, + onChange, + placeholder, + className, + style, + onFocus, + onBlur, + }, + ref, + ) => { + const inputStyles = getInputStyles(variant); + const combinedStyles = { ...inputStyles, ...style }; + + return ( + onChange(e.target.value)} + placeholder={placeholder} + className={className} + style={combinedStyles} + onFocus={onFocus} + onBlur={onBlur} + /> + ); + }, +); + +Input.displayName = "Input"; diff --git a/components/atoms/SearchFilter.tsx b/components/atoms/SearchFilter.tsx new file mode 100644 index 0000000..73b4b93 --- /dev/null +++ b/components/atoms/SearchFilter.tsx @@ -0,0 +1,24 @@ +import React from "react"; +import { Input } from "./Input"; + +interface Props { + value: string; + onFilterChange: (value: string) => void; + placeholder?: string; +} + +export const SearchFilter = React.forwardRef( + ({ value, onFilterChange, placeholder = "Search..." }, ref) => { + return ( + + ); + }, +); + +SearchFilter.displayName = "SearchFilter"; diff --git a/components/atoms/Select.tsx b/components/atoms/Select.tsx new file mode 100644 index 0000000..2c8d02e --- /dev/null +++ b/components/atoms/Select.tsx @@ -0,0 +1,26 @@ +import React, { ReactNode } from "react"; + +type SelectProps = { + options: { value: string | number; display: ReactNode }[]; + onChange: (value: string) => void; + defaultValue?: string; +}; + +export const Select: React.FC = ({ + options, + onChange, + defaultValue, +}) => { + return ( + + ); +}; diff --git a/components/atoms/SelectableListItem.tsx b/components/atoms/SelectableListItem.tsx new file mode 100644 index 0000000..f17e97f --- /dev/null +++ b/components/atoms/SelectableListItem.tsx @@ -0,0 +1,100 @@ +import React, { ReactNode, useState } from "react"; + +interface SelectableListItemProps { + selected?: boolean; + focused?: boolean; + icon?: ReactNode; + children: ReactNode; + onClick?: () => void; + className?: string; + title?: string; +} + +export const SelectableListItem: React.FC = ({ + selected = false, + focused = false, + icon, + children, + onClick, + className = "", + title, +}) => { + const [isHovered, setIsHovered] = useState(false); + + const handleClick = () => { + if (onClick) { + setTimeout(() => { + onClick(); + }, 150); + } + }; + + return ( +
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + title={title} + style={{ + display: "flex", + alignItems: "center", + gap: "12px", + padding: "8px 12px", + cursor: onClick ? "pointer" : "default", + borderBottom: "1px solid var(--background-modifier-border)", + transition: "background-color 150ms ease", + background: + selected || isHovered || focused + ? "var(--background-modifier-hover)" + : "", + outline: focused + ? "2px solid var(--interactive-accent)" + : "none", + outlineOffset: "-2px", + }} + > + {icon && ( + + {icon} + + )} + + {children} + + {selected && ( +
+ + + +
+ )} +
+ ); +}; diff --git a/components/atoms/StatusDisplay.tsx b/components/atoms/StatusDisplay.tsx new file mode 100644 index 0000000..34c5d1f --- /dev/null +++ b/components/atoms/StatusDisplay.tsx @@ -0,0 +1,143 @@ +import { NoteStatus } from "@/types/noteStatus"; +import { FC, memo, useState } from "react"; +import { getStatusTooltip } from "@/utils/statusUtils"; + +export type StatusDisplayVariant = "chip" | "badge" | "template"; + +interface StatusDisplayProps { + status: NoteStatus; + variant: StatusDisplayVariant; + removable?: boolean; + onRemove?: () => void; + onClick?: () => void; +} + +export const StatusDisplay: FC = memo( + ({ status, variant, removable = false, onRemove, onClick }) => { + const [isRemoving, setIsRemoving] = useState(false); + + const getDisplayName = () => { + return status.templateId + ? `${status.name} (${status.templateId})` + : status.name; + }; + + const handleRemove = (e: React.MouseEvent) => { + e.stopPropagation(); + if (onRemove) { + setIsRemoving(true); + setTimeout(() => { + onRemove(); + }, 150); + } + }; + + const handleClick = () => { + if (onClick) { + onClick(); + } + }; + + if (variant === "chip") { + return ( +
+ + {status.icon ? status.icon : "📝"} + + + {getDisplayName()} + + {removable && ( +
+ + + + +
+ )} +
+ ); + } + + if (variant === "badge") { + return ( +
+
+ + {status.icon ? status.icon : "📝"} + + + {getDisplayName()} + +
+
+ ); + } + + if (variant === "template") { + return ( +
+ + + {status.icon ? status.icon : "📝"} {getDisplayName()} + +
+ ); + } + + return null; + }, +); diff --git a/components/atoms/StatusSelector.tsx b/components/atoms/StatusSelector.tsx new file mode 100644 index 0000000..b256056 --- /dev/null +++ b/components/atoms/StatusSelector.tsx @@ -0,0 +1,79 @@ +import React, { memo, useCallback } from "react"; +import { NoteStatus } from "@/types/noteStatus"; +import { SelectableListItem } from "./SelectableListItem"; +import { getStatusTooltip, isStatusSelected } from "@/utils/statusUtils"; + +interface StatusOptionProps { + status: NoteStatus; + isSelected: boolean; + isFocused: boolean; + onSelect: () => void; +} + +export const StatusModalOption: React.FC = memo( + ({ status, isSelected, isFocused, onSelect }) => { + const displayName = status.templateId + ? `${status.name} (${status.templateId})` + : status.name; + + return ( + + {displayName} + + ); + }, +); + +export interface Props { + currentStatuses: NoteStatus[]; + availableStatuses: NoteStatus[]; + focusedIndex?: number; + onToggleStatus: (status: NoteStatus, selected: boolean) => void; +} + +export const StatusSelector: React.FC = ({ + currentStatuses, + availableStatuses, + focusedIndex = -1, + onToggleStatus, +}) => { + const handleSelectStatus = useCallback( + async (status: NoteStatus) => { + const selected = isStatusSelected(status, currentStatuses); + onToggleStatus(status, !selected); + }, + [currentStatuses, onToggleStatus], + ); + + return ( +
+ {availableStatuses.map((status, index) => ( + handleSelectStatus(status)} + /> + ))} +
+ ); +}; diff --git a/components/status-bar/index.ts b/components/status-bar/index.ts deleted file mode 100644 index 6338305..0000000 --- a/components/status-bar/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { StatusBarController } from "./status-bar-controller"; - -export { StatusBarController as StatusBar }; -export default StatusBarController; diff --git a/components/status-bar/status-bar-controller.ts b/components/status-bar/status-bar-controller.ts deleted file mode 100644 index 8c526ec..0000000 --- a/components/status-bar/status-bar-controller.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { NoteStatusSettings } from "../../models/types"; -import { StatusService } from "../../services/status-service"; -import { StatusBarView } from "./status-bar-view"; - -/** - * Controller for the status bar - */ -export class StatusBarController { - private view: StatusBarView; - private settings: NoteStatusSettings; - private statusService: StatusService; - private currentStatuses: string[] = ["unknown"]; - - constructor( - statusBarContainer: HTMLElement, - settings: NoteStatusSettings, - statusService: StatusService, - ) { - this.view = new StatusBarView(statusBarContainer); - this.settings = settings; - this.statusService = statusService; - - this.update(["unknown"]); - } - - /** - * Update the status bar with new statuses - */ - public update(statuses: string[]): void { - this.currentStatuses = statuses; - this.render(); - } - - /** - * Render the status bar - */ - private render(): void { - this.view.reset(); - - if (!this.settings.showStatusBar) { - this.view.hide(); - return; - } - - if (!this.settings.useMultipleStatuses) { - this.renderStatuses([this.currentStatuses[0]]); - } else { - this.renderStatuses(this.currentStatuses); - } - - this.handleAutoHide(); - } - - /** - * Render statuses - handles both single and multiple status cases - */ - private renderStatuses(statuses: string[]): void { - const statusDetails = statuses.map((status) => { - const statusObj = this.statusService - .getAllStatuses() - .find((s) => s.name === status); - return { - name: status, - icon: this.statusService.getStatusIcon(status), - tooltipText: statusObj?.description - ? `${status} - ${statusObj.description}` - : status, - }; - }); - - this.view.renderStatuses(statusDetails); - } - - /** - * Handle auto-hide behavior - */ - private handleAutoHide(): void { - const onlyUnknown = - this.currentStatuses.length === 1 && - this.currentStatuses[0] === "unknown"; - - if (this.settings.autoHideStatusBar && onlyUnknown) { - this.view.hide(); - } else { - this.view.show(); - } - } - - /** - * Update settings reference - */ - public updateSettings(settings: NoteStatusSettings): void { - this.settings = settings; - this.render(); - } - - /** - * Clean up when plugin is unloaded - */ - public unload(): void { - this.view.destroy(); - } -} diff --git a/components/status-bar/status-bar-view.ts b/components/status-bar/status-bar-view.ts deleted file mode 100644 index f3f745d..0000000 --- a/components/status-bar/status-bar-view.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { setTooltip } from "obsidian"; - -/** - * Renders the status bar UI - */ -export class StatusBarView { - private element: HTMLElement; - - constructor(element: HTMLElement) { - this.element = element; - this.element.addClass("note-status-bar"); - } - - /** - * Clears the element and resets CSS classes - */ - reset(): void { - this.element.empty(); - this.element.removeClass("left", "hidden", "auto-hide", "visible"); - this.element.addClass("note-status-bar"); - } - - /** - * Hide the status bar - */ - hide(): void { - this.element.addClass("hidden"); - this.element.removeClass("visible"); - } - - /** - * Show the status bar - */ - show(): void { - this.element.removeClass("hidden"); - this.element.addClass("visible"); - } - - renderStatuses( - statuses: Array<{ name: string; icon: string; tooltipText: string }>, - ): void { - if (statuses.length === 1) { - this.renderSingleStatus( - statuses[0].name, - statuses[0].icon, - statuses[0].tooltipText, - ); - } else { - this.renderMultipleStatuses(statuses); - } - } - - /** - * Render a single status - */ - private renderSingleStatus( - status: string, - icon: string, - tooltipText: string, - ): void { - const statusText = this.element.createEl("span", { - text: `Status: ${status}`, - cls: `note-status-${status}`, - }); - setTooltip(statusText, tooltipText); - - const statusIcon = this.element.createEl("span", { - text: icon, - cls: `note-status-icon status-${status}`, - }); - setTooltip(statusIcon, tooltipText); - } - - /** - * Render multiple statuses - */ - private renderMultipleStatuses( - statuses: Array<{ name: string; icon: string; tooltipText: string }>, - ): void { - this.element.createEl("span", { - text: "Statuses: ", - cls: "note-status-label", - }); - - const badgesContainer = this.element.createEl("span", { - cls: "note-status-badges", - }); - - statuses.forEach((status) => - this.createStatusBadge(badgesContainer, status), - ); - } - - /** - * Create a status badge for multiple status display - */ - private createStatusBadge( - container: HTMLElement, - status: { name: string; icon: string; tooltipText: string }, - ): void { - const badge = container.createEl("span", { - cls: `note-status-badge status-${status.name}`, - }); - setTooltip(badge, status.tooltipText); - - badge.createEl("span", { - text: status.icon, - cls: "note-status-badge-icon", - }); - - badge.createEl("span", { - text: status.name, - cls: "note-status-badge-text", - }); - } - - /** - * Clean up the element - */ - destroy(): void { - this.element.empty(); - } -} diff --git a/components/status-dropdown/dropdown-events.ts b/components/status-dropdown/dropdown-events.ts deleted file mode 100644 index 55942df..0000000 --- a/components/status-dropdown/dropdown-events.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * Event handlers setup and cleanup for the dropdown - */ - -/** - * Setup event handlers for the dropdown - */ -export function setupDropdownEvents(options: { - clickOutsideHandler: (e: MouseEvent) => void; - escapeKeyHandler: (e: KeyboardEvent) => void; -}): void { - const { clickOutsideHandler, escapeKeyHandler } = options; - - document.addEventListener("click", clickOutsideHandler); - document.addEventListener("keydown", escapeKeyHandler); -} - -/** - * Remove dropdown event handlers - */ -export function removeDropdownEvents(options: { - clickOutsideHandler: (e: MouseEvent) => void; - escapeKeyHandler: (e: KeyboardEvent) => void; -}): void { - const { clickOutsideHandler, escapeKeyHandler } = options; - - document.removeEventListener("click", clickOutsideHandler); - document.removeEventListener("keydown", escapeKeyHandler); -} diff --git a/components/status-dropdown/dropdown-manager.ts b/components/status-dropdown/dropdown-manager.ts deleted file mode 100644 index fae9b4d..0000000 --- a/components/status-dropdown/dropdown-manager.ts +++ /dev/null @@ -1,273 +0,0 @@ -import { MarkdownView, Editor, Notice, TFile, App } from "obsidian"; -import { DropdownUI } from "./dropdown-ui"; -import { DropdownOptions, DropdownDependencies } from "./types"; -import { createDummyTarget } from "./dropdown-position"; -import { StatusService } from "services/status-service"; -import { NoteStatusSettings } from "models/types"; - -/** - * High-level manager for status dropdown interactions - */ -export class DropdownManager { - private app: App; - private settings: NoteStatusSettings; - private statusService: StatusService; - private currentStatuses: string[] = ["unknown"]; - private toolbarButton?: HTMLElement; - private dropdownUI: DropdownUI; - - constructor( - app: App, - settings: NoteStatusSettings, - statusService: StatusService, - ) { - this.app = app; - this.settings = settings; - this.statusService = statusService; - - const deps: DropdownDependencies = { app, settings, statusService }; - this.dropdownUI = new DropdownUI(deps); - this.setupDropdownCallbacks(); - } - - /** - * Set up dropdown callbacks - */ - private setupDropdownCallbacks(): void { - this.dropdownUI.setOnRemoveStatusHandler(async (status, targetFile) => { - if (!targetFile) return; - - const isMultiple = Array.isArray(targetFile); - - await this.statusService.handleStatusChange({ - files: targetFile, - statuses: status, - operation: "remove", - showNotice: isMultiple, - }); - }); - - this.dropdownUI.setOnSelectStatusHandler(async (status, targetFile) => { - // Check if handling multiple files - const isMultipleFiles = - Array.isArray(targetFile) && targetFile.length > 1; - - if (isMultipleFiles) { - const files = targetFile as TFile[]; - // Count how many files already have this status - const filesWithStatus = files.filter((file) => - this.statusService.getFileStatuses(file).includes(status), - ); - - // If ALL have the status, remove it. Otherwise, add it - const operation = - filesWithStatus.length === files.length - ? "remove" - : !this.settings.useMultipleStatuses - ? "set" - : "add"; - - await this.statusService.handleStatusChange({ - files: targetFile, - statuses: status, - isMultipleSelection: true, - operation: operation, - }); - } else { - // For individual files, maintain default behavior - await this.statusService.handleStatusChange({ - files: targetFile, - statuses: status, - }); - } - }); - } - - /** - * Updates the dropdown UI based on current statuses - */ - public update(currentStatuses: string[] | string, _file?: TFile): void { - this.currentStatuses = Array.isArray(currentStatuses) - ? [...currentStatuses] - : [currentStatuses]; - - this.dropdownUI.updateStatuses(this.currentStatuses); - } - - /** - * Updates settings reference - */ - public updateSettings(settings: NoteStatusSettings): void { - this.settings = settings; - this.dropdownUI.updateSettings(settings); - } - - /** - * Get position from cursor or fallback - */ - private getCursorPosition( - editor: Editor, - view: MarkdownView, - ): { x: number; y: number } { - try { - const cursor = editor.getCursor("head"); - const lineElement = view.contentEl.querySelector( - `.cm-line:nth-child(${cursor.line + 1})`, - ); - - if (lineElement) { - const rect = lineElement.getBoundingClientRect(); - return { x: rect.left + 20, y: rect.bottom + 5 }; - } - - const editorEl = view.contentEl.querySelector(".cm-editor"); - if (editorEl) { - const rect = editorEl.getBoundingClientRect(); - return { x: rect.left + 100, y: rect.top + 100 }; - } - } catch (error) { - console.error("Error getting position for dropdown:", error); - } - - return { x: window.innerWidth / 2, y: window.innerHeight / 3 }; - } - - /** - * Universal function to open the status dropdown - */ - public openStatusDropdown(options: DropdownOptions): void { - const activeFile = this.app.workspace.getActiveFile(); - const files = options.files || (activeFile ? [activeFile] : []); - if (!files.length) { - new Notice("No files selected"); - return; - } - if (!files.length || !files[0]) { - new Notice("No files selected"); - return; - } - - if (this.dropdownUI.isOpen) { - this.resetDropdown(); - return; - } - - const isSingleFile = files.length === 1; - - // Set up target files appropriately - if (isSingleFile) { - const targetFile = files[0]; - this.dropdownUI.setTargetFile(targetFile); - const currentStatuses = - this.statusService.getFileStatuses(targetFile); - this.dropdownUI.updateStatuses(currentStatuses); - } else { - // For multiple files, set the whole collection - this.dropdownUI.setTargetFiles(files); - const commonStatuses = this.findCommonStatuses(files); - this.dropdownUI.updateStatuses(commonStatuses); - } - - this.positionAndOpenDropdown(options); - } - - /** - * Reset dropdown state before opening - */ - public resetDropdown(): void { - this.dropdownUI.close(); - this.dropdownUI.setTargetFile(null); - } - - /** - * Position and open the dropdown - */ - private positionAndOpenDropdown(options: { - target?: HTMLElement; - position?: { x: number; y: number }; - editor?: Editor; - view?: MarkdownView; - }): void { - if (options.editor && options.view) { - const position = this.getCursorPosition( - options.editor, - options.view, - ); - this.openWithPosition(position); - return; - } - - if (options.target) { - if (options.position) { - this.dropdownUI.open(options.target, options.position); - } else { - const rect = options.target.getBoundingClientRect(); - this.dropdownUI.open(options.target, { - x: rect.left, - y: rect.bottom + 5, - }); - } - return; - } - - if (options.position) { - this.openWithPosition(options.position); - return; - } - - this.openWithPosition({ - x: window.innerWidth / 2, - y: window.innerHeight / 3, - }); - } - - /** - * Open dropdown at a specific position using dummy target - */ - private openWithPosition(position: { x: number; y: number }): void { - const dummyTarget = createDummyTarget(position); - this.dropdownUI.open(dummyTarget, position); - - setTimeout(() => { - if (dummyTarget.parentNode) { - dummyTarget.parentNode.removeChild(dummyTarget); - } - }, 100); - } - - /** - * Find common statuses across multiple files - */ - private findCommonStatuses(files: TFile[]): string[] { - if (files.length === 0) return ["unknown"]; - - const firstFileStatuses = this.statusService.getFileStatuses(files[0]); - - return firstFileStatuses.filter( - (status) => - status !== "unknown" && - files.every((file) => - this.statusService.getFileStatuses(file).includes(status), - ), - ); - } - - /** - * Render method (kept for compatibility) - */ - public render(): void { - // No-op - dropdown component handles rendering internally - } - - /** - * Remove dropdown when plugin is unloaded - */ - public unload(): void { - this.dropdownUI.dispose(); - - if (this.toolbarButton) { - this.toolbarButton.remove(); - this.toolbarButton = undefined; - } - } -} diff --git a/components/status-dropdown/dropdown-position.ts b/components/status-dropdown/dropdown-position.ts deleted file mode 100644 index 751b38f..0000000 --- a/components/status-dropdown/dropdown-position.ts +++ /dev/null @@ -1,123 +0,0 @@ -/** - * Dropdown positioning utilities - */ - -/** - * Position the dropdown - */ -export function positionDropdown(options: { - dropdownElement: HTMLElement; - targetEl: HTMLElement; - position?: { x: number; y: number }; -}): void { - const { dropdownElement, targetEl, position } = options; - - if (position) { - positionAt(dropdownElement, position.x, position.y); - } else { - positionRelativeTo(dropdownElement, targetEl); - } -} - -/** - * Position the dropdown at specific coordinates - */ -function positionAt(dropdownElement: HTMLElement, x: number, y: number): void { - dropdownElement.addClass("note-status-popover-fixed"); - dropdownElement.style.setProperty("--pos-x-px", `${x}px`); - dropdownElement.style.setProperty("--pos-y-px", `${y}px`); - - setTimeout(() => adjustPositionToViewport(dropdownElement), 0); -} - -/** - * Adjust the dropdown position to ensure it's visible in the viewport - */ -function adjustPositionToViewport(dropdownElement: HTMLElement): void { - const rect = dropdownElement.getBoundingClientRect(); - - if (rect.right > window.innerWidth) { - dropdownElement.addClass("note-status-popover-right"); - dropdownElement.style.setProperty("--right-offset-px", "10px"); - } else { - dropdownElement.removeClass("note-status-popover-right"); - } - - if (rect.bottom > window.innerHeight) { - dropdownElement.addClass("note-status-popover-bottom"); - dropdownElement.style.setProperty("--bottom-offset-px", "10px"); - } else { - dropdownElement.removeClass("note-status-popover-bottom"); - } - - const maxHeight = window.innerHeight - rect.top - 20; - dropdownElement.style.setProperty("--max-height-px", `${maxHeight}px`); -} - -/** - * Position the dropdown relative to a target element - */ -function positionRelativeTo( - dropdownElement: HTMLElement, - targetEl: HTMLElement, -): void { - dropdownElement.addClass("note-status-popover-fixed"); - - const targetRect = targetEl.getBoundingClientRect(); - - dropdownElement.style.setProperty( - "--pos-y-px", - `${targetRect.bottom + 5}px`, - ); - dropdownElement.style.setProperty("--pos-x-px", `${targetRect.left}px`); - - setTimeout(() => adjustRelativePosition(dropdownElement, targetRect), 0); -} - -/** - * Adjust position when positioned relative to an element - */ -function adjustRelativePosition( - dropdownElement: HTMLElement, - targetRect: DOMRect, -): void { - const rect = dropdownElement.getBoundingClientRect(); - - if (rect.right > window.innerWidth) { - dropdownElement.addClass("note-status-popover-right"); - dropdownElement.style.setProperty( - "--right-offset-px", - `${window.innerWidth - targetRect.right}px`, - ); - } else { - dropdownElement.removeClass("note-status-popover-right"); - } - - if (rect.bottom > window.innerHeight) { - dropdownElement.addClass("note-status-popover-bottom"); - dropdownElement.style.setProperty( - "--bottom-offset-px", - `${window.innerHeight - targetRect.top + 5}px`, - ); - } else { - dropdownElement.removeClass("note-status-popover-bottom"); - } - - const maxHeight = window.innerHeight - rect.top - 20; - dropdownElement.style.setProperty("--max-height-px", `${maxHeight}px`); -} - -/** - * Create a dummy target element for positioning - */ -export function createDummyTarget(position: { - x: number; - y: number; -}): HTMLElement { - const dummyTarget = document.createElement("div"); - dummyTarget.addClass("note-status-dummy-target"); - dummyTarget.style.setProperty("--pos-x-px", `${position.x}px`); - dummyTarget.style.setProperty("--pos-y-px", `${position.y}px`); - document.body.appendChild(dummyTarget); - return dummyTarget; -} diff --git a/components/status-dropdown/dropdown-render.ts b/components/status-dropdown/dropdown-render.ts deleted file mode 100644 index aba185e..0000000 --- a/components/status-dropdown/dropdown-render.ts +++ /dev/null @@ -1,358 +0,0 @@ -import { setIcon, TFile, setTooltip } from "obsidian"; -import { StatusRemoveHandler, StatusSelectHandler } from "./types"; -import { NoteStatusSettings, Status } from "models/types"; -import { StatusService } from "services/status-service"; - -/** - * Render the dropdown content - */ -export function renderDropdownContent(options: { - dropdownElement: HTMLElement; - settings: NoteStatusSettings; - statusService: StatusService; - currentStatuses: string[]; - targetFile: TFile | null; - targetFiles: TFile[]; - onRemoveStatus: StatusRemoveHandler; - onSelectStatus: StatusSelectHandler; -}): void { - const { - dropdownElement, - settings, - statusService, - currentStatuses, - targetFile, - targetFiles, - onRemoveStatus, - onSelectStatus, - } = options; - - dropdownElement.empty(); - - // Create UI sections - createHeader(dropdownElement, targetFiles); - - const target = targetFiles.length > 1 ? targetFiles : targetFile; - createStatusChips( - dropdownElement, - currentStatuses, - statusService, - target ?? [], - onRemoveStatus, - ); - const searchInput = createSearchFilter(dropdownElement); - - // Create status options container - const statusOptionsContainer = dropdownElement.createDiv({ - cls: "note-status-options-container", - }); - - // Get all available statuses (excluding 'unknown') - const allStatuses = statusService - .getAllStatuses() - .filter((status) => status.name !== "unknown"); - - // Function to populate options with filtering - const populateOptions = (filter = "") => { - populateStatusOptions({ - container: statusOptionsContainer, - statuses: allStatuses, - currentStatuses, - settings, - targetFiles, - onSelectStatus, - filter, - }); - }; - - // Initial population - populateOptions(); - - // Add search functionality - searchInput.addEventListener("input", () => { - populateOptions(searchInput.value); - }); - - // Focus search input after a short delay - setTimeout(() => searchInput.focus(), 50); -} - -/** - * Create the dropdown header - */ -function createHeader( - dropdownElement: HTMLElement, - targetFiles: TFile[], -): void { - const headerEl = dropdownElement.createDiv({ - cls: "note-status-popover-header", - }); - const titleEl = headerEl.createDiv({ cls: "note-status-popover-title" }); - - const iconContainer = titleEl.createDiv({ - cls: "note-status-popover-icon", - }); - setIcon(iconContainer, "tag"); - - titleEl.createSpan({ - text: "Note status", - cls: "note-status-popover-label", - }); - - // If multiple files are selected, show count - if (targetFiles.length > 1) { - titleEl.createSpan({ - text: ` (${targetFiles.length} files)`, - cls: "note-status-popover-count", - }); - } -} - -/** - * Create the status chips section - */ -function createStatusChips( - dropdownElement: HTMLElement, - currentStatuses: string[], - statusService: StatusService, - targetFile: TFile | TFile[], - onRemoveStatus: StatusRemoveHandler, -): void { - const chipsContainer = dropdownElement.createDiv({ - cls: "note-status-popover-chips", - }); - - const hasNoValidStatus = - currentStatuses.length === 0 || - (currentStatuses.length === 1 && currentStatuses[0] === "unknown"); - - if (hasNoValidStatus) { - chipsContainer.createDiv({ - cls: "note-status-empty-indicator", - text: "No status assigned", - }); - } else { - createStatusChipElements( - chipsContainer, - currentStatuses, - statusService, - targetFile, - onRemoveStatus, - ); - } -} - -/** - * Create chips for all current statuses - */ -function createStatusChipElements( - container: HTMLElement, - currentStatuses: string[], - statusService: StatusService, - target: TFile | TFile[], - onRemoveStatus: StatusRemoveHandler, -): void { - currentStatuses.forEach((status) => { - if (status === "unknown") return; - - const statusObj = statusService - .getAllStatuses() - .find((s) => s.name === status); - if (!statusObj) return; - - createSingleStatusChip( - container, - status, - statusObj, - target, - onRemoveStatus, - ); - }); -} - -/** - * Create a single status chip - */ -function createSingleStatusChip( - container: HTMLElement, - status: string, - statusObj: Status, - target: TFile | TFile[], - onRemoveStatus: StatusRemoveHandler, -): void { - const chipEl = container.createDiv({ - cls: `note-status-chip status-${status}`, - }); - - // Status icon and name - chipEl.createSpan({ - text: statusObj.icon, - cls: "note-status-chip-icon", - }); - - chipEl.createSpan({ - text: statusObj.name, - cls: "note-status-chip-text", - }); - - addRemoveButton(chipEl, status, statusObj, target, onRemoveStatus); -} - -/** - * Add a remove button to a status chip - */ -function addRemoveButton( - chipEl: HTMLElement, - status: string, - statusObj: Status, - targetFile: TFile | TFile[] | null, - onRemoveStatus: StatusRemoveHandler, -): void { - const tooltipValue = statusObj.description - ? `${status} - ${statusObj.description}` - : status; - - setTooltip(chipEl, tooltipValue); - - const removeBtn = chipEl.createDiv({ - cls: "note-status-chip-remove", - attr: { - "aria-label": `Remove ${status} status`, - title: `Remove ${status} status`, - }, - }); - setIcon(removeBtn, "x"); - - removeBtn.addEventListener("click", async (e) => { - e.stopPropagation(); - - chipEl.addClass("note-status-chip-removing"); - - setTimeout(async () => { - if (targetFile) { - await onRemoveStatus(status, targetFile); - } - }, 150); - }); -} - -/** - * Create the search filter input - */ -function createSearchFilter(dropdownElement: HTMLElement): HTMLInputElement { - const searchContainer = dropdownElement.createDiv({ - cls: "note-status-popover-search", - }); - - return searchContainer.createEl("input", { - type: "text", - placeholder: "Filter statuses...", - cls: "note-status-popover-search-input", - }); -} - -/** - * Populate status options with optional filtering - */ -function populateStatusOptions(options: { - container: HTMLElement; - statuses: Status[]; - currentStatuses: string[]; - settings: NoteStatusSettings; - targetFiles: TFile[]; - onSelectStatus: StatusSelectHandler; - filter?: string; -}): void { - const { - container, - statuses, - currentStatuses, - settings, - targetFiles, - onSelectStatus, - filter = "", - } = options; - - container.empty(); - - const filteredStatuses = filter - ? statuses.filter( - (status) => - status.name.toLowerCase().includes(filter.toLowerCase()) || - status.icon.includes(filter), - ) - : statuses; - - if (filteredStatuses.length === 0) { - container.createDiv({ - cls: "note-status-empty-options", - text: filter - ? `No statuses match "${filter}"` - : "No statuses found", - }); - return; - } - - filteredStatuses.forEach((status) => { - createStatusOption({ - container, - status, - isSelected: currentStatuses.includes(status.name), - settings, - targetFiles, - onSelectStatus, - }); - }); -} - -/** - * Create a single status option element - */ -function createStatusOption(options: { - container: HTMLElement; - status: Status; - isSelected: boolean; - settings: NoteStatusSettings; - targetFiles: TFile[]; - onSelectStatus: StatusSelectHandler; -}): void { - const { container, status, isSelected, targetFiles, onSelectStatus } = - options; - - const optionEl = container.createDiv({ - cls: `note-status-option ${isSelected ? "is-selected" : ""} status-${status.name}`, - }); - - // Status icon and name - optionEl.createSpan({ - text: status.icon, - cls: "note-status-option-icon", - }); - - optionEl.createSpan({ - text: status.name, - cls: "note-status-option-text", - }); - - // Add tooltip if description available - if (status.description) { - setTooltip(optionEl, `${status.name} - ${status.description}`); - } - - // Check icon for selected status - if (isSelected) { - const checkIcon = optionEl.createDiv({ - cls: "note-status-option-check", - }); - setIcon(checkIcon, "check"); - } - - optionEl.addEventListener("click", () => { - optionEl.addClass("note-status-option-selecting"); - setTimeout(async () => { - if (targetFiles.length > 0) { - await onSelectStatus(status.name, targetFiles); - } - }, 150); - }); -} diff --git a/components/status-dropdown/dropdown-ui.ts b/components/status-dropdown/dropdown-ui.ts deleted file mode 100644 index 975e5ef..0000000 --- a/components/status-dropdown/dropdown-ui.ts +++ /dev/null @@ -1,241 +0,0 @@ -import { App, TFile } from "obsidian"; -import { - DropdownDependencies, - StatusRemoveHandler, - StatusSelectHandler, -} from "./types"; -import { positionDropdown } from "./dropdown-position"; -import { renderDropdownContent } from "./dropdown-render"; -import { setupDropdownEvents } from "./dropdown-events"; -import { StatusService } from "services/status-service"; -import { NoteStatusSettings } from "models/types"; - -/** - * Core UI component for the status dropdown - */ -export class DropdownUI { - private app: App; - private statusService: StatusService; - private settings: NoteStatusSettings; - - private dropdownElement: HTMLElement | null = null; - private currentStatuses: string[] = ["unknown"]; - private targetFile: TFile | null = null; - private targetFiles: TFile[] = []; - private animationDuration = 220; - - public isOpen = false; - private onRemoveStatus: StatusRemoveHandler = async () => {}; - private onSelectStatus: StatusSelectHandler = async () => {}; - - // Event handlers - private clickOutsideHandler: (e: MouseEvent) => void; - private escapeKeyHandler: (e: KeyboardEvent) => void; - - constructor({ app, settings, statusService }: DropdownDependencies) { - this.app = app; - this.statusService = statusService; - this.settings = settings; - - // Bind methods - this.clickOutsideHandler = this.handleClickOutside.bind(this); - this.escapeKeyHandler = this.handleEscapeKey.bind(this); - } - - /** - * Set the target file for status updates - */ - public setTargetFile(file: TFile | null): void { - this.targetFile = file; - this.targetFiles = file ? [file] : []; - } - - /** - * Set multiple target files for status updates - */ - public setTargetFiles(files: TFile[]): void { - this.targetFiles = [...files]; - this.targetFile = files.length === 1 ? files[0] : null; - } - - /** - * Register handler for removing a status - */ - public setOnRemoveStatusHandler(handler: StatusRemoveHandler): void { - this.onRemoveStatus = handler; - } - - /** - * Register handler for selecting a status - */ - public setOnSelectStatusHandler(handler: StatusSelectHandler): void { - this.onSelectStatus = handler; - } - - /** - * Updates the current statuses - */ - public updateStatuses(statuses: string[] | string): void { - this.currentStatuses = Array.isArray(statuses) - ? [...statuses] - : [statuses]; - - if (this.isOpen && this.dropdownElement) { - this.refreshDropdownContent(); - } - } - - /** - * Updates settings reference - */ - public updateSettings(settings: NoteStatusSettings): void { - this.settings = settings; - - if (this.isOpen && this.dropdownElement) { - this.refreshDropdownContent(); - } - } - - /** - * Toggle the dropdown visibility - */ - public toggle( - targetEl: HTMLElement, - position?: { x: number; y: number }, - ): void { - if (this.isOpen) { - this.close(); - setTimeout(() => { - if (!this.isOpen && !this.dropdownElement) { - this.open(targetEl, position); - } - }, 50); - } else { - this.open(targetEl, position); - } - } - - /** - * Open the dropdown - */ - public open( - targetEl: HTMLElement, - position?: { x: number; y: number }, - ): void { - if (this.isOpen || this.dropdownElement) { - this.close(); - setTimeout(() => this.actuallyOpen(targetEl, position), 10); - return; - } - - this.actuallyOpen(targetEl, position); - } - - /** - * Actually open the dropdown (internal method) - */ - private actuallyOpen( - targetEl: HTMLElement, - position?: { x: number; y: number }, - ): void { - this.isOpen = true; - - // Create dropdown element - this.createDropdownElement(); - this.refreshDropdownContent(); - - // Position the dropdown - positionDropdown({ - dropdownElement: this.dropdownElement!, - targetEl, - position, - }); - - this.dropdownElement?.addClass("note-status-popover-animate-in"); - - // Add event listeners with slight delay to prevent immediate triggering - setTimeout(() => { - setupDropdownEvents({ - clickOutsideHandler: this.clickOutsideHandler, - escapeKeyHandler: this.escapeKeyHandler, - }); - }, 10); - } - - /** - * Create the dropdown element - */ - private createDropdownElement(): void { - this.dropdownElement = document.createElement("div"); - this.dropdownElement.addClass( - "note-status-popover", - "note-status-unified-dropdown", - ); - document.body.appendChild(this.dropdownElement); - } - - /** - * Close the dropdown - */ - public close(): void { - if (!this.dropdownElement || !this.isOpen) return; - - this.dropdownElement.addClass("note-status-popover-animate-out"); - - document.removeEventListener("click", this.clickOutsideHandler); - document.removeEventListener("keydown", this.escapeKeyHandler); - - this.isOpen = false; - - if (this.dropdownElement) { - this.dropdownElement.remove(); - this.dropdownElement = null; - } - } - - /** - * Refresh the dropdown content - */ - private refreshDropdownContent(): void { - if (!this.dropdownElement) return; - - renderDropdownContent({ - dropdownElement: this.dropdownElement, - settings: this.settings, - statusService: this.statusService, - currentStatuses: this.currentStatuses, - targetFile: this.targetFile, - targetFiles: this.targetFiles, - onRemoveStatus: this.onRemoveStatus, - onSelectStatus: this.onSelectStatus, - }); - } - - /** - * Handle click outside the dropdown - */ - private handleClickOutside(e: MouseEvent): void { - if ( - this.dropdownElement && - !this.dropdownElement.contains(e.target as Node) - ) { - this.close(); - } - } - - /** - * Handle escape key to close dropdown - */ - private handleEscapeKey(e: KeyboardEvent): void { - if (e.key === "Escape") { - this.close(); - } - } - - /** - * Dispose of resources - */ - public dispose(): void { - this.close(); - } -} diff --git a/components/status-dropdown/index.ts b/components/status-dropdown/index.ts deleted file mode 100644 index b65b013..0000000 --- a/components/status-dropdown/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { DropdownManager } from "./dropdown-manager"; -import { DropdownOptions } from "./types"; - -export type { DropdownOptions }; -export { DropdownManager as StatusDropdown }; -export default DropdownManager; diff --git a/components/status-dropdown/types.ts b/components/status-dropdown/types.ts deleted file mode 100644 index 08af411..0000000 --- a/components/status-dropdown/types.ts +++ /dev/null @@ -1,42 +0,0 @@ -// # Tipos comunes -import { NoteStatusSettings } from "models/types"; -import { App, TFile, Editor, MarkdownView } from "obsidian"; -import { StatusService } from "services/status-service"; - -/** - * Options for opening status dropdown - */ -export interface DropdownOptions { - target?: HTMLElement; - position?: { x: number; y: number }; - files?: TFile[]; - editor?: Editor; - view?: MarkdownView; - mode?: "replace" | "add" | "remove" | "toggle"; - onStatusChange?: (statuses: string[]) => void; -} - -/** - * Status removal handler function type - */ -export type StatusRemoveHandler = ( - status: string, - targetFile?: TFile | TFile[], -) => Promise; - -/** - * Status selection handler function type - */ -export type StatusSelectHandler = ( - status: string, - targetFile: TFile | TFile[], -) => Promise; - -/** - * Common dependencies for dropdown components - */ -export interface DropdownDependencies { - app: App; - settings: NoteStatusSettings; - statusService: StatusService; -} diff --git a/components/toolbar-button.ts b/components/toolbar-button.ts deleted file mode 100644 index 9e78c3d..0000000 --- a/components/toolbar-button.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { NoteStatusSettings } from "models/types"; -import { StatusService } from "services/status-service"; - -export class ToolbarButton { - private element: HTMLElement | null = null; - private settings: NoteStatusSettings; - private statusService: StatusService; - - constructor(settings: NoteStatusSettings, statusService: StatusService) { - this.settings = settings; - this.statusService = statusService; - } - - public createElement(): HTMLElement { - const button = document.createElement("button"); - button.addClass( - "note-status-toolbar-button", - "clickable-icon", - "view-action", - ); - button.setAttribute("aria-label", "Note status"); - - this.element = button; - return button; - } - - public updateDisplay(statuses: string[]): void { - if (!this.element) return; - - this.element.empty(); - - const hasValidStatus = statuses.length > 0 && statuses[0] !== "unknown"; - const badgeContainer = document.createElement("div"); - badgeContainer.addClass("note-status-toolbar-badge-container"); - - if (hasValidStatus) { - this.renderStatusBadge(badgeContainer, statuses); - } else { - this.renderUnknownBadge(badgeContainer); - } - - this.element.appendChild(badgeContainer); - } - - private renderStatusBadge( - container: HTMLElement, - statuses: string[], - ): void { - const primaryStatus = statuses[0]; - const icon = this.statusService.getStatusIcon(primaryStatus); - - const iconSpan = document.createElement("span"); - iconSpan.addClass( - `note-status-toolbar-icon`, - `status-${primaryStatus}`, - ); - iconSpan.textContent = icon; - container.appendChild(iconSpan); - - if (this.settings.useMultipleStatuses && statuses.length > 1) { - const countBadge = document.createElement("span"); - countBadge.addClass("note-status-count-badge"); - countBadge.textContent = `${statuses.length}`; - container.appendChild(countBadge); - } - } - - private renderUnknownBadge(container: HTMLElement): void { - const iconSpan = document.createElement("span"); - iconSpan.addClass("note-status-toolbar-icon", "status-unknown"); - iconSpan.textContent = this.statusService.getStatusIcon("unknown"); - container.appendChild(iconSpan); - } - - public updateSettings(settings: NoteStatusSettings): void { - this.settings = settings; - } - - public destroy(): void { - if (this.element) { - this.element.remove(); - this.element = null; - } - } -} diff --git a/constants/defaults.ts b/constants/defaultSettings.ts similarity index 50% rename from constants/defaults.ts rename to constants/defaultSettings.ts index 71c1fbb..a0e81e5 100644 --- a/constants/defaults.ts +++ b/constants/defaultSettings.ts @@ -1,10 +1,8 @@ -import { NoteStatusSettings } from "../models/types"; -import { DEFAULT_ENABLED_TEMPLATES } from "../constants/status-templates"; +import { PluginSettings } from "types/pluginSettings"; +import { DEFAULT_ENABLED_TEMPLATES } from "./predefinedTemplates"; -/** - * Default plugin settings - */ -export const DEFAULT_SETTINGS: NoteStatusSettings = { +export const DEFAULT_PLUGIN_SETTINGS: PluginSettings = { + fileExplorerIconPosition: "absolute-right", statusColors: { active: "var(--text-success)", onHold: "var(--text-warning)", @@ -16,9 +14,7 @@ export const DEFAULT_SETTINGS: NoteStatusSettings = { autoHideStatusBar: false, customStatuses: [], showStatusIconsInExplorer: true, - hideUnknownStatusInExplorer: false, // Default to show unknown status - collapsedStatuses: {}, - compactView: false, + hideUnknownStatusInExplorer: true, // Default to hide unknown status enabledTemplates: DEFAULT_ENABLED_TEMPLATES, useCustomStatusesOnly: false, useMultipleStatuses: true, @@ -27,14 +23,3 @@ export const DEFAULT_SETTINGS: NoteStatusSettings = { excludeUnknownStatus: true, // Default to exclude unknown status files for better performance quickStatusCommands: ["active", "completed"], // Add default quick commands }; - -/** - * Default colors in hexadecimal format for backup and reset - */ -export const DEFAULT_COLORS: Record = { - active: "#00ff00", // Green for success - onHold: "#ffaa00", // Orange for warning - completed: "#00aaff", // Blue for accent - dropped: "#ff0000", // Red for error - unknown: "#888888", // Gray for muted -}; diff --git a/constants/icons.ts b/constants/icons.ts deleted file mode 100644 index 00d4571..0000000 --- a/constants/icons.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** - * SVG icon definitions - */ -export const ICONS = { - statusPane: ` - - `, - search: ` - - - `, - clear: ` - - - `, - standardView: ``, - compactView: ``, - refresh: ``, - collapseDown: ``, - collapseRight: ``, - file: ``, -}; diff --git a/constants/predefinedTemplates.ts b/constants/predefinedTemplates.ts new file mode 100644 index 0000000..a1ca1eb --- /dev/null +++ b/constants/predefinedTemplates.ts @@ -0,0 +1,209 @@ +import { StatusTemplate } from "types/pluginSettings"; + +/** + * Predefined status templates + */ +export const PREDEFINED_TEMPLATES: StatusTemplate[] = [ + { + id: "colorful", + name: "Colorful workflow", + description: + "A colorful set of workflow statuses with descriptive icons", + statuses: [ + { + name: "idea", + icon: "💡", + color: "#FFEB3B", + templateId: "colorful", + }, + { + name: "draft", + icon: "📝", + color: "#E0E0E0", + templateId: "colorful", + }, + { + name: "inProgress", + icon: "🔧", + color: "#FFC107", + templateId: "colorful", + }, + { + name: "editing", + icon: "🖊️", + color: "#2196F3", + templateId: "colorful", + }, + { + name: "pending", + icon: "⏳", + color: "#9C27B0", + templateId: "colorful", + }, + { + name: "onHold", + icon: "⏸", + color: "#9E9E9E", + templateId: "colorful", + }, + { + name: "needsUpdate", + icon: "🔄", + color: "#FF5722", + templateId: "colorful", + }, + { + name: "completed", + icon: "✅", + color: "#4CAF50", + templateId: "colorful", + }, + { + name: "archived", + icon: "📦", + color: "#795548", + templateId: "colorful", + }, + ], + }, + { + id: "minimal", + name: "Minimal workflow", + description: "A simplified set of essential workflow statuses", + statuses: [ + { + name: "todo", + icon: "📌", + color: "#F44336", + templateId: "minimal", + }, + { + name: "inProgress", + icon: "⚙️", + color: "#2196F3", + templateId: "minimal", + }, + { + name: "review", + icon: "👀", + color: "#9C27B0", + templateId: "minimal", + }, + { + name: "done", + icon: "✓", + color: "#4CAF50", + templateId: "minimal", + }, + ], + }, + { + id: "academic", + name: "Academic research", + description: "Status workflow for academic research and writing", + statuses: [ + { + name: "research", + icon: "🔍", + color: "#2196F3", + templateId: "academic", + }, + { + name: "outline", + icon: "📑", + color: "#9E9E9E", + templateId: "academic", + }, + { + name: "draft", + icon: "✏️", + color: "#FFC107", + templateId: "academic", + }, + { + name: "review", + icon: "🔬", + color: "#9C27B0", + templateId: "academic", + }, + { + name: "revision", + icon: "📝", + color: "#FF5722", + templateId: "academic", + }, + { + name: "final", + icon: "📚", + color: "#4CAF50", + templateId: "academic", + }, + { + name: "published", + icon: "🎓", + color: "#795548", + templateId: "academic", + }, + ], + }, + { + id: "project", + name: "Project management", + description: "Status workflow for project management and tracking", + statuses: [ + { + name: "planning", + icon: "🗓️", + color: "#9E9E9E", + templateId: "project", + }, + { + name: "backlog", + icon: "📋", + color: "#E0E0E0", + templateId: "project", + }, + { + name: "ready", + icon: "🚦", + color: "#8BC34A", + templateId: "project", + }, + { + name: "inDevelopment", + icon: "👨‍💻", + color: "#2196F3", + templateId: "project", + }, + { + name: "testing", + icon: "🧪", + color: "#9C27B0", + templateId: "project", + }, + { + name: "review", + icon: "👁️", + color: "#FFC107", + templateId: "project", + }, + { + name: "approved", + icon: "👍", + color: "#4CAF50", + templateId: "project", + }, + { + name: "live", + icon: "🚀", + color: "#3F51B5", + templateId: "project", + }, + ], + }, +]; + +/** + * Default template IDs that should be enabled by default + */ +export const DEFAULT_ENABLED_TEMPLATES = ["colorful"]; diff --git a/constants/status-templates.ts b/constants/status-templates.ts deleted file mode 100644 index 2cceac3..0000000 --- a/constants/status-templates.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { Status } from "../models/types"; - -/** - * Status Template interface - */ -export interface StatusTemplate { - id: string; - name: string; - description: string; - statuses: Status[]; -} - -/** - * Predefined status templates - */ -export const PREDEFINED_TEMPLATES: StatusTemplate[] = [ - { - id: "colorful", - name: "Colorful workflow", - description: - "A colorful set of workflow statuses with descriptive icons", - statuses: [ - { name: "idea", icon: "💡", color: "#FFEB3B" }, - { name: "draft", icon: "📝", color: "#E0E0E0" }, - { name: "inProgress", icon: "🔧", color: "#FFC107" }, - { name: "editing", icon: "🖊️", color: "#2196F3" }, - { name: "pending", icon: "⏳", color: "#9C27B0" }, - { name: "onHold", icon: "⏸", color: "#9E9E9E" }, - { name: "needsUpdate", icon: "🔄", color: "#FF5722" }, - { name: "completed", icon: "✅", color: "#4CAF50" }, - { name: "archived", icon: "📦", color: "#795548" }, - ], - }, - { - id: "minimal", - name: "Minimal workflow", - description: "A simplified set of essential workflow statuses", - statuses: [ - { name: "todo", icon: "📌", color: "#F44336" }, - { name: "inProgress", icon: "⚙️", color: "#2196F3" }, - { name: "review", icon: "👀", color: "#9C27B0" }, - { name: "done", icon: "✓", color: "#4CAF50" }, - ], - }, - { - id: "academic", - name: "Academic research", - description: "Status workflow for academic research and writing", - statuses: [ - { name: "research", icon: "🔍", color: "#2196F3" }, - { name: "outline", icon: "📑", color: "#9E9E9E" }, - { name: "draft", icon: "✏️", color: "#FFC107" }, - { name: "review", icon: "🔬", color: "#9C27B0" }, - { name: "revision", icon: "📝", color: "#FF5722" }, - { name: "final", icon: "📚", color: "#4CAF50" }, - { name: "published", icon: "🎓", color: "#795548" }, - ], - }, - { - id: "project", - name: "Project management", - description: "Status workflow for project management and tracking", - statuses: [ - { name: "planning", icon: "🗓️", color: "#9E9E9E" }, - { name: "backlog", icon: "📋", color: "#E0E0E0" }, - { name: "ready", icon: "🚦", color: "#8BC34A" }, - { name: "inDevelopment", icon: "👨‍💻", color: "#2196F3" }, - { name: "testing", icon: "🧪", color: "#9C27B0" }, - { name: "review", icon: "👁️", color: "#FFC107" }, - { name: "approved", icon: "👍", color: "#4CAF50" }, - { name: "live", icon: "🚀", color: "#3F51B5" }, - ], - }, -]; - -/** - * Default template IDs that should be enabled by default - */ -export const DEFAULT_ENABLED_TEMPLATES = ["colorful"]; diff --git a/core/commandsService.ts b/core/commandsService.ts new file mode 100644 index 0000000..9b13b6e --- /dev/null +++ b/core/commandsService.ts @@ -0,0 +1,314 @@ +import { Plugin, Notice, TFile, App } from "obsidian"; +import { NoteStatusService, BaseNoteStatusService } from "./noteStatusService"; +import settingsService from "./settingsService"; +import eventBus from "./eventBus"; + +export class CommandsService { + private plugin: Plugin; + private app: App; + private registeredCommands: Set = new Set(); + + constructor(plugin: Plugin) { + this.plugin = plugin; + this.app = plugin.app; + } + + private createStatusService(file: TFile): NoteStatusService { + const service = new NoteStatusService(file); + service.populateStatuses(); + return service; + } + + registerAllCommands(): void { + // Change status of current note + this.plugin.addCommand({ + id: "change-status", + name: "Change status of current note", + checkCallback: (checking: boolean) => { + const file = this.app.workspace.getActiveFile(); + if (!file) return false; + + if (!checking) { + const statusService = this.createStatusService(file); + statusService.populateStatuses(); + eventBus.publish("triggered-open-modal", { + statusService: statusService, + }); + } + return true; + }, + }); + this.registeredCommands.add("change-status"); + + // Cycle through statuses + this.plugin.addCommand({ + id: "cycle-status", + name: "Cycle to next status", + checkCallback: (checking: boolean) => { + if (settingsService.settings.useMultipleStatuses) { + // For now the cycle status is for single statuses + return false; + } + const file = this.app.workspace.getActiveFile(); + if (!file) { + return false; + } + + if ( + !checking && + !settingsService.settings.useMultipleStatuses + ) { + const allStatuses = + BaseNoteStatusService.getAllAvailableStatuses(); + + if (allStatuses.length === 0) { + new Notice("No statuses available"); + return; + } + + // Get the current file data + const statusService = this.createStatusService(file); + statusService.populateStatuses(); + const statuses = + statusService.statuses[ + settingsService.settings.tagPrefix + ] || []; + const currentStatus = statuses.length + ? statuses?.[0].name + : undefined; + let nextIndex = 0; + if (currentStatus) { + const currentIndex = allStatuses.findIndex( + (s) => s.name === currentStatus, + ); + if (currentIndex !== -1) { + nextIndex = + currentIndex === -1 + ? 0 + : (currentIndex + 1) % allStatuses.length; + } + } + + const nextStatus = allStatuses[nextIndex]; + const scopedIdentifier = nextStatus.templateId + ? BaseNoteStatusService.formatStatusIdentifier({ + templateId: nextStatus.templateId, + name: nextStatus.name, + }) + : nextStatus.name; + + statusService + .addStatus( + settingsService.settings.tagPrefix, + scopedIdentifier, + ) + .then((resolve) => { + new Notice(`Status changed to ${nextStatus.name}`); + }); + } + return true; + }, + }); + this.registeredCommands.add("cycle-status"); + + // Register dynamic quick status commands + this.registerQuickStatusCommands(); + + // Clear status + this.plugin.addCommand({ + id: "clear-status", + name: "Clear status", + checkCallback: (checking: boolean) => { + const file = this.app.workspace.getActiveFile(); + if (!file) return false; + if (!checking) { + const statusService = this.createStatusService(file); + statusService + .clearStatus(settingsService.settings.tagPrefix) + .then(() => { + new Notice("Status cleared"); + }); + } + return true; + }, + }); + this.registeredCommands.add("clear-status"); + + // Copy status from current note + this.plugin.addCommand({ + id: "copy-status", + name: "Copy status from current note", + checkCallback: (checking: boolean) => { + const file = this.app.workspace.getActiveFile(); + if (!file) return false; + if (!checking) { + const statuses = this.getFileStatuses(file); + navigator.clipboard + .writeText(statuses.join(", ")) + .then(() => { + new Notice(`Copied status: ${statuses.join(", ")}`); + }); + } + return true; + }, + }); + this.registeredCommands.add("copy-status"); + + // Paste status to current note + this.plugin.addCommand({ + id: "paste-status", + name: "Paste status to current note", + checkCallback: (checking: boolean) => { + const file = this.app.workspace.getActiveFile(); + if (!file) return false; + if (!checking) { + const statusService = this.createStatusService(file); + statusService.populateStatuses(); + navigator.clipboard.readText().then((text) => { + const statuses = text.split(", "); + + statusService + .overrideStatuses( + settingsService.settings.tagPrefix, + statuses, + ) + .then(() => { + new Notice( + `Pasted status: ${statuses.join(", ")}`, + ); + }); + }); + } + return true; + }, + }); + this.registeredCommands.add("paste-status"); + + // Toggle multiple statuses mode + this.plugin.addCommand({ + id: "toggle-multiple-statuses", + name: "Toggle multiple statuses mode", + callback: () => { + const currentValue = + settingsService.settings.useMultipleStatuses; + settingsService.setValue("useMultipleStatuses", !currentValue); + new Notice( + `Multiple statuses mode ${!currentValue ? "enabled" : "disabled"}`, + ); + }, + }); + this.registeredCommands.add("toggle-multiple-statuses"); + + // Search notes by status + this.plugin.addCommand({ + id: "search-by-status", + name: "Search notes by current status", + checkCallback: (checking: boolean) => { + const file = this.app.workspace.getActiveFile(); + if (!file) return false; + + if (!checking) { + const statuses = this.getFileStatuses(file); + + if (!statuses || statuses.length === 0) return false; + + const tagPrefix = settingsService.settings.tagPrefix; + const queries = statuses.map( + (status) => `[${tagPrefix}:"${status}"]`, + ); + const query = queries.join(" OR "); + + // @ts-ignore + this.app.internalPlugins.plugins[ + "global-search" + ].instance.openGlobalSearch(query); + } + return true; + }, + }); + this.registeredCommands.add("search-by-status"); + } + + /** + * Register quick status commands based on settings + */ + private registerQuickStatusCommands(): void { + const quickCommands = + settingsService.settings.quickStatusCommands || []; + const allStatuses = BaseNoteStatusService.getAllAvailableStatuses(); + + quickCommands.forEach((statusName) => { + const status = allStatuses.find((s) => s.name === statusName); + if (!status) return; + + this.plugin.addCommand({ + id: `set-status-${statusName}`, + name: `Set status to ${statusName}`, + checkCallback: (checking: boolean) => { + const file = this.app.workspace.getActiveFile(); + if (!file) return false; + if (settingsService.settings.useMultipleStatuses) { + return false; + } + + if ( + !checking && + !settingsService.settings.useMultipleStatuses + ) { + const statusService = this.createStatusService(file); + const scopedIdentifier = status.templateId + ? BaseNoteStatusService.formatStatusIdentifier({ + templateId: status.templateId, + name: status.name, + }) + : status.name; + + statusService + .overrideStatuses( + settingsService.settings.tagPrefix, + [scopedIdentifier], + ) + .then(() => { + new Notice(`Status set to ${statusName}`); + }); + } + return true; + }, + }); + this.registeredCommands.add(`set-status-${statusName}`); + }); + } + + private getFileStatuses(file: TFile): string[] { + const statusService = this.createStatusService(file); + statusService.populateStatuses(); + const tagPrefix = settingsService.settings.tagPrefix; + const statuses = statusService.statuses[tagPrefix] || []; + return statuses.length > 0 ? statuses.map((s) => s.name) : ["unknown"]; + } + + /** + * Remove all quick status commands + */ + private removeQuickStatusCommands(): void { + const allStatuses = BaseNoteStatusService.getAllAvailableStatuses(); + + //TODO: If there is an update the + allStatuses.forEach((status) => { + // @ts-ignore + this.app.commands.removeCommand( + `note-status:set-status-${status.name}`, + ); + // @ts-ignore + this.app.commands.removeCommand( + `note-status:toggle-status-${status.name}`, + ); + }); + } + + public destroy(): void { + // Remove existing commands + this.removeQuickStatusCommands(); + this.registeredCommands.clear(); + } +} diff --git a/core/eventBus.ts b/core/eventBus.ts new file mode 100644 index 0000000..57cdf0c --- /dev/null +++ b/core/eventBus.ts @@ -0,0 +1,50 @@ +import { EventBusEvents, EventName } from "@/types/eventBus"; + +class EventBus { + private events: { [K in EventName]: Map } = { + "active-file-change": new Map(), + "plugin-settings-changed": new Map(), + "frontmatter-manually-changed": new Map(), + "triggered-open-modal": new Map(), + }; + + subscribe( + event: T, + callback: EventBusEvents[T], + id: string, + ) { + this.events[event].set(id, callback); + return () => { + this.events[event].delete(id); + }; + } + + unsubscribe(event: EventName, id: string) { + this.events[event].delete(id); + } + + publish( + event: "active-file-change", + ...args: Parameters + ): void; + publish( + event: "plugin-settings-changed", + ...args: Parameters + ): void; + publish( + event: "frontmatter-manually-changed", + ...args: Parameters + ): void; + publish( + event: "triggered-open-modal", + ...args: Parameters + ): void; + publish(event: EventName, ...args: unknown[]) { + const callbacks = this.events[event]; + callbacks.forEach((callback) => { + (callback as (...args: unknown[]) => void)(...args); + }); + } +} + +export default new EventBus(); diff --git a/core/lazyElementObserver.ts b/core/lazyElementObserver.ts new file mode 100644 index 0000000..298aa4b --- /dev/null +++ b/core/lazyElementObserver.ts @@ -0,0 +1,156 @@ +/** + * Interface for element processing callbacks + */ +export interface IElementProcessor { + processElement(element: HTMLElement): void; +} + +/** + * Service for observing DOM changes in file explorer + * Single responsibility: DOM observation and element detection + */ +export class LazyElementObserver { + private mutationObserver: MutationObserver | null = null; + private intersectionObserver: IntersectionObserver | null = null; + private processedElements = new WeakSet(); + private elementProcessor: IElementProcessor; + + private readonly INTERSECTION_ROOT_MARGIN = "50px"; + private readonly FILE_SELECTOR = "[data-path]"; + + constructor(elementProcessor: IElementProcessor) { + this.elementProcessor = elementProcessor; + } + + /** + * Sets up observers for a container element + */ + setupObservers(container: Element): void { + this.cleanup(); + + this.initializeIntersectionObserver(); + this.initializeMutationObserver(container); + this.observeExistingElements(container); + } + + /** + * Marks element as unprocessed for re-processing + */ + markElementForReprocessing(element: HTMLElement): void { + this.processedElements.delete(element); + } + + /** + * Cleanup all observers and reset state + */ + cleanup(): void { + this.disconnectObservers(); + this.resetState(); + } + + /** + * Initializes intersection observer for visibility detection + */ + private initializeIntersectionObserver(): void { + this.intersectionObserver = new IntersectionObserver( + (entries) => this.handleIntersectionEntries(entries), + { rootMargin: this.INTERSECTION_ROOT_MARGIN }, + ); + } + + /** + * Handles intersection observer entries + */ + private handleIntersectionEntries( + entries: IntersectionObserverEntry[], + ): void { + entries.forEach((entry) => { + if (entry.isIntersecting) { + const element = entry.target as HTMLElement; + this.processElementIfNeeded(element); + } + }); + } + + /** + * Processes element if not already processed + */ + private processElementIfNeeded(element: HTMLElement): void { + if (!this.processedElements.has(element)) { + this.elementProcessor.processElement(element); + this.processedElements.add(element); + } + } + + /** + * Initializes mutation observer for DOM changes + */ + private initializeMutationObserver(container: Element): void { + this.mutationObserver = new MutationObserver((mutations) => + this.handleMutations(mutations), + ); + + this.mutationObserver.observe(container, { + childList: true, + subtree: true, + }); + } + + /** + * Handles mutation observer entries + */ + private handleMutations(mutations: MutationRecord[]): void { + mutations.forEach((mutation) => { + mutation.addedNodes.forEach((node) => { + if (node.nodeType === Node.ELEMENT_NODE) { + this.handleAddedElement(node as HTMLElement); + } + }); + }); + } + + /** + * Handles newly added DOM elements + */ + private handleAddedElement(element: HTMLElement): void { + this.observeElement(element); + + const fileElements = element.querySelectorAll(this.FILE_SELECTOR); + fileElements.forEach((el) => this.observeElement(el as HTMLElement)); + } + + /** + * Observes existing elements in container + */ + private observeExistingElements(container: Element): void { + const fileElements = container.querySelectorAll(this.FILE_SELECTOR); + fileElements.forEach((el) => this.observeElement(el as HTMLElement)); + } + + /** + * Starts observing a single element + */ + private observeElement(element: HTMLElement): void { + const dataPath = element.getAttribute("data-path"); + if (dataPath && this.intersectionObserver) { + this.intersectionObserver.observe(element); + } + } + + /** + * Disconnects all observers + */ + private disconnectObservers(): void { + this.mutationObserver?.disconnect(); + this.intersectionObserver?.disconnect(); + } + + /** + * Resets internal state + */ + private resetState(): void { + this.mutationObserver = null; + this.intersectionObserver = null; + this.processedElements = new WeakSet(); + } +} diff --git a/core/noteStatusService.ts b/core/noteStatusService.ts new file mode 100644 index 0000000..998a64a --- /dev/null +++ b/core/noteStatusService.ts @@ -0,0 +1,540 @@ +import { App, TFile } from "obsidian"; +import { + GroupedStatuses, + NoteStatus, + NoteStatus as NoteStatusType, + StatusIdentifier, + ScopedStatusName, +} from "@/types/noteStatus"; +import settingsService from "@/core/settingsService"; +import eventBus from "./eventBus"; +import { PREDEFINED_TEMPLATES } from "@/constants/predefinedTemplates"; + +export abstract class BaseNoteStatusService { + static app: App; + statuses: GroupedStatuses; + + constructor() { + this.statuses = {}; + } + + static initialize(app: App) { + BaseNoteStatusService.app = app; + } + + static parseStatusIdentifier( + identifier: StatusIdentifier, + ): ScopedStatusName { + if (typeof identifier === "string") { + if (identifier.includes(":")) { + const [templateId, name] = identifier.split(":", 2); + return { templateId, name }; + } + return { name: identifier }; + } + return identifier; + } + + static formatStatusIdentifier(scopedName: ScopedStatusName): string { + if (scopedName.templateId) { + return `${scopedName.templateId}:${scopedName.name}`; + } + return scopedName.name; + } + + static resolveStatusFromIdentifier( + identifier: StatusIdentifier, + ): NoteStatus | undefined { + const parsed = BaseNoteStatusService.parseStatusIdentifier(identifier); + const availableStatuses = + BaseNoteStatusService.getAllAvailableStatuses(); + + if (parsed.templateId) { + return availableStatuses.find( + (s) => + s.name === parsed.name && + s.templateId === parsed.templateId, + ); + } + + return availableStatuses.find((s) => s.name === parsed.name); + } + + private static allEnabledTemplatesStatuses(): NoteStatusType[] { + if (settingsService.settings.useCustomStatusesOnly) { + return []; + } + const enabledTemplates = PREDEFINED_TEMPLATES.filter((template) => + settingsService.settings.enabledTemplates.includes(template.id), + ); + return enabledTemplates.flatMap((t) => t.statuses); + } + + static getAllAvailableStatuses(): NoteStatus[] { + const availableStatuses = [ + ...settingsService.settings.customStatuses, + ...BaseNoteStatusService.allEnabledTemplatesStatuses(), + ]; + return availableStatuses; + } + + // static async insertStatusMetadataInEditor(file: TFile): Promise { + // const tagPrefix = settingsService.settings.tagPrefix; + // + // await BaseNoteStatusService.app.fileManager.processFrontMatter( + // file, + // (frontmatter) => { + // const noteStatusFrontmatter = + // (frontmatter?.[tagPrefix] as string[]) || []; + // if (!noteStatusFrontmatter.length) { + // frontmatter[tagPrefix] = []; + // } + // }, + // ); + // } + + protected statusNameToObject(statusName: string | StatusIdentifier) { + if (typeof statusName === "string") { + return BaseNoteStatusService.resolveStatusFromIdentifier( + statusName, + ); + } + return BaseNoteStatusService.resolveStatusFromIdentifier(statusName); + } + + protected getStatusMetadataKeys(): string[] { + return [settingsService.settings.tagPrefix]; + } + + abstract populateStatuses(): void; + abstract removeStatus( + frontmatterTagName: string, + status: NoteStatus, + ): Promise; + abstract addStatus( + frontmatterTagName: string, + statusIdentifier: StatusIdentifier, + ): Promise; +} + +export class NoteStatusService extends BaseNoteStatusService { + private file: TFile; + + constructor(file: TFile) { + super(); + this.file = file; + } + + public populateStatuses(): void { + const STATUS_METADATA_KEYS = this.getStatusMetadataKeys(); + this.statuses = Object.fromEntries( + STATUS_METADATA_KEYS.map((key) => [key, []]), + ); + + if (!this.file) { + return; + } + + const cachedMetadata = + BaseNoteStatusService.app.metadataCache.getFileCache(this.file); + const frontmatter = cachedMetadata?.frontmatter; + if (!frontmatter) { + return; + } + + STATUS_METADATA_KEYS.forEach((key) => { + const value = frontmatter[key]; + if (value) { + let statuses: (NoteStatusType | undefined)[] = []; + if (Array.isArray(value)) { + statuses = value.map((v) => + this.statusNameToObject(v.toString()), + ); + } else { + statuses = [this.statusNameToObject(value.toString())]; + } + + statuses = statuses.filter((s) => s !== undefined); + if ( + statuses.length && + !settingsService.settings.useMultipleStatuses + ) { + statuses = statuses.slice(0, 1); + } + this.statuses[key] = statuses as NoteStatusType[]; + } + }); + } + + async removeStatus( + frontmatterTagName: string, + status: NoteStatus, + ): Promise { + let removed = false; + const targetIdentifier = status.templateId + ? BaseNoteStatusService.formatStatusIdentifier({ + templateId: status.templateId, + name: status.name, + }) + : status.name; + + await BaseNoteStatusService.app.fileManager.processFrontMatter( + this.file, + (frontmatter) => { + const noteStatusFrontmatter = + (frontmatter?.[frontmatterTagName] as string[]) || []; + if (!noteStatusFrontmatter.length) return; + + if (Array.isArray(noteStatusFrontmatter)) { + // First try to find exact match (scoped or legacy) + let i = noteStatusFrontmatter.findIndex( + (statusName: string) => statusName === targetIdentifier, + ); + + // If not found and we're looking for a scoped status, try legacy format + if (i === -1 && status.templateId) { + i = noteStatusFrontmatter.findIndex( + (statusName: string) => statusName === status.name, + ); + } + + if (i !== -1) { + noteStatusFrontmatter.splice(i, 1); + removed = true; + } + } + }, + ); + + if (removed) { + eventBus.publish("frontmatter-manually-changed", { + file: this.file, + }); + } + return removed; + } + + async clearStatus(frontmatterTagName: string): Promise { + await BaseNoteStatusService.app.fileManager.processFrontMatter( + this.file, + (frontmatter) => { + if (frontmatterTagName in frontmatter) { + frontmatter[frontmatterTagName] = []; + } + }, + ); + eventBus.publish("frontmatter-manually-changed", { file: this.file }); + return true; + } + + async overrideStatuses( + frontmatterTagName: string, + statusIdentifiers: StatusIdentifier[], + ): Promise { + const formattedIdentifiers = statusIdentifiers.map((id) => + typeof id === "string" + ? id + : BaseNoteStatusService.formatStatusIdentifier(id), + ); + await BaseNoteStatusService.app.fileManager.processFrontMatter( + this.file, + (frontmatter) => { + if ( + frontmatterTagName in frontmatter && + Array.isArray(frontmatter[frontmatterTagName]) + ) { + frontmatter[frontmatterTagName].splice(0); + frontmatter[frontmatterTagName].push( + ...formattedIdentifiers, + ); + } + frontmatter[frontmatterTagName] = [...formattedIdentifiers]; + }, + ); + eventBus.publish("frontmatter-manually-changed", { file: this.file }); + return true; + } + + async addStatus( + frontmatterTagName: string, + statusIdentifier: StatusIdentifier, + ): Promise { + let added = false; + const formattedIdentifier = + typeof statusIdentifier === "string" + ? statusIdentifier + : BaseNoteStatusService.formatStatusIdentifier( + statusIdentifier, + ); + + await BaseNoteStatusService.app.fileManager.processFrontMatter( + this.file, + (frontmatter) => { + const noteStatusFrontmatter = + (frontmatter?.[frontmatterTagName] as string[]) || []; + if (!settingsService.settings.useMultipleStatuses) { + frontmatter[frontmatterTagName] = [formattedIdentifier]; + added = true; + } else { + // Ensure frontmatter property exists as an array + if ( + !frontmatter[frontmatterTagName] || + !Array.isArray(frontmatter[frontmatterTagName]) + ) { + frontmatter[frontmatterTagName] = []; + } + + // Check if we already have this status (exact match) + const exactMatch = noteStatusFrontmatter.findIndex( + (statusName: string) => + statusName === formattedIdentifier, + ); + + if (exactMatch === -1) { + // Check if we have a legacy version of this scoped status + let legacyIndex = -1; + if ( + typeof statusIdentifier !== "string" && + statusIdentifier.templateId + ) { + legacyIndex = noteStatusFrontmatter.findIndex( + (statusName: string) => + statusName === statusIdentifier.name, + ); + } + + if (legacyIndex !== -1) { + // Replace legacy with scoped version + noteStatusFrontmatter[legacyIndex] = + formattedIdentifier; + added = true; + } else { + // Add new status + frontmatter[frontmatterTagName].push( + formattedIdentifier, + ); + added = true; + } + } + } + }, + ); + if (added) { + eventBus.publish("frontmatter-manually-changed", { + file: this.file, + }); + } + return added; + } +} + +export class MultipleNoteStatusService extends BaseNoteStatusService { + private files: TFile[]; + + constructor(files: TFile[]) { + super(); + this.files = files; + } + + selectedFilesQTY() { + return this.files.length; + } + + public populateStatuses(): void { + const STATUS_METADATA_KEYS = this.getStatusMetadataKeys(); + + this.statuses = Object.fromEntries( + STATUS_METADATA_KEYS.map((key) => [key, []]), + ); + + const allStatuses = new Map>(); + + this.files.forEach((file) => { + const cachedMetadata = + BaseNoteStatusService.app.metadataCache.getFileCache(file); + const frontmatter = cachedMetadata?.frontmatter; + if (!frontmatter) return; + + STATUS_METADATA_KEYS.forEach((key) => { + const value = frontmatter[key]; + if (value) { + if (!allStatuses.has(key)) { + allStatuses.set(key, new Set()); + } + + const statusNames = Array.isArray(value) ? value : [value]; + statusNames.forEach((name) => + allStatuses.get(key)!.add(name.toString()), + ); + } + }); + }); + + STATUS_METADATA_KEYS.forEach((key) => { + const statusNames = allStatuses.get(key); + if (statusNames) { + const statuses = Array.from(statusNames) + .map((name) => this.statusNameToObject(name)) + .filter((s) => s !== undefined) as NoteStatusType[]; + + this.statuses[key] = statuses; + } + }); + } + + async removeStatus( + frontmatterTagName: string, + status: NoteStatus, + ): Promise { + let removedFromAny = false; + const targetIdentifier = status.templateId + ? BaseNoteStatusService.formatStatusIdentifier({ + templateId: status.templateId, + name: status.name, + }) + : status.name; + + const promises = this.files.map(async (file) => { + let removed = false; + await BaseNoteStatusService.app.fileManager.processFrontMatter( + file, + (frontmatter) => { + const noteStatusFrontmatter = + frontmatter[frontmatterTagName]; + if (!noteStatusFrontmatter) return; + + if (Array.isArray(noteStatusFrontmatter)) { + // First try to find exact match (scoped or legacy) + let index = noteStatusFrontmatter.findIndex( + (statusName: string) => + statusName === targetIdentifier, + ); + + // If not found and we're looking for a scoped status, try legacy format + if (index === -1 && status.templateId) { + index = noteStatusFrontmatter.findIndex( + (statusName: string) => + statusName === status.name, + ); + } + + if (index !== -1) { + noteStatusFrontmatter.splice(index, 1); + removed = true; + } + } else if ( + noteStatusFrontmatter === targetIdentifier || + (status.templateId && + noteStatusFrontmatter === status.name) + ) { + delete frontmatter[frontmatterTagName]; + removed = true; + } + }, + ); + return removed; + }); + + const results = await Promise.all(promises); + removedFromAny = results.some((result) => result); + + if (removedFromAny) { + this.files.forEach((file) => { + eventBus.publish("frontmatter-manually-changed", { file }); + }); + } + + return removedFromAny; + } + + async addStatus( + frontmatterTagName: string, + statusIdentifier: StatusIdentifier, + ): Promise { + let addedToAny = false; + const formattedIdentifier = + typeof statusIdentifier === "string" + ? statusIdentifier + : BaseNoteStatusService.formatStatusIdentifier( + statusIdentifier, + ); + + const promises = this.files.map(async (file) => { + let added = false; + await BaseNoteStatusService.app.fileManager.processFrontMatter( + file, + (frontmatter) => { + const noteStatusFrontmatter = + frontmatter[frontmatterTagName]; + + if (!noteStatusFrontmatter) { + frontmatter[frontmatterTagName] = [formattedIdentifier]; + added = true; + } else if (Array.isArray(noteStatusFrontmatter)) { + if ( + !noteStatusFrontmatter.includes(formattedIdentifier) + ) { + noteStatusFrontmatter.push(formattedIdentifier); + added = true; + } + } else { + if (noteStatusFrontmatter !== formattedIdentifier) { + frontmatter[frontmatterTagName] = [ + noteStatusFrontmatter, + formattedIdentifier, + ]; + added = true; + } + } + }, + ); + return added; + }); + + const results = await Promise.all(promises); + addedToAny = results.some((result) => result); + + if (addedToAny) { + this.files.forEach((file) => { + eventBus.publish("frontmatter-manually-changed", { file }); + }); + } + + return addedToAny; + } + + getFilesWithStatus( + frontmatterTagName: string, + status: NoteStatus, + ): TFile[] { + const targetIdentifier = status.templateId + ? BaseNoteStatusService.formatStatusIdentifier({ + templateId: status.templateId, + name: status.name, + }) + : status.name; + + return this.files.filter((file) => { + const cachedMetadata = + BaseNoteStatusService.app.metadataCache.getFileCache(file); + const frontmatter = cachedMetadata?.frontmatter; + if (!frontmatter) return false; + + const value = frontmatter[frontmatterTagName]; + if (!value) return false; + + if (Array.isArray(value)) { + // Check for exact match first, then legacy format if scoped + return ( + value.includes(targetIdentifier) || + (status.templateId && value.includes(status.name)) + ); + } + // Check for exact match first, then legacy format if scoped + return ( + value === targetIdentifier || + (status.templateId && value === status.name) + ); + }); + } +} diff --git a/core/selectorService.ts b/core/selectorService.ts new file mode 100644 index 0000000..2775ab3 --- /dev/null +++ b/core/selectorService.ts @@ -0,0 +1,18 @@ +import { App } from "obsidian"; +import { + MultipleNoteStatusService, + NoteStatusService, +} from "./noteStatusService"; + +class SelectorService { + static app: App; + public noteStatusService: NoteStatusService | MultipleNoteStatusService; + + constructor( + noteStatusService: NoteStatusService | MultipleNoteStatusService, + ) { + this.noteStatusService = noteStatusService; + } +} + +export default SelectorService; diff --git a/core/settingsService.ts b/core/settingsService.ts new file mode 100644 index 0000000..068ef4a --- /dev/null +++ b/core/settingsService.ts @@ -0,0 +1,46 @@ +import { DEFAULT_PLUGIN_SETTINGS } from "@/constants/defaultSettings"; +import { PluginSettings } from "@/types/pluginSettings"; +import { Plugin } from "obsidian"; +import eventBus from "./eventBus"; + +class SettingsService { + private plugin: Plugin; + public settings: PluginSettings; + + constructor() {} + + async initialize(plugin: Plugin): Promise { + this.plugin = plugin; + await this.loadSettings(); + } + + setValue(key: keyof PluginSettings, value: unknown) { + if (!(key in this.settings)) { + throw new Error(`The "${key}" setting is not a known setting key`); + } + // const oldValue = this.settings[key]; // TODO: Send the old value + this.settings[key] = value; + this.saveSettings().catch(console.error); + // INFO: Send the propgation event + eventBus.publish("plugin-settings-changed", { + key, + value, + currentSettings: this.settings, + }); + } + + private async loadSettings() { + this.settings = Object.assign( + {}, + DEFAULT_PLUGIN_SETTINGS, + await this.plugin.loadData(), + ); + return this.settings; + } + + private async saveSettings() { + await this.plugin.saveData(this.settings); + } +} + +export default new SettingsService(); diff --git a/esbuild.config.mjs b/esbuild.config.mjs index 11eb737..f6dca29 100644 --- a/esbuild.config.mjs +++ b/esbuild.config.mjs @@ -26,7 +26,7 @@ const context = await esbuild.context({ banner: { js: banner, }, - entryPoints: ["main.ts"], + entryPoints: ["main.tsx"], bundle: true, external: [ "obsidian", @@ -51,6 +51,9 @@ const context = await esbuild.context({ treeShaking: true, outfile: "main.js", minify: prod, + jsx: "transform", + jsxFactory: "React.createElement", + jsxFragment: "React.Fragment", }); // CSS context for watch mode diff --git a/integrations/commands/command-integration.ts b/integrations/commands/command-integration.ts deleted file mode 100644 index efa9201..0000000 --- a/integrations/commands/command-integration.ts +++ /dev/null @@ -1,323 +0,0 @@ -// integrations/commands/command-integration.ts -import { App, Editor, MarkdownView, Notice, TFile } from "obsidian"; -import { NoteStatusSettings } from "../../models/types"; -import { StatusService } from "services/status-service"; -import { StatusDropdown } from "components/status-dropdown"; -import { StatusPaneViewController } from "views/status-pane-view"; -import NoteStatus from "main"; - -export class CommandIntegration { - private app: App; - private plugin: NoteStatus; - private settings: NoteStatusSettings; - private statusService: StatusService; - private statusDropdown: StatusDropdown; - - constructor( - app: App, - plugin: NoteStatus, - settings: NoteStatusSettings, - statusService: StatusService, - statusDropdown: StatusDropdown, - ) { - this.app = app; - this.plugin = plugin; - this.settings = settings; - this.statusService = statusService; - this.statusDropdown = statusDropdown; - } - - public updateSettings(settings: NoteStatusSettings): void { - this.settings = settings; - // Re-register commands when settings change - this.unload(); - this.registerCommands(); - } - - public registerCommands(): void { - // Open status pane - this.plugin.addCommand({ - id: "open-status-pane", - name: "Open status pane", - callback: () => StatusPaneViewController.open(this.app), - }); - - // Change status of current note - this.plugin.addCommand({ - id: "change-status", - name: "Change status of current note", - checkCallback: (checking: boolean) => { - const file = this.app.workspace.getActiveFile(); - if (!file) return false; - - if (!checking) { - this.statusDropdown.openStatusDropdown({ files: [file] }); - } - return true; - }, - }); - - // Add status to current note - this.plugin.addCommand({ - id: "add-status", - name: "Add status to current note", - checkCallback: (checking: boolean) => { - const file = this.app.workspace.getActiveFile(); - if (!file) return false; - - if (!checking) { - this.statusDropdown.openStatusDropdown({ - files: [file], - mode: this.settings.useMultipleStatuses - ? "add" - : "replace", - }); - } - return true; - }, - }); - - // Insert status metadata - this.plugin.addCommand({ - id: "insert-status-metadata", - name: "Insert status metadata", - editorCheckCallback: ( - checking: boolean, - editor: Editor, - view: MarkdownView, - ) => { - if (!view.file) return false; - - const statuses = this.statusService.getFileStatuses(view.file); - const hasNoStatus = - statuses.length === 1 && statuses[0] === "unknown"; - - if (!checking && hasNoStatus) { - this.statusService.insertStatusMetadataInEditor(editor); - new Notice("Status metadata inserted"); - } - return hasNoStatus; - }, - }); - - // Cycle through statuses - this.plugin.addCommand({ - id: "cycle-status", - name: "Cycle to next status", - checkCallback: (checking: boolean) => { - const file = this.app.workspace.getActiveFile(); - if (!file) return false; - - if (!checking) { - this.cycleStatus(file); - } - return true; - }, - }); - - // Register dynamic quick status commands - this.registerQuickStatusCommands(); - - // Clear status - this.plugin.addCommand({ - id: "clear-status", - name: "Clear status (set to unknown)", - checkCallback: (checking: boolean) => { - const file = this.app.workspace.getActiveFile(); - if (!file) return false; - - if (!checking) { - this.statusService.handleStatusChange({ - files: file, - statuses: "unknown", - operation: "set", - }); - new Notice("Status cleared"); - } - return true; - }, - }); - - // Copy status from current note - this.plugin.addCommand({ - id: "copy-status", - name: "Copy status from current note", - checkCallback: (checking: boolean) => { - const file = this.app.workspace.getActiveFile(); - if (!file) return false; - - if (!checking) { - const statuses = this.statusService.getFileStatuses(file); - this.app.clipboard = statuses; - new Notice(`Copied status: ${statuses.join(", ")}`); - } - return true; - }, - }); - - // Paste status to current note - this.plugin.addCommand({ - id: "paste-status", - name: "Paste status to current note", - checkCallback: (checking: boolean) => { - const file = this.app.workspace.getActiveFile(); - const clipboard = this.app.clipboard; - if (!file || !clipboard || !Array.isArray(clipboard)) - return false; - - if (!checking) { - this.statusService.handleStatusChange({ - files: file, - statuses: clipboard, - operation: "set", - }); - new Notice(`Pasted status: ${clipboard.join(", ")}`); - } - return true; - }, - }); - - // Toggle multiple statuses mode - this.plugin.addCommand({ - id: "toggle-multiple-statuses", - name: "Toggle multiple statuses mode", - callback: () => { - this.settings.useMultipleStatuses = - !this.settings.useMultipleStatuses; - this.plugin.saveSettings(); - new Notice( - `Multiple statuses mode ${this.settings.useMultipleStatuses ? "enabled" : "disabled"}`, - ); - }, - }); - - // Search notes by status - this.plugin.addCommand({ - id: "search-by-status", - name: "Search notes by current status", - checkCallback: (checking: boolean) => { - const file = this.app.workspace.getActiveFile(); - if (!file) return false; - - if (!checking) { - const statuses = this.statusService.getFileStatuses(file); - const query = `[${this.settings.tagPrefix}:"${statuses[0]}"]`; - this.app.internalPlugins - .getPluginById("global-search") - ?.instance.openGlobalSearch(query); - } - return true; - }, - }); - } - - /** - * Register quick status commands based on settings - */ - private registerQuickStatusCommands(): void { - const quickCommands = this.settings.quickStatusCommands || []; - const allStatuses = this.statusService.getAllStatuses(); - - quickCommands.forEach((statusName) => { - const status = allStatuses.find((s) => s.name === statusName); - if (!status) return; - - this.plugin.addCommand({ - id: `set-status-${statusName}`, - name: `Set status to ${statusName}`, - checkCallback: (checking: boolean) => { - const file = this.app.workspace.getActiveFile(); - if (!file) return false; - - if (!checking) { - this.statusService.handleStatusChange({ - files: file, - statuses: statusName, - operation: "set", - }); - new Notice(`Status set to ${statusName}`); - } - return true; - }, - }); - - // Add toggle command for multiple status mode - if (this.settings.useMultipleStatuses) { - this.plugin.addCommand({ - id: `toggle-status-${statusName}`, - name: `Toggle status ${statusName}`, - checkCallback: (checking: boolean) => { - const file = this.app.workspace.getActiveFile(); - if (!file) return false; - - if (!checking) { - this.statusService.handleStatusChange({ - files: file, - statuses: statusName, - operation: "toggle", - }); - const currentStatuses = - this.statusService.getFileStatuses(file); - const hasStatus = - currentStatuses.includes(statusName); - new Notice( - `Status ${statusName} ${hasStatus ? "added" : "removed"}`, - ); - } - return true; - }, - }); - } - }); - } - - private cycleStatus(file: TFile): void { - const allStatuses = this.statusService - .getAllStatuses() - .filter((s) => s.name !== "unknown") - .map((s) => s.name); - - if (allStatuses.length === 0) { - new Notice("No statuses available"); - return; - } - - const currentStatuses = this.statusService.getFileStatuses(file); - const currentStatus = currentStatuses[0]; - - let nextIndex = 0; - if (currentStatus !== "unknown") { - const currentIndex = allStatuses.indexOf(currentStatus); - nextIndex = (currentIndex + 1) % allStatuses.length; - } - - this.statusService.handleStatusChange({ - files: file, - statuses: allStatuses[nextIndex], - operation: "set", - }); - - new Notice(`Status changed to ${allStatuses[nextIndex]}`); - } - - public unload(): void { - // Remove existing commands - this.removeQuickStatusCommands(); - } - - /** - * Remove all quick status commands - */ - private removeQuickStatusCommands(): void { - const allStatuses = this.statusService.getAllStatuses(); - - allStatuses.forEach((status) => { - this.app.commands.removeCommand( - `note-status:set-status-${status.name}`, - ); - this.app.commands.removeCommand( - `note-status:toggle-status-${status.name}`, - ); - }); - } -} diff --git a/integrations/commands/commandsIntegration.ts b/integrations/commands/commandsIntegration.ts new file mode 100644 index 0000000..f210119 --- /dev/null +++ b/integrations/commands/commandsIntegration.ts @@ -0,0 +1,55 @@ +import eventBus from "@/core/eventBus"; +import { Plugin } from "obsidian"; +import { CommandsService } from "../../core/commandsService"; + +export class CommandsIntegration { + private static instance: CommandsIntegration | null = null; + private plugin: Plugin; + private commandsService: CommandsService; + + constructor(plugin: Plugin) { + if (CommandsIntegration.instance) { + throw new Error( + "CommandsIntegration instance already created. Use getInstance() instead.", + ); + } + this.plugin = plugin; + this.commandsService = new CommandsService(plugin); + CommandsIntegration.instance = this; + } + + static getInstance(): CommandsIntegration | null { + return CommandsIntegration.instance; + } + + async integrate(): Promise { + // Register all commands from the service + this.commandsService.registerAllCommands(); + + eventBus.subscribe( + "plugin-settings-changed", + ({ key }) => { + if ( + key === "quickStatusCommands" || + key === "useMultipleStatuses" + ) { + this.commandsService.destroy(); + /// BUG: if removed a command will persist because is not removed, you need the oldStates to send it to be disabled // const oldValue = this.settings[key]; // TODO: Send the old value + this.commandsService.registerAllCommands(); // INFO: Reset the registered commands + } + }, + "commandsIntegrationSubscription2", + ); + } + + destroy(): void { + eventBus.unsubscribe( + "plugin-settings-changed", + "commandsIntegrationSubscription2", + ); + if (this.commandsService) { + this.commandsService.destroy(); + } + CommandsIntegration.instance = null; + } +} diff --git a/integrations/context-menu/contextMenuIntegration.tsx b/integrations/context-menu/contextMenuIntegration.tsx new file mode 100644 index 0000000..577ed8c --- /dev/null +++ b/integrations/context-menu/contextMenuIntegration.tsx @@ -0,0 +1,127 @@ +import eventBus from "core/eventBus"; +import { + Menu, + Notice, + Plugin, + TAbstractFile, + TFile, + WorkspaceLeaf, +} from "obsidian"; +import { + MultipleNoteStatusService, + NoteStatusService, +} from "@/core/noteStatusService"; + +export class ContextMenuIntegration { + private static instance: ContextMenuIntegration | null = null; + private plugin: Plugin; + private currentLeaf: WorkspaceLeaf | null = null; + private noteStatusService: + | NoteStatusService + | MultipleNoteStatusService + | null = null; + + constructor(plugin: Plugin) { + if (ContextMenuIntegration.instance) { + throw new Error("The context menu instance is already created"); + } + this.plugin = plugin; + ContextMenuIntegration.instance = this; + } + + async integrate() { + this.plugin.registerEvent( + this.plugin.app.workspace.on( + "files-menu", + ( + menu: Menu, + files: TAbstractFile[], + source: string, + leaf?: WorkspaceLeaf, + ) => { + menu.addItem((item) => { + item.setTitle("Change note state") + .setIcon("rotate-ccw") // Lucide icon + .onClick(async () => { + const tFiles = files.filter( + (f): f is TFile => f instanceof TFile, + ); + if (tFiles.length) { + this.openMultipleFilesStatusesModal(tFiles); + } else { + new Notice( + "The selected files are not valid to add status, just .md files can have status in this plugin version", + ); + } + }); + }); + }, + ), + ); + this.plugin.registerEvent( + this.plugin.app.workspace.on("file-menu", (menu, file, f, f2) => { + menu.addItem((item) => { + item.setTitle("Change note state") + .setIcon("rotate-ccw") // Lucide icon + .onClick(async () => { + if (file instanceof TFile) { + this.openSingleFileStatusesModal(file); + } else { + new Notice( + "The selected file is not valid to add status, just .md files can have status in this plugin version", + ); + } + }); + }); + }), + ); + + this.plugin.registerEvent( + this.plugin.app.workspace.on( + "editor-menu", + (menu, editor, view) => { + menu.addItem((item) => { + item.setTitle("Change note state") + .setIcon("rotate-ccw") // Lucide icon + .onClick(async () => { + if (view.file) { + if (view.file instanceof TFile) { + this.openSingleFileStatusesModal( + view.file, + ); + } else { + new Notice( + "The selected file is not valid to add status, just .md files can have status in this plugin version", + ); + } + } + }); + }); + }, + ), + ); + } + + private openMultipleFilesStatusesModal(tFiles: TFile[]) { + const multipleStatusService = new MultipleNoteStatusService(tFiles); + multipleStatusService.populateStatuses(); + eventBus.publish("triggered-open-modal", { + statusService: multipleStatusService, + }); + } + private openSingleFileStatusesModal(tFile: TFile) { + const noteStatusService = new NoteStatusService(tFile); + noteStatusService.populateStatuses(); + eventBus.publish("triggered-open-modal", { + statusService: noteStatusService, + }); + } + + destroy() { + this.currentLeaf = null; + this.noteStatusService = null; + ContextMenuIntegration.instance = null; + } +} + +export default ContextMenuIntegration; diff --git a/integrations/context-menu/file-context-menu-integration.ts b/integrations/context-menu/file-context-menu-integration.ts deleted file mode 100644 index c92c06d..0000000 --- a/integrations/context-menu/file-context-menu-integration.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { App, Menu, TFile } from "obsidian"; -import { NoteStatusSettings } from "models/types"; -import { StatusService } from "services/status-service"; -import { ExplorerIntegration } from "integrations/explorer/explorer-integration"; -import { StatusContextMenu } from "integrations/context-menu/status-context-menu"; - -/** - * Gestiona la integración de menús contextuales con el explorador de archivos - */ -export class FileContextMenuIntegration { - private app: App; - private settings: NoteStatusSettings; - private statusService: StatusService; - private explorerIntegration: ExplorerIntegration; - private statusContextMenu: StatusContextMenu; - private fileMenuEventRef: (menu: Menu, file: TFile, source: string) => void; - private filesMenuEventRef: (menu: Menu, files: TFile[]) => void; - - constructor( - app: App, - settings: NoteStatusSettings, - statusService: StatusService, - explorerIntegration: ExplorerIntegration, - statusContextMenu: StatusContextMenu, - ) { - this.app = app; - this.settings = settings; - this.statusService = statusService; - this.explorerIntegration = explorerIntegration; - this.statusContextMenu = statusContextMenu; - } - - /** - * Actualiza la configuración - */ - public updateSettings(settings: NoteStatusSettings): void { - this.settings = settings; - } - - /** - * Registra los eventos del menú contextual de archivos - */ - public registerFileContextMenuEvents(): void { - this.fileMenuEventRef = (menu, file, source) => { - if ( - source === "file-explorer-context-menu" && - file instanceof TFile && - file.extension === "md" - ) { - this.addStatusChangeMenu(menu, file); - } - }; - - this.filesMenuEventRef = (menu, files) => { - const mdFiles = files.filter( - (file) => file instanceof TFile && file.extension === "md", - ) as TFile[]; - - if (mdFiles.length > 0) { - this.addBatchStatusChangeMenu(menu, mdFiles); - } - }; - - this.app.workspace.on("file-menu", this.fileMenuEventRef); - this.app.workspace.on("files-menu", this.filesMenuEventRef); - } - - /** - * Añade opción de cambio de estado al menú contextual de un archivo - */ - private addStatusChangeMenu(menu: Menu, file: TFile): void { - this.statusContextMenu.addStatusMenuItemToSingleFile( - menu, - file, - (file) => { - const selectedFiles = - this.explorerIntegration.getSelectedFiles(); - if (selectedFiles.length > 1) { - this.statusContextMenu.showForFiles(selectedFiles); - } else { - this.statusContextMenu.showForFiles([file]); - } - }, - ); - } - - /** - * Añade opción de cambio de estado para múltiples archivos - */ - private addBatchStatusChangeMenu(menu: Menu, files: TFile[]): void { - this.statusContextMenu.addStatusMenuItemToBatch( - menu, - files, - (files) => { - this.statusContextMenu.showForFiles(files); - }, - ); - } - - public unload(): void { - if (this.fileMenuEventRef) { - this.app.workspace.off("file-menu", this.fileMenuEventRef); - } - if (this.filesMenuEventRef) { - this.app.workspace.off("files-menu", this.filesMenuEventRef); - } - } -} diff --git a/integrations/context-menu/status-context-menu.ts b/integrations/context-menu/status-context-menu.ts deleted file mode 100644 index 807fc23..0000000 --- a/integrations/context-menu/status-context-menu.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { App, Menu, TFile } from "obsidian"; -import { NoteStatusSettings } from "models/types"; -import { StatusService } from "services/status-service"; -import { StatusDropdown } from "components/status-dropdown"; -import { ExplorerIntegration } from "integrations/explorer/explorer-integration"; - -/** - * Gestiona los menús contextuales para cambios de estado - */ -export class StatusContextMenu { - private app: App; - private settings: NoteStatusSettings; - private statusService: StatusService; - private statusDropdown: StatusDropdown; - private explorerIntegration: ExplorerIntegration; - - constructor( - app: App, - settings: NoteStatusSettings, - statusService: StatusService, - statusDropdown: StatusDropdown, - explorerIntegration: ExplorerIntegration, - ) { - this.app = app; - this.settings = settings; - this.statusService = statusService; - this.statusDropdown = statusDropdown; - this.explorerIntegration = explorerIntegration; - } - - /** - * Actualiza la configuración - */ - public updateSettings(settings: NoteStatusSettings): void { - this.settings = settings; - } - - /** - * Añade ítem de menú para cambiar estado de un archivo - */ - public addStatusMenuItemToSingleFile( - menu: Menu, - file: TFile, - onClick: (file: TFile) => void, - ): void { - menu.addItem((item) => - item - .setTitle("Change status") - .setIcon("tag") - .onClick(() => onClick(file)), - ); - } - - /** - * Añade ítem de menú para cambiar estado de múltiples archivos - */ - public addStatusMenuItemToBatch( - menu: Menu, - files: TFile[], - onClick: (files: TFile[]) => void, - ): void { - menu.addItem((item) => - item - .setTitle("Change status") - .setIcon("tag") - .onClick(() => onClick(files)), - ); - } - - /** - * Muestra el menú contextual para cambiar estado de uno o más archivos - * @param files Archivos a los que cambiar el estado - * @param position Posición opcional para mostrar el menú - */ - public showForFiles( - files: TFile[], - position?: { x: number; y: number }, - ): void { - if (files.length === 0) return; - - if (files.length === 1) { - this.showForSingleFile(files[0], position); - } else { - this.showForMultipleFiles(files, position); - } - } - - /** - * Muestra el menú contextual para un solo archivo - * @param file Archivo al que cambiar el estado - * @param position Posición opcional para mostrar el menú - */ - private showForSingleFile( - file: TFile, - position?: { x: number; y: number }, - ): void { - if (!(file instanceof TFile) || file.extension !== "md") return; - - this.statusDropdown.openStatusDropdown({ - position, - files: [file], - }); - } - - /** - * Muestra el menú contextual para múltiples archivos - * @param files Archivos a los que cambiar el estado - * @param position Posición opcional para mostrar el menú - */ - private showForMultipleFiles( - files: TFile[], - position?: { x: number; y: number }, - ): void { - const menu = new Menu(); - - // Elemento de información (deshabilitado) - menu.addItem((item) => { - item.setTitle(`Update ${files.length} files`).setDisabled(true); - return item; - }); - - // Opción para gestionar estados - menu.addItem((item) => - item - .setTitle("Manage statuses...") - .setIcon("tag") - .onClick(() => { - this.statusDropdown.openStatusDropdown({ - position, - files, - }); - }), - ); - - // Mostrar el menú en la posición adecuada - if (position) { - menu.showAtPosition(position); - } else { - // Use a centered position - menu.showAtPosition({ - x: window.innerWidth / 2, - y: window.innerHeight / 3, - }); - } - } -} diff --git a/integrations/editor/editor-integration.ts b/integrations/editor/editor-integration.ts deleted file mode 100644 index 3997a70..0000000 --- a/integrations/editor/editor-integration.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { App, Editor, MarkdownView, Menu } from "obsidian"; -import { NoteStatusSettings } from "../../models/types"; -import { StatusService } from "services/status-service"; -import { StatusDropdown } from "components/status-dropdown"; - -export class EditorIntegration { - private app: App; - private settings: NoteStatusSettings; - private statusService: StatusService; - private statusDropdown: StatusDropdown; - private editorMenuRef: ( - menu: Menu, - editor: Editor, - view: MarkdownView, - ) => void; - - constructor( - app: App, - settings: NoteStatusSettings, - statusService: StatusService, - statusDropdown: StatusDropdown, - ) { - this.app = app; - this.settings = settings; - this.statusService = statusService; - this.statusDropdown = statusDropdown; - } - - public updateSettings(settings: NoteStatusSettings): void { - this.settings = settings; - } - - public registerEditorMenus(): void { - this.editorMenuRef = ( - menu: Menu, - editor: Editor, - view: MarkdownView, - ) => { - if (view.file) { - this.addStatusMenuItems(menu, editor, view); - } - }; - - this.app.workspace.on("editor-menu", this.editorMenuRef); - } - - private addStatusMenuItems( - menu: Menu, - editor: Editor, - view: MarkdownView, - ): void { - menu.addItem((item) => - item - .setTitle("Change note status") - .setIcon("tag") - .onClick(() => { - if (view.file) { - this.statusDropdown.openStatusDropdown({ - files: [view.file], - editor, - view, - }); - } - }), - ); - - // Only show insert metadata if it doesn't exist - if (view.file) { - const statuses = this.statusService.getFileStatuses(view.file); - if (statuses.length === 1 && statuses[0] === "unknown") { - menu.addItem((item) => - item - .setTitle("Insert status metadata") - .setIcon("plus-circle") - .onClick(() => { - this.insertStatusMetadata(editor); - }), - ); - } - } - } - - public insertStatusMetadata(editor: Editor): void { - this.statusService.insertStatusMetadataInEditor(editor); - } - - public unload(): void { - if (this.editorMenuRef) { - this.app.workspace.off("editor-menu", this.editorMenuRef); - } - } -} diff --git a/integrations/editor/index.ts b/integrations/editor/index.ts deleted file mode 100644 index c0393ff..0000000 --- a/integrations/editor/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { EditorIntegration } from "./editor-integration"; -export { ToolbarIntegration } from "./toolbar-integration"; diff --git a/integrations/editor/toolbar-integration.ts b/integrations/editor/toolbar-integration.ts deleted file mode 100644 index 8a1958f..0000000 --- a/integrations/editor/toolbar-integration.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { App, MarkdownView } from "obsidian"; -import { NoteStatusSettings } from "../../models/types"; -import { StatusService } from "../../services/status-service"; -import { StatusDropdown } from "../../components/status-dropdown"; -import { ToolbarButton } from "components/toolbar-button"; - -/** - * Gestiona la integración con la barra de herramientas del editor - */ -export class ToolbarIntegration { - private app: App; - private settings: NoteStatusSettings; - private statusService: StatusService; - private statusDropdown: StatusDropdown; - private buttonView: ToolbarButton; - private buttonElement: HTMLElement | null = null; - private currentLeafId: string | null = null; - - constructor( - app: App, - settings: NoteStatusSettings, - statusService: StatusService, - statusDropdown: StatusDropdown, - ) { - this.app = app; - this.settings = settings; - this.statusService = statusService; - this.statusDropdown = statusDropdown; - this.buttonView = new ToolbarButton(settings, statusService); - } - - public updateSettings(settings: NoteStatusSettings): void { - this.settings = settings; - this.buttonView.updateSettings(settings); - this.updateStatusDisplay([]); - } - - public addToolbarButtonToActiveLeaf(statuses?: string[]): void { - const activeLeaf = this.app.workspace.activeLeaf; - if (!activeLeaf?.view || !(activeLeaf.view instanceof MarkdownView)) - return; - - const leafId = activeLeaf.id || activeLeaf.view.containerEl.id; - - // Only recreate button if we're on a different leaf or button doesn't exist - if ( - this.currentLeafId !== leafId || - !this.buttonElement || - !this.isButtonInDOM() - ) { - this.recreateButton(activeLeaf.view, leafId); - } - - this.updateButtonDisplay(statuses); - } - - private recreateButton(view: MarkdownView, leafId: string): void { - const toolbarContainer = view.containerEl.querySelector( - ".view-header .view-actions", - ); - if (!toolbarContainer) return; - - // Remove old button if it exists - this.removeToolbarButton(); - - // Create new button - this.buttonElement = this.buttonView.createElement(); - this.buttonElement.addEventListener( - "click", - this.handleButtonClick.bind(this), - ); - - if (toolbarContainer.firstChild) { - toolbarContainer.insertBefore( - this.buttonElement, - toolbarContainer.firstChild, - ); - } else { - toolbarContainer.appendChild(this.buttonElement); - } - - this.currentLeafId = leafId; - } - - private isButtonInDOM(): boolean { - return this.buttonElement?.isConnected === true; - } - - private removeToolbarButton(): void { - this.app.workspace.iterateAllLeaves((leaf) => { - if (leaf.view instanceof MarkdownView) { - const buttons = leaf.view.containerEl.querySelectorAll( - ".note-status-toolbar-button", - ); - buttons.forEach((button) => button.remove()); - } - }); - - this.buttonElement = null; - this.currentLeafId = null; - } - - private updateButtonDisplay(overrideStatuses?: string[]): void { - if (!this.buttonElement) return; - - const activeFile = this.app.workspace.getActiveFile(); - if (!activeFile) return; - - const statuses = overrideStatuses?.length - ? overrideStatuses - : this.statusService.getFileStatuses(activeFile); - this.buttonView.updateDisplay(statuses); - } - - private handleButtonClick(e: MouseEvent): void { - e.stopPropagation(); - e.preventDefault(); - const activeFile = this.app.workspace.getActiveFile(); - if (!activeFile) return; - - this.statusDropdown.openStatusDropdown({ - target: this.buttonElement || undefined, - files: [activeFile], - }); - } - - public updateStatusDisplay(statuses: string[]): void { - this.updateButtonDisplay(statuses); - } - - public unload(): void { - // this.buttonView.destroy(); - this.removeToolbarButton(); - } -} diff --git a/integrations/explorer/explorer-integration.ts b/integrations/explorer/explorer-integration.ts deleted file mode 100644 index 8c60027..0000000 --- a/integrations/explorer/explorer-integration.ts +++ /dev/null @@ -1,432 +0,0 @@ -import { App, TFile, setTooltip, debounce } from "obsidian"; -import { NoteStatusSettings, FileExplorerView } from "../../models/types"; -import { StatusService } from "services/status-service"; - -/** - * Manages the logic for file explorer status integration - */ -export class ExplorerIntegration { - private app: App; - private settings: NoteStatusSettings; - private statusService: StatusService; - private ui: ExplorerIntegrationUI; - private iconUpdateQueue = new Set(); - private isProcessingQueue = false; - private debouncedUpdateAll: ReturnType; - private fileItemsWasUndefined = false; - - constructor( - app: App, - settings: NoteStatusSettings, - statusService: StatusService, - ) { - this.app = app; - this.settings = settings; - this.statusService = statusService; - this.ui = new ExplorerIntegrationUI( - app, - settings, - statusService, - () => { - this.fileItemsWasUndefined = true; - }, - ); - this.debouncedUpdateAll = debounce( - this.processUpdateQueue.bind(this), - 100, - true, - ); - - // Listen for layout changes to detect when file explorer becomes available - this.app.workspace.on("layout-change", () => { - this.checkFileExplorerAvailability(); - }); - } - - /** - * Check if file explorer became available after being unavailable - */ - private checkFileExplorerAvailability(): void { - if (!this.settings.showStatusIconsInExplorer) return; - - const fileExplorer = this.ui.findFileExplorerView(); - if (fileExplorer?.fileItems && this.fileItemsWasUndefined) { - this.fileItemsWasUndefined = false; - setTimeout(() => this.updateAllFileExplorerIcons(), 100); - } - } - - /** - * Updates settings and refreshes UI if necessary - */ - public updateSettings(settings: NoteStatusSettings): void { - const shouldRefreshIcons = - this.settings.showStatusIconsInExplorer !== - settings.showStatusIconsInExplorer || - this.settings.hideUnknownStatusInExplorer !== - settings.hideUnknownStatusInExplorer; - - this.settings = { ...settings }; - this.ui.updateSettings(settings); - - if (shouldRefreshIcons) { - this.ui.removeAllFileExplorerIcons(); - - if (settings.showStatusIconsInExplorer) { - setTimeout(() => this.updateAllFileExplorerIcons(), 50); - } - } else if (settings.showStatusIconsInExplorer) { - this.updateAllFileExplorerIcons(); - } - } - - /** - * Updates icons for a specific file - */ - public updateFileExplorerIcons(file: TFile): void { - if ( - !file || - !this.settings.showStatusIconsInExplorer || - file.extension !== "md" - ) - return; - - const activeFile = this.app.workspace.getActiveFile(); - if (activeFile?.path === file.path) { - this.ui.updateSingleFileIconDirectly(file, this.statusService); - } - - this.queueFileUpdate(file); - } - - /** - * Updates all icons in the explorer - */ - public updateAllFileExplorerIcons(): void { - if (!this.settings.showStatusIconsInExplorer) { - this.ui.removeAllFileExplorerIcons(); - return; - } - - this.processFilesInBatches(); - } - - /** - * Gets selected files from the explorer - */ - public getSelectedFiles(): TFile[] { - return this.ui.getSelectedFiles(); - } - - /** - * Queue a file for icon update - */ - private queueFileUpdate(file: TFile): void { - if (!this.settings.showStatusIconsInExplorer || file.extension !== "md") - return; - - this.iconUpdateQueue.add(file.path); - this.debouncedUpdateAll(); - } - - /** - * Process the update queue - */ - private async processUpdateQueue(): Promise { - if (this.isProcessingQueue || this.iconUpdateQueue.size === 0) return; - - this.isProcessingQueue = true; - - try { - const fileExplorerView = this.ui.findFileExplorerView(); - if (!fileExplorerView || !fileExplorerView.fileItems) { - this.fileItemsWasUndefined = true; - setTimeout(() => this.debouncedUpdateAll(), 200); - return; - } - - const allPaths = Array.from(this.iconUpdateQueue); - const batchSize = 50; - - for (let i = 0; i < allPaths.length; i += batchSize) { - await this.processBatch( - allPaths.slice(i, i + batchSize), - fileExplorerView, - ); - - if (i + batchSize < allPaths.length) { - await new Promise((resolve) => setTimeout(resolve, 0)); - } - } - } catch (error) { - console.error( - "Note Status: Error processing file update queue", - error, - ); - } finally { - this.isProcessingQueue = false; - - if (this.iconUpdateQueue.size > 0) { - this.debouncedUpdateAll(); - } - } - } - - /** - * Process a batch of files - */ - private async processBatch( - paths: string[], - fileExplorerView: FileExplorerView, - ): Promise { - for (const path of paths) { - const file = this.app.vault.getFileByPath(path); - if (file instanceof TFile) { - this.ui.updateSingleFileIcon( - file, - fileExplorerView, - this.statusService, - ); - } - this.iconUpdateQueue.delete(path); - } - } - - /** - * Process files in batches - */ - private async processFilesInBatches(): Promise { - const files = this.app.vault.getMarkdownFiles(); - const batchSize = 100; - - for (let i = 0; i < files.length; i += batchSize) { - files - .slice(i, i + batchSize) - .forEach((file) => this.queueFileUpdate(file)); - - if (i + batchSize < files.length) { - await new Promise((resolve) => setTimeout(resolve, 0)); - } - } - } - - /** - * Cleanup when unloading the plugin - */ - public unload(): void { - this.ui.removeAllFileExplorerIcons(); - this.debouncedUpdateAll.cancel(); - } -} - -/** - * Manages UI operations for file explorer icons - */ -export class ExplorerIntegrationUI { - private app: App; - private settings: NoteStatusSettings; - private onFileItemsUndefined: () => void; - - constructor( - app: App, - settings: NoteStatusSettings, - statusService: StatusService, - onFileItemsUndefined: () => void, - ) { - this.app = app; - this.settings = settings; - this.onFileItemsUndefined = onFileItemsUndefined; - } - - /** - * Updates the settings - */ - public updateSettings(settings: NoteStatusSettings): void { - this.settings = settings; - } - - /** - * Finds the file explorer view - */ - public findFileExplorerView(): FileExplorerView | null { - const leaf = this.app.workspace.getLeavesOfType("file-explorer")[0]; - if (leaf?.view) return leaf.view as FileExplorerView; - - for (const leaf of this.app.workspace.getLeavesOfType("")) { - if (leaf.view && "fileItems" in leaf.view) { - return leaf.view as FileExplorerView; - } - } - - return null; - } - - /** - * Updates a single file icon - */ - public updateSingleFileIcon( - file: TFile, - fileExplorerView: FileExplorerView, - statusService: StatusService, - ): void { - if (!this.settings.showStatusIconsInExplorer || file.extension !== "md") - return; - - try { - // Check if fileItems is initialized - if (!fileExplorerView.fileItems) { - this.onFileItemsUndefined(); - return; - } - - const fileItem = fileExplorerView.fileItems[file.path]; - if (!fileItem) return; - - const titleEl = fileItem.titleEl || fileItem.selfEl; - if (!titleEl) return; - - const statuses = statusService.getFileStatuses(file); - - this.removeExistingIcons(titleEl); - - if (this.shouldSkipIcon(statuses)) return; - - this.addStatusIcons(titleEl, statuses, statusService); - } catch (error) { - console.error( - `Note Status: Error updating icon for ${file.path}`, - error, - ); - } - } - - /** - * Updates the icon for the active file directly - */ - public updateSingleFileIconDirectly( - file: TFile, - statusService: StatusService, - ): void { - const fileExplorer = this.findFileExplorerView(); - if (fileExplorer) { - this.updateSingleFileIcon(file, fileExplorer, statusService); - } - } - - /** - * Removes all file explorer icons - */ - public removeAllFileExplorerIcons(): void { - const fileExplorer = this.findFileExplorerView(); - if (!fileExplorer?.fileItems) return; - - Object.values(fileExplorer.fileItems).forEach((fileItem) => { - const titleEl = fileItem.titleEl || fileItem.selfEl; - if (titleEl) this.removeExistingIcons(titleEl); - }); - } - - /** - * Gets the currently selected files - */ - public getSelectedFiles(): TFile[] { - const fileExplorer = this.findFileExplorerView(); - if (!fileExplorer?.fileItems) return []; - - return Object.values(fileExplorer.fileItems) - .filter( - (item) => - item.el?.classList.contains("is-selected") && - item.file instanceof TFile && - item.file.extension === "md", - ) - .map((item) => item.file as TFile); - } - - /** - * Checks if the icon should be skipped based on status - */ - private shouldSkipIcon(statuses: string[]): boolean { - return ( - this.settings.hideUnknownStatusInExplorer && - statuses.length === 1 && - statuses[0] === "unknown" - ); - } - - /** - * Removes existing status icons - */ - private removeExistingIcons(element: HTMLElement): void { - const iconSelectors = ".note-status-icon, .note-status-icon-container"; - element.querySelectorAll(iconSelectors).forEach((icon) => { - if ( - icon.classList.contains("note-status-icon") || - icon.classList.contains("note-status-icon-container") - ) { - icon.remove(); - } - }); - } - - /** - * Adds status icons to an element - */ - private addStatusIcons( - titleEl: HTMLElement, - statuses: string[], - statusService: StatusService, - ): void { - const iconContainer = document.createElement("span"); - iconContainer.className = "note-status-icon-container"; - - if ( - this.settings.useMultipleStatuses && - statuses.length > 0 && - statuses[0] !== "unknown" - ) { - statuses.forEach((status) => - this.addSingleStatusIcon(iconContainer, status, statusService), - ); - } else { - const primaryStatus = statuses[0] || "unknown"; - if ( - primaryStatus !== "unknown" || - !this.settings.hideUnknownStatusInExplorer - ) { - this.addSingleStatusIcon( - iconContainer, - primaryStatus, - statusService, - ); - } - } - - if (iconContainer.childElementCount > 0) { - titleEl.appendChild(iconContainer); - } - } - - /** - * Adds a single status icon - */ - private addSingleStatusIcon( - container: HTMLElement, - status: string, - statusService: StatusService, - ): void { - const iconEl = document.createElement("span"); - iconEl.className = `note-status-icon nav-file-tag status-${status}`; - iconEl.textContent = statusService.getStatusIcon(status); - - const statusObj = statusService - .getAllStatuses() - .find((s) => s.name === status); - const tooltipValue = statusObj?.description - ? `${status} - ${statusObj.description}` - : status; - setTooltip(iconEl, tooltipValue); - - container.appendChild(iconEl); - } -} diff --git a/integrations/explorer/index.ts b/integrations/explorer/index.ts deleted file mode 100644 index 8cccce5..0000000 --- a/integrations/explorer/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { ExplorerIntegration } from "./explorer-integration"; diff --git a/integrations/file-explorer/file-explorer-integration.tsx b/integrations/file-explorer/file-explorer-integration.tsx new file mode 100644 index 0000000..f06de9f --- /dev/null +++ b/integrations/file-explorer/file-explorer-integration.tsx @@ -0,0 +1,258 @@ +import { FileExplorerIcon } from "@/components/FileExplorer/FileExplorerIcon"; +import eventBus from "@/core/eventBus"; +import { + LazyElementObserver, + IElementProcessor, +} from "@/core/lazyElementObserver"; +import { NoteStatusService } from "@/core/noteStatusService"; +import settingsService from "@/core/settingsService"; +import { GroupedStatuses } from "@/types/noteStatus"; +import { Plugin, TFile, View } from "obsidian"; +import { createRoot } from "react-dom/client"; +import { StatusesInfoPopup } from "../popups/statusesInfoPopupIntegration"; + +export class FileExplorerIntegration implements IElementProcessor { + private plugin: Plugin; + private observerService: LazyElementObserver; + + private readonly ICON_CLASS = "custom-icon"; + private readonly EVENT_SUBSCRIPTION_ID = + "file-explorer-integration-subscription1"; + private readonly FILE_EXPLORER_TYPE = "file-explorer"; + private readonly CONTAINER_SELECTOR = ".nav-files-container"; + + constructor(plugin: Plugin) { + this.plugin = plugin; + this.observerService = new LazyElementObserver(this); + } + + /** + * Initializes file explorer integration + */ + async integrate(): Promise { + this.setupWorkspaceIntegration(); + + eventBus.subscribe( + "frontmatter-manually-changed", + ({ file }) => { + const element = this.findFileElement(file.path); + if (!element) return; + + this.refreshElementIcon(element, file.path); + }, + this.EVENT_SUBSCRIPTION_ID, + ); + + eventBus.subscribe( + "plugin-settings-changed", + ({ key }) => { + if ( + key === "showStatusIconsInExplorer" || + key === "hideUnknownStatusInExplorer" || + key === "enabledTemplates" || + key === "useCustomStatusesOnly" || + key === "customStatuses" || + key === "useMultipleStatuses" || + key === "tagPrefix" || + key === "strictStatuses" || + key === "fileExplorerIconPosition" + ) { + this.destroy(); + this.integrate().catch((r) => console.error(r)); + } + }, + "fileExplorerIntegrationSubscription2", + ); + } + + private getFileNoteStatusService( + dataPath: string, + ): NoteStatusService | null { + if (!dataPath.endsWith(".md")) { + return null; + } + + const file = this.plugin.app.vault.getAbstractFileByPath( + dataPath, + ) as TFile; + if (!file) { + return null; + } + + const noteStatusService = new NoteStatusService(file); + noteStatusService.populateStatuses(); + return noteStatusService; + } + + /** + * Processes visible file explorer elements (IElementProcessor implementation) + */ + processElement(element: HTMLElement, dataPath?: string | null): void { + if (!dataPath) { + dataPath = element.getAttribute("data-path"); + } + if (dataPath) { + const textEl = element.querySelector( + ".nav-file-title-content, .tree-item-inner, .nav-folder-title-content", + ); + if (!textEl) { + return; + } + + const noteStatusService = this.getFileNoteStatusService(dataPath); + + // Only render icons for markdown files + if (noteStatusService) { + this.render(textEl, noteStatusService.statuses); + } + } + } + + render(element: Element, statuses: GroupedStatuses): void { + // Remove existing icon + const existingIcon = element.querySelector(`.${this.ICON_CLASS}`); + if (existingIcon) { + existingIcon.remove(); + } + + if (!settingsService.settings.showStatusIconsInExplorer) { + return; + } + + let positionClassName = ""; + if ( + settingsService.settings.fileExplorerIconPosition === + "absolute-right" + ) { + positionClassName = "custom-icon__absolute-right"; + } + const icon = createSpan({ cls: [this.ICON_CLASS, positionClassName] }); + const root = createRoot(icon); + root.render( + this.openModalInfo(s)} + onMouseLeave={this.closeModalInfo} + hideUnknownStatus={ + settingsService.settings.hideUnknownStatusInExplorer + } + />, + ); + + if ( + settingsService.settings.fileExplorerIconPosition === + "file-name-right" + ) { + element.append(icon); + } else { + element.prepend(icon); + } + } + + private openModalInfo(statuses: GroupedStatuses) { + if (!this.plugin) { + return; + } + StatusesInfoPopup.open(statuses); + } + private closeModalInfo() { + StatusesInfoPopup.close(); + } + + /** + * Cleanup integration and unsubscribe from events + */ + destroy(): void { + this.observerService.cleanup(); + eventBus.unsubscribe( + "frontmatter-manually-changed", + this.EVENT_SUBSCRIPTION_ID, + ); + eventBus.unsubscribe( + "frontmatter-manually-changed", + "fileExplorerIntegrationSubscription2", + ); + } + + /** + * Sets up workspace-level integration + */ + private setupWorkspaceIntegration(): void { + this.plugin.app.workspace.onLayoutReady(() => { + this.patchFileExplorer(); + this.registerActiveLeafChangeHandler(); + }); + } + + /** + * Registers handler for active leaf changes + */ + private registerActiveLeafChangeHandler(): void { + this.plugin.registerEvent( + this.plugin.app.workspace.on("active-leaf-change", () => { + this.patchFileExplorer(); + }), + ); + } + + /** + * Finds file element in the file explorer + */ + private findFileElement(filePath: string): HTMLElement | null { + const fileExplorerView = this.getFileExplorerView(); + if (!fileExplorerView) return null; + + return fileExplorerView.containerEl.querySelector( + `[data-path="${filePath}"]`, + ) as HTMLElement; + } + + /** + * Refreshes icon for a specific element + */ + private refreshElementIcon(element: HTMLElement, dataPath: string): void { + // + // Remove existing icon + // this.iconRenderer.removeIcon(element); + + // Mark for reprocessing + this.observerService.markElementForReprocessing(element); + + this.processElement(element, dataPath); + // + // // Re-add icon with updated status + // this.iconRenderer.renderIcon(element, dataPath); + } + + /** + * Patches the file explorer with observers + */ + private patchFileExplorer(): void { + const container = this.getFileExplorerContainer(); + if (!container) return; + + this.observerService.setupObservers(container); + } + + /** + * Gets the file explorer view + */ + private getFileExplorerView(): View | null { + const leaves = this.plugin.app.workspace.getLeavesOfType( + this.FILE_EXPLORER_TYPE, + ); + return leaves[0]?.view || null; + } + + /** + * Gets the file explorer container element + */ + private getFileExplorerContainer(): Element | null { + const fileExplorerView = this.getFileExplorerView(); + if (!fileExplorerView) return null; + + return fileExplorerView.containerEl.querySelector( + this.CONTAINER_SELECTOR, + ); + } +} diff --git a/integrations/metadata-cache/index.ts b/integrations/metadata-cache/index.ts deleted file mode 100644 index 92e849e..0000000 --- a/integrations/metadata-cache/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { MetadataIntegration } from "./metadata-integration"; diff --git a/integrations/metadata-cache/metadata-integration.ts b/integrations/metadata-cache/metadata-integration.ts deleted file mode 100644 index 07fe629..0000000 --- a/integrations/metadata-cache/metadata-integration.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { App, TFile } from "obsidian"; -import { NoteStatusSettings } from "../../models/types"; -import { ExplorerIntegration } from "../explorer/explorer-integration"; -import { StatusService } from "services/status-service"; - -export class MetadataIntegration { - private app: App; - private settings: NoteStatusSettings; - private statusService: StatusService; - private explorerIntegration: ExplorerIntegration; - private metadataChangedRef: (file: TFile) => void; - private metadataResolvedRef: () => void; - - constructor( - app: App, - settings: NoteStatusSettings, - statusService: StatusService, - explorerIntegration: ExplorerIntegration, - ) { - this.app = app; - this.settings = settings; - this.statusService = statusService; - this.explorerIntegration = explorerIntegration; - } - - public updateSettings(settings: NoteStatusSettings): void { - this.settings = settings; - } - - public registerMetadataEvents(): void { - this.metadataChangedRef = (file) => { - if (file instanceof TFile && file.extension === "md") { - this.handleMetadataChanged(file); - } - }; - - this.metadataResolvedRef = () => { - setTimeout(() => { - if (this.settings.showStatusIconsInExplorer) { - this.explorerIntegration.updateAllFileExplorerIcons(); - } - }, 500); - }; - - this.app.metadataCache.on("changed", this.metadataChangedRef); - this.app.metadataCache.on("resolved", this.metadataResolvedRef); - } - - private handleMetadataChanged(file: TFile): void { - if (this.settings.showStatusIconsInExplorer) { - this.explorerIntegration.updateFileExplorerIcons(file); - } - - const activeFile = this.app.workspace.getActiveFile(); - if (activeFile?.path === file.path) { - const statuses = this.statusService.getFileStatuses(file); - window.dispatchEvent( - new CustomEvent("note-status:status-changed", { - detail: { statuses, file: file.path }, - }), - ); - } - } - - public unload(): void { - if (this.metadataChangedRef) { - this.app.metadataCache.off("changed", this.metadataChangedRef); - } - if (this.metadataResolvedRef) { - this.app.metadataCache.off("resolved", this.metadataResolvedRef); - } - } -} diff --git a/integrations/modals/statusModalIntegration.tsx b/integrations/modals/statusModalIntegration.tsx new file mode 100644 index 0000000..0a8b838 --- /dev/null +++ b/integrations/modals/statusModalIntegration.tsx @@ -0,0 +1,139 @@ +import { App, Modal, Notice } from "obsidian"; +import { createRoot, Root } from "react-dom/client"; +import { + ChangeStatusModal, + Props, +} from "@/components/ChangeStatusModal/ChangeStatusModal"; +import SelectorService from "@/core/selectorService"; +import { + MultipleNoteStatusService, + NoteStatusService, + BaseNoteStatusService, +} from "@/core/noteStatusService"; +import eventBus from "@/core/eventBus"; + +export class StatusModalIntegration extends Modal { + private root: Root | null = null; + private static instance: StatusModalIntegration | null = null; + private selectorService: SelectorService; + + constructor(app: App) { + super(app); + } + + static open( + app: App, + noteStatusService: NoteStatusService | MultipleNoteStatusService, + ) { + if (StatusModalIntegration.instance) { + throw new Error("Status Modal is already open"); + } + StatusModalIntegration.instance = new StatusModalIntegration(app); + StatusModalIntegration.instance.selectorService = new SelectorService( + noteStatusService, + ); + + eventBus.subscribe( + "frontmatter-manually-changed", + () => { + // TODO: There are multiple calls to populateStatuses, in this case the noteStatusService is passed by reference, so redundant computations + // FIXME: Line 27 + StatusModalIntegration.instance?.selectorService.noteStatusService.populateStatuses(); + StatusModalIntegration.instance?.render(); + }, + "statusModalIntegrationSubscription1", + ); + + StatusModalIntegration.instance.open(); + } + + private onRemoveStatus: Props["onRemoveStatus"] = async ( + frontmatterTagName, + status, + ) => { + const removed = + await this.selectorService.noteStatusService.removeStatus( + frontmatterTagName, + status, + ); + if (removed) { + new Notice( + `Status ${status.name} removed successfully from the note`, + ); + } else { + new Notice( + `Something went wrong removing the status ${status.name} from the note`, + ); + } + }; + private onSelectStatus: Props["onSelectStatus"] = async ( + frontmatterTagName, + status, + ) => { + const scopedIdentifier = status.templateId + ? BaseNoteStatusService.formatStatusIdentifier({ + templateId: status.templateId, + name: status.name, + }) + : status.name; + + const added = await this.selectorService.noteStatusService.addStatus( + frontmatterTagName, + scopedIdentifier, + ); + if (added) { + new Notice(`Status ${status.name} added successfully to the note`); + } else { + new Notice( + `Something went wrong adding the status ${status.name} to the note`, + ); + } + }; + + onOpen() { + this.render(); + } + + private render() { + const { contentEl } = this; + + if (!this.root) { + this.root = createRoot(contentEl); + } + + let filesQuantity = 1; + if ( + this.selectorService.noteStatusService instanceof + MultipleNoteStatusService + ) { + filesQuantity = + this.selectorService.noteStatusService.selectedFilesQTY(); + } + this.root.render( + , + ); + } + + onClose() { + if (this.root) { + this.root.unmount(); + this.root = null; + } + const { contentEl } = this; + contentEl.empty(); + StatusModalIntegration.instance = null; + + eventBus.unsubscribe( + "frontmatter-manually-changed", + "statusModalIntegrationSubscription1", + ); + } +} diff --git a/integrations/popups/statusesInfoPopupIntegration.tsx b/integrations/popups/statusesInfoPopupIntegration.tsx new file mode 100644 index 0000000..b35ce00 --- /dev/null +++ b/integrations/popups/statusesInfoPopupIntegration.tsx @@ -0,0 +1,47 @@ +import { createRoot, Root } from "react-dom/client"; +import { GroupedStatuses } from "@/types/noteStatus"; +import { StatusFileInfoPopup } from "@/components/StatusFileInfoPopup/StatusFileInfoPopup"; + +export class StatusesInfoPopup { + private root: Root | null = null; + private static instance: StatusesInfoPopup | null = null; + private element: HTMLElement | null = null; + private statuses: GroupedStatuses; + + private constructor() {} + + static open(statuses: GroupedStatuses) { + StatusesInfoPopup.close(); + + StatusesInfoPopup.instance = new StatusesInfoPopup(); + StatusesInfoPopup.instance.statuses = statuses; + StatusesInfoPopup.instance.show(); + } + + static close() { + if (StatusesInfoPopup.instance) { + StatusesInfoPopup.instance.destroy(); + StatusesInfoPopup.instance = null; + } + } + + private show() { + this.element = createDiv({ cls: "" }); + + document.body.appendChild(this.element); + + this.root = createRoot(this.element); + this.root.render(); + } + + private destroy() { + if (this.root) { + this.root.unmount(); + this.root = null; + } + if (this.element) { + this.element.remove(); + this.element = null; + } + } +} diff --git a/integrations/settings/pluginSettings.tsx b/integrations/settings/pluginSettings.tsx new file mode 100644 index 0000000..8f7a9cb --- /dev/null +++ b/integrations/settings/pluginSettings.tsx @@ -0,0 +1,52 @@ +import SettingsUI from "@/components/SettingsUI.tsx"; +import settingsService from "@/core/settingsService"; +import { Plugin, PluginSettingTab } from "obsidian"; +import { createRoot, Root } from "react-dom/client"; + +export class PluginSettingIntegration extends PluginSettingTab { + private static instance: PluginSettingIntegration | null = null; + private root: Root | null = null; + private plugin: Plugin; + + constructor(plugin: Plugin) { + if (PluginSettingIntegration.instance) { + throw new Error("The status bar instance is already created"); + } + super(plugin.app, plugin); + this.plugin = plugin; + PluginSettingIntegration.instance = this; + } + + async integrate() { + // INFO: This will integrate the "display" function component + this.plugin.addSettingTab(this); + } + + display(): void { + const { containerEl } = this; + + if (this.root) { + this.root.unmount(); + this.root = null; + } + containerEl.empty(); + this.root = createRoot(containerEl); + + this.root.render( + { + settingsService.setValue(key, value); + }} + />, + ); + } + + destroy(): void { + if (this.root) { + this.root.unmount(); + this.root = null; + } + PluginSettingIntegration.instance = null; + } +} diff --git a/integrations/settings/settings-controller.ts b/integrations/settings/settings-controller.ts deleted file mode 100644 index 27822cd..0000000 --- a/integrations/settings/settings-controller.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { App } from "obsidian"; -import { Status } from "../../models/types"; -import NoteStatus from "main"; -import { StatusService } from "services/status-service"; -import { NoteStatusSettingsUI } from "./settings-ui"; -import { SettingsUICallbacks } from "./types"; - -/** - * Controller for settings UI, handles business logic and plugin state updates - */ -export class NoteStatusSettingsController implements SettingsUICallbacks { - private app: App; - private plugin: NoteStatus; - private statusService: StatusService; - private ui: NoteStatusSettingsUI; - - constructor(app: App, plugin: NoteStatus, statusService: StatusService) { - this.app = app; - this.plugin = plugin; - this.statusService = statusService; - this.ui = new NoteStatusSettingsUI(this); - } - - /** - * Renders the settings interface - */ - display(containerEl: HTMLElement): void { - this.ui.render(containerEl, this.plugin.settings); - } - - /** - * Handles template enable/disable toggle - */ - onTemplateToggle: SettingsUICallbacks["onTemplateToggle"] = async ( - templateId, - enabled, - ) => { - if (enabled) { - if (!this.plugin.settings.enabledTemplates.includes(templateId)) { - this.plugin.settings.enabledTemplates.push(templateId); - } - } else { - this.plugin.settings.enabledTemplates = - this.plugin.settings.enabledTemplates.filter( - (id: string) => id !== templateId, - ); - } - - await this.plugin.saveSettings(); - }; - - /** - * Handles general setting changes - */ - onSettingChange: SettingsUICallbacks["onSettingChange"] = async ( - key, - value, - ) => { - this.plugin.settings[key] = value; - await this.plugin.saveSettings(); - }; - - /** - * Handles custom status field changes - */ - onCustomStatusChange: SettingsUICallbacks["onCustomStatusChange"] = async ( - index, - field, - value, - ) => { - const status = this.plugin.settings.customStatuses[index]; - if (!status) return; - - if (field === "name") { - const oldName = status.name; - status.name = value; - - if (oldName !== status.name) { - this.plugin.settings.statusColors[status.name] = - this.plugin.settings.statusColors[oldName]; - delete this.plugin.settings.statusColors[oldName]; - } - } else if (field === "color") { - this.plugin.settings.statusColors[status.name] = value; - } else { - status[field] = value; - } - - await this.plugin.saveSettings(); - }; - - /** - * Handles custom status removal - */ - onCustomStatusRemove: SettingsUICallbacks["onCustomStatusRemove"] = async ( - index, - ) => { - const status = this.plugin.settings.customStatuses[index]; - if (!status) return; - - this.plugin.settings.customStatuses.splice(index, 1); - delete this.plugin.settings.statusColors[status.name]; - - await this.plugin.saveSettings(); - - // Re-render the custom statuses section - const statusList = document.querySelector( - ".custom-status-list", - ) as HTMLElement; - if (statusList) { - this.ui.renderCustomStatuses(statusList, this.plugin.settings); - } - }; - - /** - * Handles adding new custom status - */ - - onCustomStatusAdd: SettingsUICallbacks["onCustomStatusAdd"] = async () => { - const newStatus: Status = { - name: `status${this.plugin.settings.customStatuses.length + 1}`, - icon: "⭐", - }; - - this.plugin.settings.customStatuses.push(newStatus); - this.plugin.settings.statusColors[newStatus.name] = "#ffffff"; - - await this.plugin.saveSettings(); - - // Re-render the custom statuses section - const statusList = document.querySelector( - ".custom-status-list", - ) as HTMLElement; - if (statusList) { - this.ui.renderCustomStatuses(statusList, this.plugin.settings); - } - }; -} diff --git a/integrations/settings/settings-tab.ts b/integrations/settings/settings-tab.ts deleted file mode 100644 index 5fc0042..0000000 --- a/integrations/settings/settings-tab.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { App, PluginSettingTab } from "obsidian"; -import NoteStatus from "main"; -import { StatusService } from "services/status-service"; -import { NoteStatusSettingsController } from "./settings-controller"; - -/** - * Settings tab for the Note Status plugin - delegates to controller - */ -export class NoteStatusSettingTab extends PluginSettingTab { - private controller: NoteStatusSettingsController; - - constructor(app: App, plugin: NoteStatus, statusService: StatusService) { - super(app, plugin); - this.controller = new NoteStatusSettingsController( - app, - plugin, - statusService, - ); - } - - /** - * Displays the settings interface - */ - display(): void { - this.controller.display(this.containerEl); - } -} diff --git a/integrations/settings/settings-ui.ts b/integrations/settings/settings-ui.ts deleted file mode 100644 index 9559ebd..0000000 --- a/integrations/settings/settings-ui.ts +++ /dev/null @@ -1,479 +0,0 @@ -import { Setting } from "obsidian"; -import { NoteStatusSettings, Status } from "../../models/types"; -import { PREDEFINED_TEMPLATES } from "../../constants/status-templates"; -import { SettingsUICallbacks } from "./types"; - -/** - * Pure UI component for rendering settings interface - */ -export class NoteStatusSettingsUI { - private callbacks: SettingsUICallbacks; - private quickCommandsContainer: HTMLElement | null = null; - - constructor(callbacks: SettingsUICallbacks) { - this.callbacks = callbacks; - } - - /** - * Renders the complete settings interface - */ - render(containerEl: HTMLElement, settings: NoteStatusSettings): void { - containerEl.empty(); - - this.renderTemplateSettings(containerEl, settings); - this.renderUISettings(containerEl, settings); - this.renderTagSettings(containerEl, settings); - this.renderCustomStatusSettings(containerEl, settings); - this.renderQuickCommandsSettings(containerEl, settings); - } - - /** - * Renders the status templates section - */ - private renderTemplateSettings( - containerEl: HTMLElement, - settings: NoteStatusSettings, - ): void { - new Setting(containerEl).setName("Status templates").setHeading(); - - containerEl.createEl("p", { - text: "Enable predefined templates to quickly add common status workflows", - cls: "setting-item-description", - }); - - const templatesContainer = containerEl.createDiv({ - cls: "templates-container", - }); - - PREDEFINED_TEMPLATES.forEach((template) => { - const templateEl = templatesContainer.createDiv({ - cls: "template-item", - }); - const headerEl = templateEl.createDiv({ cls: "template-header" }); - - const isEnabled = settings.enabledTemplates.includes(template.id); - const checkbox = headerEl.createEl("input", { - type: "checkbox", - cls: "template-checkbox", - }); - checkbox.checked = isEnabled; - - checkbox.addEventListener("change", () => { - this.callbacks.onTemplateToggle(template.id, checkbox.checked); - this.refreshQuickCommandsList(settings); - }); - - headerEl.createEl("span", { - text: template.name, - cls: "template-name", - }); - - templateEl.createEl("div", { - text: template.description, - cls: "template-description", - }); - - const statusesEl = templateEl.createDiv({ - cls: "template-statuses", - }); - template.statuses.forEach((status) => { - const statusEl = statusesEl.createEl("div", { - cls: "template-status-chip", - }); - const colorDot = statusEl.createEl("span", { - cls: "status-color-dot", - }); - colorDot.style.setProperty( - "--dot-color", - status.color || "#ffffff", - ); - statusEl.createSpan({ text: `${status.icon} ${status.name}` }); - }); - }); - } - - /** - * Renders the UI display settings section - */ - private renderUISettings( - containerEl: HTMLElement, - settings: NoteStatusSettings, - ): void { - new Setting(containerEl).setName("User interface").setHeading(); - - new Setting(containerEl) - .setName("Show status bar") - .setDesc("Display the status bar") - .addToggle((toggle) => - toggle - .setValue(settings.showStatusBar) - .onChange((value) => - this.callbacks.onSettingChange("showStatusBar", value), - ), - ); - - new Setting(containerEl) - .setName("Auto-hide status bar") - .setDesc("Hide the status bar when status is unknown") - .addToggle((toggle) => - toggle - .setValue(settings.autoHideStatusBar) - .onChange((value) => - this.callbacks.onSettingChange( - "autoHideStatusBar", - value, - ), - ), - ); - - new Setting(containerEl) - .setName("Show status icons in file explorer") - .setDesc("Display status icons in the file explorer") - .addToggle((toggle) => - toggle - .setValue(settings.showStatusIconsInExplorer) - .onChange((value) => - this.callbacks.onSettingChange( - "showStatusIconsInExplorer", - value, - ), - ), - ); - - new Setting(containerEl) - .setName("Hide unknown status in file explorer") - .setDesc( - "Hide status icons for files with unknown status in the file explorer", - ) - .addToggle((toggle) => - toggle - .setValue(settings.hideUnknownStatusInExplorer || false) - .onChange((value) => - this.callbacks.onSettingChange( - "hideUnknownStatusInExplorer", - value, - ), - ), - ); - - new Setting(containerEl) - .setName("Default to compact view") - .setDesc("Start the status pane in compact view by default") - .addToggle((toggle) => - toggle - .setValue(settings.compactView || false) - .onChange((value) => - this.callbacks.onSettingChange("compactView", value), - ), - ); - - new Setting(containerEl) - .setName("Exclude unassigned notes from status pane") - .setDesc( - "Improves performance by excluding notes with no assigned status from the status pane. Recommended for large vaults.", - ) - .addToggle((toggle) => - toggle - .setValue(settings.excludeUnknownStatus || false) - .onChange((value) => - this.callbacks.onSettingChange( - "excludeUnknownStatus", - value, - ), - ), - ); - } - - /** - * Renders the status tag configuration section - */ - private renderTagSettings( - containerEl: HTMLElement, - settings: NoteStatusSettings, - ): void { - new Setting(containerEl).setName("Status tag").setHeading(); - - new Setting(containerEl) - .setName("Enable multiple statuses") - .setDesc("Allow notes to have multiple statuses at the same time") - .addToggle((toggle) => - toggle - .setValue(settings.useMultipleStatuses) - .onChange((value) => - this.callbacks.onSettingChange( - "useMultipleStatuses", - value, - ), - ), - ); - - new Setting(containerEl) - .setName("Status tag prefix") - .setDesc( - "The YAML frontmatter tag name used for status (default: obsidian-note-status)", - ) - .addText((text) => - text.setValue(settings.tagPrefix).onChange((value) => { - if (value.trim()) { - this.callbacks.onSettingChange( - "tagPrefix", - value.trim(), - ); - } - }), - ); - - new Setting(containerEl) - .setName("Strict status validation") - .setDesc( - "Only show statuses that are defined in templates or custom statuses. ⚠️ WARNING: When enabled, any unknown statuses will be automatically removed when modifying file statuses.", - ) - .addToggle((toggle) => - toggle - .setValue(settings.strictStatuses || false) - .onChange((value) => - this.callbacks.onSettingChange("strictStatuses", value), - ), - ); - } - - /** - * Renders the quick commands configuration section - */ - private renderQuickCommandsSettings( - containerEl: HTMLElement, - settings: NoteStatusSettings, - ): void { - new Setting(containerEl) - .setName("Quick status commands") - .setDesc( - "Select which statuses should have dedicated commands in the command palette. These can be assigned hotkeys for quick access.", - ) - .setHeading(); - - this.quickCommandsContainer = containerEl.createDiv({ - cls: "quick-commands-container", - }); - this.populateQuickCommandsList(settings); - } - - /** - * Populate the quick commands list - */ - private populateQuickCommandsList(settings: NoteStatusSettings): void { - if (!this.quickCommandsContainer) return; - - this.quickCommandsContainer.empty(); - // Get all available statuses from service - const allStatuses = this.getAllAvailableStatuses(settings); - const currentQuickCommands = settings.quickStatusCommands || []; - - allStatuses.forEach((status) => { - if (!this.quickCommandsContainer) return; - const setting = new Setting(this.quickCommandsContainer) - .setName(`${status.icon} ${status.name}`) - .addToggle((toggle) => - toggle - .setValue(currentQuickCommands.includes(status.name)) - .onChange(async (value) => { - const updatedCommands: string[] = value - ? [ - ...currentQuickCommands.filter( - (cmd: string) => - cmd !== status.name, - ), - status.name, - ] - : currentQuickCommands.filter( - (cmd: string) => cmd !== status.name, - ); - - await this.callbacks.onSettingChange( - "quickStatusCommands", - updatedCommands, - ); - }), - ); - - if (status.description) { - setting.setDesc(status.description); - } - }); - - if (allStatuses.length === 0) { - this.quickCommandsContainer.createDiv({ - text: "No statuses available. Enable templates or add custom statuses first.", - cls: "setting-item-description", - }); - } - } - /** - * Refresh the quick commands list when statuses change - */ - private refreshQuickCommandsList(settings: NoteStatusSettings): void { - // Add small delay to ensure settings are updated - setTimeout(() => { - this.populateQuickCommandsList(settings); - }, 50); - } - - /** - * Get all available statuses from templates and custom statuses - */ - private getAllAvailableStatuses( - settings: NoteStatusSettings, - ): Array<{ name: string; icon: string; description?: string }> { - const statuses: Array<{ - name: string; - icon: string; - description?: string; - }> = []; - - // Add custom statuses - statuses.push(...settings.customStatuses); - - // Add template statuses if not using custom only - if (!settings.useCustomStatusesOnly) { - for (const templateId of settings.enabledTemplates) { - const template = PREDEFINED_TEMPLATES.find( - (t) => t.id === templateId, - ); - if (template) { - for (const status of template.statuses) { - if (!statuses.find((s) => s.name === status.name)) { - statuses.push(status); - } - } - } - } - } - - return statuses.filter((s) => s.name !== "unknown"); - } - - /** - * Renders the custom status management section - */ - private renderCustomStatusSettings( - containerEl: HTMLElement, - settings: NoteStatusSettings, - ): void { - new Setting(containerEl).setName("Custom statuses").setHeading(); - - new Setting(containerEl) - .setName("Use only custom statuses") - .setDesc( - "Ignore template statuses and use only the custom statuses defined below", - ) - .addToggle((toggle) => - toggle - .setValue(settings.useCustomStatusesOnly || false) - .onChange(async (value) => { - await this.callbacks.onSettingChange( - "useCustomStatusesOnly", - value, - ); - // Update quick commands list when custom-only mode changes - this.refreshQuickCommandsList(settings); - }), - ); - - const statusList = containerEl.createDiv({ cls: "custom-status-list" }); - this.renderCustomStatuses(statusList, settings); - - new Setting(containerEl) - .setName("Add new status") - .setDesc("Add a custom status with a name, icon, and color") - .addButton((button) => - button - .setButtonText("Add Status") - .setCta() - .onClick(async () => { - await this.callbacks.onCustomStatusAdd(); - this.refreshQuickCommandsList(settings); - }), - ); - } - - /** - * Renders the list of custom statuses with edit controls - */ - renderCustomStatuses( - statusList: HTMLElement, - settings: NoteStatusSettings, - ): void { - statusList.empty(); - - settings.customStatuses.forEach((status: Status, index: number) => { - const setting = new Setting(statusList) - .setName(status.name) - .setClass("status-item"); - - setting.addText((text) => - text - .setPlaceholder("Name") - .setValue(status.name) - .onChange(async (value) => { - await this.callbacks.onCustomStatusChange( - index, - "name", - value || "unnamed", - ); - // Update quick commands list when status name changes - this.refreshQuickCommandsList(settings); - }), - ); - - setting.addText((text) => - text - .setPlaceholder("Icon") - .setValue(status.icon) - .onChange(async (value) => { - await this.callbacks.onCustomStatusChange( - index, - "icon", - value || "❓", - ); - this.refreshQuickCommandsList(settings); - }), - ); - - setting.addColorPicker((colorPicker) => - colorPicker - .setValue(settings.statusColors[status.name] || "#ffffff") - .onChange((value) => - this.callbacks.onCustomStatusChange( - index, - "color", - value, - ), - ), - ); - - setting.addText((text) => - text - .setPlaceholder("Description") - .setValue(status.description || "") - .onChange(async (value) => { - await this.callbacks.onCustomStatusChange( - index, - "description", - value, - ); - this.refreshQuickCommandsList(settings); - }), - ); - - setting.addButton((button) => - button - .setButtonText("Remove") - .setClass("status-remove-button") - .setWarning() - .onClick(async () => { - await this.callbacks.onCustomStatusRemove(index); - this.refreshQuickCommandsList(settings); - }), - ); - }); - } -} diff --git a/integrations/settings/types.ts b/integrations/settings/types.ts deleted file mode 100644 index 3552f8a..0000000 --- a/integrations/settings/types.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { NoteStatusSettings, Status } from "../../models/types"; - -/** - * Callbacks interface for settings UI interactions - */ -export interface SettingsUICallbacks { - /** Handle template enable/disable toggle */ - onTemplateToggle: (templateId: string, enabled: boolean) => Promise; - /** Handle general setting changes */ - onSettingChange: ( - key: keyof NoteStatusSettings, - value: boolean | string | string[], - ) => Promise; - /** Handle custom status field changes */ - onCustomStatusChange: ( - index: number, - field: keyof Status, - value: string, - ) => Promise; - /** Handle custom status removal */ - onCustomStatusRemove: (index: number) => Promise; - /** Handle adding new custom status */ - onCustomStatusAdd: () => Promise; -} diff --git a/integrations/status-bar/status-bar.tsx b/integrations/status-bar/status-bar.tsx new file mode 100644 index 0000000..0cf7a80 --- /dev/null +++ b/integrations/status-bar/status-bar.tsx @@ -0,0 +1,161 @@ +import { createRoot, Root } from "react-dom/client"; +import eventBus from "core/eventBus"; +import { MarkdownView, Plugin, WorkspaceLeaf } from "obsidian"; +import settingsService from "@/core/settingsService"; +import { StatusBarEnableButton } from "@/components/StatusBar/StatusBarEnableButton"; +import { StatusBar } from "@/components/StatusBar/StatusBar"; +import { NoteStatusService } from "@/core/noteStatusService"; + +export class StatusBarIntegration { + private static instance: StatusBarIntegration | null = null; + private root: Root | null = null; + private plugin: Plugin; + private statusBarContainer: HTMLElement; + private noteStatusService: NoteStatusService | null = null; + private currentLeaf: WorkspaceLeaf | null = null; + + constructor(plugin: Plugin) { + if (StatusBarIntegration.instance) { + throw new Error("The status bar instance is already created"); + } + this.plugin = plugin; + StatusBarIntegration.instance = this; + } + + async integrate() { + this.statusBarContainer = this.plugin.addStatusBarItem(); + this.statusBarContainer.classList.add("mod-clickable"); + + eventBus.subscribe( + "active-file-change", + ({ leaf }) => { + if (this.isValidMarkdownLeaf(leaf)) { + this.currentLeaf = leaf; + this.handleActiveFileChange().catch(console.error); + } + }, + "statusBarIntegrationSubscription1", + ); + + eventBus.subscribe( + "plugin-settings-changed", + ({ key }) => { + if (key === "showStatusBar") { + this.render(); // INFO: Force a render to set disabled or enabled + } + if (key === "enabledTemplates") { + this.handleActiveFileChange().catch(console.error); // INFO: Force a re-read of the statuses and render + } + if (key === "autoHideStatusBar") { + this.render(); + } + if (key === "useMultipleStatuses") { + this.handleActiveFileChange().catch(console.error); // INFO: Force a re-read of the statuses and render + } + if (key === "tagPrefix") { + this.handleActiveFileChange().catch(console.error); // INFO: Force a re-read of the statuses and render + } + if (key === "useCustomStatusesOnly") { + this.handleActiveFileChange().catch(console.error); // INFO: Force a re-read of the statuses and render + } + if (key === "customStatuses") { + this.handleActiveFileChange().catch(console.error); // INFO: Force a re-read of the statuses and render + } + }, + "statusBarIntegrationSubscription2", + ); + + eventBus.subscribe( + "frontmatter-manually-changed", + () => { + this.handleActiveFileChange().catch(console.error); + }, + "statusBarIntegrationSubscription3", + ); + } + + private async handleActiveFileChange() { + this.extractStatusesFromLeaf(this.currentLeaf); + this.render(); + } + + private extractStatusesFromLeaf(leaf: WorkspaceLeaf | null) { + if (!this.isValidMarkdownLeaf(leaf)) { + return {}; + } + + const markdownView = leaf!.view as MarkdownView; + if (!markdownView.file) { + return {}; + } + + this.noteStatusService = new NoteStatusService(markdownView.file); + this.noteStatusService.populateStatuses(); + } + + private isValidMarkdownLeaf(leaf: WorkspaceLeaf | null): boolean { + return leaf !== null && leaf.view.getViewType() === "markdown"; + } + + private openStatusModal() { + if (!this.noteStatusService) { + throw new Error( + "open status modal failed bcse there is no noteStatusService available", + ); + } + eventBus.publish("triggered-open-modal", { + statusService: this.noteStatusService, + }); + } + + private render() { + if (!this.root) { + this.root = createRoot(this.statusBarContainer); + this.statusBarContainer.style.padding = "unset"; + } + if (!settingsService.settings.showStatusBar) { + this.root.render( + + settingsService.setValue("showStatusBar", true) + } + />, + ); + } else { + this.root.render( + this.openStatusModal()} + />, + ); + } + } + + destroy() { + eventBus.unsubscribe( + "active-file-change", + "statusBarIntegrationSubscription1", + ); + eventBus.unsubscribe( + "plugin-settings-changed", + "statusBarIntegrationSubscription2", + ); + eventBus.unsubscribe( + "plugin-settings-changed", + "statusBarIntegrationSubscription3", + ); + + if (this.root) { + this.root.unmount(); + this.root = null; + } + this.currentLeaf = null; + this.noteStatusService = null; + StatusBarIntegration.instance = null; + } +} + +export default StatusBarIntegration; diff --git a/integrations/views/grouped-dashboard-view.tsx b/integrations/views/grouped-dashboard-view.tsx new file mode 100644 index 0000000..335f432 --- /dev/null +++ b/integrations/views/grouped-dashboard-view.tsx @@ -0,0 +1,219 @@ +import { ItemView, WorkspaceLeaf, TFile } from "obsidian"; +import { Root, createRoot } from "react-dom/client"; +import { GroupedStatusView as GroupedStatusViewComponent } from "@/components/GroupedStatusView/GroupedStatusView"; +import { BaseNoteStatusService } from "@/core/noteStatusService"; +import eventBus from "@/core/eventBus"; +import settingsService from "@/core/settingsService"; +import { NoteStatus } from "@/types/noteStatus"; + +export const VIEW_TYPE_GROUPED_DASHBOARD = "grouped-dashboard-view"; + +interface FileItem { + id: string; + name: string; + path: string; +} + +interface StatusItem { + name: string; + color: string; + icon?: string; +} + +interface FilesByStatus { + [statusName: string]: FileItem[]; +} + +interface GroupedByStatus { + [frontmatterTag: string]: FilesByStatus; +} + +export class GroupedDashboardView extends ItemView { + root: Root | null = null; + + constructor(leaf: WorkspaceLeaf) { + super(leaf); + } + + getViewType() { + return VIEW_TYPE_GROUPED_DASHBOARD; + } + + getDisplayText() { + return "Grouped Status Dashboard"; + } + + getIcon() { + return "list-tree"; + } + + private convertTFileToFileItem = (file: TFile): FileItem => ({ + id: file.path, + name: file.basename, + path: file.path, + }); + + private convertStatusToStatusItem = (status: NoteStatus): StatusItem => ({ + name: status.name, + color: status.color || "", + icon: status.icon, + }); + + private getAllFiles = (): FileItem[] => { + const tFiles = BaseNoteStatusService.app.vault.getMarkdownFiles(); + return tFiles.map(this.convertTFileToFileItem); + }; + + private processFiles = (files: FileItem[]): GroupedByStatus => { + const result: GroupedByStatus = {}; + const statusMetadataKeys = [settingsService.settings.tagPrefix]; + const availableStatuses = + BaseNoteStatusService.getAllAvailableStatuses(); + // Create maps for both scoped and legacy status lookup + const statusMap = new Map( + availableStatuses.map((s) => { + const key = s.templateId ? `${s.templateId}:${s.name}` : s.name; + return [key, s]; + }), + ); + const legacyStatusMap = new Map( + availableStatuses.map((s) => [s.name, s]), + ); + + statusMetadataKeys.forEach((key) => { + result[key] = {}; + availableStatuses.forEach((status) => { + const statusKey = status.templateId + ? `${status.templateId}:${status.name}` + : status.name; + result[key][statusKey] = []; + }); + }); + + files.forEach((file) => { + // Find the TFile to get metadata + const tFile = BaseNoteStatusService.app.vault.getAbstractFileByPath( + file.path, + ) as TFile; + if (!tFile) return; + + const cachedMetadata = + BaseNoteStatusService.app.metadataCache.getFileCache(tFile); + const frontmatter = cachedMetadata?.frontmatter; + + if (!frontmatter) return; + + statusMetadataKeys.forEach((key) => { + const value = frontmatter[key]; + if (value) { + const statusNames = Array.isArray(value) ? value : [value]; + statusNames.forEach((statusName) => { + const statusStr = statusName.toString(); + // Try to find status by exact match first, then by legacy name + let resolvedStatus = statusMap.get(statusStr); + if (!resolvedStatus) { + resolvedStatus = legacyStatusMap.get(statusStr); + } + + if (resolvedStatus) { + const statusKey = resolvedStatus.templateId + ? `${resolvedStatus.templateId}:${resolvedStatus.name}` + : resolvedStatus.name; + if (!result[key][statusKey]) { + result[key][statusKey] = []; + } + result[key][statusKey].push(file); + } + }); + } + }); + }); + + return result; + }; + + private getAvailableStatuses = (): StatusItem[] => { + const statuses = BaseNoteStatusService.getAllAvailableStatuses(); + return statuses.map(this.convertStatusToStatusItem); + }; + + private getAvailableStatusesWithTemplateInfo = () => { + return BaseNoteStatusService.getAllAvailableStatuses(); + }; + + private handleFileClick = (file: FileItem) => { + const tFile = BaseNoteStatusService.app.vault.getAbstractFileByPath( + file.path, + ) as TFile; + if (tFile) { + const leaf = BaseNoteStatusService.app.workspace.getLeaf(); + leaf.openFile(tFile); + } + }; + + private subscribeToEvents = (onDataChange: () => void) => { + eventBus.subscribe( + "frontmatter-manually-changed", + onDataChange, + "grouped-dashboard-view-subscription", + ); + eventBus.subscribe( + "plugin-settings-changed", + ({ key }) => { + if ( + key === "tagPrefix" || + key === "enabledTemplates" || + key === "useCustomStatusesOnly" || + key === "customStatuses" || + key === "useMultipleStatuses" || + key === "strictStatuses" || + key === "excludeUnknownStatus" + ) { + onDataChange(); + } + }, + "grouped-dashboard-view-settings-subscription", + ); + const unsubscribeActiveFile = BaseNoteStatusService.app.workspace.on( + "active-leaf-change", + onDataChange, + ); + + return () => { + eventBus.unsubscribe( + "frontmatter-manually-changed", + "grouped-dashboard-view-subscription", + ); + eventBus.unsubscribe( + "plugin-settings-changed", + "grouped-dashboard-view-settings-subscription", + ); + BaseNoteStatusService.app.workspace.offref(unsubscribeActiveFile); + }; + }; + + async onOpen() { + const container = this.containerEl.children[1]; + container.empty(); + container.addClass("grouped-dashboard-view-container"); + + this.root = createRoot(container); + this.root.render( + , + ); + } + + async onClose() { + this.root?.unmount(); + this.root = null; + } +} diff --git a/integrations/views/grouped-status-view.tsx b/integrations/views/grouped-status-view.tsx new file mode 100644 index 0000000..df267dc --- /dev/null +++ b/integrations/views/grouped-status-view.tsx @@ -0,0 +1,204 @@ +import { ItemView, WorkspaceLeaf, TFile } from "obsidian"; +import { Root, createRoot } from "react-dom/client"; +import { + FileItem, + GroupedByStatus, + GroupedStatusView as GroupedStatusViewComponent, + StatusItem, +} from "@/components/GroupedStatusView/GroupedStatusView"; +import { BaseNoteStatusService } from "@/core/noteStatusService"; +import eventBus from "@/core/eventBus"; +import settingsService from "@/core/settingsService"; +import { NoteStatus } from "@/types/noteStatus"; + +export const VIEW_TYPE_EXAMPLE = "grouped-status-view"; + +export class GroupedStatusView extends ItemView { + root: Root | null = null; + + constructor(leaf: WorkspaceLeaf) { + super(leaf); + } + + getViewType() { + return VIEW_TYPE_EXAMPLE; + } + + getDisplayText() { + return "Grouped Status View"; + } + + getIcon() { + return "list-tree"; + } + + private convertTFileToFileItem = (file: TFile): FileItem => ({ + id: file.path, + name: file.basename, + path: file.path, + }); + + private convertStatusToStatusItem = (status: NoteStatus): StatusItem => ({ + name: status.name, + color: status.color || "white", + icon: status.icon, + }); + + private getAllFiles = (): FileItem[] => { + const tFiles = BaseNoteStatusService.app.vault.getMarkdownFiles(); + return tFiles.map(this.convertTFileToFileItem); + }; + + private processFiles = (files: FileItem[]): GroupedByStatus => { + const result: GroupedByStatus = {}; + const statusMetadataKeys = [settingsService.settings.tagPrefix]; + const availableStatuses = + BaseNoteStatusService.getAllAvailableStatuses(); + // Create maps for both scoped and legacy status lookup + const statusMap = new Map( + availableStatuses.map((s) => { + const key = s.templateId ? `${s.templateId}:${s.name}` : s.name; + return [key, s]; + }), + ); + const legacyStatusMap = new Map( + availableStatuses.map((s) => [s.name, s]), + ); + + statusMetadataKeys.forEach((key) => { + result[key] = {}; + availableStatuses.forEach((status) => { + const statusKey = status.templateId + ? `${status.templateId}:${status.name}` + : status.name; + result[key][statusKey] = []; + }); + }); + + files.forEach((file) => { + // Find the TFile to get metadata + const tFile = BaseNoteStatusService.app.vault.getAbstractFileByPath( + file.path, + ) as TFile; + if (!tFile) return; + + const cachedMetadata = + BaseNoteStatusService.app.metadataCache.getFileCache(tFile); + const frontmatter = cachedMetadata?.frontmatter; + + if (!frontmatter) return; + + statusMetadataKeys.forEach((key) => { + const value = frontmatter[key]; + if (value) { + const statusNames = Array.isArray(value) ? value : [value]; + statusNames.forEach((statusName) => { + const statusStr = statusName.toString(); + // Try to find status by exact match first, then by legacy name + let resolvedStatus = statusMap.get(statusStr); + if (!resolvedStatus) { + resolvedStatus = legacyStatusMap.get(statusStr); + } + + if (resolvedStatus) { + const statusKey = resolvedStatus.templateId + ? `${resolvedStatus.templateId}:${resolvedStatus.name}` + : resolvedStatus.name; + if (!result[key][statusKey]) { + result[key][statusKey] = []; + } + result[key][statusKey].push(file); + } + }); + } + }); + }); + + return result; + }; + + private getAvailableStatuses = (): StatusItem[] => { + const statuses = BaseNoteStatusService.getAllAvailableStatuses(); + return statuses.map(this.convertStatusToStatusItem); + }; + + private getAvailableStatusesWithTemplateInfo = () => { + return BaseNoteStatusService.getAllAvailableStatuses(); + }; + + private handleFileClick = (file: FileItem) => { + const tFile = BaseNoteStatusService.app.vault.getAbstractFileByPath( + file.path, + ) as TFile; + if (tFile) { + const leaf = BaseNoteStatusService.app.workspace.getLeaf(); + leaf.openFile(tFile); + } + }; + + private subscribeToEvents = (onDataChange: () => void) => { + eventBus.subscribe( + "frontmatter-manually-changed", + onDataChange, + "grouped-status-view-subscription", + ); + eventBus.subscribe( + "plugin-settings-changed", + ({ key }) => { + if ( + key === "tagPrefix" || + key === "enabledTemplates" || + key === "useCustomStatusesOnly" || + key === "customStatuses" || + key === "useMultipleStatuses" || + key === "strictStatuses" || + key === "excludeUnknownStatus" + ) { + onDataChange(); + } + }, + "grouped-status-view-settings-subscription", + ); + const unsubscribeActiveFile = BaseNoteStatusService.app.workspace.on( + "active-leaf-change", + onDataChange, + ); + + return () => { + eventBus.unsubscribe( + "frontmatter-manually-changed", + "grouped-status-view-subscription", + ); + eventBus.unsubscribe( + "plugin-settings-changed", + "grouped-status-view-settings-subscription", + ); + BaseNoteStatusService.app.workspace.offref(unsubscribeActiveFile); + }; + }; + + async onOpen() { + const container = this.containerEl.children[1]; + container.empty(); + container.addClass("grouped-status-view-container"); + + this.root = createRoot(container); + this.root.render( + , + ); + } + + async onClose() { + this.root?.unmount(); + this.root = null; + } +} diff --git a/integrations/views/status-dashboard-view.tsx b/integrations/views/status-dashboard-view.tsx new file mode 100644 index 0000000..768e6d0 --- /dev/null +++ b/integrations/views/status-dashboard-view.tsx @@ -0,0 +1,371 @@ +import { ItemView, WorkspaceLeaf, Notice, App, TFile } from "obsidian"; +import { Root, createRoot } from "react-dom/client"; +import { StatusDashboard } from "@/components/StatusDashboard/StatusDashboard"; +import { DashboardAction } from "@/components/StatusDashboard/QuickActionsPanel"; +import { + BaseNoteStatusService, + NoteStatusService, +} from "@/core/noteStatusService"; +import settingsService from "@/core/settingsService"; +import eventBus from "@/core/eventBus"; +import { VaultStats } from "@/components/StatusDashboard/useVaultStats"; +import { NoteStatus } from "@/types/noteStatus"; + +interface AppWithCommands extends App { + commands: { + executeCommandById(commandId: string): boolean; + }; +} + +interface CurrentNoteInfo { + file: TFile | null; + statuses: Record; + lastModified: number; +} + +export const VIEW_TYPE_STATUS_DASHBOARD = "status-dashboard-view"; + +export class StatusDashboardView extends ItemView { + root: Root | null = null; + private vaultStats: VaultStats = { + totalNotes: 0, + notesWithStatus: 0, + statusDistribution: {}, + tagDistribution: {}, + recentChanges: [], + }; + private currentNote: CurrentNoteInfo = { + file: null, + statuses: {}, + lastModified: 0, + }; + private isLoading: boolean = true; + + constructor(leaf: WorkspaceLeaf) { + super(leaf); + } + + getViewType() { + return VIEW_TYPE_STATUS_DASHBOARD; + } + + getDisplayText() { + return "Status Dashboard"; + } + + getIcon() { + return "bar-chart-2"; + } + + private calculateVaultStats = (): VaultStats => { + const files = this.app.vault.getMarkdownFiles(); + const availableStatuses = + BaseNoteStatusService.getAllAvailableStatuses(); + const statusMetadataKeys = [settingsService.settings.tagPrefix]; + + let notesWithStatus = 0; + const statusDistribution: Record = {}; + const tagDistribution: Record = {}; + + // Initialize distribution using full scoped identifiers + availableStatuses.forEach((status) => { + const scopedIdentifier = status.templateId + ? `${status.templateId}:${status.name}` + : status.name; + statusDistribution[scopedIdentifier] = 0; + }); + + statusMetadataKeys.forEach((tag) => { + tagDistribution[tag] = 0; + }); + + files.forEach((file) => { + const cachedMetadata = this.app.metadataCache.getFileCache(file); + const frontmatter = cachedMetadata?.frontmatter; + + if (!frontmatter) return; + + let hasAnyStatus = false; + + statusMetadataKeys.forEach((key) => { + const value = frontmatter[key]; + if (value) { + hasAnyStatus = true; + tagDistribution[key]++; + + const statusNames = Array.isArray(value) ? value : [value]; + statusNames.forEach((statusName) => { + const statusStr = statusName.toString(); + + // Determine the scoped identifier to use + let scopedIdentifier: string; + + if (statusStr.includes(":")) { + // Already scoped + scopedIdentifier = statusStr; + } else { + // Legacy status - find first template that has this status + const firstTemplateWithStatus = + availableStatuses.find( + (s) => s.name === statusStr && s.templateId, + ); + scopedIdentifier = firstTemplateWithStatus + ? `${firstTemplateWithStatus.templateId}:${statusStr}` + : statusStr; // Fallback to unscoped if no template found + } + + // Initialize status if not already present + if ( + !statusDistribution.hasOwnProperty(scopedIdentifier) + ) { + statusDistribution[scopedIdentifier] = 0; + } + statusDistribution[scopedIdentifier]++; + }); + } + }); + + if (hasAnyStatus) { + notesWithStatus++; + } + }); + + return { + totalNotes: files.length, + notesWithStatus, + statusDistribution, + tagDistribution, + recentChanges: [], + }; + }; + + private updateCurrentNote = () => { + const activeFile = this.app.workspace.getActiveFile(); + + if (!activeFile) { + this.currentNote = { file: null, statuses: {}, lastModified: 0 }; + this.renderDashboard(); + return; + } + + const noteStatusService = new NoteStatusService(activeFile); + noteStatusService.populateStatuses(); + + this.currentNote = { + file: activeFile, + statuses: noteStatusService.statuses, + lastModified: activeFile.stat.mtime, + }; + this.renderDashboard(); + }; + + private loadData = () => { + this.isLoading = true; + this.renderDashboard(); + + try { + this.vaultStats = this.calculateVaultStats(); + this.updateCurrentNote(); + } finally { + this.isLoading = false; + this.renderDashboard(); + } + }; + + private handleAction = (action: DashboardAction, value?: string) => { + const appWithCommands = this.app as AppWithCommands; + + switch (action) { + case "refresh": + this.loadData(); + break; + case "open-grouped-view": + this.openGroupedView(); + break; + case "find-unassigned": + this.findUnassignedNotes(); + break; + case "change-status": + appWithCommands.commands.executeCommandById( + "note-status:change-status", + ); + break; + case "cycle-status": + appWithCommands.commands.executeCommandById( + "note-status:cycle-status", + ); + break; + case "clear-status": + appWithCommands.commands.executeCommandById( + "note-status:clear-status", + ); + break; + case "copy-status": + appWithCommands.commands.executeCommandById( + "note-status:copy-status", + ); + break; + case "paste-status": + appWithCommands.commands.executeCommandById( + "note-status:paste-status", + ); + break; + case "search-by-status": + appWithCommands.commands.executeCommandById( + "note-status:search-by-status", + ); + break; + case "toggle-multiple-mode": + appWithCommands.commands.executeCommandById( + "note-status:toggle-multiple-statuses", + ); + break; + case "set-quick-status": + if (value) { + const commandId = `note-status:set-status-${value}`; + const result = + appWithCommands.commands.executeCommandById(commandId); + if (!result) { + new Notice( + `Quick status command failed: ${value}. Make sure the status exists and multiple statuses mode is disabled.`, + ); + } + } + break; + case "search-by-specific-status": + if (value) { + this.searchBySpecificStatus(value); + } + break; + } + }; + + private openGroupedView() { + const leaf = this.app.workspace.getLeaf(); + leaf.setViewState({ type: "grouped-status-view", active: true }); + } + + private findUnassignedNotes() { + const files = this.app.vault.getMarkdownFiles(); + const filesWithoutStatus = files.filter((file) => { + const cachedMetadata = this.app.metadataCache.getFileCache(file); + const frontmatter = cachedMetadata?.frontmatter; + return ( + !frontmatter || !frontmatter[settingsService.settings.tagPrefix] + ); + }); + + if (filesWithoutStatus.length === 0) { + new Notice("All notes have status assigned!"); + return; + } + + new Notice( + `Found ${filesWithoutStatus.length} notes without status. Opening search...`, + ); + + // Create a search query to find files without the status tag + const tagPrefix = settingsService.settings.tagPrefix; + const query = `-[${tagPrefix}:]`; + + // @ts-ignore + this.app.internalPlugins.plugins[ + "global-search" + ].instance.openGlobalSearch(query); + } + + private searchBySpecificStatus(statusName: string) { + const tagPrefix = settingsService.settings.tagPrefix; + const query = `[${tagPrefix}:"${statusName}"]`; + + // @ts-ignore + this.app.internalPlugins.plugins[ + "global-search" + ].instance.openGlobalSearch(query); + } + + private renderDashboard() { + if (!this.root) return; + + this.root.render( + , + ); + } + + async onOpen() { + const container = this.containerEl.children[1]; + container.empty(); + container.addClass("status-dashboard-view-container"); + + this.root = createRoot(container); + + // Set up event listeners + const handleFileChange = () => { + this.updateCurrentNote(); + }; + + const handleVaultChange = () => { + this.loadData(); + }; + + eventBus.subscribe( + "frontmatter-manually-changed", + handleVaultChange, + "status-dashboard-vault-subscription", + ); + eventBus.subscribe( + "active-file-change", + handleFileChange, + "status-dashboard-file-subscription", + ); + eventBus.subscribe( + "plugin-settings-changed", + ({ key }) => { + if ( + key === "tagPrefix" || + key === "enabledTemplates" || + key === "useCustomStatusesOnly" || + key === "customStatuses" || + key === "useMultipleStatuses" || + key === "strictStatuses" || + key === "excludeUnknownStatus" + ) { + handleVaultChange(); + handleFileChange(); + } + }, + "status-dashboard-settings-subscription", + ); + + this.app.workspace.on("active-leaf-change", handleFileChange); + + // Initial load + this.loadData(); + } + + async onClose() { + // Clean up event listeners + eventBus.unsubscribe( + "frontmatter-manually-changed", + "status-dashboard-vault-subscription", + ); + eventBus.unsubscribe( + "active-file-change", + "status-dashboard-file-subscription", + ); + eventBus.unsubscribe( + "plugin-settings-changed", + "status-dashboard-settings-subscription", + ); + + this.root?.unmount(); + this.root = null; + } +} diff --git a/integrations/workspace/index.ts b/integrations/workspace/index.ts deleted file mode 100644 index 9a37a29..0000000 --- a/integrations/workspace/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { WorkspaceIntegration } from "./workspace-integration"; diff --git a/integrations/workspace/workspace-integration.ts b/integrations/workspace/workspace-integration.ts deleted file mode 100644 index 6c34f6d..0000000 --- a/integrations/workspace/workspace-integration.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { App, TFile, WorkspaceLeaf } from "obsidian"; -import { NoteStatusSettings } from "../../models/types"; -import { ToolbarIntegration } from "../editor/toolbar-integration"; -import { StatusService } from "services/status-service"; - -/** - * Gestiona la integración con el workspace de Obsidian - */ -export class WorkspaceIntegration { - private app: App; - private settings: NoteStatusSettings; - private statusService: StatusService; - private toolbarIntegration: ToolbarIntegration; - private lastActiveFile: TFile | null = null; - private fileOpenEventRef: (file: TFile) => void; - private activeLeafChangeEventRef: (file: WorkspaceLeaf) => void; - private layoutChangeEventRef: () => void; - - constructor( - app: App, - settings: NoteStatusSettings, - statusService: StatusService, - toolbarIntegration: ToolbarIntegration, - ) { - this.app = app; - this.settings = settings; - this.statusService = statusService; - this.toolbarIntegration = toolbarIntegration; - } - - /** - * Actualiza la configuración - */ - public updateSettings(settings: NoteStatusSettings): void { - this.settings = settings; - } - - /** - * Registra eventos del workspace - */ - public registerWorkspaceEvents(): void { - this.fileOpenEventRef = (file: TFile) => { - if (file instanceof TFile) { - this.handleFileOpen(file); - } - }; - - this.activeLeafChangeEventRef = (leaf: WorkspaceLeaf) => { - this.handleActiveLeafChange(leaf); - }; - - this.layoutChangeEventRef = () => { - this.handleLayoutChange(); - }; - - this.app.workspace.on("file-open", this.fileOpenEventRef); - this.app.workspace.on( - "active-leaf-change", - this.activeLeafChangeEventRef, - ); - this.app.workspace.on("layout-change", this.layoutChangeEventRef); - } - - /** - * Maneja apertura de archivo - */ - private handleFileOpen(file: TFile): void { - // Añade el botón de la barra de herramientas - this.toolbarIntegration.addToolbarButtonToActiveLeaf(); - - // Actualiza estado - this.propagateNoteStatusChange(file); - } - - /** - * Maneja cambio de hoja activa - */ - private handleActiveLeafChange(leaf: WorkspaceLeaf): void { - // Añade el botón de la barra de herramientas - this.toolbarIntegration.addToolbarButtonToActiveLeaf(); - - //const activeFile = this.app.workspace.getActiveFile(); - // Solo actualiza si el archivo realmente cambió - // if (this.lastActiveFile?.path !== activeFile?.path) { - // this.lastActiveFile = activeFile; - // this.propagateNoteStatusChange(); - // } - } - - /** - * Maneja y propaga el cambio de layout - */ - private handleLayoutChange(): void { - window.dispatchEvent(new CustomEvent("note-status:update-pane")); - } - - /** - * Verifica y propaga el estado de la nota activa - */ - private propagateNoteStatusChange(file: TFile): void { - try { - const activeFile = this.app.workspace.getActiveFile(); - let fileStatuses: string[] = []; - if (activeFile && activeFile.extension === "md") { - fileStatuses = this.statusService.getFileStatuses(activeFile); - } - // Dispara evento para que otros componentes se actualicen - window.dispatchEvent( - new CustomEvent("note-status:status-changed", { - detail: { statuses: fileStatuses, file: file }, - }), - ); - } catch (error) { - console.error("Error checking note status:", error); - } - } - public unload(): void { - if (this.fileOpenEventRef) { - this.app.workspace.off("file-open", this.fileOpenEventRef); - } - if (this.activeLeafChangeEventRef) { - this.app.workspace.off( - "active-leaf-change", - this.activeLeafChangeEventRef, - ); - } - if (this.layoutChangeEventRef) { - this.app.workspace.off("layout-change", this.layoutChangeEventRef); - } - } -} diff --git a/main.ts b/main.ts deleted file mode 100644 index 2e8ad6b..0000000 --- a/main.ts +++ /dev/null @@ -1,267 +0,0 @@ -import { Plugin, Notice } from "obsidian"; -import { DEFAULT_SETTINGS } from "./constants/defaults"; -import { NoteStatusSettings } from "./models/types"; -import { StatusService } from "services/status-service"; -import { StyleService } from "services/style-service"; - -// Importar integraciones -import { ExplorerIntegration } from "./integrations/explorer"; -import { EditorIntegration, ToolbarIntegration } from "./integrations/editor"; -import { MetadataIntegration } from "./integrations/metadata-cache"; -import { WorkspaceIntegration } from "./integrations/workspace"; -import { FileContextMenuIntegration } from "integrations/context-menu/file-context-menu-integration"; -import { NoteStatusSettingTab } from "integrations/settings/settings-tab"; -import { CommandIntegration } from "integrations/commands/command-integration"; - -// Importar vistas -import { StatusPaneViewController } from "./views/status-pane-view"; - -// Importar componentes UI -import { StatusBar } from "components/status-bar"; -import { StatusDropdown } from "components/status-dropdown"; -import { StatusContextMenu } from "integrations/context-menu/status-context-menu"; - -export default class NoteStatus extends Plugin { - settings: NoteStatusSettings; - - // Servicios - statusService: StatusService; - styleService: StyleService; - - // Componentes UI - statusBar: StatusBar; - statusDropdown: StatusDropdown; - - // Integraciones - explorerIntegration: ExplorerIntegration; - fileContextMenuIntegration: FileContextMenuIntegration; - editorIntegration: EditorIntegration; - toolbarIntegration: ToolbarIntegration; - metadataIntegration: MetadataIntegration; - workspaceIntegration: WorkspaceIntegration; - commandIntegration: CommandIntegration; - - statusPane: StatusPaneViewController; - - private boundHandleStatusChanged: (event: CustomEvent) => void; - - async onload() { - try { - await this.loadSettings(); - this.initializeServices(); - this.registerViews(); - this.initializeUI(); - this.initializeIntegrations(); - this.setupCustomEvents(); - } catch (error) { - console.error("Error loading Note Status plugin:", error); - new Notice( - "Error loading Note Status plugin. Check console for details.", - ); - } - } - - private async loadSettings() { - this.settings = Object.assign( - {}, - DEFAULT_SETTINGS, - await this.loadData(), - ); - } - - private initializeServices() { - this.statusService = new StatusService(this.app, this.settings); - this.styleService = new StyleService(this.settings); - } - - private registerViews() { - // Register status pane view - this.registerView("status-pane", (leaf) => { - this.statusPane = new StatusPaneViewController(leaf, this); - return this.statusPane; - }); - - // Add ribbon icon - this.addRibbonIcon("tag", "Open status pane", () => { - this.openStatusPane(); - }); - - // Añadir pestaña de configuración - this.addSettingTab( - new NoteStatusSettingTab(this.app, this, this.statusService), - ); - } - - private initializeIntegrations() { - this.explorerIntegration = new ExplorerIntegration( - this.app, - this.settings, - this.statusService, - ); - this.toolbarIntegration = new ToolbarIntegration( - this.app, - this.settings, - this.statusService, - this.statusDropdown, - ); - const statusContextMenu = new StatusContextMenu( - this.app, - this.settings, - this.statusService, - this.statusDropdown, - this.explorerIntegration, - ); - this.fileContextMenuIntegration = new FileContextMenuIntegration( - this.app, - this.settings, - this.statusService, - this.explorerIntegration, - statusContextMenu, - ); - - this.editorIntegration = new EditorIntegration( - this.app, - this.settings, - this.statusService, - this.statusDropdown, - ); - this.commandIntegration = new CommandIntegration( - this.app, - this, - this.settings, - this.statusService, - this.statusDropdown, - ); - this.metadataIntegration = new MetadataIntegration( - this.app, - this.settings, - this.statusService, - this.explorerIntegration, - ); - - this.workspaceIntegration = new WorkspaceIntegration( - this.app, - this.settings, - this.statusService, - this.toolbarIntegration, - ); - - // Registrar eventos en cada integración - this.fileContextMenuIntegration.registerFileContextMenuEvents(); - this.editorIntegration.registerEditorMenus(); - this.commandIntegration.registerCommands(); - this.metadataIntegration.registerMetadataEvents(); - this.workspaceIntegration.registerWorkspaceEvents(); - } - - private setupCustomEvents() { - this.boundHandleStatusChanged = this.handleStatusChanged.bind(this); - window.addEventListener( - "note-status:status-changed", - this.boundHandleStatusChanged, - ); - } - - private initializeUI() { - this.statusDropdown = new StatusDropdown( - this.app, - this.settings, - this.statusService, - ); - - // Inicializar barra de estado - this.statusBar = new StatusBar( - this.addStatusBarItem(), - this.settings, - this.statusService, - ); - - // Inicializar iconos del explorador (con retraso para evitar ralentizar el inicio) - if (this.settings.showStatusIconsInExplorer) { - setTimeout(() => { - this.explorerIntegration.updateAllFileExplorerIcons(); - }, 2000); - } - } - - private handleStatusChanged(event: CustomEvent) { - const { statuses, file } = event.detail; - - // Actualizar barra de estado - this.statusBar.update(statuses); - // Actualizar toolbar - const activeFile = this.app.workspace.getActiveFile(); - if (activeFile?.path === file) { - this.toolbarIntegration.updateStatusDisplay(statuses); - } - // Actualizar dropdown - this.statusDropdown.update(statuses); - // Actualizar status pane si está abierto - const statusPaneLeaf = - this.app.workspace.getLeavesOfType("status-pane")[0]; - if ( - statusPaneLeaf?.view && - statusPaneLeaf.view instanceof StatusPaneViewController - ) { - (statusPaneLeaf.view as StatusPaneViewController).update(); - } - // Actualizar explorador si es necesario - if (this.settings.showStatusIconsInExplorer && file) { - const fileObj = this.app.vault.getFileByPath(file); - if (fileObj) { - this.explorerIntegration.updateFileExplorerIcons(fileObj); - } - } - } - - private async openStatusPane() { - await StatusPaneViewController.open(this.app); - } - - async saveSettings() { - await this.saveData(this.settings); - - // Actualizar servicios - this.statusService.updateSettings(this.settings); - this.styleService.updateSettings(this.settings); - - // Actualizar integraciones - this.explorerIntegration.updateSettings(this.settings); - this.fileContextMenuIntegration.updateSettings(this.settings); - this.editorIntegration.updateSettings(this.settings); - this.metadataIntegration?.updateSettings(this.settings); - this.commandIntegration?.updateSettings(this.settings); - this.toolbarIntegration.updateSettings(this.settings); - this.workspaceIntegration.updateSettings(this.settings); - this.statusPane?.updateSettings(this.settings); - // Actualizar componentes UI - this.statusBar.updateSettings(this.settings); - this.statusDropdown.updateSettings(this.settings); - } - - onunload() { - // Clean up event listeners - if (this.boundHandleStatusChanged) { - window.removeEventListener( - "note-status:status-changed", - this.boundHandleStatusChanged, - ); - } - - // Clean up integrations - this.explorerIntegration?.unload(); - this.toolbarIntegration?.unload(); - this.fileContextMenuIntegration?.unload(); - this.workspaceIntegration?.unload(); - this.metadataIntegration?.unload(); - this.editorIntegration?.unload(); - this.commandIntegration?.unload(); - - // Clean up services - this.styleService?.unload(); - - // Clean up UI components - this.statusBar?.unload(); - this.statusDropdown?.unload(); - } -} diff --git a/main.tsx b/main.tsx new file mode 100644 index 0000000..50fcbd8 --- /dev/null +++ b/main.tsx @@ -0,0 +1,191 @@ +import { Plugin, WorkspaceLeaf } from "obsidian"; +import StatusBarIntegration from "integrations/status-bar/status-bar"; +import eventBus from "core/eventBus"; +import { PluginSettingIntegration } from "./integrations/settings/pluginSettings"; +import settingsService from "./core/settingsService"; +import { BaseNoteStatusService } from "./core/noteStatusService"; +import { StatusModalIntegration } from "./integrations/modals/statusModalIntegration"; +import ContextMenuIntegration from "./integrations/context-menu/contextMenuIntegration"; +import { FileExplorerIntegration } from "./integrations/file-explorer/file-explorer-integration"; +import { CommandsIntegration } from "./integrations/commands/commandsIntegration"; +import { + GroupedStatusView, + VIEW_TYPE_EXAMPLE, +} from "./integrations/views/grouped-status-view"; +import { + StatusDashboardView, + VIEW_TYPE_STATUS_DASHBOARD, +} from "./integrations/views/status-dashboard-view"; + +export default class NoteStatusPlugin extends Plugin { + private statusBarIntegration: StatusBarIntegration; + private pluginSettingsIntegration: PluginSettingIntegration; + private contextMenuIntegration: ContextMenuIntegration; + private fileExplorerIntegration: FileExplorerIntegration; + private commandsIntegration: CommandsIntegration; + + async onload() { + BaseNoteStatusService.initialize(this.app); + await this.loadPluginSettings(); + + // INFO: initialize all integrations + Promise.all([ + this.loadContextMenu(), + this.loadStatusBar(), + this.loadFileExplorer(), + this.loadCommands(), + this.loadEventBus(), + ]); + + this.registerView( + VIEW_TYPE_EXAMPLE, + (leaf) => new GroupedStatusView(leaf), + ); + + this.registerView( + VIEW_TYPE_STATUS_DASHBOARD, + (leaf) => new StatusDashboardView(leaf), + ); + + this.addRibbonIcon("dice", "Activate grouped view", () => { + this.activateView(); + }); + + this.addRibbonIcon("bar-chart-2", "Status Dashboard", () => { + this.activateDashboard(); + }); + } + + async onunload() { + // Clean up all integrations + this.statusBarIntegration?.destroy(); + this.contextMenuIntegration?.destroy(); + this.fileExplorerIntegration?.destroy(); + this.commandsIntegration?.destroy(); + this.pluginSettingsIntegration?.destroy(); + + // Clean up event subscriptions + eventBus.unsubscribe( + "triggered-open-modal", + "main-triggered-open-modal-subscriptor", + ); + } + + async activateView() { + const { workspace } = this.app; + + let leaf: WorkspaceLeaf | null = null; + const leaves = workspace.getLeavesOfType(VIEW_TYPE_EXAMPLE); + + if (leaves.length > 0) { + // A leaf with our view already exists, use that + leaf = leaves[0]; + } else { + // Our view could not be found in the workspace, create a new leaf + // in the right sidebar for it + leaf = workspace.getRightLeaf(false); + if (!leaf) { + console.error( + "getRightLeaf return null, unable to setup the view", + ); + } else { + await leaf.setViewState({ + type: VIEW_TYPE_EXAMPLE, + active: true, + }); + } + } + + // "Reveal" the leaf in case it is in a collapsed sidebar + if (!leaf) { + console.error("leaf not found, unable to activate the view"); + } else { + workspace.revealLeaf(leaf); + } + } + + async activateDashboard() { + const { workspace } = this.app; + + let leaf: WorkspaceLeaf | null = null; + const leaves = workspace.getLeavesOfType(VIEW_TYPE_STATUS_DASHBOARD); + + if (leaves.length > 0) { + // A leaf with our view already exists, use that + leaf = leaves[0]; + } else { + // Our view could not be found in the workspace, create a new leaf + // in the right sidebar for it + leaf = workspace.getRightLeaf(false); + if (!leaf) { + console.error( + "getRightLeaf return null, unable to setup the dashboard", + ); + } else { + await leaf.setViewState({ + type: VIEW_TYPE_STATUS_DASHBOARD, + active: true, + }); + } + } + + // "Reveal" the leaf in case it is in a collapsed sidebar + if (!leaf) { + console.error("leaf not found, unable to activate the dashboard"); + } else { + workspace.revealLeaf(leaf); + } + } + + private async loadEventBus() { + // Propagate to custom event bus the new active file + this.app.workspace.on( + "active-leaf-change", + (leaf: WorkspaceLeaf | null) => { + eventBus.publish("active-file-change", { leaf }); + }, + ); + + // Propagate to custom event bus the manually frontmatter data + this.registerEvent( + this.app.metadataCache.on("changed", (file) => { + eventBus.publish("frontmatter-manually-changed", { file }); + }), + ); + + // Register listeners + eventBus.subscribe( + "triggered-open-modal", + ({ statusService }) => { + StatusModalIntegration.open(this.app, statusService); + }, + "main-triggered-open-modal-subscriptor", + ); + } + + async loadPluginSettings() { + // INFO: Loads the settings data + await settingsService.initialize(this); + // INFO: Integrates the plugin settings section + this.pluginSettingsIntegration = new PluginSettingIntegration(this); + await this.pluginSettingsIntegration.integrate(); + } + + private async loadContextMenu() { + this.contextMenuIntegration = new ContextMenuIntegration(this); + await this.contextMenuIntegration.integrate(); + } + private async loadStatusBar() { + this.statusBarIntegration = new StatusBarIntegration(this); + await this.statusBarIntegration.integrate(); + } + private async loadFileExplorer() { + this.fileExplorerIntegration = new FileExplorerIntegration(this); + await this.fileExplorerIntegration.integrate(); + } + + private async loadCommands() { + this.commandsIntegration = new CommandsIntegration(this); + await this.commandsIntegration.integrate(); + } +} diff --git a/models/types.ts b/models/types.ts deleted file mode 100644 index 03a7e1a..0000000 --- a/models/types.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { TFile, View } from "obsidian"; - -declare module "obsidian" { - interface WorkspaceLeaf { - // TODO: Is deprecated, fix me - id: string; - } - - interface Commands { - removeCommand(id: string): void; - } - - interface App { - clipboard?: unknown; - internalPlugins: InternalPlugins; - commands: Commands; - } - - interface GlobalSearchPlugin { - openGlobalSearch(query: string): void; - } - - interface InternalPlugins { - getPluginById( - id: "global-search", - ): { instance: GlobalSearchPlugin } | undefined; - } -} - -/** - * Defines a status with name, icon and color - */ -export interface Status { - name: string; - icon: string; - color?: string; // Optional color property - description?: string; // Optional description property - [key: string]: unknown; -} - -/** - * Plugin settings interface - */ -export interface NoteStatusSettings { - statusColors: Record; - showStatusBar: boolean; - autoHideStatusBar: boolean; - customStatuses: Status[]; - showStatusIconsInExplorer: boolean; - hideUnknownStatusInExplorer: boolean; - collapsedStatuses: Record; - compactView: boolean; - enabledTemplates: string[]; // IDs of enabled templates - useCustomStatusesOnly: boolean; // Whether to use only custom statuses or include templates - useMultipleStatuses: boolean; // Whether to allow multiple statuses per note - tagPrefix: string; // Prefix for the status tag (default: 'status') - strictStatuses: boolean; // Whether to only show known statuses - excludeUnknownStatus: boolean; // Whether to exclude files with unknown status from the status pane - quickStatusCommands: string[]; - [key: string]: unknown; -} - -/** - * Extended interface for file explorer view - */ -export interface FileExplorerView extends View { - fileItems: Record< - string, - { - el?: HTMLElement; - file?: TFile; - titleEl?: HTMLElement; - selfEl?: HTMLElement; - } - >; -} diff --git a/package-lock.json b/package-lock.json index c55e9e9..dc59c22 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,21 @@ { "name": "obsidian-sample-plugin", - "version": "1.0.0", + "version": "1.0.14", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "obsidian-sample-plugin", - "version": "1.0.0", + "version": "1.0.14", "license": "MIT", + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, "devDependencies": { "@types/node": "^16.11.6", + "@types/react": "^18.2.0", + "@types/react-dom": "^18.2.0", "@typescript-eslint/eslint-plugin": "^6.21.0", "@typescript-eslint/parser": "^6.21.0", "builtin-modules": "3.3.0", @@ -18,6 +24,8 @@ "eslint": "^8.57.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-import": "^2.29.1", + "eslint-plugin-react": "^7.32.0", + "eslint-plugin-react-hooks": "^4.6.0", "husky": "^8.0.0", "lint-staged": "^15.2.2", "obsidian": "latest", @@ -31,21 +39,20 @@ "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.2.tgz", "integrity": "sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==", "dev": true, - "license": "MIT", "peer": true, "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } }, "node_modules/@codemirror/view": { - "version": "6.36.5", - "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.36.5.tgz", - "integrity": "sha512-cd+FZEUlu3GQCYnguYm3EkhJ8KJVisqqUsCOKedBoAt/d9c76JUUap6U0UrpElln5k6VyrEOYliMuDAKIeDQLg==", + "version": "6.37.2", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.37.2.tgz", + "integrity": "sha512-XD3LdgQpxQs5jhOOZ2HRVT+Rj59O4Suc7g2ULvZ+Yi8eCkickrkZ5JFuoDhs2ST1mNI5zSsNYgR3NGa4OUrbnw==", "dev": true, - "license": "MIT", "peer": true, "dependencies": { "@codemirror/state": "^6.5.0", + "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } @@ -58,7 +65,6 @@ "arm" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "android" @@ -75,7 +81,6 @@ "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "android" @@ -92,7 +97,6 @@ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "android" @@ -109,7 +113,6 @@ "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "darwin" @@ -126,7 +129,6 @@ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "darwin" @@ -143,7 +145,6 @@ "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "freebsd" @@ -160,7 +161,6 @@ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "freebsd" @@ -177,7 +177,6 @@ "arm" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" @@ -194,7 +193,6 @@ "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" @@ -211,7 +209,6 @@ "ia32" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" @@ -228,7 +225,6 @@ "loong64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" @@ -245,7 +241,6 @@ "mips64el" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" @@ -262,7 +257,6 @@ "ppc64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" @@ -279,7 +273,6 @@ "riscv64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" @@ -296,7 +289,6 @@ "s390x" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" @@ -313,7 +305,6 @@ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" @@ -330,7 +321,6 @@ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "netbsd" @@ -347,7 +337,6 @@ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "openbsd" @@ -364,7 +353,6 @@ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "sunos" @@ -381,7 +369,6 @@ "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "win32" @@ -398,7 +385,6 @@ "ia32" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "win32" @@ -415,7 +401,6 @@ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "win32" @@ -425,11 +410,10 @@ } }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.5.1", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.5.1.tgz", - "integrity": "sha512-soEIOALTfTK6EjmKMMoLugwaP0rzkad90iIWd1hMO9ARkSAyjfMfkRRhLvD5qH7vvM0Cg72pieUfR6yh6XxC4w==", + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", + "integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==", "dev": true, - "license": "MIT", "dependencies": { "eslint-visitor-keys": "^3.4.3" }, @@ -448,7 +432,6 @@ "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", "dev": true, - "license": "MIT", "engines": { "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } @@ -458,7 +441,6 @@ "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", "dev": true, - "license": "MIT", "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", @@ -477,12 +459,33 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/@eslint/js": { "version": "8.57.1", "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", "dev": true, - "license": "MIT", "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } @@ -493,7 +496,6 @@ "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", "deprecated": "Use @eslint/config-array instead", "dev": true, - "license": "Apache-2.0", "dependencies": { "@humanwhocodes/object-schema": "^2.0.3", "debug": "^4.3.1", @@ -503,12 +505,33 @@ "node": ">=10.10.0" } }, + "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", "dev": true, - "license": "Apache-2.0", "engines": { "node": ">=12.22" }, @@ -522,15 +545,13 @@ "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", "deprecated": "Use @eslint/object-schema instead", - "dev": true, - "license": "BSD-3-Clause" + "dev": true }, "node_modules/@marijn/find-cluster-break": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz", "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==", "dev": true, - "license": "MIT", "peer": true }, "node_modules/@nodelib/fs.scandir": { @@ -538,7 +559,6 @@ "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", "dev": true, - "license": "MIT", "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" @@ -552,7 +572,6 @@ "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", "dev": true, - "license": "MIT", "engines": { "node": ">= 8" } @@ -562,7 +581,6 @@ "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", "dev": true, - "license": "MIT", "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" @@ -582,17 +600,15 @@ "resolved": "https://registry.npmjs.org/@types/codemirror/-/codemirror-5.60.8.tgz", "integrity": "sha512-VjFgDF/eB+Aklcy15TtOTLQeMjTo07k7KAjql8OK5Dirr7a6sJY4T1uVBDuTVG9VEmn1uUsohOpYnVfgC6/jyw==", "dev": true, - "license": "MIT", "dependencies": { "@types/tern": "*" } }, "node_modules/@types/estree": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", - "integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==", - "dev": true, - "license": "MIT" + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true }, "node_modules/@types/json-schema": { "version": "7.0.15", @@ -610,8 +626,32 @@ "version": "16.18.126", "resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.126.tgz", "integrity": "sha512-OTcgaiwfGFBKacvfwuHzzn1KLxH/er8mluiy8/uM3sGXHaRe73RrSIj01jow9t4kJEW633Ov+cOexXeiApTyAw==", + "dev": true + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true + }, + "node_modules/@types/react": { + "version": "18.3.23", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.23.tgz", + "integrity": "sha512-/LDXMQh55EzZQ0uVAZmKKhfENivEvWz6E+EYzh+/MCjMhNsotd+ZHhBGIjFDTi6+fz0OhQQQLbTgdQIxxCsC0w==", "dev": true, - "license": "MIT" + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "peerDependencies": { + "@types/react": "^18.0.0" + } }, "node_modules/@types/semver": { "version": "7.7.0", @@ -624,7 +664,6 @@ "resolved": "https://registry.npmjs.org/@types/tern/-/tern-0.23.9.tgz", "integrity": "sha512-ypzHFE/wBzh+BlH6rrBgS5I/Z7RD21pGhZ2rltb/+ZrVM1awdZwjx7hE5XfuYgHWk9uvV5HLZN3SloevCAp3Bw==", "dev": true, - "license": "MIT", "dependencies": { "@types/estree": "*" } @@ -777,30 +816,6 @@ } } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", - "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", - "dev": true, - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/@typescript-eslint/utils": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.21.0.tgz", @@ -847,15 +862,13 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", - "dev": true, - "license": "ISC" + "dev": true }, "node_modules/acorn": { - "version": "8.14.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", - "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, - "license": "MIT", "bin": { "acorn": "bin/acorn" }, @@ -868,7 +881,6 @@ "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", "dev": true, - "license": "MIT", "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } @@ -878,7 +890,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, - "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -910,7 +921,6 @@ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, - "license": "MIT", "engines": { "node": ">=8" } @@ -920,7 +930,6 @@ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, - "license": "MIT", "dependencies": { "color-convert": "^2.0.1" }, @@ -935,8 +944,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" + "dev": true }, "node_modules/array-buffer-byte-length": { "version": "1.0.2", @@ -955,17 +963,19 @@ } }, "node_modules/array-includes": { - "version": "3.1.8", - "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.8.tgz", - "integrity": "sha512-itaWrbYbqpGXkGhZPGUulwnhVf5Hpy1xiCFsGqyIGglbBxmG5vSjxQen3/WGOjPpNEv1RtBLKxbmVXm8HpJStQ==", + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", "dev": true, "dependencies": { - "call-bind": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", "define-properties": "^1.2.1", - "es-abstract": "^1.23.2", - "es-object-atoms": "^1.0.0", - "get-intrinsic": "^1.2.4", - "is-string": "^1.0.7" + "es-abstract": "^1.24.0", + "es-object-atoms": "^1.1.1", + "get-intrinsic": "^1.3.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" }, "engines": { "node": ">= 0.4" @@ -983,6 +993,26 @@ "node": ">=8" } }, + "node_modules/array.prototype.findlast": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", + "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/array.prototype.findlastindex": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz", @@ -1040,6 +1070,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/array.prototype.tosorted": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", + "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3", + "es-errors": "^1.3.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/arraybuffer.prototype.slice": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", @@ -1089,18 +1135,15 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "dev": true, - "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "balanced-match": "^1.0.0" } }, "node_modules/braces": { @@ -1120,7 +1163,6 @@ "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.3.0.tgz", "integrity": "sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==", "dev": true, - "license": "MIT", "engines": { "node": ">=6" }, @@ -1180,7 +1222,6 @@ "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", "dev": true, - "license": "MIT", "engines": { "node": ">=6" } @@ -1190,7 +1231,6 @@ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, - "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -1238,7 +1278,6 @@ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, - "license": "MIT", "dependencies": { "color-name": "~1.1.4" }, @@ -1250,8 +1289,7 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/colorette": { "version": "2.0.20", @@ -1272,15 +1310,20 @@ "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true + }, + "node_modules/crelt": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", + "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", "dev": true, - "license": "MIT" + "peer": true }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dev": true, - "license": "MIT", "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -1290,6 +1333,12 @@ "node": ">= 8" } }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "dev": true + }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -1342,11 +1391,10 @@ } }, "node_modules/debug": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", - "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", "dev": true, - "license": "MIT", "dependencies": { "ms": "^2.1.3" }, @@ -1363,8 +1411,7 @@ "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/define-data-property": { "version": "1.1.4", @@ -1417,7 +1464,6 @@ "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", "dev": true, - "license": "Apache-2.0", "dependencies": { "esutils": "^2.0.2" }, @@ -1458,9 +1504,9 @@ } }, "node_modules/es-abstract": { - "version": "1.23.10", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.10.tgz", - "integrity": "sha512-MtUbM072wlJNyeYAe0mhzrD+M6DIJa96CZAOBBrhDbgKnB4MApIKefcyAB1eOdYn8cUNZgvwBvEzdoAYsxgEIw==", + "version": "1.24.0", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.0.tgz", + "integrity": "sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==", "dev": true, "dependencies": { "array-buffer-byte-length": "^1.0.2", @@ -1490,7 +1536,9 @@ "is-array-buffer": "^3.0.5", "is-callable": "^1.2.7", "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", "is-regex": "^1.2.1", + "is-set": "^2.0.3", "is-shared-array-buffer": "^1.0.4", "is-string": "^1.1.1", "is-typed-array": "^1.1.15", @@ -1505,6 +1553,7 @@ "safe-push-apply": "^1.0.0", "safe-regex-test": "^1.1.0", "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", "string.prototype.trim": "^1.2.10", "string.prototype.trimend": "^1.0.9", "string.prototype.trimstart": "^1.0.8", @@ -1540,6 +1589,33 @@ "node": ">= 0.4" } }, + "node_modules/es-iterator-helpers": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.2.1.tgz", + "integrity": "sha512-uDn+FE1yrDzyC0pCo961B2IHbdM8y/ACZsKD4dG6WqrjV53BADjwa7D+1aom2rsNVfLyDgU/eigvlJGJ08OQ4w==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-set-tostringtag": "^2.0.3", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.6", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "iterator.prototype": "^1.1.4", + "safe-array-concat": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -1602,7 +1678,6 @@ "integrity": "sha512-9n3AsBRe6sIyOc6kmoXg2ypCLgf3eZSraWFRpnkto+svt8cZNuKTkb1bhQcitBcvIqjNiK7K0J3KPmwGSfkA8g==", "dev": true, "hasInstallScript": true, - "license": "MIT", "bin": { "esbuild": "bin/esbuild" }, @@ -1639,7 +1714,6 @@ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", "dev": true, - "license": "MIT", "engines": { "node": ">=10" }, @@ -1653,7 +1727,6 @@ "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, - "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -1795,6 +1868,16 @@ "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" } }, + "node_modules/eslint-plugin-import/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, "node_modules/eslint-plugin-import/node_modules/debug": { "version": "3.2.7", "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", @@ -1816,6 +1899,18 @@ "node": ">=0.10.0" } }, + "node_modules/eslint-plugin-import/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/eslint-plugin-import/node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -1825,25 +1920,115 @@ "semver": "bin/semver.js" } }, - "node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "node_modules/eslint-plugin-react": { + "version": "7.37.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", + "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "dependencies": { + "array-includes": "^3.1.8", + "array.prototype.findlast": "^1.2.5", + "array.prototype.flatmap": "^1.3.3", + "array.prototype.tosorted": "^1.1.4", + "doctrine": "^2.1.0", + "es-iterator-helpers": "^1.2.1", + "estraverse": "^5.3.0", + "hasown": "^2.0.2", + "jsx-ast-utils": "^2.4.1 || ^3.0.0", + "minimatch": "^3.1.2", + "object.entries": "^1.1.9", + "object.fromentries": "^2.0.8", + "object.values": "^1.2.1", + "prop-types": "^15.8.1", + "resolve": "^2.0.0-next.5", + "semver": "^6.3.1", + "string.prototype.matchall": "^4.0.12", + "string.prototype.repeat": "^1.0.0" }, - "funding": { - "url": "https://opencollective.com/eslint" + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" } }, - "node_modules/eslint/node_modules/eslint-scope": { + "node_modules/eslint-plugin-react-hooks": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.2.tgz", + "integrity": "sha512-QzliNJq4GinDBcD8gPB5v0wh6g8q3SUi6EFF0x8N/BL9PoVs0atuGc47ozMRyOWAKdwaZ5OnbOEa3WR+dSGKuQ==", + "dev": true, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0" + } + }, + "node_modules/eslint-plugin-react/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint-plugin-react/node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eslint-plugin-react/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/eslint-plugin-react/node_modules/resolve": { + "version": "2.0.0-next.5", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz", + "integrity": "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==", + "dev": true, + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/eslint-plugin-react/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/eslint-scope": { "version": "7.2.2", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", "dev": true, - "license": "BSD-2-Clause", "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" @@ -1855,14 +2040,38 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint/node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", "dev": true, - "license": "BSD-2-Clause", "engines": { - "node": ">=4.0" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" } }, "node_modules/espree": { @@ -1870,7 +2079,6 @@ "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", "dev": true, - "license": "BSD-2-Clause", "dependencies": { "acorn": "^8.9.0", "acorn-jsx": "^5.3.2", @@ -1888,7 +2096,6 @@ "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", "dev": true, - "license": "BSD-3-Clause", "dependencies": { "estraverse": "^5.1.0" }, @@ -1896,22 +2103,11 @@ "node": ">=0.10" } }, - "node_modules/esquery/node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=4.0" - } - }, "node_modules/esrecurse": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", "dev": true, - "license": "BSD-2-Clause", "dependencies": { "estraverse": "^5.2.0" }, @@ -1919,12 +2115,11 @@ "node": ">=4.0" } }, - "node_modules/esrecurse/node_modules/estraverse": { + "node_modules/estraverse": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", "dev": true, - "license": "BSD-2-Clause", "engines": { "node": ">=4.0" } @@ -1934,7 +2129,6 @@ "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", "dev": true, - "license": "BSD-2-Clause", "engines": { "node": ">=0.10.0" } @@ -1972,8 +2166,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/fast-glob": { "version": "3.3.3", @@ -2007,22 +2200,19 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/fast-levenshtein": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/fastq": { "version": "1.19.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", "dev": true, - "license": "ISC", "dependencies": { "reusify": "^1.0.4" } @@ -2032,7 +2222,6 @@ "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", "dev": true, - "license": "MIT", "dependencies": { "flat-cache": "^3.0.4" }, @@ -2057,7 +2246,6 @@ "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", "dev": true, - "license": "MIT", "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" @@ -2074,7 +2262,6 @@ "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", "dev": true, - "license": "MIT", "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.3", @@ -2088,8 +2275,7 @@ "version": "3.3.3", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", - "dev": true, - "license": "ISC" + "dev": true }, "node_modules/for-each": { "version": "0.3.5", @@ -2110,8 +2296,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true, - "license": "ISC" + "dev": true }, "node_modules/function-bind": { "version": "1.1.2", @@ -2235,7 +2420,6 @@ "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", "deprecated": "Glob versions prior to v9 are no longer supported", "dev": true, - "license": "ISC", "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -2256,7 +2440,6 @@ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", "dev": true, - "license": "ISC", "dependencies": { "is-glob": "^4.0.3" }, @@ -2264,12 +2447,33 @@ "node": ">=10.13.0" } }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/globals": { "version": "13.24.0", "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", "dev": true, - "license": "MIT", "dependencies": { "type-fest": "^0.20.2" }, @@ -2332,8 +2536,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/has-bigints": { "version": "1.1.0", @@ -2352,7 +2555,6 @@ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true, - "license": "MIT", "engines": { "node": ">=8" } @@ -2452,7 +2654,6 @@ "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", "dev": true, - "license": "MIT", "engines": { "node": ">= 4" } @@ -2462,7 +2663,6 @@ "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", "dev": true, - "license": "MIT", "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" @@ -2479,7 +2679,6 @@ "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", "dev": true, - "license": "MIT", "engines": { "node": ">=0.8.19" } @@ -2490,7 +2689,6 @@ "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", "dev": true, - "license": "ISC", "dependencies": { "once": "^1.3.0", "wrappy": "1" @@ -2500,8 +2698,7 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, - "license": "ISC" + "dev": true }, "node_modules/internal-slot": { "version": "1.1.0", @@ -2649,7 +2846,6 @@ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", "dev": true, - "license": "MIT", "engines": { "node": ">=0.10.0" } @@ -2704,7 +2900,6 @@ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", "dev": true, - "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" }, @@ -2724,6 +2919,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -2754,7 +2961,6 @@ "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", "dev": true, - "license": "MIT", "engines": { "node": ">=8" } @@ -2917,15 +3123,35 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true + }, + "node_modules/iterator.prototype": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", + "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", "dev": true, - "license": "ISC" + "dependencies": { + "define-data-property": "^1.1.4", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "get-proto": "^1.0.0", + "has-symbols": "^1.1.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" }, "node_modules/js-yaml": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", "dev": true, - "license": "MIT", "dependencies": { "argparse": "^2.0.1" }, @@ -2937,22 +3163,19 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/json5": { "version": "1.0.2", @@ -2966,12 +3189,26 @@ "json5": "lib/cli.js" } }, + "node_modules/jsx-ast-utils": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", + "dev": true, + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" + }, + "engines": { + "node": ">=4.0" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", "dev": true, - "license": "MIT", "dependencies": { "json-buffer": "3.0.1" } @@ -2981,7 +3218,6 @@ "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", "dev": true, - "license": "MIT", "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" @@ -3063,7 +3299,6 @@ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", "dev": true, - "license": "MIT", "dependencies": { "p-locate": "^5.0.0" }, @@ -3078,8 +3313,7 @@ "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/log-update": { "version": "6.1.0", @@ -3170,6 +3404,17 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -3232,16 +3477,18 @@ } }, "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", + "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", "dev": true, - "license": "ISC", "dependencies": { - "brace-expansion": "^1.1.7" + "brace-expansion": "^2.0.1" }, "engines": { - "node": "*" + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/minimist": { @@ -3258,7 +3505,6 @@ "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz", "integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==", "dev": true, - "license": "MIT", "engines": { "node": "*" } @@ -3267,15 +3513,13 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/npm-run-path": { "version": "5.3.0", @@ -3304,6 +3548,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -3345,6 +3598,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/object.entries": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", + "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/object.fromentries": { "version": "2.0.8", "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", @@ -3400,7 +3668,6 @@ "resolved": "https://registry.npmjs.org/obsidian/-/obsidian-1.8.7.tgz", "integrity": "sha512-h4bWwNFAGRXlMlMAzdEiIM2ppTGlrh7uGOJS6w4gClrsjc+ei/3YAtU2VdFUlCiPuTHpY4aBpFJJW75S1Tl/JA==", "dev": true, - "license": "MIT", "dependencies": { "@types/codemirror": "5.60.8", "moment": "2.29.4" @@ -3415,7 +3682,6 @@ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", "dev": true, - "license": "ISC", "dependencies": { "wrappy": "1" } @@ -3440,7 +3706,6 @@ "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", "dev": true, - "license": "MIT", "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", @@ -3475,7 +3740,6 @@ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", "dev": true, - "license": "MIT", "dependencies": { "yocto-queue": "^0.1.0" }, @@ -3491,7 +3755,6 @@ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", "dev": true, - "license": "MIT", "dependencies": { "p-limit": "^3.0.2" }, @@ -3507,7 +3770,6 @@ "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", "dev": true, - "license": "MIT", "dependencies": { "callsites": "^3.0.0" }, @@ -3520,7 +3782,6 @@ "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", "dev": true, - "license": "MIT", "engines": { "node": ">=8" } @@ -3530,7 +3791,6 @@ "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", "dev": true, - "license": "MIT", "engines": { "node": ">=0.10.0" } @@ -3540,7 +3800,6 @@ "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", "dev": true, - "license": "MIT", "engines": { "node": ">=8" } @@ -3598,7 +3857,6 @@ "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", "dev": true, - "license": "MIT", "engines": { "node": ">= 0.8.0" } @@ -3618,12 +3876,22 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dev": true, + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "dev": true, - "license": "MIT", "engines": { "node": ">=6" } @@ -3646,8 +3914,36 @@ "type": "consulting", "url": "https://feross.org/support" } - ], - "license": "MIT" + ] + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "dev": true }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", @@ -3716,7 +4012,6 @@ "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", "dev": true, - "license": "MIT", "engines": { "node": ">=4" } @@ -3757,7 +4052,6 @@ "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", "dev": true, - "license": "MIT", "engines": { "iojs": ">=1.0.0", "node": ">=0.10.0" @@ -3775,7 +4069,6 @@ "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, - "license": "ISC", "dependencies": { "glob": "^7.1.3" }, @@ -3805,7 +4098,6 @@ "url": "https://feross.org/support" } ], - "license": "MIT", "dependencies": { "queue-microtask": "^1.2.2" } @@ -3862,6 +4154,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, "node_modules/semver": { "version": "7.7.2", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", @@ -3925,7 +4225,6 @@ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", "dev": true, - "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" }, @@ -3938,7 +4237,6 @@ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", "dev": true, - "license": "MIT", "engines": { "node": ">=8" } @@ -4064,6 +4362,19 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/string-argv": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", @@ -4117,6 +4428,43 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/string.prototype.matchall": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", + "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "regexp.prototype.flags": "^1.5.3", + "set-function-name": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.repeat": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", + "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", + "dev": true, + "dependencies": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + } + }, "node_modules/string.prototype.trim": { "version": "1.2.10", "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", @@ -4178,7 +4526,6 @@ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "dev": true, - "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" }, @@ -4212,7 +4559,6 @@ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", "dev": true, - "license": "MIT", "engines": { "node": ">=8" }, @@ -4225,7 +4571,6 @@ "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.2.tgz", "integrity": "sha512-wnD1HyVqpJUI2+eKZ+eo1UwghftP6yuFheBqqe+bWCotBjC2K1YnteJILRMs3SM4V/0dLEW1SC27MWP5y+mwmw==", "dev": true, - "license": "MIT", "peer": true }, "node_modules/supports-color": { @@ -4233,7 +4578,6 @@ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, - "license": "MIT", "dependencies": { "has-flag": "^4.0.0" }, @@ -4257,8 +4601,7 @@ "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/to-regex-range": { "version": "5.0.1", @@ -4300,15 +4643,13 @@ "version": "2.4.0", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz", "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==", - "dev": true, - "license": "0BSD" + "dev": true }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", "dev": true, - "license": "MIT", "dependencies": { "prelude-ls": "^1.2.1" }, @@ -4321,7 +4662,6 @@ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", "dev": true, - "license": "(MIT OR CC0-1.0)", "engines": { "node": ">=10" }, @@ -4408,7 +4748,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.7.4.tgz", "integrity": "sha512-C0WQT0gezHuw6AdY1M2jxUO83Rjf0HP7Sk1DtXj6j1EwkQNZrHAg2XPWlq62oqEhYvONq5pkC2Y9oPljWToLmQ==", "dev": true, - "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4440,7 +4779,6 @@ "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", "dev": true, - "license": "BSD-2-Clause", "dependencies": { "punycode": "^2.1.0" } @@ -4450,7 +4788,6 @@ "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", "dev": true, - "license": "MIT", "peer": true }, "node_modules/which": { @@ -4458,7 +4795,6 @@ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "dev": true, - "license": "ISC", "dependencies": { "isexe": "^2.0.0" }, @@ -4559,7 +4895,6 @@ "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", "dev": true, - "license": "MIT", "engines": { "node": ">=0.10.0" } @@ -4624,8 +4959,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, - "license": "ISC" + "dev": true }, "node_modules/yaml": { "version": "2.8.0", @@ -4644,7 +4978,6 @@ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", "dev": true, - "license": "MIT", "engines": { "node": ">=10" }, diff --git a/package.json b/package.json index 9eb8605..ab21ca3 100644 --- a/package.json +++ b/package.json @@ -17,8 +17,14 @@ "keywords": [], "author": "", "license": "MIT", + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, "devDependencies": { "@types/node": "^16.11.6", + "@types/react": "^18.2.0", + "@types/react-dom": "^18.2.0", "@typescript-eslint/eslint-plugin": "^6.21.0", "@typescript-eslint/parser": "^6.21.0", "builtin-modules": "3.3.0", @@ -27,6 +33,8 @@ "eslint": "^8.57.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-import": "^2.29.1", + "eslint-plugin-react": "^7.32.0", + "eslint-plugin-react-hooks": "^4.6.0", "husky": "^8.0.0", "lint-staged": "^15.2.2", "obsidian": "latest", diff --git a/services/status-service.ts b/services/status-service.ts deleted file mode 100644 index 98000e5..0000000 --- a/services/status-service.ts +++ /dev/null @@ -1,434 +0,0 @@ -import { App, TFile, Editor, Notice } from "obsidian"; -import { NoteStatusSettings, Status } from "../models/types"; -import { PREDEFINED_TEMPLATES } from "../constants/status-templates"; - -/** - * Service for handling note status operations - */ -export class StatusService { - private app: App; - private settings: NoteStatusSettings; - private allStatuses: Status[] = []; - - constructor(app: App, settings: NoteStatusSettings) { - this.app = app; - this.settings = settings; - this.updateAllStatuses(); - } - - /** - * Updates the settings reference and recalculates all statuses - */ - public updateSettings(settings: NoteStatusSettings): void { - this.settings = settings; - this.updateAllStatuses(); - } - - /** - * Updates the combined list of all statuses (from templates and custom) - */ - private updateAllStatuses(): void { - // Start with custom statuses - this.allStatuses = [...this.settings.customStatuses]; - - // Add statuses from enabled templates if not using custom only - if (!this.settings.useCustomStatusesOnly) { - const templateStatuses = this.getTemplateStatuses(); - - for (const status of templateStatuses) { - const existingIndex = this.allStatuses.findIndex( - (s) => s.name.toLowerCase() === status.name.toLowerCase(), - ); - - if (existingIndex === -1) { - // Add new status - this.allStatuses.push(status); - } else if (status.color) { - // Update color in settings if it doesn't exist - if (!this.settings.statusColors[status.name]) { - this.settings.statusColors[status.name] = status.color; - } - } - } - } - } - - /** - * Gets all statuses from enabled templates - */ - public getTemplateStatuses(): Status[] { - return this.settings.enabledTemplates - .map((id) => PREDEFINED_TEMPLATES.find((t) => t.id === id)) - .filter(Boolean) - .flatMap((template) => (template ? template.statuses : [])); - } - - /** - * Get all available statuses (combined from templates and custom) - */ - public getAllStatuses(): Status[] { - return this.allStatuses; - } - - /** - * Get the statuses of a file from its metadata - */ - public getFileStatuses(file: TFile): string[] { - const cachedMetadata = this.app.metadataCache.getFileCache(file); - if (!cachedMetadata?.frontmatter) return ["unknown"]; - - const frontmatterStatus = - cachedMetadata.frontmatter[this.settings.tagPrefix]; - if (!frontmatterStatus) return ["unknown"]; - - const statuses: string[] = []; - - if (Array.isArray(frontmatterStatus)) { - for (const statusName of frontmatterStatus) { - if (this.settings.strictStatuses) { - this.addValidStatus(statusName.toString(), statuses); - } else { - const cleanStatus = statusName.toString().trim(); - if (cleanStatus) statuses.push(cleanStatus); - } - } - } else { - if (this.settings.strictStatuses) { - this.addValidStatus(frontmatterStatus.toString(), statuses); - } else { - const cleanStatus = frontmatterStatus.toString().trim(); - if (cleanStatus) statuses.push(cleanStatus); - } - } - - return statuses.length > 0 ? statuses : ["unknown"]; - } - - /** - * Add a status to the list if it's valid - */ - private addValidStatus(statusName: string, targetStatuses: string[]): void { - const normalizedStatus = statusName.toLowerCase(); - const matchingStatus = this.allStatuses.find( - (s) => s.name.toLowerCase() === normalizedStatus, - ); - - if (matchingStatus) { - targetStatuses.push(matchingStatus.name); - } - } - - /** - * Get the icon for a given status - */ - public getStatusIcon(status: string): string { - const customStatus = this.allStatuses.find( - (s) => s.name.toLowerCase() === status.toLowerCase(), - ); - return customStatus ? customStatus.icon : "❓"; - } - - /** - * Insert status metadata in the editor - */ - public insertStatusMetadataInEditor(editor: Editor): void { - const content = editor.getValue(); - const frontMatterMatch = content.match(/^---\n([\s\S]+?)\n---/); - - // Check if status metadata already exists - const statusTagRegex = new RegExp( - `${this.settings.tagPrefix}:\\s*\\[?[^\\]]*\\]?`, - "m", - ); - - if (frontMatterMatch) { - const frontMatter = frontMatterMatch[1]; - if (frontMatter.match(statusTagRegex)) { - // Status already exists, do nothing - return; - } - // Add to existing frontmatter - const defaultStatuses = ["unknown"]; - const statusMetadata = `${this.settings.tagPrefix}: ${JSON.stringify(defaultStatuses)}`; - this.insertIntoExistingFrontmatter( - editor, - content, - frontMatterMatch, - statusMetadata, - ); - } else { - // No frontmatter, check if status exists in content somehow - if (content.match(statusTagRegex)) { - return; - } - // Create new frontmatter - const defaultStatuses = ["unknown"]; - const statusMetadata = `${this.settings.tagPrefix}: ${JSON.stringify(defaultStatuses)}`; - this.createFrontmatterWithStatus(editor, content, statusMetadata); - } - } - - /** - * Insert status metadata into existing frontmatter - */ - private insertIntoExistingFrontmatter( - editor: Editor, - content: string, - frontMatterMatch: RegExpMatchArray, - statusMetadata: string, - ): void { - const frontMatter = frontMatterMatch[1]; - const statusTagRegex = new RegExp( - `${this.settings.tagPrefix}:\\s*\\[?[^\\]]*\\]?`, - "m", - ); - - const updatedFrontMatter = frontMatter.match(statusTagRegex) - ? frontMatter.replace(statusTagRegex, statusMetadata) - : `${frontMatter}\n${statusMetadata}`; - - const updatedContent = content.replace( - /^---\n([\s\S]+?)\n---/, - `---\n${updatedFrontMatter}\n---`, - ); - editor.setValue(updatedContent); - } - - /** - * Create new frontmatter with status metadata - */ - private createFrontmatterWithStatus( - editor: Editor, - content: string, - statusMetadata: string, - ): void { - const newFrontMatter = `---\n${statusMetadata}\n---\n\n${content.trim()}`; - editor.setValue(newFrontMatter); - } - - /** - * Get all markdown files with optional filtering - */ - public getMarkdownFiles(searchQuery = ""): TFile[] { - const files = this.app.vault.getMarkdownFiles(); - if (!searchQuery) return files; - - const lowerQuery = searchQuery.toLowerCase(); - return files.filter((file) => - file.basename.toLowerCase().includes(lowerQuery), - ); - } - - /** - * Group files by their status - */ - public groupFilesByStatus(searchQuery = ""): Record { - const statusGroups: Record = {}; - - // Initialize groups for all statuses - for (const status of this.allStatuses) { - statusGroups[status.name] = []; - } - - // Ensure 'unknown' status exists - statusGroups["unknown"] = statusGroups["unknown"] || []; - - // Get and process all files matching the search query - const files = this.getMarkdownFiles(searchQuery); - for (const file of files) { - const statuses = this.getFileStatuses(file); - - for (const status of statuses) { - if (statusGroups[status]) { - statusGroups[status].push(file); - } - } - } - - return statusGroups; - } - - /** - * Centralizes all status modification operations - */ - private async modifyNoteStatus(options: { - files: TFile | TFile[]; - statuses: string | string[]; - operation: "set" | "add" | "remove" | "toggle"; - showNotice?: boolean; - }): Promise { - const { operation, showNotice = true } = options; - const targetFiles = Array.isArray(options.files) - ? options.files - : [options.files]; - const targetStatuses = Array.isArray(options.statuses) - ? options.statuses - : [options.statuses]; - - if (targetFiles.length === 0) { - if (showNotice) new Notice("No files selected"); - return; - } - - // Process each file - const updatePromises = targetFiles.map(async (file) => { - if (!file || file.extension !== "md") return; - - // Get current statuses for the file - const currentStatuses = this.getFileStatuses(file); - let newStatuses: string[] = []; - - switch (operation) { - case "set": - // Replace all statuses with the new ones - newStatuses = [...targetStatuses]; - break; - - case "add": - // Add new statuses without duplicates - newStatuses = [ - ...new Set([ - ...currentStatuses.filter((s) => s !== "unknown"), - ...targetStatuses, - ]), - ]; - break; - - case "remove": - // Remove specified statuses - newStatuses = currentStatuses.filter( - (status) => !targetStatuses.includes(status), - ); - break; - - case "toggle": - // Toggle each status (add if not present, remove if present) - newStatuses = [...currentStatuses]; - for (const status of targetStatuses) { - if (currentStatuses.includes(status)) { - newStatuses = newStatuses.filter( - (s) => s !== status, - ); - } else { - newStatuses = [ - ...newStatuses.filter((s) => s !== "unknown"), - status, - ]; - } - } - break; - } - - // Handle empty result (should revert to unknown) - if (newStatuses.length === 0) { - newStatuses = ["unknown"]; - } - - // Apply updates to frontmatter - await this.app.fileManager.processFrontMatter( - file, - (frontmatter) => { - frontmatter[this.settings.tagPrefix] = newStatuses; - }, - ); - - this.notifyStatusChanged(newStatuses, file); - }); - - await Promise.all(updatePromises); - - // Show notification for batch operations - if (showNotice && targetFiles.length > 1) { - const statusText = - targetStatuses.length === 1 - ? targetStatuses[0] - : `${targetStatuses.length} statuses`; - const operationText = - operation === "set" - ? "updated" - : operation === "add" - ? "added to" - : operation === "remove" - ? "removed from" - : "toggled on"; - - new Notice( - `${statusText} ${operationText} ${targetFiles.length} files`, - ); - } - } - - /** - * Handles UI-triggered status changes with appropriate logic based on context - * This is the central function that all UI components should call for status changes - */ - public async handleStatusChange(options: { - files: TFile | TFile[]; - statuses: string | string[]; - isMultipleSelection?: boolean; - allowMultipleStatuses?: boolean; - operation?: "set" | "add" | "remove" | "toggle"; - showNotice?: boolean; - afterChange?: (updatedStatuses: string[]) => void; - }): Promise { - const { - files, - statuses, - isMultipleSelection = false, - allowMultipleStatuses = this.settings.useMultipleStatuses, - operation: explicitOperation, - showNotice = isMultipleSelection, - } = options; - - const targetFiles = Array.isArray(files) ? files : [files]; - const targetStatuses = Array.isArray(statuses) ? statuses : [statuses]; - - // Determine operation based on context if not explicitly specified - let operation: "set" | "add" | "remove" | "toggle"; - - if (explicitOperation) { - operation = explicitOperation; - } else if (isMultipleSelection) { - // For multiple files, we need to check if we should add or remove - const firstStatus = targetStatuses[0]; // Use first status for multi-file operations - const filesWithStatus = targetFiles.filter((file) => - this.getFileStatuses(file).includes(firstStatus), - ); - - operation = - filesWithStatus.length > targetFiles.length / 2 - ? "remove" - : "add"; - } else { - // For single file operations - if (allowMultipleStatuses) { - operation = "toggle"; - } else { - operation = "set"; - } - } - - // Apply the changes - await this.modifyNoteStatus({ - files: targetFiles, - statuses: targetStatuses, - operation, - showNotice, - }); - } - - /** - * Dispatch status changed event - */ - private notifyStatusChanged(statuses: string[], file?: TFile): void { - // Dispatch the specific status change event - window.dispatchEvent( - new CustomEvent("note-status:status-changed", { - detail: { - statuses, - file: file?.path, - }, - }), - ); - } -} diff --git a/services/style-service.ts b/services/style-service.ts deleted file mode 100644 index 896abec..0000000 --- a/services/style-service.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { NoteStatusSettings } from "../models/types"; -import { PREDEFINED_TEMPLATES } from "../constants/status-templates"; - -/** - * Handles dynamic styling for the plugin - */ -export class StyleService { - private dynamicStyleEl?: HTMLStyleElement; - private settings: NoteStatusSettings; - - constructor(settings: NoteStatusSettings) { - this.settings = settings; - this.initializeDynamicStyles(); - } - - /** - * Updates the settings reference - */ - public updateSettings(settings: NoteStatusSettings): void { - this.settings = settings; - this.updateDynamicStyles(); - } - - /** - * Creates the style element if it doesn't exist - */ - private initializeDynamicStyles(): void { - if (!this.dynamicStyleEl) { - this.dynamicStyleEl = document.createElement("style"); - document.head.appendChild(this.dynamicStyleEl); - } - this.updateDynamicStyles(); - } - - /** - * Updates the dynamic styles based on current settings - */ - private updateDynamicStyles(): void { - if (!this.dynamicStyleEl) { - this.initializeDynamicStyles(); - return; - } - - const allColors = this.getAllStatusColors(); - const cssRules = this.generateColorCssRules(allColors); - this.dynamicStyleEl.textContent = cssRules; - } - - /** - * Get all status colors including those from enabled templates - */ - private getAllStatusColors(): Record { - const colors = { ...this.settings.statusColors }; - - if (this.settings.useCustomStatusesOnly) return colors; - - // Add colors from template statuses - for (const templateId of this.settings.enabledTemplates) { - const template = PREDEFINED_TEMPLATES.find( - (t) => t.id === templateId, - ); - if (!template) continue; - - for (const status of template.statuses) { - if (status.color && !colors[status.name]) { - colors[status.name] = status.color; - } - } - } - - return colors; - } - - /** - * Generate CSS rules for status colors - */ - private generateColorCssRules(colors: Record): string { - let css = ""; - - for (const [status, color] of Object.entries(colors)) { - css += ` - .status-${status} { - color: ${color} !important; - } - .note-status-bar .note-status-${status}, - .nav-file-title .note-status-${status} { - color: ${color} !important; - } - `; - } - - return css; - } - - /** - * Cleans up styles when plugin is unloaded - */ - public unload(): void { - if (this.dynamicStyleEl) { - this.dynamicStyleEl.remove(); - this.dynamicStyleEl = undefined; - } - } -} diff --git a/styles.css b/styles.css index 4179f0b..5ef03fa 100644 --- a/styles.css +++ b/styles.css @@ -1 +1,1053 @@ -:root{--status-transition-time: .22s;--status-border-radius: var(--radius-s, 4px);--status-box-shadow: var(--shadow-s, 0 2px 8px rgba(0, 0, 0, .15));--status-hover-shadow: var(--shadow-m, 0 4px 12px rgba(0, 0, 0, .2));--status-icon-size: 16px;--ease-in-out: cubic-bezier(.4, 0, .2, 1);--ease-out: cubic-bezier(0, 0, .2, 1);--ease-in: cubic-bezier(.4, 0, 1, 1)}@keyframes note-status-scale-in{0%{opacity:0;transform:scale(.9)}to{opacity:1;transform:scale(1)}}@keyframes note-status-slide-in-fade{0%{opacity:0;transform:translateY(-10px)}to{opacity:1;transform:translateY(0)}}@keyframes note-status-fade-in-slide-down{0%{opacity:0;transform:translateY(-6px)}to{opacity:1;transform:translateY(0)}}@keyframes note-status-pulse{0%{transform:scale(1)}50%{transform:scale(.98)}to{transform:scale(1)}}.theme-dark .note-status-popover{box-shadow:var(--shadow-l)}.theme-dark .note-status-option-selecting{box-shadow:0 0 0 1px var(--interactive-accent)}@media (forced-colors: active){.note-status-chip,.note-status-option.is-selected{border:1px solid currentColor}.note-status-action-button:focus-visible,.note-status-chip:focus-visible,.note-status-option:focus-visible{outline:2px solid currentColor;outline-offset:2px}}@media print{.note-status-unified-dropdown,.note-status-bar,.note-status-pane{display:none!important}}.note-status-empty-indicator{color:var(--text-muted);font-style:italic;padding:var(--size-4-1, 4px);display:flex;align-items:center;justify-content:center;color:var(--text-accent);font-size:1.2em}.note-status-action-button{display:flex;align-items:center;justify-content:center;width:28px;height:28px;border-radius:var(--radius-s);background:transparent;color:var(--text-muted);cursor:pointer;transition:all .15s ease;border:none;padding:0}.note-status-action-button:hover{background-color:var(--background-modifier-hover);color:var(--text-normal)}.note-status-action-button.note-status-button-active{background-color:var(--interactive-accent);color:var(--text-on-accent);transform:scale(.95)}.note-status-loading{display:flex;justify-content:center;align-items:center;padding:20px;color:var(--text-muted);font-style:italic}.note-status-loading span{position:relative;padding-left:24px}.note-status-loading span:before{content:"";position:absolute;left:0;top:50%;width:16px;height:16px;margin-top:-8px;border:2px solid var(--background-modifier-border);border-top-color:var(--text-accent);border-radius:50%;animation:note-status-loading-spinner .8s linear infinite}@keyframes note-status-loading-spinner{to{transform:rotate(360deg)}}.note-status-dummy-target{position:fixed;z-index:1000;width:0;height:0;left:var(--pos-x-px, 0);top:var(--pos-y-px, 0);pointer-events:none}.note-status-popover-fixed{position:fixed;z-index:999;--pos-x: 0;--pos-y: 0;--max-height: 300px;left:var(--pos-x-px, 0);top:var(--pos-y-px, 0);max-height:var(--max-height-px, 300px)}.note-status-popover-right{left:auto!important;right:var(--right-offset-px, 10px)}.note-status-popover-bottom{top:auto!important;bottom:var(--bottom-offset-px, 10px)}.note-status-bar{display:flex;align-items:center;gap:6px;padding:4px 8px;border-radius:var(--radius-s);transition:all .2s var(--ease-out);cursor:pointer;height:22px}.note-status-bar:hover{background-color:var(--background-modifier-hover)}.note-status-badges{display:flex;align-items:center;gap:4px}.note-status-badge{display:flex;align-items:center;padding:2px 6px;border-radius:10px;background:var(--background-secondary-alt);font-size:var(--font-ui-smaller);max-width:100px;margin-right:2px}.note-status-badge-icon{margin-right:2px}.note-status-badge-text{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.status-bar .note-status-icon:after{bottom:auto;top:-30px}.note-status-bar.hidden{display:none}.note-status-bar.auto-hide{opacity:.5;transition:opacity .2s var(--ease-out)}.note-status-bar.visible{opacity:1}.note-status-pane{padding:var(--size-4-2, 8px);background:var(--background-secondary);color:var(--text-normal);font-family:var(--font-interface);overflow-y:auto;height:100%;display:flex;flex-direction:column}.note-status-header{position:sticky;top:0;z-index:10;background:var(--background-secondary);padding-bottom:var(--size-4-2, 8px);border-bottom:1px solid var(--background-modifier-border);margin-bottom:var(--size-4-2, 8px);display:flex;flex-direction:column;gap:var(--size-4-2, 8px)}.note-status-search{width:100%;position:relative}.search-input-wrapper,.note-status-search-wrapper{position:relative;display:flex;align-items:center}.search-input-icon,.note-status-search-icon{position:absolute;left:var(--size-4-1, 4px);color:var(--text-muted);display:flex;align-items:center;pointer-events:none;padding:4px}.note-status-search-input,.note-status-popover-search-input{width:100%;padding:var(--input-padding);padding-left:calc(var(--size-4-1, 4px) * 3 + 18px);border:var(--input-border-width) solid var(--background-modifier-border);border-radius:var(--input-radius);background:var(--background-primary);color:var(--text-normal);outline:none;transition:all var(--status-transition-time) var(--ease-out)}.note-status-search-input:focus,.note-status-popover-search-input:focus{border-color:var(--interactive-accent);box-shadow:0 0 0 2px var(--interactive-accent-hover)}.search-input-clear-button,.note-status-search-clear-button{opacity:0;position:absolute;right:var(--size-4-1, 4px);cursor:pointer;color:var(--text-muted);display:flex;align-items:center;transition:opacity var(--status-transition-time) var(--ease-out);padding:4px;border-radius:50%}.search-input-clear-button.is-visible,.note-status-search-clear-button.is-visible{opacity:1}.search-input-clear-button:hover,.note-status-search-clear-button:hover{color:var(--text-normal);background-color:var(--background-modifier-hover)}.status-pane-actions-container{display:flex;gap:var(--size-4-2, 8px);justify-content:flex-end;margin-top:var(--size-4-1, 4px)}.note-status-view-toggle,.note-status-actions-refresh{padding:var(--size-4-1, 4px) var(--size-4-2, 8px);border:var(--input-border-width) solid var(--background-modifier-border);border-radius:var(--button-radius);background:var(--background-primary);color:var(--text-normal);cursor:pointer;transition:all var(--status-transition-time) var(--ease-out);display:flex;align-items:center;justify-content:center}.note-status-view-toggle:hover,.note-status-actions-refresh:hover{background:var(--background-modifier-hover);box-shadow:var(--shadow-s)}.note-status-view-toggle:active,.note-status-actions-refresh:active{transform:translateY(1px);box-shadow:none}.note-status-groups-container{flex:1;overflow-y:auto;padding-right:2px}.note-status-group{margin-bottom:var(--size-4-3, 12px);border-radius:var(--radius-s);background:var(--background-primary-alt);box-shadow:var(--shadow-xs);overflow:hidden;animation:note-status-fade-in-slide-down .3s var(--ease-out)}.note-status-group .nav-folder-title{cursor:pointer;padding:var(--size-4-1, 4px) var(--size-4-2, 8px);background:var(--background-secondary-alt);transition:background var(--status-transition-time) var(--ease-out)}.note-status-group .nav-folder-title:hover{background:var(--background-modifier-hover)}.note-status-group .nav-folder-title-content{font-weight:var(--font-semibold);display:flex;align-items:center;gap:var(--size-4-1, 4px)}.note-status-collapse-indicator{margin-right:var(--size-4-2, 8px);display:flex;align-items:center;color:var(--text-muted);transition:transform var(--status-transition-time) var(--ease-in-out);opacity:1}.note-status-is-collapsed .note-status-collapse-indicator{transform:rotate(-90deg)}.note-status-group .nav-folder-children{padding:var(--size-4-1, 4px);background:var(--background-primary);transition:height var(--status-transition-time) var(--ease-out)}.note-status-group.nav-folder.note-status-is-collapsed .nav-folder-children{display:none}.nav-file{border-radius:var(--radius-s);transition:background var(--status-transition-time) var(--ease-out);margin-bottom:2px;position:relative}.nav-file:hover{background:var(--background-modifier-hover)}.nav-file-title{display:flex;align-items:center;padding:var(--size-4-1, 4px) var(--size-4-2, 8px);flex-wrap:nowrap}.nav-file-icon{color:var(--text-muted);margin-right:var(--size-4-2, 8px);display:flex;align-items:center;flex-shrink:0}.nav-file-title-content{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.note-status-compact-view .nav-file-title{padding:2px var(--size-4-2, 8px);font-size:var(--font-smaller)}.note-status-compact-view .nav-folder-children{padding:0}.note-status-compact-view .nav-file{margin-bottom:0;border-radius:0;border-bottom:1px solid var(--background-modifier-border)}.note-status-compact-view .nav-file:last-child{border-bottom:none}.note-status-empty-message{margin-bottom:12px;text-align:center;color:var(--text-muted);font-style:italic}.note-status-button-container{display:flex;justify-content:center;margin-top:16px}.note-status-show-unassigned-button{background-color:var(--interactive-accent);color:var(--text-on-accent);padding:8px 16px;border-radius:var(--radius-s, 4px);border:none;cursor:pointer;font-size:var(--font-ui-small);transition:all .2s var(--ease-out);box-shadow:var(--shadow-s)}.note-status-show-unassigned-button:hover{background-color:var(--interactive-accent-hover);transform:translateY(-1px);box-shadow:var(--shadow-m)}.note-status-show-unassigned-button:active{transform:translateY(0);box-shadow:var(--shadow-xs)}.note-status-pagination{display:flex;justify-content:space-between;align-items:center;padding:8px;border-top:1px solid var(--background-modifier-border);margin-top:8px}.note-status-pagination-button{padding:4px 8px;border-radius:var(--radius-s);background:var(--background-secondary);border:var(--input-border-width) solid var(--background-modifier-border);color:var(--text-normal);cursor:pointer;transition:all .15s var(--ease-out);font-size:var(--font-smaller)}.note-status-pagination-button:hover{background:var(--background-modifier-hover);box-shadow:var(--shadow-s)}.note-status-pagination-info{font-size:var(--font-smaller);color:var(--text-muted)}.note-status-popover{position:absolute;background:var(--background-primary);border-radius:var(--radius-m);box-shadow:var(--status-box-shadow);z-index:calc(var(--layer-popover) + 10);max-height:300px;display:flex;flex-direction:column;border:1px solid var(--background-modifier-border);min-width:200px;overflow:hidden;width:auto!important;opacity:0;transform:scale(.95);transition:opacity var(--status-transition-time) var(--ease-out),transform var(--status-transition-time) var(--ease-out);transform-origin:top left;pointer-events:all!important}.note-status-popover.note-status-popover-animate-in{opacity:1;transform:scale(1)}.note-status-popover.note-status-popover-animate-out{opacity:0;transform:scale(.95);pointer-events:none}.note-status-popover-search{padding:8px;position:sticky;top:0;background:var(--background-primary);z-index:3;border-bottom:1px solid var(--background-modifier-border)}.note-status-options-container{overflow-y:auto;overflow-x:hidden;max-height:250px;padding:var(--size-4-1, 4px);scrollbar-width:thin;scrollbar-color:var(--background-modifier-border) transparent}.note-status-options-container::-webkit-scrollbar{width:6px}.note-status-options-container::-webkit-scrollbar-thumb{background-color:var(--background-modifier-border);border-radius:3px}.note-status-options-container::-webkit-scrollbar-track{background-color:transparent}.note-status-empty-options{padding:16px 12px;text-align:center;color:var(--text-muted);font-style:italic;font-size:var(--font-smaller)}.note-status-option{display:flex;align-items:center;padding:8px 12px;margin:2px 4px;border-radius:var(--radius-s);background:var(--background-primary);color:var(--text-normal);cursor:pointer!important;transition:background-color .15s ease}.note-status-option:hover{background:var(--background-modifier-hover)}.note-status-option.is-selected{background:var(--background-secondary-alt);font-weight:var(--font-medium)}.note-status-option-selecting{background-color:var(--interactive-accent)!important;color:var(--text-on-accent)!important;animation:note-status-pulse .3s var(--ease-out)}.note-status-option-icon{margin-right:8px;font-size:1.1em}.note-status-option-text{flex:1;font-size:var(--font-smaller);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.note-status-option-check{margin-left:auto;color:var(--text-accent)}.note-status-unified-dropdown{width:280px;max-width:90vw;border-radius:var(--radius-m);overflow:hidden;box-shadow:var(--shadow-m);border:1px solid var(--background-modifier-border);background:var(--background-primary);z-index:9999!important;pointer-events:all!important}.note-status-popover-animate-in{animation:note-status-dropdown-fade-in .22s var(--ease-out) forwards}@keyframes note-status-dropdown-fade-in{0%{opacity:0;transform:scale(.95)}to{opacity:1;transform:scale(1)}}@keyframes note-status-dropdown-fade-out{0%{opacity:1;transform:scale(1)}to{opacity:0;transform:scale(.95)}}.note-status-popover-header{padding:12px;border-bottom:1px solid var(--background-modifier-border);background:var(--background-secondary-alt);display:flex;align-items:center;position:sticky;top:0;z-index:2}.note-status-popover-title{display:flex;align-items:center;gap:8px;font-weight:var(--font-semibold);color:var(--text-normal)}.note-status-popover-icon{display:flex;align-items:center;justify-content:center;color:var(--text-accent)}.note-status-popover-chips{display:flex;flex-wrap:wrap;gap:6px;padding:12px;background:var(--background-primary-alt);max-height:120px;overflow-y:auto;border-bottom:1px solid var(--background-modifier-border)}.note-status-chip{display:inline-flex;align-items:center;padding:4px 10px;background:var(--background-secondary);border-radius:16px;box-shadow:var(--shadow-xs);font-size:var(--font-smaller);transition:all .15s var(--ease-out);border:1px solid var(--background-modifier-border);max-width:180px;animation:note-status-scale-in .2s var(--ease-out)}.note-status-chip.clickable{cursor:pointer}.note-status-chip.clickable:hover{background:var(--background-modifier-hover);transform:translateY(-1px);box-shadow:var(--status-hover-shadow)}.note-status-chip-removing{transform:scale(.8);opacity:0;pointer-events:none;transition:all .15s ease-out}.note-status-chip-icon{margin-right:6px}.note-status-chip-text{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.note-status-chip-remove{margin-left:6px;width:16px;height:16px;display:flex;align-items:center;justify-content:center;border-radius:50%;background:var(--background-modifier-border);color:var(--text-muted);cursor:pointer;transition:all .15s var(--ease-out)}.note-status-chip-remove:hover{background:var(--text-accent);color:var(--text-on-accent);transform:scale(1.1)}.note-status-toolbar-badge-container{display:flex;align-items:center;justify-content:center;position:relative}.note-status-toolbar-icon{color:var(--text-normal)!important;display:flex!important;align-items:center!important;justify-content:center!important}.note-status-count-badge{position:absolute;top:-5px;right:-8px;background-color:var(--interactive-accent);color:var(--text-on-accent);border-radius:10px;font-size:10px;min-width:14px;height:14px;padding:0 3px;display:flex;align-items:center;justify-content:center;font-weight:var(--font-bold)}.note-status-icon-container{display:inline-flex;margin-left:var(--size-4-1, 4px);gap:2px;align-items:center;flex-shrink:0}.note-status-icon{font-size:var(--font-ui-smaller);display:inline-flex;align-items:center;justify-content:center;width:var(--status-icon-size);height:var(--status-icon-size);position:relative;z-index:500}.nav-file-title .note-status-icon:after{bottom:150%}.status-color-dot{display:inline-block;width:8px;height:8px;border-radius:50%;background-color:var(--dot-color, #ffffff);margin-right:4px}.template-buttons{margin-top:15px;display:flex;gap:10px}.template-item{border:1px solid var(--background-modifier-border);border-radius:var(--status-border-radius);margin-bottom:10px;padding:10px;background:var(--background-primary-alt)}.template-header{display:flex;align-items:center;margin-bottom:5px}.template-checkbox{margin-right:10px}.template-name{font-weight:700}.template-description{font-size:.9em;color:var(--text-muted);margin-bottom:8px}.template-statuses{display:flex;flex-wrap:wrap;gap:5px}.template-status-chip{display:inline-flex;align-items:center;padding:2px 8px;border-radius:12px;background:var(--background-secondary);font-size:.85em} +/* styles/components/setting-item.css */ +.setting-item-full { + width: 100%; +} +.setting-item-vertical { + flex-direction: column; + align-items: start; + gap: 4px; +} + +/* styles/components/template-status-chip.css */ +.template-status-chip { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: var(--radius-xl); + background: var(--background-secondary); + font-size: 0.85em; + border: 1px solid var(--color-base-30); + gap: 4px; +} +.template-status-color-dot { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + background-color: var(--dot-color, --color-base-30); +} + +/* styles/components/template-item.css */ +.template-item { + border: var(--border-width) solid var(--color-base-30); + border-radius: var(--radius-xl); + margin-bottom: var(--size-4-2); + padding: var(--size-4-3); + background: var(--background-primary-alt); + cursor: pointer; + transition: background-color 0.1s ease; +} +.template-item:hover { + background: var(--background-modifier-hover); +} +.template-item.enabled { + background: var(--background-modifier-success-hover); +} +.template-item.enabled:hover { + background: var(--background-modifier-success); +} +.template-header { + display: flex; + align-items: center; +} +.template-checkbox { + pointer-events: none; +} +.template-statuses { + margin-top: 7px; + display: flex; + flex-wrap: wrap; + gap: 5px; +} + +/* styles/components/custom-status-item.css */ +.custom-status-card { + background: var(--background-secondary); + border-radius: var(--radius-m); + padding: var(--size-4-3); + margin-bottom: var(--size-4-2); +} +.custom-status-preview { + display: flex; + align-items: center; + gap: var(--size-4-2); + padding: var(--size-4-2); + background: var(--background-primary-alt); + border-radius: var(--radius-s); + border-left: 4px solid var(--text-muted); +} +.custom-status-icon-input { + width: 40px; + text-align: center; + font-size: 1.2em; + background: transparent; + border: none; + padding: 2px; +} +.custom-status-text-inputs { + flex: 1; +} +.custom-status-name-input { + background: transparent; + border: none; + color: var(--text-normal); + font-weight: var(--font-semibold); + font-size: var(--font-ui-medium); + width: 100%; + margin-bottom: 2px; +} +.custom-status-description-input { + background: transparent; + border: none; + color: var(--text-muted); + font-size: var(--font-ui-smaller); + width: 100%; +} +.custom-status-controls { + display: flex; + align-items: center; + gap: var(--size-4-2); +} +.custom-status-color-input { + width: 32px; + height: 32px; + border-radius: var(--radius-s); + cursor: pointer; +} +.custom-status-remove-btn { + font-size: 16px; +} + +/* styles/components/quick-commands-settings.css */ +.quick-commands-container { + margin-top: 1em; +} + +/* styles/components/status-group.css */ +.status-group { + margin-bottom: 1.5em; +} +.status-group-description { + font-size: 0.85em; + opacity: 0.7; +} +.status-group-items { + margin-left: 1em; +} + +/* styles/components/status-bar-enable-button.css */ +.status-bar-enable-button { + opacity: var(--icon-opacity); +} +.status-bar-enable-button:hover { + opacity: var(--icon-opacity-hover); + background: var(--background-modifier-active); +} + +/* styles/components/status-badge.css */ +.status-badge-container { + display: inline-flex; + flex-direction: column; + align-items: center; + border-radius: var(--radius-s); + transition: all var(--anim-duration-moderate) var(--anim-motion-smooth); + overflow: hidden; + min-width: auto; + max-width: none; +} +.status-badge-item { + display: flex; + align-items: center; + gap: var(--size-2-1); + padding: var(--size-2-1) var(--size-2-3); + transition: all var(--anim-duration-fast) ease; +} +.status-badge-icon { + transition: transform var(--anim-duration-fast) ease; +} +.status-badge-text { + font-weight: var(--font-weight-medium); +} + +/* styles/components/status-bar-group.css */ +.status-bar-group-container { + display: flex; + gap: var(--size-2-1); + flex-wrap: wrap; + align-items: center; +} +@keyframes status-slide-in { + from { + opacity: 0; + transform: scale(0.8) translateY(4px); + } + to { + opacity: 1; + transform: scale(1) translateY(0); + } +} + +/* styles/components/status-bar.css */ +.status-bar-group-row { + display: flex; + align-items: center; + gap: var(--size-4-2); +} + +/* styles/components/collapsible-counter.css */ +.collapsible-counter-container { + color: var(--text-muted); + padding: var(--size-2-1) var(--size-2-3); + border-radius: var(--radius-s); + background-color: var(--background-modifier-border); + border: 1px solid var(--background-modifier-border-hover); + font-weight: var(--font-weight-medium); + transition: all var(--anim-duration-fast) ease; + min-width: 20px; + text-align: center; +} + +/* styles/components/group-label.css */ +.group-label-container { + font-size: var(--font-text-size); + text-transform: uppercase; + letter-spacing: 0.8px; + font-weight: var(--font-weight-semibold); + transition: color var(--anim-duration-fast) ease; +} + +/* styles/components/file-explorer-icon.css */ +.status-wrapper { + position: relative; + display: inline-block; +} +.status-minimal { + display: inline-flex; + align-items: center; + gap: 2px; + margin-left: 6px; + cursor: pointer; + opacity: 0.6; + transition: all 0.2s ease; + padding: 2px; + border-radius: var(--radius-s); +} +.status-minimal:hover { + opacity: 1; + background: color-mix(in srgb, var(--primary-color) 10%, transparent); + transform: scale(1.05); +} +.status-icon { + font-size: 11px; + line-height: 1; + filter: grayscale(0.4); + transition: filter 0.2s ease; +} +.status-minimal:hover .status-icon { + filter: grayscale(0); +} +.status-count { + background: var(--primary-color); + color: var(--text-on-accent); + font-size: var(--font-smaller); + font-weight: var(--font-semibold); + min-width: 12px; + height: 12px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + margin-left: 1px; + box-shadow: var(--shadow-s); + border: 1px solid var(--background-primary); +} +.status-modal { + position: fixed; + left: 50%; + transform: translateX(-50%); + background: var(--background-primary); + border: var(--border-width) solid var(--background-modifier-border); + border-radius: var(--radius-l); + box-shadow: var(--shadow-l); + z-index: var(--layer-tooltip); + min-width: 280px; + max-width: 360px; + animation: modalIn 0.15s ease-out; + backdrop-filter: blur(8px); +} +.status-modal.bottom { + top: calc(100% + 8px); +} +.status-modal.top { + bottom: calc(100% + 8px); +} +@keyframes modalIn { + from { + opacity: 0; + transform: translateX(-50%) scale(0.95); + } + to { + opacity: 1; + transform: translateX(-50%) scale(1); + } +} +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: var(--size-4-4) var(--size-4-4) var(--size-4-2) var(--size-4-4); + border-bottom: var(--border-width) solid var(--background-modifier-border); +} +.modal-title { + font-size: var(--font-ui-medium); + font-weight: var(--font-semibold); + color: var(--text-normal); +} +.modal-total { + background: var(--background-modifier-hover); + color: var(--text-muted); + font-size: var(--font-smallest); + font-weight: var(--font-medium); + padding: var(--size-2-1) var(--size-2-3); + border-radius: var(--radius-m); +} +.status-group:not(:last-child) { + margin-bottom: var(--size-4-4); +} +.group-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: var(--size-2-3); +} +.group-name { + font-size: var(--font-ui-small); + font-weight: var(--font-semibold); + color: var(--text-normal); + text-transform: capitalize; +} +.group-count { + background: var(--background-modifier-hover); + color: var(--text-muted); + font-size: var(--font-smallest); + font-weight: var(--font-medium); + padding: var(--size-2-1) var(--size-2-2); + border-radius: var(--radius-s); + min-width: 16px; + text-align: center; +} +.status-list { + display: flex; + flex-direction: column; + gap: var(--size-2-2); +} +.status-item { + display: flex; + align-items: center; + gap: var(--size-4-2); + padding: var(--size-2-3) var(--size-4-2); + background: color-mix(in srgb, var(--item-color) 8%, transparent); + border: var(--border-width) solid color-mix(in srgb, var(--item-color) 20%, transparent); + border-radius: var(--radius-s); + transition: all 0.2s ease; + cursor: pointer; +} +.status-item:hover { + background: color-mix(in srgb, var(--item-color) 12%, transparent); + border-color: color-mix(in srgb, var(--item-color) 35%, transparent); + transform: translateX(2px); +} +.item-icon { + font-size: var(--font-ui-medium); + flex-shrink: 0; +} +.item-info { + display: flex; + flex-direction: column; + gap: var(--size-2-1); + min-width: 0; +} +.item-name { + font-size: var(--font-ui-small); + font-weight: var(--font-medium); + color: var(--text-normal); +} +.item-description { + font-size: var(--font-smallest); + color: var(--text-muted); + line-height: var(--line-height-tight); +} +.modal-arrow { + position: absolute; + left: 50%; + transform: translateX(-50%); + width: 10px; + height: 10px; + background: var(--background-primary); + border: var(--border-width) solid var(--background-modifier-border); + rotate: 45deg; +} +.status-modal.bottom .modal-arrow { + top: -5px; + border-bottom: none; + border-right: none; +} +.status-modal.top .modal-arrow { + bottom: -5px; + border-top: none; + border-left: none; +} + +/* styles/components/status-file-info-popup.css */ +.status-info-popup { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + z-index: 1000; + background: var(--background-primary); + border: var(--border-width) solid var(--background-modifier-border); + border-radius: var(--radius-l); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2); + min-width: 280px; + max-width: 400px; + pointer-events: none; + overflow: hidden; + backdrop-filter: blur(8px); +} +.status-popup-header { + background: linear-gradient(135deg, var(--background-secondary) 0%, var(--background-modifier-hover) 100%); + border-bottom: var(--border-width) solid var(--background-modifier-border); + padding: var(--size-4-2) var(--size-4-3); + font-weight: var(--font-weight-semibold); + color: var(--text-normal); + font-size: var(--font-ui-small); + display: flex; + align-items: center; + gap: var(--size-2-2); +} +.status-header-icon { + font-size: var(--font-ui-medium); + opacity: 0.8; +} +.status-popup-content { + padding: var(--size-4-2); + max-height: none; + overflow: visible; +} +.status-popup-empty { + padding: var(--size-4-4) var(--size-4-3); + text-align: center; + color: var(--text-muted); + font-size: var(--font-ui-smaller); + background: var(--background-primary); + border: var(--border-width) solid var(--background-modifier-border); + border-radius: var(--radius-l); + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + z-index: 1000; + pointer-events: none; + display: flex; + flex-direction: column; + align-items: center; + gap: var(--size-2-2); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2); +} +.status-empty-icon { + font-size: var(--font-ui-large); + opacity: 0.6; +} +.status-group { + margin-bottom: var(--size-4-3); + background: var(--background-secondary); + border-radius: var(--radius-m); + border: var(--border-width) solid var(--background-modifier-border-hover); + overflow: hidden; +} +.status-group:last-child { + margin-bottom: 0; +} +.status-group-header { + padding: var(--size-2-3) var(--size-4-2); + background: linear-gradient(90deg, var(--interactive-accent-hover) 0%, var(--interactive-accent) 100%); + color: var(--text-on-accent); + font-size: var(--font-ui-smaller); + font-weight: var(--font-weight-medium); + display: flex; + align-items: center; + justify-content: space-between; + border-bottom: var(--border-width) solid var(--background-modifier-border); +} +.status-group-name { + text-transform: capitalize; + letter-spacing: 0.3px; +} +.status-group-count { + background: rgba(255, 255, 255, 0.2); + color: var(--text-on-accent); + font-size: var(--font-ui-smaller); + font-weight: var(--font-weight-semibold); + padding: var(--size-2-1) var(--size-2-3); + border-radius: var(--radius-s); + min-width: var(--size-4-3); + text-align: center; + backdrop-filter: blur(4px); +} +.status-group-items { + padding: var(--size-2-3); + display: grid; + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + gap: var(--size-2-2); + background: var(--background-primary); +} +.status-item { + display: flex; + flex-direction: column; + gap: var(--size-2-1); + padding: var(--size-2-2) var(--size-2-3); + background: var(--background-modifier-hover); + border-radius: var(--radius-s); + border: var(--border-width) solid var(--background-modifier-border); + transition: all 0.15s ease; + min-width: 0; +} +.status-item:hover { + transform: translateY(-1px); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + border-color: var(--interactive-accent); +} +.status-description { + color: var(--text-muted); + font-size: var(--font-ui-smaller); + line-height: 1.3; + background: var(--background-primary-alt); + padding: var(--size-2-1) var(--size-2-2); + border-radius: var(--radius-s); + border-left: 2px solid var(--interactive-accent); + font-style: italic; + opacity: 0.8; + word-wrap: break-word; + hyphens: auto; +} + +/* styles/components/grouped-status-view.css */ +.grouped-status-view-container { + height: 100%; + overflow: hidden; +} +.grouped-status-view { + height: 100%; + display: flex; + flex-direction: column; + background-color: var(--background-primary); +} +.grouped-status-header { + padding: var(--size-4-2) var(--size-4-3); + border-bottom: 1px solid var(--background-modifier-border); + background-color: var(--background-secondary); + flex-shrink: 0; +} +.grouped-status-filters { + display: flex; + flex-direction: column; + gap: var(--size-4-2); +} +.grouped-status-note-filter { + display: flex; + flex-direction: column; +} +.grouped-status-note-input { + width: 100%; + padding: var(--size-2-1) var(--size-2-3); + border: 1px solid var(--background-modifier-border); + border-radius: var(--radius-s); + background: var(--background-primary); + color: var(--text-normal); + font-size: var(--font-ui-medium); + transition: border-color var(--anim-duration-fast) var(--anim-motion-smooth); +} +.groupped-status-note-input:focus { + outline: none; + border-color: var(--interactive-accent); + box-shadow: 0 0 0 2px var(--interactive-accent-hover); +} +.groupped-status-note-input::placeholder { + color: var(--text-muted); +} +.groupped-status-title { + margin: 0 0 var(--size-4-2) 0; + font-size: var(--font-ui-large); + font-weight: var(--font-weight-semibold); + color: var(--text-normal); +} +.groupped-status-content { + flex: 1; + overflow-y: auto; + padding: var(--size-4-2); + max-height: calc(100vh - 120px); +} +.groupped-status-tag-section { + margin-bottom: var(--size-4-4); +} +.groupped-status-tag-section:last-child { + margin-bottom: 0; +} +.groupped-status-tag-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--size-4-2) var(--size-4-3); + cursor: pointer; + transition: background-color var(--anim-duration-fast) var(--anim-motion-smooth); + border-radius: var(--radius-m); + background-color: var(--background-secondary); + border: 1px solid var(--background-modifier-border); + margin-bottom: var(--size-4-2); +} +.groupped-status-tag-header:hover { + background-color: var(--background-modifier-hover); +} +.groupped-status-tag-content { + padding-left: var(--size-4-2); +} +.groupped-status-group { + margin-bottom: var(--size-4-2); + border: 1px solid var(--background-modifier-border); + border-radius: var(--radius-m); + background-color: var(--background-primary); + overflow: hidden; +} +.groupped-status-group-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--size-4-2) var(--size-4-3); + cursor: pointer; + transition: background-color var(--anim-duration-fast) var(--anim-motion-smooth); + border-bottom: 1px solid transparent; +} +.groupped-status-group-header:hover { + background-color: var(--background-modifier-hover); +} +.groupped-status-group-header:active { + background-color: var(--background-modifier-active); +} +.groupped-status-group-info { + display: flex; + align-items: center; + gap: var(--size-2-1); +} +.groupped-status-files { + border-top: 1px solid var(--background-modifier-border); + background-color: var(--background-secondary); +} +.groupped-status-files-list { + max-height: 300px; + overflow-y: auto; +} +.groupped-status-file-item { + display: flex; + flex-direction: column; + padding: var(--size-2-3) var(--size-4-3); + cursor: pointer; + transition: background-color var(--anim-duration-fast) var(--anim-motion-smooth); + border-bottom: 1px solid var(--background-modifier-border-hover); +} +.groupped-status-file-item:last-child { + border-bottom: none; +} +.groupped-status-file-item:hover { + background-color: var(--background-modifier-hover); +} +.groupped-status-file-item:active { + background-color: var(--background-modifier-active); +} +.groupped-status-file-name { + font-weight: var(--font-weight-medium); + color: var(--text-normal); + margin-bottom: var(--size-2-1); + font-size: var(--font-ui-medium); +} +.groupped-status-file-path { + font-size: var(--font-ui-small); + color: var(--text-muted); + opacity: 0.8; +} +.groupped-status-loading { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + font-size: var(--font-ui-medium); + color: var(--text-muted); +} +.groupped-status-empty { + display: flex; + align-items: center; + justify-content: center; + padding: var(--size-4-6); + font-size: var(--font-ui-medium); + color: var(--text-muted); + text-align: center; +} +@media (max-width: 768px) { + .groupped-status-header { + padding: var(--size-4-1) var(--size-4-2); + } + .groupped-status-content { + padding: var(--size-4-1); + } + .groupped-status-group-header { + padding: var(--size-4-1) var(--size-4-2); + } + .groupped-status-file-item { + padding: var(--size-2-2) var(--size-4-2); + } +} +.theme-dark .groupped-status-view { + border-color: var(--background-modifier-border); +} +.theme-dark .groupped-status-group { + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); +} +.theme-light .groupped-status-group { + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); +} +.groupped-status-content::-webkit-scrollbar { + width: var(--scrollbar-width); +} +.groupped-status-content::-webkit-scrollbar-track { + background: var(--background-secondary); +} +.groupped-status-content::-webkit-scrollbar-thumb { + background: var(--scrollbar-thumb-bg); + border-radius: var(--radius-s); +} +.groupped-status-content::-webkit-scrollbar-thumb:hover { + background: var(--scrollbar-thumb-bg-hover); +} +.groupped-status-load-more { + display: flex; + justify-content: center; + padding: var(--size-4-2); + border-top: 1px solid var(--background-modifier-border-hover); +} +.groupped-status-load-btn { + padding: var(--size-2-2) var(--size-4-2); + border: 1px solid var(--background-modifier-border); + border-radius: var(--radius-s); + background-color: var(--interactive-normal); + color: var(--text-normal); + cursor: pointer; + transition: all var(--anim-duration-fast) var(--anim-motion-smooth); + font-size: var(--font-ui-small); + font-weight: var(--font-weight-medium); +} +.groupped-status-load-btn:hover { + background-color: var(--interactive-hover); + transform: translateY(-1px); +} +.groupped-status-load-btn:active { + background-color: var(--interactive-active); + transform: translateY(0); +} +.groupped-status-files-list::-webkit-scrollbar { + width: var(--scrollbar-width); +} +.groupped-status-files-list::-webkit-scrollbar-track { + background: var(--background-secondary); +} +.groupped-status-files-list::-webkit-scrollbar-thumb { + background: var(--scrollbar-thumb-bg); + border-radius: var(--radius-s); +} +.groupped-status-files-list::-webkit-scrollbar-thumb:hover { + background: var(--scrollbar-thumb-bg-hover); +} + +/* styles/components/status-dashboard.css */ +.status-dashboard-view-container { + height: 100%; + overflow: hidden; +} +.status-dashboard { + height: 100%; + display: flex; + flex-direction: column; + background-color: var(--background-primary); +} +.status-dashboard-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--size-4-3) var(--size-4-4); + border-bottom: 1px solid var(--background-modifier-border); + background-color: var(--background-secondary); + flex-shrink: 0; +} +.status-dashboard-title { + margin: 0; + font-size: var(--font-ui-larger); + font-weight: var(--font-weight-bold); + color: var(--text-normal); +} +.status-dashboard-refresh { + padding: var(--size-2-2) var(--size-2-3); + border: 1px solid var(--background-modifier-border); + border-radius: var(--radius-s); + background-color: var(--interactive-normal); + color: var(--text-normal); + cursor: pointer; + transition: all var(--anim-duration-fast) var(--anim-motion-smooth); + font-size: var(--font-ui-medium); +} +.status-dashboard-refresh:hover { + background-color: var(--interactive-hover); + transform: rotate(90deg); +} +.status-dashboard-loading { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + font-size: var(--font-ui-medium); + color: var(--text-muted); +} +.status-dashboard-content { + flex: 1; + overflow-y: auto; + padding: var(--size-4-3); + display: flex; + flex-direction: column; + gap: var(--size-4-4); + max-height: calc(100vh - 80px); +} +.status-dashboard-section { + background-color: var(--background-secondary); + border: 1px solid var(--background-modifier-border); + border-radius: var(--radius-m); + overflow: hidden; +} +.status-dashboard-section-header { + padding: var(--size-4-2) var(--size-4-3); + background-color: var(--background-modifier-hover); + border-bottom: 1px solid var(--background-modifier-border); +} +.status-dashboard-section-header h3 { + margin: 0; + font-size: var(--font-ui-large); + font-weight: var(--font-weight-semibold); + color: var(--text-normal); +} +.status-dashboard-current-note { + padding: var(--size-4-3); +} +.current-note-info { + margin-bottom: var(--size-4-3); + padding-bottom: var(--size-4-2); + border-bottom: 1px solid var(--background-modifier-border-hover); +} +.current-note-name { + font-size: var(--font-ui-large); + font-weight: var(--font-weight-semibold); + color: var(--text-normal); + margin-bottom: var(--size-2-1); +} +.current-note-path { + font-size: var(--font-ui-small); + color: var(--text-muted); + margin-bottom: var(--size-2-1); + font-family: var(--font-monospace); +} +.current-note-modified { + font-size: var(--font-ui-smaller); + color: var(--text-faint); + font-style: italic; +} +.current-note-statuses { + display: flex; + flex-direction: column; + gap: var(--size-4-2); +} +.current-note-tag-group { + display: flex; + flex-direction: column; + gap: var(--size-2-2); +} +.current-note-status-list { + display: flex; + flex-wrap: wrap; + gap: var(--size-2-2); +} +.current-note-no-status { + font-size: var(--font-ui-small); + color: var(--text-muted); + font-style: italic; + padding: var(--size-2-1) var(--size-2-3); + background-color: var(--background-modifier-border-hover); + border-radius: var(--radius-s); +} +.current-note-empty { + text-align: center; + color: var(--text-muted); + font-style: italic; + padding: var(--size-4-4); +} +.vault-overview { + padding: var(--size-4-3); +} +.vault-stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: var(--size-4-2); +} +.vault-stat-card { + text-align: center; + padding: var(--size-4-2); + background-color: var(--background-primary); + border: 1px solid var(--background-modifier-border); + border-radius: var(--radius-m); + transition: all var(--anim-duration-fast) var(--anim-motion-smooth); +} +.vault-stat-card:hover { + background-color: var(--background-modifier-hover); + transform: translateY(-2px); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); +} +.vault-stat-number { + font-size: var(--font-ui-largest); + font-weight: var(--font-weight-bold); + color: var(--interactive-accent); + margin-bottom: var(--size-2-1); +} +.vault-stat-label { + font-size: var(--font-ui-small); + color: var(--text-muted); + font-weight: var(--font-weight-medium); + text-transform: uppercase; + letter-spacing: 0.5px; +} +.status-distribution { + padding: var(--size-4-3); +} +.status-chart { + display: flex; + flex-direction: column; + gap: var(--size-4-2); +} +.status-chart-item { + display: flex; + flex-direction: column; + gap: var(--size-2-1); +} +.status-chart-info { + display: flex; + align-items: center; + gap: var(--size-2-3); +} +.status-chart-count { + font-size: var(--font-ui-small); + color: var(--text-muted); + font-weight: var(--font-weight-medium); +} +.status-chart-bar { + height: 8px; + background-color: var(--background-modifier-border); + border-radius: var(--radius-s); + overflow: hidden; + position: relative; +} +.status-chart-fill { + height: 100%; + transition: width var(--anim-duration-moderate) var(--anim-motion-smooth); + border-radius: var(--radius-s); +} +.status-distribution-empty { + text-align: center; + color: var(--text-muted); + font-style: italic; + padding: var(--size-4-4); +} +.quick-actions { + padding: var(--size-4-3); + display: flex; + flex-wrap: wrap; + gap: var(--size-4-2); +} +.quick-action-btn { + padding: var(--size-2-3) var(--size-4-2); + border: 1px solid var(--background-modifier-border); + border-radius: var(--radius-m); + background-color: var(--interactive-normal); + color: var(--text-normal); + cursor: pointer; + transition: all var(--anim-duration-fast) var(--anim-motion-smooth); + font-size: var(--font-ui-medium); + font-weight: var(--font-weight-medium); + text-decoration: none; + flex: 1; + min-width: 200px; + text-align: center; +} +.quick-action-btn:hover { + background-color: var(--interactive-hover); + transform: translateY(-1px); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} +.quick-action-btn:active { + background-color: var(--interactive-active); + transform: translateY(0); +} +@media (max-width: 768px) { + .status-dashboard-header { + padding: var(--size-4-2) var(--size-4-3); + } + .status-dashboard-content { + padding: var(--size-4-2); + } + .status-dashboard-current-note { + padding: var(--size-4-2); + } + .vault-overview { + padding: var(--size-4-2); + } + .vault-stats-grid { + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + gap: var(--size-4-1); + } + .quick-actions { + flex-direction: column; + } + .quick-action-btn { + min-width: auto; + } +} +.theme-dark .status-dashboard-section { + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); +} +.theme-dark .vault-stat-card:hover { + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3); +} +.theme-light .status-dashboard-section { + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); +} +.theme-light .vault-stat-card:hover { + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15); +} +.status-dashboard-content::-webkit-scrollbar { + width: var(--scrollbar-width); +} +.status-dashboard-content::-webkit-scrollbar-track { + background: var(--background-secondary); +} +.status-dashboard-content::-webkit-scrollbar-thumb { + background: var(--scrollbar-thumb-bg); + border-radius: var(--radius-s); +} +.status-dashboard-content::-webkit-scrollbar-thumb:hover { + background: var(--scrollbar-thumb-bg-hover); +} + +/* styles/index.css */ diff --git a/styles/base.css b/styles/base.css deleted file mode 100644 index 096a10b..0000000 --- a/styles/base.css +++ /dev/null @@ -1,94 +0,0 @@ -/* - * Base styles and variables for Note Status Plugin - */ -:root { - --status-transition-time: 0.22s; - --status-border-radius: var(--radius-s, 4px); - --status-box-shadow: var(--shadow-s, 0 2px 8px rgba(0, 0, 0, 0.15)); - --status-hover-shadow: var(--shadow-m, 0 4px 12px rgba(0, 0, 0, 0.2)); - --status-icon-size: 16px; - - /* Animation curves */ - --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1); - --ease-out: cubic-bezier(0, 0, 0.2, 1); - --ease-in: cubic-bezier(0.4, 0, 1, 1); -} - -/* Animations */ -@keyframes note-status-scale-in { - from { - opacity: 0; - transform: scale(0.9); - } - to { - opacity: 1; - transform: scale(1); - } -} - -@keyframes note-status-slide-in-fade { - from { - opacity: 0; - transform: translateY(-10px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -@keyframes note-status-fade-in-slide-down { - from { - opacity: 0; - transform: translateY(-6px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -@keyframes note-status-pulse { - 0% { - transform: scale(1); - } - 50% { - transform: scale(0.98); - } - 100% { - transform: scale(1); - } -} - -/* Dark mode adjustments */ -.theme-dark .note-status-popover { - box-shadow: var(--shadow-l); -} - -.theme-dark .note-status-option-selecting { - box-shadow: 0 0 0 1px var(--interactive-accent); -} - -/* High contrast improvements */ -@media (forced-colors: active) { - .note-status-chip, - .note-status-option.is-selected { - border: 1px solid currentColor; - } - - .note-status-action-button:focus-visible, - .note-status-chip:focus-visible, - .note-status-option:focus-visible { - outline: 2px solid currentColor; - outline-offset: 2px; - } -} - -/* Print styles */ -@media print { - .note-status-unified-dropdown, - .note-status-bar, - .note-status-pane { - display: none !important; - } -} diff --git a/styles/components/collapsible-counter.css b/styles/components/collapsible-counter.css new file mode 100644 index 0000000..cad34f9 --- /dev/null +++ b/styles/components/collapsible-counter.css @@ -0,0 +1,22 @@ +/* ========================================================================== + Collapsible Counter Component + ========================================================================== */ + +.collapsible-counter-container { + padding: var(--size-2-1) var(--size-2-3); + border-radius: var(--radius-s); + background: var(--background-modifier-border); + border: 1px solid var(--background-modifier-border-hover); + color: var(--text-muted); + font-weight: var(--font-medium); + font-size: var(--font-ui-smaller); + cursor: pointer; + transition: all var(--anim-duration-fast) ease; + min-width: 28px; + text-align: center; +} + +.collapsible-counter-container:hover { + background: var(--background-modifier-border-hover); + color: var(--text-normal); +} diff --git a/styles/components/dashboard.css b/styles/components/dashboard.css new file mode 100644 index 0000000..e929206 --- /dev/null +++ b/styles/components/dashboard.css @@ -0,0 +1,325 @@ +/* ========================================================================== + Status Dashboard + ========================================================================== */ + +.status-dashboard-view-container { + height: 100%; + overflow: hidden; +} + +.status-dashboard { + background: var(--background-primary); +} + +/* Header */ +.status-dashboard-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: var(--size-4-3) var(--size-4-4); + background: var(--background-secondary); + border-bottom: 1px solid var(--background-modifier-border); +} + +.status-dashboard-title { + margin: 0; + font-size: var(--font-ui-larger); + font-weight: var(--font-bold); +} + +.status-dashboard-refresh { + padding: var(--size-2-2) var(--size-2-3); + border: 1px solid var(--background-modifier-border); + border-radius: var(--radius-s); + background: var(--interactive-normal); + color: var(--text-normal); + cursor: pointer; + font-size: var(--font-ui-medium); + transition: all var(--anim-duration-fast) ease; +} + +.status-dashboard-refresh:hover { + background: var(--interactive-hover); + transform: rotate(90deg); +} + +/* Content */ +.status-dashboard-content { + flex: 1; + overflow-y: auto; + padding: var(--size-4-3); + display: flex; + flex-direction: column; + gap: var(--size-4-4); +} + +/* Sections */ +.status-dashboard-section { + background: var(--background-secondary); + border: 1px solid var(--background-modifier-border); + border-radius: var(--radius-m); + overflow: hidden; +} + +.status-dashboard-section-header { + padding: var(--size-4-2) var(--size-4-3); + background: var(--background-modifier-hover); + border-bottom: 1px solid var(--background-modifier-border); +} + +.status-dashboard-section-header h3 { + margin: 0; + font-size: var(--font-ui-large); + font-weight: var(--font-semibold); +} + +/* Current Note */ +.status-dashboard-current-note { + padding: var(--size-4-3); +} + +.current-note-info { + margin-bottom: var(--size-4-3); + padding-bottom: var(--size-4-2); + border-bottom: 1px solid var(--background-modifier-border); +} + +.current-note-name { + font-size: var(--font-ui-large); + font-weight: var(--font-semibold); + margin-bottom: var(--size-2-1); +} + +.current-note-path { + font-size: var(--font-ui-small); + color: var(--text-muted); + font-family: var(--font-monospace); + margin-bottom: var(--size-2-1); +} + +.current-note-modified { + font-size: var(--font-ui-smaller); + color: var(--text-faint); + font-style: italic; +} + +.current-note-statuses { + display: flex; + flex-direction: column; + gap: var(--size-4-2); +} + +.current-note-tag-group { + display: flex; + flex-direction: column; + gap: var(--size-2-2); +} + +.current-note-status-list { + display: flex; + flex-wrap: wrap; + gap: var(--size-2-2); +} + +.current-note-no-status { + font-size: var(--font-ui-small); + color: var(--text-muted); + font-style: italic; + padding: var(--size-2-1) var(--size-2-3); + background: var(--background-modifier-border); + border-radius: var(--radius-s); +} + +.current-note-empty { + text-align: center; + color: var(--text-muted); + font-style: italic; + padding: var(--size-4-4); +} + +/* Vault Stats */ +.vault-overview { + padding: var(--size-4-3); +} + +.vault-stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: var(--size-4-2); +} + +.vault-stat-card { + text-align: center; + padding: var(--size-4-2); + background: var(--background-primary); + border: 1px solid var(--background-modifier-border); + border-radius: var(--radius-m); + transition: all var(--anim-duration-fast) ease; +} + +.vault-stat-card:hover { + background: var(--background-modifier-hover); + transform: translateY(-2px); + box-shadow: var(--shadow-s); +} + +.vault-stat-number { + font-size: var(--font-ui-largest); + font-weight: var(--font-bold); + color: var(--interactive-accent); + margin-bottom: var(--size-2-1); +} + +.vault-stat-label { + font-size: var(--font-ui-small); + color: var(--text-muted); + font-weight: var(--font-medium); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +/* Status Distribution */ +.status-distribution { + padding: var(--size-4-3); +} + +.status-chart { + display: flex; + flex-direction: column; + gap: var(--size-4-2); +} + +.status-chart-item { + display: flex; + flex-direction: column; + gap: var(--size-2-1); +} + +.status-chart-item--clickable { + cursor: pointer; + transition: all var(--anim-duration-fast) ease; + padding: var(--size-2-2); + border-radius: var(--radius-s); +} + +.status-chart-item--clickable:hover { + background: var(--background-modifier-hover); + transform: translateY(-1px); +} + +.status-chart-info { + display: flex; + align-items: center; + gap: var(--size-2-3); +} + +.status-chart-count { + font-size: var(--font-ui-small); + color: var(--text-muted); + font-weight: var(--font-medium); +} + +.status-chart-bar { + height: 8px; + background: var(--background-modifier-border); + border-radius: var(--radius-s); + overflow: hidden; +} + +.status-chart-fill { + height: 100%; + transition: width var(--anim-duration-moderate) ease; +} + +.status-distribution-empty { + text-align: center; + color: var(--text-muted); + font-style: italic; + padding: var(--size-4-4); +} + +/* Quick Actions */ +.quick-actions { + padding: var(--size-4-3); + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: var(--size-4-3); +} + +.quick-actions-group { + display: flex; + flex-direction: column; + gap: var(--size-2-2); +} + +.quick-actions-group-title { + font-size: var(--font-ui-small); + font-weight: var(--font-semibold); + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; + padding-bottom: var(--size-2-1); + border-bottom: 1px solid var(--background-modifier-border); + margin-bottom: var(--size-2-1); +} + +.quick-action-btn { + width: 100%; + padding: var(--size-2-3) var(--size-4-2); + border: 1px solid var(--background-modifier-border); + border-radius: var(--radius-m); + background: var(--interactive-normal); + color: var(--text-normal); + cursor: pointer; + font-size: var(--font-ui-medium); + font-weight: var(--font-medium); + text-align: left; + display: flex; + align-items: center; + gap: var(--size-2-2); + transition: all var(--anim-duration-fast) ease; +} + +.quick-action-btn:hover:not(:disabled) { + background: var(--interactive-hover); + transform: translateY(-1px); + box-shadow: var(--shadow-s); +} + +.quick-action-btn:disabled { + opacity: 0.5; + cursor: not-allowed; + background: var(--background-modifier-border); + color: var(--text-faint); +} + +/* Mobile responsive */ +@media (max-width: 768px) { + .quick-actions { + grid-template-columns: 1fr; + } +} + +/* States */ +.status-dashboard-loading { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + font-size: var(--font-ui-medium); + color: var(--text-muted); +} + +/* Scrollbar */ +.status-dashboard-content::-webkit-scrollbar { + width: var(--scrollbar-width); +} + +.status-dashboard-content::-webkit-scrollbar-thumb { + background: var(--scrollbar-thumb-bg); + border-radius: var(--radius-s); +} + +.status-dashboard-content::-webkit-scrollbar-thumb:hover { + background: var(--scrollbar-active-bg); +} diff --git a/styles/components/dropdown.css b/styles/components/dropdown.css deleted file mode 100644 index c465ac8..0000000 --- a/styles/components/dropdown.css +++ /dev/null @@ -1,263 +0,0 @@ -/* - * Dropdown component styles - */ -.note-status-popover { - position: absolute; - background: var(--background-primary); - border-radius: var(--radius-m); - box-shadow: var(--status-box-shadow); - z-index: calc(var(--layer-popover) + 10); - max-height: 300px; - display: flex; - flex-direction: column; - border: 1px solid var(--background-modifier-border); - min-width: 200px; - overflow: hidden; - width: auto !important; - opacity: 0; - transform: scale(0.95); - transition: - opacity var(--status-transition-time) var(--ease-out), - transform var(--status-transition-time) var(--ease-out); - transform-origin: top left; - pointer-events: all !important; -} - -.note-status-popover.note-status-popover-animate-in { - opacity: 1; - transform: scale(1); -} - -.note-status-popover.note-status-popover-animate-out { - opacity: 0; - transform: scale(0.95); - pointer-events: none; -} - -/* Search container */ -.note-status-popover-search { - padding: 8px; - position: sticky; - top: 0; - background: var(--background-primary); - z-index: 3; - border-bottom: 1px solid var(--background-modifier-border); -} - -/* Status options container */ -.note-status-options-container { - overflow-y: auto; - overflow-x: hidden; - max-height: 250px; - padding: var(--size-4-1, 4px); - scrollbar-width: thin; - scrollbar-color: var(--background-modifier-border) transparent; -} - -.note-status-options-container::-webkit-scrollbar { - width: 6px; -} - -.note-status-options-container::-webkit-scrollbar-thumb { - background-color: var(--background-modifier-border); - border-radius: 3px; -} - -.note-status-options-container::-webkit-scrollbar-track { - background-color: transparent; -} - -/* Empty status options */ -.note-status-empty-options { - padding: 16px 12px; - text-align: center; - color: var(--text-muted); - font-style: italic; - font-size: var(--font-smaller); -} - -/* Status option items */ -.note-status-option { - display: flex; - align-items: center; - padding: 8px 12px; - margin: 2px 4px; - border-radius: var(--radius-s); - background: var(--background-primary); - color: var(--text-normal); - cursor: pointer !important; - transition: background-color 0.15s ease; -} - -.note-status-option:hover { - background: var(--background-modifier-hover); -} - -.note-status-option.is-selected { - background: var(--background-secondary-alt); - font-weight: var(--font-medium); -} - -.note-status-option-selecting { - background-color: var(--interactive-accent) !important; - color: var(--text-on-accent) !important; - animation: note-status-pulse 0.3s var(--ease-out); -} - -.note-status-option-icon { - margin-right: 8px; - font-size: 1.1em; -} - -.note-status-option-text { - flex: 1; - font-size: var(--font-smaller); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.note-status-option-check { - margin-left: auto; - color: var(--text-accent); -} - -/* Unified dropdown */ -.note-status-unified-dropdown { - width: 280px; - max-width: 90vw; - border-radius: var(--radius-m); - overflow: hidden; - box-shadow: var(--shadow-m); - border: 1px solid var(--background-modifier-border); - background: var(--background-primary); - z-index: 9999 !important; - pointer-events: all !important; -} - -/* Animation for unified dropdown */ -.note-status-popover-animate-in { - animation: note-status-dropdown-fade-in 0.22s var(--ease-out) forwards; -} - -@keyframes note-status-dropdown-fade-in { - from { - opacity: 0; - transform: scale(0.95); - } - to { - opacity: 1; - transform: scale(1); - } -} - -@keyframes note-status-dropdown-fade-out { - from { - opacity: 1; - transform: scale(1); - } - to { - opacity: 0; - transform: scale(0.95); - } -} - -/* Popover header */ -.note-status-popover-header { - padding: 12px; - border-bottom: 1px solid var(--background-modifier-border); - background: var(--background-secondary-alt); - display: flex; - align-items: center; - position: sticky; - top: 0; - z-index: 2; -} - -.note-status-popover-title { - display: flex; - align-items: center; - gap: 8px; - font-weight: var(--font-semibold); - color: var(--text-normal); -} - -.note-status-popover-icon { - display: flex; - align-items: center; - justify-content: center; - color: var(--text-accent); -} - -.note-status-popover-chips { - display: flex; - flex-wrap: wrap; - gap: 6px; - padding: 12px; - background: var(--background-primary-alt); - max-height: 120px; - overflow-y: auto; - border-bottom: 1px solid var(--background-modifier-border); -} - -/* Status chips styling */ -.note-status-chip { - display: inline-flex; - align-items: center; - padding: 4px 10px; - background: var(--background-secondary); - border-radius: 16px; - box-shadow: var(--shadow-xs); - font-size: var(--font-smaller); - transition: all 0.15s var(--ease-out); - border: 1px solid var(--background-modifier-border); - max-width: 180px; - animation: note-status-scale-in 0.2s var(--ease-out); -} - -.note-status-chip.clickable { - cursor: pointer; -} - -.note-status-chip.clickable:hover { - background: var(--background-modifier-hover); - transform: translateY(-1px); - box-shadow: var(--status-hover-shadow); -} - -.note-status-chip-removing { - transform: scale(0.8); - opacity: 0; - pointer-events: none; - transition: all 0.15s ease-out; -} - -.note-status-chip-icon { - margin-right: 6px; -} - -.note-status-chip-text { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.note-status-chip-remove { - margin-left: 6px; - width: 16px; - height: 16px; - display: flex; - align-items: center; - justify-content: center; - border-radius: 50%; - background: var(--background-modifier-border); - color: var(--text-muted); - cursor: pointer; - transition: all 0.15s var(--ease-out); -} - -.note-status-chip-remove:hover { - background: var(--text-accent); - color: var(--text-on-accent); - transform: scale(1.1); -} diff --git a/styles/components/explorer.css b/styles/components/explorer.css deleted file mode 100644 index 4d63bae..0000000 --- a/styles/components/explorer.css +++ /dev/null @@ -1,58 +0,0 @@ -/* - * Explorer integration styles - */ -.note-status-toolbar-badge-container { - display: flex; - align-items: center; - justify-content: center; - position: relative; -} - -.note-status-toolbar-icon { - color: var(--text-normal) !important; - display: flex !important; - align-items: center !important; - justify-content: center !important; -} - -.note-status-count-badge { - position: absolute; - top: -5px; - right: -8px; - background-color: var(--interactive-accent); - color: var(--text-on-accent); - border-radius: 10px; - font-size: 10px; - min-width: 14px; - height: 14px; - padding: 0 3px; - display: flex; - align-items: center; - justify-content: center; - font-weight: var(--font-bold); -} - -/* Explorer icons container */ -.note-status-icon-container { - display: inline-flex; - margin-left: var(--size-4-1, 4px); - gap: 2px; - align-items: center; - flex-shrink: 0; -} - -/* Individual status icons in explorer */ -.note-status-icon { - font-size: var(--font-ui-smaller); - display: inline-flex; - align-items: center; - justify-content: center; - width: var(--status-icon-size); - height: var(--status-icon-size); - position: relative; - z-index: 500; /* Higher than most elements */ -} - -.nav-file-title .note-status-icon::after { - bottom: 150%; /* Position above in file explorer */ -} diff --git a/styles/components/file-explorer.css b/styles/components/file-explorer.css new file mode 100644 index 0000000..2ad913c --- /dev/null +++ b/styles/components/file-explorer.css @@ -0,0 +1,159 @@ +/* ========================================================================== + File Explorer Integration + ========================================================================== */ + +.custom-icon { + display: inline-flex; +} + +.custom-icon__absolute-right { + position: absolute; + right: 0; +} + +.status-wrapper { + position: relative; + display: inline-block; +} + +.status-minimal { + display: inline-flex; + align-items: center; + gap: 2px; + margin-left: var(--size-2-2); + padding: 2px; + border-radius: var(--radius-s); + cursor: pointer; + opacity: 0.7; + transition: all var(--anim-duration-fast) ease; +} + +.status-minimal:hover { + opacity: 1; + background: var(--background-modifier-hover); + transform: scale(1.05); +} + +/* Use data attribute for dynamic colors */ +.status-minimal[data-status-color] .status-minimal__count { + background: var(--interactive-accent); +} + +.status-minimal__icon { + font-size: 11px; + line-height: 1; + filter: grayscale(0.2); + transition: filter var(--anim-duration-fast) ease; +} + +.status-minimal:hover .status-minimal__icon { + filter: grayscale(0); +} + +.status-minimal__count { + min-width: 14px; + height: 14px; + padding: 0 3px; + background: var(--interactive-accent); + color: var(--text-on-accent); + font-size: var(--font-ui-smaller); + font-weight: var(--font-semibold); + border-radius: var(--radius-m); + display: flex; + align-items: center; + justify-content: center; +} + +/* Status Info Popup */ +.status-info-popup { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + z-index: var(--layer-popover); + background: var(--background-primary); + border: 1px solid var(--background-modifier-border); + border-radius: var(--radius-m); + box-shadow: var(--shadow-l); + min-width: 280px; + max-width: 400px; + overflow: hidden; + pointer-events: none; +} + +.status-popup-header { + display: flex; + align-items: center; + gap: var(--size-2-2); + padding: var(--size-4-2) var(--size-4-3); + background: var(--background-secondary); + border-bottom: 1px solid var(--background-modifier-border); + font-weight: var(--font-semibold); + font-size: var(--font-ui-small); +} + +.status-popup-content { + padding: var(--size-4-2); +} + +.status-popup-empty { + padding: var(--size-4-4); + text-align: center; + color: var(--text-muted); + font-style: italic; +} + +/* Status Groups in Popup */ +.status-group { + margin-bottom: var(--size-4-2); +} + +.status-group:last-child { + margin-bottom: 0; +} + +.status-group-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: var(--size-2-2) var(--size-2-3); + background: var(--background-secondary-alt); + border-radius: var(--radius-s); + margin-bottom: var(--size-2-2); +} + +.status-group-name { + text-transform: capitalize; + font-weight: var(--font-medium); + font-size: var(--font-ui-smaller); +} + +.status-group-count { + padding: 1px var(--size-2-2); + background: var(--background-modifier-border); + border-radius: var(--radius-s); + font-size: var(--font-ui-smaller); + color: var(--text-muted); +} + +.status-group-items { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); + gap: var(--size-2-2); +} + +.status-item { + display: flex; + flex-direction: column; + gap: var(--size-2-1); + padding: var(--size-2-2); + background: var(--background-primary-alt); + border: 1px solid var(--background-modifier-border); + border-radius: var(--radius-s); +} + +.status-description { + font-size: var(--font-ui-smaller); + color: var(--text-muted); + line-height: 1.4; +} diff --git a/styles/components/group-label.css b/styles/components/group-label.css new file mode 100644 index 0000000..c5204dd --- /dev/null +++ b/styles/components/group-label.css @@ -0,0 +1,25 @@ +/* ========================================================================== + Group Label Component + ========================================================================== */ + +.group-label-container { + display: flex; + align-items: center; + gap: var(--size-2-2); + font-size: var(--font-ui-small); + font-weight: var(--font-semibold); + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-muted); + transition: color var(--anim-duration-fast) ease; +} + +.group-label-container--highlighted { + color: var(--text-normal); +} + +.group-label-container svg { + width: 16px; + height: 16px; + opacity: 0.8; +} diff --git a/styles/components/grouped-view.css b/styles/components/grouped-view.css new file mode 100644 index 0000000..e8c5b0e --- /dev/null +++ b/styles/components/grouped-view.css @@ -0,0 +1,260 @@ +/* ========================================================================== + Grouped Status View + ========================================================================== */ + +.grouped-status-view-container { + height: 100%; + overflow: hidden; +} + +/* Header */ +.grouped-status-header { + padding: var(--size-4-2) var(--size-4-3); + background: var(--background-secondary); + border-bottom: 1px solid var(--background-modifier-border); +} + +.grouped-status-title { + margin: 0 0 var(--size-4-2) 0; + font-size: var(--font-ui-large); + font-weight: var(--font-semibold); +} + +/* Filters */ +.grouped-status-filters { + display: flex; + flex-direction: column; + gap: var(--size-4-2); +} + +.grouped-status-note-input { + width: 100%; + padding: var(--size-2-2) var(--size-2-3); + border: 1px solid var(--background-modifier-border); + border-radius: var(--radius-s); + background: var(--background-primary); + color: var(--text-normal); + font-size: var(--font-ui-medium); +} + +.grouped-status-note-input:focus { + outline: none; + border-color: var(--interactive-accent); + box-shadow: 0 0 0 2px var(--background-modifier-accent); +} + +/* Content */ +.grouped-status-content { + flex: 1; + overflow-y: auto; + padding: var(--size-4-2); +} + +/* Tag Section */ +.grouped-status-tag-section { + margin-bottom: var(--size-4-3); +} + +.grouped-status-tag-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: var(--size-4-2) var(--size-4-3); + background: var(--background-secondary); + border: 1px solid var(--background-modifier-border); + border-radius: var(--radius-m); + cursor: pointer; + transition: all var(--anim-duration-fast) ease; + margin-bottom: var(--size-4-2); +} + +.grouped-status-tag-header:hover { + background: var(--background-modifier-hover); +} + +.grouped-status-tag-content { + padding-left: var(--size-4-2); +} + +/* Status Group */ +.grouped-status-group { + margin-bottom: var(--size-4-2); + background: var(--background-primary); + border: 1px solid var(--background-modifier-border); + border-radius: var(--radius-m); + overflow: hidden; +} + +.grouped-status-group-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: var(--size-4-2) var(--size-4-3); + cursor: pointer; + transition: background var(--anim-duration-fast) ease; +} + +.grouped-status-group-header:hover { + background: var(--background-modifier-hover); +} + +.grouped-status-group-info { + display: flex; + align-items: center; + gap: var(--size-2-2); +} + +/* Files List */ +.grouped-status-files { + background: var(--background-secondary); + border-top: 1px solid var(--background-modifier-border); +} + +.grouped-status-files-list { + max-height: 300px; + overflow-y: auto; +} + +.grouped-status-file-item { + padding: var(--size-2-3) var(--size-4-3); + cursor: pointer; + transition: background var(--anim-duration-fast) ease; + border-bottom: 1px solid var(--background-modifier-border-hover); +} + +.grouped-status-file-item:hover { + background: var(--background-modifier-hover); +} + +.grouped-status-file-item:last-child { + border-bottom: none; +} + +.grouped-status-file-name { + font-weight: var(--font-medium); + color: var(--text-normal); + margin-bottom: 2px; +} + +.grouped-status-file-path { + font-size: var(--font-ui-small); + color: var(--text-muted); + opacity: 0.8; +} + +/* Load More */ +.grouped-status-load-more { + display: flex; + justify-content: center; + padding: var(--size-4-2); + border-top: 1px solid var(--background-modifier-border); +} + +.grouped-status-load-btn { + padding: var(--size-2-2) var(--size-4-2); + border: 1px solid var(--background-modifier-border); + border-radius: var(--radius-s); + background: var(--interactive-normal); + color: var(--text-normal); + cursor: pointer; + font-size: var(--font-ui-small); + font-weight: var(--font-medium); + transition: all var(--anim-duration-fast) ease; +} + +.grouped-status-load-btn:hover { + background: var(--interactive-hover); + transform: translateY(-1px); +} + +/* States */ +.grouped-status-loading, +.grouped-status-empty { + display: flex; + align-items: center; + justify-content: center; + padding: var(--size-4-6); + color: var(--text-muted); + font-size: var(--font-ui-medium); +} + +/* Scrollbar */ +.grouped-status-content::-webkit-scrollbar, +.grouped-status-files-list::-webkit-scrollbar { + width: var(--scrollbar-width); +} + +.grouped-status-content::-webkit-scrollbar-thumb, +.grouped-status-files-list::-webkit-scrollbar-thumb { + background: var(--scrollbar-thumb-bg); + border-radius: var(--radius-s); +} + +.grouped-status-content::-webkit-scrollbar-thumb:hover, +.grouped-status-files-list::-webkit-scrollbar-thumb:hover { + background: var(--scrollbar-active-bg); +} + +/* Template Filter (BEM) */ +.grouped-status-filters__template { + display: flex; + flex-direction: column; + gap: var(--size-2-1); +} + +.grouped-status-filters__template-select { + width: 100%; + padding: var(--size-2-2) var(--size-2-3); + border: 1px solid var(--background-modifier-border); + border-radius: var(--radius-s); + background: var(--background-primary); + color: var(--text-normal); + font-size: var(--font-ui-small); + cursor: pointer; +} + +.grouped-status-filters__template-select:focus { + outline: none; + border-color: var(--interactive-accent); + box-shadow: 0 0 0 2px var(--background-modifier-accent); +} + +.grouped-status-filters__note { + display: flex; + flex-direction: column; + gap: var(--size-2-1); +} + +.grouped-status-filters__note-input { + width: 100%; + padding: var(--size-2-2) var(--size-2-3); + border: 1px solid var(--background-modifier-border); + border-radius: var(--radius-s); + background: var(--background-primary); + color: var(--text-normal); + font-size: var(--font-ui-small); +} + +.grouped-status-filters__note-input:focus { + outline: none; + border-color: var(--interactive-accent); + box-shadow: 0 0 0 2px var(--background-modifier-accent); +} + +/* Template Badge (BEM) */ +.grouped-status-group__status { + display: flex; + align-items: center; + gap: var(--size-2-2); +} + +.grouped-status-group__template-badge { + background: var(--background-modifier-border); + color: var(--text-muted); + padding: var(--size-2-1) var(--size-2-2); + border-radius: var(--radius-s); + font-size: var(--font-ui-smaller); + font-weight: var(--font-medium); + text-transform: capitalize; + white-space: nowrap; +} diff --git a/styles/components/settings.css b/styles/components/settings.css index 651456e..6894e8a 100644 --- a/styles/components/settings.css +++ b/styles/components/settings.css @@ -1,62 +1,303 @@ -/* - * Settings styles - */ -/* Template styling */ -.status-color-dot { - display: inline-block; - width: 8px; - height: 8px; - border-radius: 50%; - background-color: var(--dot-color, #ffffff); - margin-right: 4px; +/* ========================================================================== + Settings UI Components + ========================================================================== */ + +/* Settings use Obsidian's built-in classes mostly */ + +/* Custom additions for Note Status settings */ +.setting-item-full { + width: 100%; } -.template-buttons { - margin-top: 15px; - display: flex; - gap: 10px; +.setting-item-vertical { + flex-direction: column; + align-items: flex-start; + gap: var(--size-2-2); } -/* Template selection styling */ +/* Template Item */ .template-item { border: 1px solid var(--background-modifier-border); - border-radius: var(--status-border-radius); - margin-bottom: 10px; - padding: 10px; + border-radius: var(--radius-m); + margin-bottom: var(--size-4-2); + padding: var(--size-4-3); background: var(--background-primary-alt); + cursor: pointer; + transition: all var(--anim-duration-fast) ease; } -.template-header { - display: flex; - align-items: center; - margin-bottom: 5px; +.template-item:hover { + background: var(--background-modifier-hover); +} + +.template-item.enabled { + background: var(--background-modifier-success); + border-color: var(--interactive-success); } .template-checkbox { - margin-right: 10px; -} - -.template-name { - font-weight: bold; -} - -.template-description { - font-size: 0.9em; - color: var(--text-muted); - margin-bottom: 8px; + pointer-events: none; } .template-statuses { + margin-top: var(--size-2-2); display: flex; flex-wrap: wrap; - gap: 5px; + gap: var(--size-2-1); } -.template-status-chip { - display: inline-flex; - align-items: center; - padding: 2px 8px; - border-radius: 12px; +/* Custom Status Item - BEM Methodology */ +.custom-status-item { background: var(--background-secondary); - font-size: 0.85em; + border: 1px solid var(--background-modifier-border); + border-radius: var(--radius-m); + padding: var(--size-4-3); + margin-bottom: var(--size-4-2); + transition: all var(--anim-duration-fast) ease; +} + +.custom-status-item:hover { + border-color: var(--interactive-hover); +} + +.custom-status-item__row { + display: flex; + align-items: end; + gap: var(--size-4-2); + margin-bottom: var(--size-4-2); +} + +.custom-status-item__field { + display: flex; + flex-direction: column; + gap: var(--size-2-1); +} + +.custom-status-item__field--name { + flex: 2; + min-width: 120px; +} + +.custom-status-item__field--description { + flex: 2; + min-width: 120px; +} + +.custom-status-item__field--color { + flex: 0 0 auto; +} + +.custom-status-item__field--actions { + flex: 0 0 auto; + padding-top: var(--size-4-1); +} + +.custom-status-item__label { + font-size: var(--font-ui-smaller); + font-weight: var(--font-medium); + color: var(--text-muted); + margin-bottom: var(--size-2-1); +} + +.custom-status-item__label--required { + color: var(--text-error); +} + +.custom-status-item__input { + /* Base input styles handled by Input component */ +} + +.custom-status-item__input--icon { + width: 50px !important; + text-align: center; + font-size: 1.2em; +} + +.custom-status-item__input--name { + font-weight: var(--font-medium); +} + +.custom-status-item__input--name.custom-status-item__input--invalid { + border-color: var(--text-error) !important; + background: var(--background-modifier-error) !important; +} + +.custom-status-item__input--description { + color: var(--text-muted); +} + +.custom-status-item__input--color { + width: 40px !important; + height: 32px !important; + border-radius: var(--radius-s); + cursor: pointer; + border: 2px solid var(--background-modifier-border) !important; +} + +.custom-status-item__input--color:hover { + border-color: var(--interactive-hover) !important; +} + +.custom-status-item__remove-btn { + width: 32px; + height: 32px; + border-radius: var(--radius-s); + background: var(--background-modifier-error); + color: var(--text-on-accent); + border: none; + cursor: pointer; + font-size: 18px; + font-weight: bold; + transition: all var(--anim-duration-fast) ease; +} + +.custom-status-item__remove-btn:hover { + background: var(--text-error); + transform: scale(1.05); +} + +.custom-status-item__preview { + display: flex; + align-items: center; + gap: var(--size-2-2); + padding: var(--size-2-3) var(--size-4-2); + background: var(--background-primary-alt); + border-radius: var(--radius-s); + border-left: 3px solid var(--background-modifier-border); + font-size: var(--font-ui-small); +} + +.custom-status-item__preview-icon { + font-size: 1.1em; + min-width: 20px; + text-align: center; +} + +.custom-status-item__preview-text { + font-weight: var(--font-medium); +} + +.custom-status-item__preview-desc { + color: var(--text-muted); + font-style: italic; +} + +.custom-status-item__preview-desc::before { + content: "— "; +} + +.custom-status-item__error { + color: var(--text-error); + font-size: var(--font-ui-smaller); + margin-top: var(--size-2-1); + padding: var(--size-2-1) var(--size-2-2); + background: var(--background-modifier-error); + border-radius: var(--radius-s); + border-left: 3px solid var(--text-error); +} + +/* Custom Status List - BEM */ +.custom-status-list { + /* Container for multiple custom status items */ +} + +.custom-status-list__empty { + text-align: center; + padding: var(--size-4-4) var(--size-4-2); + color: var(--text-muted); + font-style: italic; + border: 2px dashed var(--background-modifier-border); + border-radius: var(--radius-m); + background: var(--background-primary-alt); +} + +.custom-status-list__empty p { + margin: 0; +} + +/* Status Group for Quick Commands */ +.status-group { + margin-bottom: var(--size-4-4); +} + +.status-group-header { + margin-bottom: var(--size-4-2); +} + +.status-group-title { + font-weight: var(--font-semibold); +} + +.status-group-description { + font-size: var(--font-ui-smaller); + color: var(--text-muted); + margin-top: var(--size-2-1); +} + +.status-group-items { + margin-left: var(--size-4-2); +} + +/* Quick Commands Container */ +.quick-commands-container { + margin-top: var(--size-4-2); +} + +/* Note Status Selector in Modal */ +.note-status-options { + max-height: 300px; + overflow-y: auto; + border: 1px solid var(--background-modifier-border); + border-radius: var(--radius-s); + background: var(--background-primary); +} + +.note-status-option { + /* Uses SelectableListItem styles */ +} + +.note-status-chips { + display: flex; + flex-wrap: wrap; + gap: var(--size-2-2); + min-height: 32px; + align-items: center; +} + +/* Selectable List Item (used by multiple components) */ +.selectable-list-item { + display: flex; + align-items: center; + gap: var(--size-4-2); + padding: var(--size-2-3) var(--size-4-2); + cursor: pointer; + border-bottom: 1px solid var(--background-modifier-border); + transition: background var(--anim-duration-fast) ease; +} + +.selectable-list-item:hover { + background: var(--background-modifier-hover); +} + +.selectable-list-item[data-focused="true"] { + outline: 2px solid var(--interactive-accent); + outline-offset: -2px; +} + +.selectable-list-item[data-selected="true"] { + background: var(--background-modifier-hover); +} + +.selectable-list-item-icon { + font-size: 16px; + min-width: 20px; +} + +.selectable-list-item-content { + flex: 1; + font-size: var(--font-ui-small); +} + +.selectable-list-item-check { + color: var(--interactive-accent); } diff --git a/styles/components/status-bar.css b/styles/components/status-bar.css index e08c5ab..47e1d9c 100644 --- a/styles/components/status-bar.css +++ b/styles/components/status-bar.css @@ -1,64 +1,45 @@ -/* - * Status bar styles - */ -.note-status-bar { - display: flex; - align-items: center; - gap: 6px; - padding: 4px 8px; - border-radius: var(--radius-s); - transition: all 0.2s var(--ease-out); +/* ========================================================================== + Status Bar Components + ========================================================================== */ + +/* Status Bar Container */ +.status-bar-item { + /* Uses Obsidian's built-in status bar item styles */ +} + +/* Enable Button */ +.status-bar-enable-button { cursor: pointer; - height: 22px; + opacity: var(--icon-opacity); + transition: opacity var(--anim-duration-fast) ease; } -.note-status-bar:hover { - background-color: var(--background-modifier-hover); +.status-bar-enable-button:hover { + opacity: var(--icon-opacity-hover); } -.note-status-badges { +/* Status Bar Group */ +.status-bar-group-row { display: flex; align-items: center; - gap: 4px; + gap: var(--size-4-2); } -.note-status-badge { +.status-bar-group-container { display: flex; + gap: var(--size-2-1); + flex-wrap: wrap; align-items: center; - padding: 2px 6px; - border-radius: 10px; - background: var(--background-secondary-alt); - font-size: var(--font-ui-smaller); - max-width: 100px; - margin-right: 2px; } -.note-status-badge-icon { - margin-right: 2px; -} - -.note-status-badge-text { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -/* Status bar positioning and visibility */ -.status-bar .note-status-icon::after { - bottom: auto; - top: -30px; -} - -/* Auto-hide behavior */ -.note-status-bar.hidden { - display: none; -} - -.note-status-bar.auto-hide { - opacity: 0.5; - transition: opacity 0.2s var(--ease-out); -} - -.note-status-bar.visible { - opacity: 1; +/* Animation for expanding status items */ +@keyframes status-slide-in { + from { + opacity: 0; + transform: scale(0.8) translateY(4px); + } + to { + opacity: 1; + transform: scale(1) translateY(0); + } } diff --git a/styles/components/status-display.css b/styles/components/status-display.css new file mode 100644 index 0000000..69a82a5 --- /dev/null +++ b/styles/components/status-display.css @@ -0,0 +1,95 @@ +/* ========================================================================== + Status Display Component - Handles chip, badge, and template variants + ========================================================================== */ + +/* Base status display */ +.note-status-chip { + display: inline-flex; + align-items: center; + gap: var(--size-2-2); + padding: var(--size-2-1) var(--size-2-3); + background: var(--interactive-accent); + color: var(--text-on-accent); + border-radius: var(--radius-s); + font-size: var(--font-ui-smaller); + cursor: pointer; + transition: all var(--anim-duration-fast) ease; +} + +.note-status-chip:hover { + opacity: 0.9; + transform: translateY(-1px); +} + +.note-status-chip--removing { + opacity: 0.5; + transform: scale(0.95); +} + +.note-status-chip__icon { + font-size: 1em; +} + +.note-status-chip__text { + font-weight: var(--font-medium); +} + +.note-status-chip__remove { + display: flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.2); + cursor: pointer; + margin-left: var(--size-2-1); +} + +.note-status-chip__remove:hover { + background: rgba(255, 255, 255, 0.3); +} + +/* Badge variant */ +.status-badge-container { + display: inline-flex; + align-items: center; + padding: var(--size-2-1) var(--size-2-3); + border-radius: var(--radius-s); + transition: all var(--anim-duration-fast) ease; + cursor: pointer; +} + +.status-badge-item { + display: flex; + align-items: center; + gap: var(--size-2-1); +} + +.status-badge-icon { + font-size: var(--font-ui-small); +} + +.status-badge-text { + font-weight: var(--font-medium); + font-size: var(--font-ui-small); +} + +/* Template variant */ +.template-status-chip { + display: inline-flex; + align-items: center; + gap: var(--size-2-1); + padding: var(--size-2-1) var(--size-2-3); + background: var(--background-secondary); + border: 1px solid var(--background-modifier-border); + border-radius: var(--radius-m); + font-size: var(--font-ui-smaller); +} + +.template-status-color-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background-color: var(--dot-color); +} diff --git a/styles/components/status-pane.css b/styles/components/status-pane.css deleted file mode 100644 index 5d19417..0000000 --- a/styles/components/status-pane.css +++ /dev/null @@ -1,313 +0,0 @@ -/* - * Status Pane Styles - */ -.note-status-pane { - padding: var(--size-4-2, 8px); - background: var(--background-secondary); - color: var(--text-normal); - font-family: var(--font-interface); - overflow-y: auto; - height: 100%; - display: flex; - flex-direction: column; -} - -/* Header elements */ -.note-status-header { - position: sticky; - top: 0; - z-index: 10; - background: var(--background-secondary); - padding-bottom: var(--size-4-2, 8px); - border-bottom: 1px solid var(--background-modifier-border); - margin-bottom: var(--size-4-2, 8px); - display: flex; - flex-direction: column; - gap: var(--size-4-2, 8px); -} - -/* Search input styles */ -.note-status-search { - width: 100%; - position: relative; -} - -.search-input-wrapper, -.note-status-search-wrapper { - position: relative; - display: flex; - align-items: center; -} - -.search-input-icon, -.note-status-search-icon { - position: absolute; - left: var(--size-4-1, 4px); - color: var(--text-muted); - display: flex; - align-items: center; - pointer-events: none; - padding: 4px; -} - -.note-status-search-input, -.note-status-popover-search-input { - width: 100%; - padding: var(--input-padding); - padding-left: calc(var(--size-4-1, 4px) * 3 + 18px); - border: var(--input-border-width) solid var(--background-modifier-border); - border-radius: var(--input-radius); - background: var(--background-primary); - color: var(--text-normal); - outline: none; - transition: all var(--status-transition-time) var(--ease-out); -} - -.note-status-search-input:focus, -.note-status-popover-search-input:focus { - border-color: var(--interactive-accent); - box-shadow: 0 0 0 2px var(--interactive-accent-hover); -} - -.search-input-clear-button, -.note-status-search-clear-button { - opacity: 0; - position: absolute; - right: var(--size-4-1, 4px); - cursor: pointer; - color: var(--text-muted); - display: flex; - align-items: center; - transition: opacity var(--status-transition-time) var(--ease-out); - padding: 4px; - border-radius: 50%; -} - -.search-input-clear-button.is-visible, -.note-status-search-clear-button.is-visible { - opacity: 1; -} - -.search-input-clear-button:hover, -.note-status-search-clear-button:hover { - color: var(--text-normal); - background-color: var(--background-modifier-hover); -} - -/* Actions toolbar */ -.status-pane-actions-container { - display: flex; - gap: var(--size-4-2, 8px); - justify-content: flex-end; - margin-top: var(--size-4-1, 4px); -} - -.note-status-view-toggle, -.note-status-actions-refresh { - padding: var(--size-4-1, 4px) var(--size-4-2, 8px); - border: var(--input-border-width) solid var(--background-modifier-border); - border-radius: var(--button-radius); - background: var(--background-primary); - color: var(--text-normal); - cursor: pointer; - transition: all var(--status-transition-time) var(--ease-out); - display: flex; - align-items: center; - justify-content: center; -} - -.note-status-view-toggle:hover, -.note-status-actions-refresh:hover { - background: var(--background-modifier-hover); - box-shadow: var(--shadow-s); -} - -.note-status-view-toggle:active, -.note-status-actions-refresh:active { - transform: translateY(1px); - box-shadow: none; -} - -/* Status Groups Container */ -.note-status-groups-container { - flex: 1; - overflow-y: auto; - padding-right: 2px; /* Prevents scrollbar from hugging the edge */ -} - -/* Status Group Styling */ -.note-status-group { - margin-bottom: var(--size-4-3, 12px); - border-radius: var(--radius-s); - background: var(--background-primary-alt); - box-shadow: var(--shadow-xs); - overflow: hidden; - animation: note-status-fade-in-slide-down 0.3s var(--ease-out); -} - -.note-status-group .nav-folder-title { - cursor: pointer; - padding: var(--size-4-1, 4px) var(--size-4-2, 8px); - background: var(--background-secondary-alt); - transition: background var(--status-transition-time) var(--ease-out); -} - -.note-status-group .nav-folder-title:hover { - background: var(--background-modifier-hover); -} - -.note-status-group .nav-folder-title-content { - font-weight: var(--font-semibold); - display: flex; - align-items: center; - gap: var(--size-4-1, 4px); -} - -/* Collapse indicators */ -.note-status-collapse-indicator { - margin-right: var(--size-4-2, 8px); - display: flex; - align-items: center; - color: var(--text-muted); - transition: transform var(--status-transition-time) var(--ease-in-out); - opacity: 1; -} - -.note-status-is-collapsed .note-status-collapse-indicator { - transform: rotate(-90deg); -} - -/* File items */ -.note-status-group .nav-folder-children { - padding: var(--size-4-1, 4px); - background: var(--background-primary); - transition: height var(--status-transition-time) var(--ease-out); -} - -.note-status-group.nav-folder.note-status-is-collapsed .nav-folder-children { - display: none; -} - -.nav-file { - border-radius: var(--radius-s); - transition: background var(--status-transition-time) var(--ease-out); - margin-bottom: 2px; - position: relative; -} - -.nav-file:hover { - background: var(--background-modifier-hover); -} - -.nav-file-title { - display: flex; - align-items: center; - padding: var(--size-4-1, 4px) var(--size-4-2, 8px); - flex-wrap: nowrap; -} - -.nav-file-icon { - color: var(--text-muted); - margin-right: var(--size-4-2, 8px); - display: flex; - align-items: center; - flex-shrink: 0; -} - -.nav-file-title-content { - flex: 1; - min-width: 0; /* Allows text to shrink properly */ - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -/* Compact view */ -.note-status-compact-view .nav-file-title { - padding: 2px var(--size-4-2, 8px); - font-size: var(--font-smaller); -} - -.note-status-compact-view .nav-folder-children { - padding: 0; -} - -.note-status-compact-view .nav-file { - margin-bottom: 0; - border-radius: 0; - border-bottom: 1px solid var(--background-modifier-border); -} - -.note-status-compact-view .nav-file:last-child { - border-bottom: none; -} - -/* Empty message and show unassigned button */ -.note-status-empty-message { - margin-bottom: 12px; - text-align: center; - color: var(--text-muted); - font-style: italic; -} - -.note-status-button-container { - display: flex; - justify-content: center; - margin-top: 16px; -} - -.note-status-show-unassigned-button { - background-color: var(--interactive-accent); - color: var(--text-on-accent); - padding: 8px 16px; - border-radius: var(--radius-s, 4px); - border: none; - cursor: pointer; - font-size: var(--font-ui-small); - transition: all 0.2s var(--ease-out); - box-shadow: var(--shadow-s); -} - -.note-status-show-unassigned-button:hover { - background-color: var(--interactive-accent-hover); - transform: translateY(-1px); - box-shadow: var(--shadow-m); -} - -.note-status-show-unassigned-button:active { - transform: translateY(0); - box-shadow: var(--shadow-xs); -} - -/* - * Pagination styles - */ -.note-status-pagination { - display: flex; - justify-content: space-between; - align-items: center; - padding: 8px; - border-top: 1px solid var(--background-modifier-border); - margin-top: 8px; -} - -.note-status-pagination-button { - padding: 4px 8px; - border-radius: var(--radius-s); - background: var(--background-secondary); - border: var(--input-border-width) solid var(--background-modifier-border); - color: var(--text-normal); - cursor: pointer; - transition: all 0.15s var(--ease-out); - font-size: var(--font-smaller); -} - -.note-status-pagination-button:hover { - background: var(--background-modifier-hover); - box-shadow: var(--shadow-s); -} - -.note-status-pagination-info { - font-size: var(--font-smaller); - color: var(--text-muted); -} diff --git a/styles/index.css b/styles/index.css index 52bb9fc..8370858 100644 --- a/styles/index.css +++ b/styles/index.css @@ -1,14 +1,16 @@ /* * Note Status Plugin for Obsidian - * Main CSS file importing all components + * Main CSS file with BEM-like naming conventions * * Author: Aleix Soler */ -@import "base.css"; -@import "utils.css"; +/* Import component stylesheets */ @import "components/status-bar.css"; -@import "components/status-pane.css"; -@import "components/dropdown.css"; -@import "components/explorer.css"; @import "components/settings.css"; +@import "components/dashboard.css"; +@import "components/grouped-view.css"; +@import "components/file-explorer.css"; +@import "components/group-label.css"; +@import "components/collapsible-counter.css"; +@import "components/status-display.css"; diff --git a/styles/utils.css b/styles/utils.css deleted file mode 100644 index 36d2e50..0000000 --- a/styles/utils.css +++ /dev/null @@ -1,108 +0,0 @@ -/* - * Utility classes for Note Status Plugin - */ -.note-status-empty-indicator { - color: var(--text-muted); - font-style: italic; - padding: var(--size-4-1, 4px); - display: flex; - align-items: center; - justify-content: center; - color: var(--text-accent); - font-size: 1.2em; -} - -/* Action buttons in modal */ -.note-status-action-button { - display: flex; - align-items: center; - justify-content: center; - width: 28px; - height: 28px; - border-radius: var(--radius-s); - background: transparent; - color: var(--text-muted); - cursor: pointer; - transition: all 0.15s ease; - border: none; - padding: 0; -} - -.note-status-action-button:hover { - background-color: var(--background-modifier-hover); - color: var(--text-normal); -} - -.note-status-action-button.note-status-button-active { - background-color: var(--interactive-accent); - color: var(--text-on-accent); - transform: scale(0.95); -} - -/* Loading indicator */ -.note-status-loading { - display: flex; - justify-content: center; - align-items: center; - padding: 20px; - color: var(--text-muted); - font-style: italic; -} - -.note-status-loading span { - position: relative; - padding-left: 24px; -} - -.note-status-loading span:before { - content: ""; - position: absolute; - left: 0; - top: 50%; - width: 16px; - height: 16px; - margin-top: -8px; - border: 2px solid var(--background-modifier-border); - border-top-color: var(--text-accent); - border-radius: 50%; - animation: note-status-loading-spinner 0.8s linear infinite; -} - -@keyframes note-status-loading-spinner { - to { - transform: rotate(360deg); - } -} - -/* Positioning classes for dropdowns */ -.note-status-dummy-target { - position: fixed; - z-index: 1000; - width: 0; - height: 0; - left: var(--pos-x-px, 0); - top: var(--pos-y-px, 0); - pointer-events: none; -} - -.note-status-popover-fixed { - position: fixed; - z-index: 999; - --pos-x: 0; - --pos-y: 0; - --max-height: 300px; - left: var(--pos-x-px, 0); - top: var(--pos-y-px, 0); - max-height: var(--max-height-px, 300px); -} - -/* Position adjustment classes */ -.note-status-popover-right { - left: auto !important; - right: var(--right-offset-px, 10px); -} - -.note-status-popover-bottom { - top: auto !important; - bottom: var(--bottom-offset-px, 10px); -} diff --git a/tsconfig.json b/tsconfig.json index c3939c0..f83eaba 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,18 @@ { "compilerOptions": { "baseUrl": ".", + "paths": { + "@/components/*": ["./components/*"], + "@/constants/*": ["./constants/*"], + "@/contexts/*": ["./contexts/*"], + "@/core/*": ["./core/*"], + "@/hooks/*": ["./hooks/*"], + "@/integrations/*": ["./integrations/*"], + "@/styles/*": ["./styles/*"], + "@/types/*": ["./types/*"], + "@/utils/*": ["./utils/*"], + "@/views/*": ["./views/*"] + }, "inlineSourceMap": true, "inlineSources": true, "module": "ESNext", @@ -11,7 +23,10 @@ "importHelpers": true, "isolatedModules": true, "strictNullChecks": true, - "lib": ["DOM", "ES5", "ES6", "ES7"] + "lib": ["DOM", "ES5", "ES6", "ES7"], + "jsx": "react-jsx", + "esModuleInterop": true, + "allowSyntheticDefaultImports": true }, - "include": ["**/*.ts"] + "include": ["**/*.ts", "**/*.tsx"] } diff --git a/types/eventBus.ts b/types/eventBus.ts new file mode 100644 index 0000000..7eb65a4 --- /dev/null +++ b/types/eventBus.ts @@ -0,0 +1,27 @@ +import { + MultipleNoteStatusService, + NoteStatusService, +} from "@/core/noteStatusService"; +import { PluginSettings } from "@/types/pluginSettings"; +import { TFile, WorkspaceLeaf } from "obsidian"; + +export type EventBusEvents = { + "active-file-change": ({ leaf }: { leaf: WorkspaceLeaf | null }) => void; + "plugin-settings-changed": ({ + key, + value, + currentSettings, + }: { + key: keyof PluginSettings; + value: unknown; + currentSettings: PluginSettings; + }) => void; + "frontmatter-manually-changed": ({ file }: { file: TFile }) => void; + "triggered-open-modal": ({ + statusService, + }: { + statusService: NoteStatusService | MultipleNoteStatusService; + }) => void; +}; + +export type EventName = keyof EventBusEvents; diff --git a/types/noteStatus.ts b/types/noteStatus.ts new file mode 100644 index 0000000..17aff56 --- /dev/null +++ b/types/noteStatus.ts @@ -0,0 +1,17 @@ +export type NoteStatus = { + name: string; + icon: string; + color?: string; // Optional color property + description?: string; // Optional description property + templateId?: string; // Optional template scope for namespacing + [key: string]: unknown; +}; + +export type ScopedStatusName = { + templateId?: string; + name: string; +}; + +export type StatusIdentifier = string | ScopedStatusName; + +export type GroupedStatuses = Record; diff --git a/types/pluginSettings.ts b/types/pluginSettings.ts new file mode 100644 index 0000000..706de04 --- /dev/null +++ b/types/pluginSettings.ts @@ -0,0 +1,29 @@ +import { NoteStatus } from "./noteStatus"; + +export interface StatusTemplate { + id: string; + name: string; + description: string; + statuses: NoteStatus[]; +} + +export type PluginSettings = { + fileExplorerIconPosition: + | "absolute-right" + | "file-name-left" + | "file-name-right"; + statusColors: Record; + showStatusBar: boolean; + autoHideStatusBar: boolean; + customStatuses: NoteStatus[]; + showStatusIconsInExplorer: boolean; + hideUnknownStatusInExplorer: boolean; + enabledTemplates: string[]; // IDs of enabled templates + useCustomStatusesOnly: boolean; // Whether to use only custom statuses or include templates + useMultipleStatuses: boolean; // Whether to allow multiple statuses per note + tagPrefix: string; // Prefix for the status tag (default: 'status') + strictStatuses: boolean; // Whether to only show known statuses + excludeUnknownStatus: boolean; // Whether to exclude files with unknown status from the status pane + quickStatusCommands: string[]; + [key: string]: unknown; +}; diff --git a/types/selectorService.ts b/types/selectorService.ts new file mode 100644 index 0000000..f1d2e76 --- /dev/null +++ b/types/selectorService.ts @@ -0,0 +1,6 @@ +export interface SelectorState { + selectedFile: File | null; + isSelectorOpened: boolean; +} + +export type SelectorListener = (state: SelectorState) => void; diff --git a/utils/statusUtils.ts b/utils/statusUtils.ts new file mode 100644 index 0000000..f43189a --- /dev/null +++ b/utils/statusUtils.ts @@ -0,0 +1,74 @@ +import { NoteStatus } from "@/types/noteStatus"; +import { TFile } from "obsidian"; + +/** + * Checks if a status is selected in the current statuses array + */ +export const isStatusSelected = ( + status: NoteStatus, + currentStatuses: NoteStatus[], +): boolean => { + return currentStatuses.some( + (s) => s.name === status.name && s.templateId === status.templateId, + ); +}; + +/** + * Generates a tooltip string for a status + */ +export const getStatusTooltip = (status: NoteStatus): string => { + return status.description + ? `${status.name} - ${status.description}` + : status.name; +}; + +/** + * Converts a TFile to a FileItem for UI display + */ +export interface FileItem { + id: string; + name: string; + path: string; +} + +export const convertTFileToFileItem = (file: TFile): FileItem => ({ + id: file.path, + name: file.basename, + path: file.path, +}); + +/** + * Gets the display name for a status, optionally including description + */ +export const getStatusDisplayName = ( + status: NoteStatus, + includeDescription = false, +): string => { + if (!includeDescription || !status.description) { + return status.name; + } + return `${status.name} - ${status.description}`; +}; + +/** + * Compares two statuses for equality by name and templateId + */ +export const isStatusEqual = ( + status1: NoteStatus, + status2: NoteStatus, +): boolean => { + return ( + status1.name === status2.name && + status1.templateId === status2.templateId + ); +}; + +/** + * Finds a status by name in an array + */ +export const findStatusByName = ( + statuses: NoteStatus[], + name: string, +): NoteStatus | undefined => { + return statuses.find((status) => status.name === name); +}; diff --git a/views/status-pane-view/index.ts b/views/status-pane-view/index.ts deleted file mode 100644 index a93c1c7..0000000 --- a/views/status-pane-view/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { StatusPaneView } from "./status-pane-view"; -export { StatusPaneViewController } from "./status-pane-view-controller"; diff --git a/views/status-pane-view/status-pane-view-controller.ts b/views/status-pane-view/status-pane-view-controller.ts deleted file mode 100644 index 49a9571..0000000 --- a/views/status-pane-view/status-pane-view-controller.ts +++ /dev/null @@ -1,197 +0,0 @@ -import { TFile, WorkspaceLeaf, View, Notice, App } from "obsidian"; -import { NoteStatusSettings } from "../../models/types"; -import { StatusService } from "../../services/status-service"; -import NoteStatus from "main"; -import { StatusPaneView } from "./status-pane-view"; - -export class StatusPaneViewController extends View { - private renderer: StatusPaneView; - private settings: NoteStatusSettings; - private statusService: StatusService; - private plugin: NoteStatus; - private searchQuery = ""; - private paginationState = { - itemsPerPage: 100, - currentPage: {} as Record, - }; - - static async open(app: App): Promise { - const existing = app.workspace.getLeavesOfType("status-pane")[0]; - if (existing) { - app.workspace.setActiveLeaf(existing); - return; - } - - const leaf = app.workspace.getLeftLeaf(false); - if (leaf) { - await leaf.setViewState({ type: "status-pane", active: true }); - } - } - - constructor(leaf: WorkspaceLeaf, plugin: NoteStatus) { - super(leaf); - this.plugin = plugin; - this.settings = plugin.settings; - this.statusService = plugin.statusService; - this.renderer = new StatusPaneView(this.statusService); - } - - getViewType(): string { - return "status-pane"; - } - - getDisplayText(): string { - return "Status pane"; - } - - getIcon(): string { - return "tag"; - } - - async onOpen(): Promise { - await this.setupPane(); - } - - onClose(): Promise { - this.containerEl.empty(); - return Promise.resolve(); - } - - private async setupPane(): Promise { - const { containerEl } = this; - containerEl.empty(); - containerEl.addClass("note-status-pane", "nav-files-container"); - containerEl.toggleClass( - "note-status-compact-view", - this.settings.compactView, - ); - - this.renderer.createHeader(containerEl, this.settings.compactView, { - onSearch: (query) => { - this.paginationState = { - itemsPerPage: 100, - currentPage: {} as Record, - }; - this.searchQuery = query; - this.renderGroups(query); - }, - onToggleView: () => { - this.settings.compactView = !this.settings.compactView; - containerEl.toggleClass( - "note-status-compact-view", - this.settings.compactView, - ); - window.dispatchEvent( - new CustomEvent("note-status:settings-changed"), - ); - this.renderGroups(this.searchQuery); - }, - onRefresh: async () => { - await this.renderGroups(this.searchQuery); - new Notice("Status pane refreshed"); - }, - }); - - const groupsContainer = containerEl.createDiv({ - cls: "note-status-groups-container", - }); - const loadingIndicator = - this.renderer.createLoadingIndicator(groupsContainer); - - setTimeout(async () => { - await this.renderGroups(""); - loadingIndicator.remove(); - }, 10); - } - - private async renderGroups(searchQuery = ""): Promise { - const groupsContainerEl = this.containerEl.querySelector( - ".note-status-groups-container", - ) as HTMLElement; - if (!groupsContainerEl) return; - - if (searchQuery) { - groupsContainerEl.empty(); - this.renderer.createLoadingIndicator( - groupsContainerEl, - `Searching for "${searchQuery}"...`, - ); - await new Promise((resolve) => setTimeout(resolve, 0)); - } else { - groupsContainerEl.empty(); - } - - const statusGroups = this.getFilteredStatusGroups(searchQuery); - groupsContainerEl.empty(); - - const hasGroups = this.renderer.renderStatusGroups( - groupsContainerEl, - statusGroups, - { - excludeUnknown: this.settings.excludeUnknownStatus, - isCompactView: this.settings.compactView, - collapsedStatuses: this.settings.collapsedStatuses, - pagination: this.paginationState, - callbacks: { - onFileClick: (file) => { - this.app.workspace.openLinkText( - file.path, - file.path, - true, - ); - }, - onStatusToggle: (status, collapsed) => { - this.settings.collapsedStatuses[status] = collapsed; - }, - onContextMenu: () => {}, - onPageChange: (status, page) => { - this.paginationState.currentPage[status] = page; - this.renderGroups(this.searchQuery); - }, - }, - }, - ); - - if (!hasGroups) { - this.renderer.renderEmptyState( - groupsContainerEl, - searchQuery, - this.settings.excludeUnknownStatus, - async () => { - this.settings.excludeUnknownStatus = false; - await this.plugin.saveSettings(); - this.renderGroups(searchQuery); - }, - ); - } - } - - private getFilteredStatusGroups(searchQuery = ""): Record { - const rawGroups = this.statusService.groupFilesByStatus(searchQuery); - const filteredGroups: Record = {}; - - Object.entries(rawGroups).forEach(([status, files]) => { - if (files.length > 0) { - filteredGroups[status] = files; - } - }); - - return filteredGroups; - } - - updateSettings(settings: NoteStatusSettings): void { - this.settings = settings; - this.containerEl.toggleClass( - "note-status-compact-view", - settings.compactView, - ); - this.renderGroups(this.searchQuery); - } - - public update(): void { - // TODO: needs to be improved/fixed, every change - // this.renderGroups(this.searchQuery).then(() => { - // - // }); - } -} diff --git a/views/status-pane-view/status-pane-view.ts b/views/status-pane-view/status-pane-view.ts deleted file mode 100644 index 53a5304..0000000 --- a/views/status-pane-view/status-pane-view.ts +++ /dev/null @@ -1,320 +0,0 @@ -import { TFile, setIcon } from "obsidian"; -import { StatusService } from "../../services/status-service"; - -export type Options = { - excludeUnknown: boolean; - isCompactView: boolean; - collapsedStatuses: Record; - pagination: { - itemsPerPage: number; - currentPage: Record; - }; - callbacks: { - onFileClick: (file: TFile) => void; - onStatusToggle: (status: string, collapsed: boolean) => void; - onContextMenu: (e: MouseEvent, file: TFile) => void; - onPageChange: (status: string, page: number) => void; - }; -}; -export class StatusPaneView { - constructor(private statusService: StatusService) {} - - createHeader( - container: HTMLElement, - isCompactView: boolean, - callbacks: { - onSearch: (query: string) => void; - onToggleView: () => void; - onRefresh: () => void; - }, - ): HTMLElement { - const header = container.createDiv({ cls: "note-status-header" }); - - // Search input - const searchContainer = header.createDiv({ - cls: "note-status-search search-input-container", - }); - const searchWrapper = searchContainer.createDiv({ - cls: "search-input-wrapper", - }); - - const searchIcon = searchWrapper.createEl("span", { - cls: "search-input-icon", - }); - setIcon(searchIcon, "search"); - - const searchInput = searchWrapper.createEl("input", { - type: "text", - placeholder: "Search notes...", - cls: "note-status-search-input search-input", - }); - - const clearBtn = searchWrapper.createEl("span", { - cls: "search-input-clear-button", - }); - setIcon(clearBtn, "x"); - - searchInput.addEventListener("input", () => { - callbacks.onSearch(searchInput.value.toLowerCase()); - clearBtn.toggleClass("is-visible", !!searchInput.value); - }); - - clearBtn.addEventListener("click", () => { - searchInput.value = ""; - callbacks.onSearch(""); - clearBtn.toggleClass("is-visible", false); - }); - - // Action buttons - const actions = header.createDiv({ - cls: "status-pane-actions-container", - }); - - const viewBtn = actions.createEl("button", { - type: "button", - title: isCompactView - ? "Switch to Standard View" - : "Switch to Compact View", - cls: "note-status-view-toggle clickable-icon", - }); - setIcon(viewBtn, isCompactView ? "layout" : "table"); - viewBtn.addEventListener("click", callbacks.onToggleView); - - const refreshBtn = actions.createEl("button", { - type: "button", - title: "Refresh statuses", - cls: "note-status-actions-refresh clickable-icon", - }); - setIcon(refreshBtn, "refresh-cw"); - refreshBtn.addEventListener("click", callbacks.onRefresh); - - return header; - } - - renderStatusGroups( - container: HTMLElement, - statusGroups: Record, - options: Options, - ): boolean { - // Clear container first - container.empty(); - - let hasGroups = false; - - Object.entries(statusGroups).forEach(([status, files]) => { - if ( - files.length > 0 && - !(status === "unknown" && options.excludeUnknown) - ) { - this.renderGroup(container, status, files, options); - hasGroups = true; - } - }); - - if (!hasGroups) { - return false; - } - - return true; - } - - private renderGroup( - container: HTMLElement, - status: string, - files: TFile[], - options: Options, - ): void { - const groupEl = container.createDiv({ - cls: "note-status-group nav-folder", - }); - const titleEl = groupEl.createDiv({ cls: "nav-folder-title" }); - const isCollapsed = options.collapsedStatuses[status] ?? false; - - // Collapse indicator - const collapseIcon = titleEl.createDiv({ - cls: "note-status-collapse-indicator", - }); - setIcon(collapseIcon, isCollapsed ? "chevron-right" : "chevron-down"); - - // Title content - const titleContent = titleEl.createDiv({ - cls: "nav-folder-title-content", - }); - const statusIcon = this.statusService.getStatusIcon(status); - titleContent.createSpan({ - text: `${status} ${statusIcon} (${files.length})`, - cls: `status-${status}`, - }); - - // Set collapse state - if (isCollapsed) { - groupEl.addClass("note-status-is-collapsed"); - } - - // Toggle collapse on click - titleEl.addEventListener("click", (e) => { - e.preventDefault(); - const newCollapsed = !groupEl.hasClass("note-status-is-collapsed"); - groupEl.toggleClass("note-status-is-collapsed", newCollapsed); - collapseIcon.empty(); - setIcon( - collapseIcon, - newCollapsed ? "chevron-right" : "chevron-down", - ); - options.callbacks.onStatusToggle(status, newCollapsed); - }); - - // Render content - const childrenEl = groupEl.createDiv({ cls: "nav-folder-children" }); - - // Pagination - const currentPage = options.pagination.currentPage[status] || 0; - const itemsPerPage = options.pagination.itemsPerPage; - const totalPages = Math.ceil(files.length / itemsPerPage); - const startIndex = currentPage * itemsPerPage; - const endIndex = Math.min(startIndex + itemsPerPage, files.length); - - // File items - files.slice(startIndex, endIndex).forEach((file) => { - this.renderFileItem( - childrenEl, - file, - status, - options.isCompactView, - options.callbacks, - ); - }); - - // Add pagination if needed - if (files.length > itemsPerPage) { - this.addPagination( - childrenEl, - status, - currentPage, - totalPages, - files.length, - options.callbacks, - ); - } - } - - private renderFileItem( - container: HTMLElement, - file: TFile, - status: string, - isCompactView: boolean, - callbacks: Options["callbacks"], - ): void { - const fileEl = container.createDiv({ cls: "nav-file" }); - const fileTitleEl = fileEl.createDiv({ cls: "nav-file-title" }); - - if (!isCompactView) { - const fileIcon = fileTitleEl.createDiv({ cls: "nav-file-icon" }); - setIcon(fileIcon, "file"); - } - - fileTitleEl.createSpan({ - text: file.basename, - cls: "nav-file-title-content", - }); - - fileTitleEl.createSpan({ - cls: `note-status-icon nav-file-tag status-${status}`, - text: this.statusService.getStatusIcon(status), - }); - - fileEl.addEventListener("click", (e) => { - e.preventDefault(); - callbacks.onFileClick(file); - }); - - fileEl.addEventListener("contextmenu", (e) => { - e.preventDefault(); - callbacks.onContextMenu(e, file); - }); - } - - private addPagination( - container: HTMLElement, - status: string, - currentPage: number, - totalPages: number, - totalItems: number, - callbacks: Options["callbacks"], - ): void { - const paginationEl = container.createDiv({ - cls: "note-status-pagination", - }); - - if (currentPage > 0) { - const prevButton = paginationEl.createEl("button", { - text: "Previous", - cls: "note-status-pagination-button", - }); - - prevButton.addEventListener("click", (e) => { - e.stopPropagation(); - callbacks.onPageChange(status, currentPage - 1); - }); - } - - paginationEl.createSpan({ - text: `Page ${currentPage + 1} of ${totalPages} (${totalItems} notes)`, - cls: "note-status-pagination-info", - }); - - if (currentPage < totalPages - 1) { - const nextButton = paginationEl.createEl("button", { - text: "Next", - cls: "note-status-pagination-button", - }); - - nextButton.addEventListener("click", (e) => { - e.stopPropagation(); - callbacks.onPageChange(status, currentPage + 1); - }); - } - } - - renderEmptyState( - container: HTMLElement, - searchQuery: string, - excludeUnknown: boolean, - onShowUnassigned: () => void, - ): void { - const emptyMessage = container.createDiv({ - cls: "note-status-empty-indicator", - }); - - if (searchQuery) { - emptyMessage.textContent = `No notes found matching "${searchQuery}"`; - return; - } - - if (excludeUnknown) { - emptyMessage.createDiv({ - text: "No notes with status found. Unassigned notes are currently hidden.", - cls: "note-status-empty-message", - }); - - const btnContainer = emptyMessage.createDiv({ - cls: "note-status-button-container", - }); - - const showUnknownBtn = btnContainer.createEl("button", { - text: "Show unassigned notes", - cls: "note-status-show-unassigned-button", - }); - - showUnknownBtn.addEventListener("click", onShowUnassigned); - } - } - - createLoadingIndicator(container: HTMLElement, text?: string): HTMLElement { - const loadingIndicator = container.createDiv({ - cls: "note-status-loading", - }); - loadingIndicator.innerHTML = `${text || "Loading notes..."}`; - return loadingIndicator; - } -} diff --git a/wiki/Performance Tuning.md b/wiki/Performance Tuning.md index 6c86122..a9926e4 100644 --- a/wiki/Performance Tuning.md +++ b/wiki/Performance Tuning.md @@ -26,7 +26,6 @@ Pagination: 50-100 items per page "showStatusIconsInExplorer": false, "excludeUnknownStatus": true, "hideUnknownStatusInExplorer": true, - "compactView": true, "enabledTemplates": ["minimal"] } ``` diff --git a/wiki/User Manual.md b/wiki/User Manual.md index 62d0851..eddce00 100644 --- a/wiki/User Manual.md +++ b/wiki/User Manual.md @@ -177,7 +177,6 @@ All commands available via Command Palette: - `Open status pane` - Show status sidebar - `Change status of current note` - Open dropdown - `Add status to current note` - Add mode dropdown -- `Insert status metadata` - Add frontmatter - `Cycle to next status` - Rotate through statuses - `Clear status` - Set to unknown