diff --git a/components/status-dropdown/dropdown-render.ts b/components/status-dropdown/dropdown-render.ts index de65496..aba185e 100644 --- a/components/status-dropdown/dropdown-render.ts +++ b/components/status-dropdown/dropdown-render.ts @@ -1,314 +1,358 @@ -import { setIcon, TFile, setTooltip } from 'obsidian'; -import { StatusRemoveHandler, StatusSelectHandler } from './types'; -import { NoteStatusSettings, Status } from 'models/types'; -import { StatusService } from 'services/status-service'; +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 + 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 { + dropdownElement, + settings, + statusService, + currentStatuses, + targetFile, + targetFiles, + onRemoveStatus, + onSelectStatus, + } = options; - 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); + 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' - }); - } +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 + 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); - } + 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 + 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); - }); + 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 + 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); + 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 + 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); - }); + 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' - }); + 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 + 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 - }); - }); + 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 + container: HTMLElement; + status: Status; + isSelected: boolean; + settings: NoteStatusSettings; + targetFiles: TFile[]; + onSelectStatus: StatusSelectHandler; }): void { - const { container, status, isSelected, settings, 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); - }); + 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/integrations/commands/command-integration.ts b/integrations/commands/command-integration.ts index 01652c0..efa9201 100644 --- a/integrations/commands/command-integration.ts +++ b/integrations/commands/command-integration.ts @@ -1,302 +1,323 @@ // 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'; +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; + 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; - } + 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 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) - }); + 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; - } - }); + // 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; - // 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; - } - }); + if (!checking) { + this.statusDropdown.openStatusDropdown({ files: [file] }); + } + 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; - } - }); + // 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; - // 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; - } - }); + if (!checking) { + this.statusDropdown.openStatusDropdown({ + files: [file], + mode: this.settings.useMultipleStatuses + ? "add" + : "replace", + }); + } + return true; + }, + }); - // Register dynamic quick status commands - this.registerQuickStatusCommands(); + // 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; - // 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; - } - }); + const statuses = this.statusService.getFileStatuses(view.file); + const hasNoStatus = + statuses.length === 1 && statuses[0] === "unknown"; - // 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 as any).clipboard = statuses; - new Notice(`Copied status: ${statuses.join(', ')}`); - } - return true; - } - }); + if (!checking && hasNoStatus) { + this.statusService.insertStatusMetadataInEditor(editor); + new Notice("Status metadata inserted"); + } + return hasNoStatus; + }, + }); - // 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 as any).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; - } - }); + // 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; - // 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'}`); - } - }); + if (!checking) { + this.cycleStatus(file); + } + return true; + }, + }); - // 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 as any).internalPlugins.getPluginById('global-search').instance.openGlobalSearch(query); - } - return true; - } - }); - } + // Register dynamic quick status commands + this.registerQuickStatusCommands(); -/** - * 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; - } - }); - } - }); - } + // 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; - 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; - } + if (!checking) { + this.statusService.handleStatusChange({ + files: file, + statuses: "unknown", + operation: "set", + }); + new Notice("Status cleared"); + } + return true; + }, + }); - 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]}`); - } + // 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; - public unload(): void { - // Remove existing commands - this.removeQuickStatusCommands(); - } + if (!checking) { + const statuses = this.statusService.getFileStatuses(file); + this.app.clipboard = statuses; + new Notice(`Copied status: ${statuses.join(", ")}`); + } + return true; + }, + }); - /** - * Remove all quick status commands - */ - private removeQuickStatusCommands(): void { - const allStatuses = this.statusService.getAllStatuses(); - - allStatuses.forEach(status => { - (this.app as any).commands.removeCommand(`note-status:set-status-${status.name}`); - (this.app as any).commands.removeCommand(`note-status:toggle-status-${status.name}`); - }); - } + // 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/context-menu/file-context-menu-integration.ts b/integrations/context-menu/file-context-menu-integration.ts index 7c3a0b8..c92c06d 100644 --- a/integrations/context-menu/file-context-menu-integration.ts +++ b/integrations/context-menu/file-context-menu-integration.ts @@ -1,95 +1,108 @@ -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'; +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: any; - private filesMenuEventRef: any; + 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; - } + 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; - } + /** + * 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: Menu, file: any, source: string) => { - if (source === 'file-explorer-context-menu' && file instanceof TFile && file.extension === 'md') { - this.addStatusChangeMenu(menu, file); - } - }; + /** + * 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: Menu, files: any[]) => { - const mdFiles = files.filter(file => - file instanceof TFile && file.extension === 'md' - ) as TFile[]; - - if (mdFiles.length > 0) { - this.addBatchStatusChangeMenu(menu, mdFiles); - } - }; + this.filesMenuEventRef = (menu, files) => { + const mdFiles = files.filter( + (file) => file instanceof TFile && file.extension === "md", + ) as TFile[]; - this.app.workspace.on('file-menu', this.fileMenuEventRef); - this.app.workspace.on('files-menu', this.filesMenuEventRef); - } + if (mdFiles.length > 0) { + this.addBatchStatusChangeMenu(menu, mdFiles); + } + }; - /** - * 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]); - } - }); - } + this.app.workspace.on("file-menu", this.fileMenuEventRef); + this.app.workspace.on("files-menu", this.filesMenuEventRef); + } - /** - * 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); - }); - } + /** + * 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]); + } + }, + ); + } - 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); - } - } + /** + * 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/editor/editor-integration.ts b/integrations/editor/editor-integration.ts index 0e86fb0..3997a70 100644 --- a/integrations/editor/editor-integration.ts +++ b/integrations/editor/editor-integration.ts @@ -1,80 +1,92 @@ -import { App, Editor, MarkdownView, Menu, TFile } from 'obsidian'; -import { NoteStatusSettings } from '../../models/types'; -import { StatusService } from 'services/status-service'; -import { StatusDropdown } from 'components/status-dropdown'; +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: any; + 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; - } + 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 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); - } - }; + 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); - } + 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); - } - } + 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/toolbar-integration.ts b/integrations/editor/toolbar-integration.ts index d712fe4..8a1958f 100644 --- a/integrations/editor/toolbar-integration.ts +++ b/integrations/editor/toolbar-integration.ts @@ -1,118 +1,135 @@ -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'; +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; + 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([]); - } + 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 addToolbarButtonToActiveLeaf(statuses?: string[]): void { - const activeLeaf = this.app.workspace.activeLeaf; - if (!activeLeaf?.view || !(activeLeaf.view instanceof MarkdownView)) return; + public updateSettings(settings: NoteStatusSettings): void { + this.settings = settings; + this.buttonView.updateSettings(settings); + this.updateStatusDisplay([]); + } - const leafId = (activeLeaf as any).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); - } + public addToolbarButtonToActiveLeaf(statuses?: string[]): void { + const activeLeaf = this.app.workspace.activeLeaf; + if (!activeLeaf?.view || !(activeLeaf.view instanceof MarkdownView)) + return; - private recreateButton(view: MarkdownView, leafId: string): void { - const toolbarContainer = view.containerEl.querySelector('.view-header .view-actions'); - if (!toolbarContainer) return; + const leafId = activeLeaf.id || activeLeaf.view.containerEl.id; - // 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; - } + // 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); + } - private isButtonInDOM(): boolean { - return this.buttonElement?.isConnected === true; - } + this.updateButtonDisplay(statuses); + } -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(); - } + 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 index 3f80dff..8c60027 100644 --- a/integrations/explorer/explorer-integration.ts +++ b/integrations/explorer/explorer-integration.ts @@ -1,350 +1,432 @@ -import { App, TFile, setTooltip, debounce } from 'obsidian'; -import { NoteStatusSettings, FileExplorerView } from '../../models/types'; -import { StatusService } from 'services/status-service'; - +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; + 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(); - }); - } + 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, + ); - /** - * 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); - } - } + // Listen for layout changes to detect when file explorer becomes available + this.app.workspace.on("layout-change", () => { + this.checkFileExplorerAvailability(); + }); + } - /** - * 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); + /** + * Check if file explorer became available after being unavailable + */ + private checkFileExplorerAvailability(): void { + if (!this.settings.showStatusIconsInExplorer) return; - if (shouldRefreshIcons) { - this.ui.removeAllFileExplorerIcons(); - - if (settings.showStatusIconsInExplorer) { - setTimeout(() => this.updateAllFileExplorerIcons(), 50); - } - } else if (settings.showStatusIconsInExplorer) { - this.updateAllFileExplorerIcons(); - } - } + const fileExplorer = this.ui.findFileExplorerView(); + if (fileExplorer?.fileItems && this.fileItemsWasUndefined) { + this.fileItemsWasUndefined = false; + setTimeout(() => this.updateAllFileExplorerIcons(), 100); + } + } - /** - * 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 settings and refreshes UI if necessary + */ + public updateSettings(settings: NoteStatusSettings): void { + const shouldRefreshIcons = + this.settings.showStatusIconsInExplorer !== + settings.showStatusIconsInExplorer || + this.settings.hideUnknownStatusInExplorer !== + settings.hideUnknownStatusInExplorer; - /** - * Updates all icons in the explorer - */ - public updateAllFileExplorerIcons(): void { - if (!this.settings.showStatusIconsInExplorer) { - this.ui.removeAllFileExplorerIcons(); - return; - } - - this.processFilesInBatches(); - } + this.settings = { ...settings }; + this.ui.updateSettings(settings); - /** - * Gets selected files from the explorer - */ - public getSelectedFiles(): TFile[] { - return this.ui.getSelectedFiles(); - } + if (shouldRefreshIcons) { + this.ui.removeAllFileExplorerIcons(); - /** - * 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(); - } + if (settings.showStatusIconsInExplorer) { + setTimeout(() => this.updateAllFileExplorerIcons(), 50); + } + } else if (settings.showStatusIconsInExplorer) { + this.updateAllFileExplorerIcons(); + } + } - /** - * 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: any): 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); - } - } + /** + * Updates icons for a specific file + */ + public updateFileExplorerIcons(file: TFile): void { + if ( + !file || + !this.settings.showStatusIconsInExplorer || + file.extension !== "md" + ) + return; - /** - * 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)); - } - } - } + const activeFile = this.app.workspace.getActiveFile(); + if (activeFile?.path === file.path) { + this.ui.updateSingleFileIconDirectly(file, this.statusService); + } - /** - * Cleanup when unloading the plugin - */ - public unload(): void { - this.ui.removeAllFileExplorerIcons(); - this.debouncedUpdateAll.cancel(); - } + 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; + 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; - } + 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; - } + /** + * 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; - } + /** + * 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; - /** - * 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); - } - } + for (const leaf of this.app.workspace.getLeavesOfType("")) { + if (leaf.view && "fileItems" in leaf.view) { + return leaf.view as FileExplorerView; + } + } - /** - * 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); - }); - } + return null; + } - /** - * 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); - } + /** + * Updates a single file icon + */ + public updateSingleFileIcon( + file: TFile, + fileExplorerView: FileExplorerView, + statusService: StatusService, + ): void { + if (!this.settings.showStatusIconsInExplorer || file.extension !== "md") + return; - /** - * 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'; + try { + // Check if fileItems is initialized + if (!fileExplorerView.fileItems) { + this.onFileItemsUndefined(); + return; + } - 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); - } + 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/metadata-cache/metadata-integration.ts b/integrations/metadata-cache/metadata-integration.ts index 6f4404a..07fe629 100644 --- a/integrations/metadata-cache/metadata-integration.ts +++ b/integrations/metadata-cache/metadata-integration.ts @@ -1,71 +1,73 @@ -import { App, TFile } from 'obsidian'; -import { NoteStatusSettings } from '../../models/types'; -import { ExplorerIntegration } from '../explorer/explorer-integration'; -import { StatusService } from 'services/status-service'; +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: any; - private metadataResolvedRef: any; + 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; - } + 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 updateSettings(settings: NoteStatusSettings): void { + this.settings = settings; + } - public registerMetadataEvents(): void { - this.metadataChangedRef = (file: TFile) => { - if (file instanceof TFile && file.extension === 'md') { - this.handleMetadataChanged(file); - } - }; + 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.metadataResolvedRef = () => { + setTimeout(() => { + if (this.settings.showStatusIconsInExplorer) { + this.explorerIntegration.updateAllFileExplorerIcons(); + } + }, 500); + }; - this.app.metadataCache.on('changed', this.metadataChangedRef); - this.app.metadataCache.on('resolved', this.metadataResolvedRef); - } + 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 } - })); - } - } + private handleMetadataChanged(file: TFile): void { + if (this.settings.showStatusIconsInExplorer) { + this.explorerIntegration.updateFileExplorerIcons(file); + } - public unload(): void { - if (this.metadataChangedRef) { - this.app.metadataCache.off('changed', this.metadataChangedRef); - } - if (this.metadataResolvedRef) { - this.app.metadataCache.off('resolved', this.metadataResolvedRef); - } - } + 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/settings/settings-controller.ts b/integrations/settings/settings-controller.ts index addcbd8..27822cd 100644 --- a/integrations/settings/settings-controller.ts +++ b/integrations/settings/settings-controller.ts @@ -1,119 +1,138 @@ -import { App } from 'obsidian'; -import { Status } from '../../models/types'; -import NoteStatus from 'main'; -import { StatusService } from 'services/status-service'; -import { NoteStatusSettingsUI, SettingsUICallbacks } from './settings-ui'; +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; + 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); - } + 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); - } + /** + * Renders the settings interface + */ + display(containerEl: HTMLElement): void { + this.ui.render(containerEl, this.plugin.settings); + } - /** - * Handles template enable/disable toggle - */ - async onTemplateToggle(templateId: string, enabled: boolean): Promise { - 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 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, + ); + } - /** - * Handles general setting changes - */ - async onSettingChange(key: string, value: any): Promise { - (this.plugin.settings as any)[key] = value; - await this.plugin.saveSettings(); - } + await this.plugin.saveSettings(); + }; - /** - * Handles custom status field changes - */ - async onCustomStatusChange(index: number, field: string, value: any): Promise { - const status = this.plugin.settings.customStatuses[index]; - if (!status) return; + /** + * Handles general setting changes + */ + onSettingChange: SettingsUICallbacks["onSettingChange"] = async ( + key, + value, + ) => { + this.plugin.settings[key] = value; + await this.plugin.saveSettings(); + }; - 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 as any)[field] = 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; - /** - * Handles custom status removal - */ - async onCustomStatusRemove(index: number): Promise { - const status = this.plugin.settings.customStatuses[index]; - if (!status) return; + if (field === "name") { + const oldName = status.name; + status.name = value; - 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); - } - } + 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; + } - /** - * Handles adding new custom status - */ - async onCustomStatusAdd(): Promise { - const newStatus: Status = { - name: `status${this.plugin.settings.customStatuses.length + 1}`, - icon: '⭐' - }; + await this.plugin.saveSettings(); + }; - this.plugin.settings.customStatuses.push(newStatus); - this.plugin.settings.statusColors[newStatus.name] = '#ffffff'; + /** + * Handles custom status removal + */ + onCustomStatusRemove: SettingsUICallbacks["onCustomStatusRemove"] = async ( + index, + ) => { + const status = this.plugin.settings.customStatuses[index]; + if (!status) return; - 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); - } - } + 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-ui.ts b/integrations/settings/settings-ui.ts index 7040c9b..9559ebd 100644 --- a/integrations/settings/settings-ui.ts +++ b/integrations/settings/settings-ui.ts @@ -1,345 +1,479 @@ -import { Setting } from 'obsidian'; -import { Status } from '../../models/types'; -import { PREDEFINED_TEMPLATES } from '../../constants/status-templates'; - -/** - * 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: string, value: any) => Promise; - /** Handle custom status field changes */ - onCustomStatusChange: (index: number, field: string, value: any) => Promise; - /** Handle custom status removal */ - onCustomStatusRemove: (index: number) => Promise; - /** Handle adding new custom status */ - onCustomStatusAdd: () => Promise; -} +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; + private callbacks: SettingsUICallbacks; + private quickCommandsContainer: HTMLElement | null = null; - constructor(callbacks: SettingsUICallbacks) { - this.callbacks = callbacks; - } + constructor(callbacks: SettingsUICallbacks) { + this.callbacks = callbacks; + } - /** - * Renders the complete settings interface - */ - render(containerEl: HTMLElement, settings: any): void { - containerEl.empty(); + /** + * 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); - } + 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: any): 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 status templates section + */ + private renderTemplateSettings( + containerEl: HTMLElement, + settings: NoteStatusSettings, + ): void { + new Setting(containerEl).setName("Status templates").setHeading(); - /** - * Renders the UI display settings section - */ - private renderUISettings(containerEl: HTMLElement, settings: any): void { - new Setting(containerEl).setName('User interface').setHeading(); + containerEl.createEl("p", { + text: "Enable predefined templates to quickly add common status workflows", + cls: "setting-item-description", + }); - 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))); + const templatesContainer = containerEl.createDiv({ + cls: "templates-container", + }); - 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))); + PREDEFINED_TEMPLATES.forEach((template) => { + const templateEl = templatesContainer.createDiv({ + cls: "template-item", + }); + const headerEl = templateEl.createDiv({ cls: "template-header" }); - 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))); + const isEnabled = settings.enabledTemplates.includes(template.id); + const checkbox = headerEl.createEl("input", { + type: "checkbox", + cls: "template-checkbox", + }); + checkbox.checked = isEnabled; - 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))); + checkbox.addEventListener("change", () => { + this.callbacks.onTemplateToggle(template.id, checkbox.checked); + this.refreshQuickCommandsList(settings); + }); - 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))); + headerEl.createEl("span", { + text: template.name, + cls: "template-name", + }); - 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))); - } + templateEl.createEl("div", { + text: template.description, + cls: "template-description", + }); - /** - * Renders the status tag configuration section - */ - private renderTagSettings(containerEl: HTMLElement, settings: any): void { - new Setting(containerEl).setName('Status tag').setHeading(); + 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}` }); + }); + }); + } - 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))); + /** + * Renders the UI display settings section + */ + private renderUISettings( + containerEl: HTMLElement, + settings: NoteStatusSettings, + ): void { + new Setting(containerEl).setName("User interface").setHeading(); - 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("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('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))); + 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, + ), + ), + ); - /** - * Renders the quick commands configuration section - */ - private renderQuickCommandsSettings(containerEl: HTMLElement, settings: any): 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); - - } + 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), + ), + ); - /** - * Populate the quick commands list - */ - private populateQuickCommandsList(settings: any): 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 => { - const setting = new Setting(this.quickCommandsContainer) - .setName(`${status.icon} ${status.name}`) - .addToggle(toggle => toggle - .setValue(currentQuickCommands.includes(status.name)) - .onChange(async (value) => { - const updatedCommands = 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: any): void { - // Add small delay to ensure settings are updated - setTimeout(() => { - this.populateQuickCommandsList(settings); - }, 50); - } + 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, + ), + ), + ); + } -/** - * Get all available statuses from templates and custom statuses - */ - private getAllAvailableStatuses(settings: any): 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 status tag configuration section + */ + private renderTagSettings( + containerEl: HTMLElement, + settings: NoteStatusSettings, + ): void { + new Setting(containerEl).setName("Status tag").setHeading(); - /** - * Renders the custom status management section - */ - private renderCustomStatusSettings(containerEl: HTMLElement, settings: any): 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("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('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); - })); - } + 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(), + ); + } + }), + ); - /** - * Renders the list of custom statuses with edit controls - */ - renderCustomStatuses(statusList: HTMLElement, settings: any): void { - statusList.empty(); - - settings.customStatuses.forEach((status: Status, index: number) => { - const setting = new Setting(statusList) - .setName(status.name) - .setClass('status-item'); + 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), + ), + ); + } - 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); - })); + /** + * 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(); - setting.addText(text => text - .setPlaceholder('Icon') - .setValue(status.icon) - .onChange(async (value) => { - await this.callbacks.onCustomStatusChange(index, 'icon', value || '❓'); - this.refreshQuickCommandsList(settings); - })); + this.quickCommandsContainer = containerEl.createDiv({ + cls: "quick-commands-container", + }); + this.populateQuickCommandsList(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); - })); + /** + * Populate the quick commands list + */ + private populateQuickCommandsList(settings: NoteStatusSettings): void { + if (!this.quickCommandsContainer) return; - setting.addButton(button => button - .setButtonText('Remove') - .setClass('status-remove-button') - .setWarning() - .onClick(async () => { - await this.callbacks.onCustomStatusRemove(index); - this.refreshQuickCommandsList(settings); - })); - }); - } + 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 new file mode 100644 index 0000000..3552f8a --- /dev/null +++ b/integrations/settings/types.ts @@ -0,0 +1,24 @@ +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/workspace/workspace-integration.ts b/integrations/workspace/workspace-integration.ts index dae584e..6c34f6d 100644 --- a/integrations/workspace/workspace-integration.ts +++ b/integrations/workspace/workspace-integration.ts @@ -1,124 +1,131 @@ -import { App, TFile, WorkspaceLeaf } from 'obsidian'; -import { NoteStatusSettings } from '../../models/types'; -import { ToolbarIntegration } from '../editor/toolbar-integration'; -import { StatusService } from 'services/status-service'; +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: any; - private activeLeafChangeEventRef: any; - private layoutChangeEventRef: any; + 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; - } + 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; - } + /** + * Actualiza la configuración + */ + public updateSettings(settings: NoteStatusSettings): void { + this.settings = settings; + } - /** - * Registra eventos del workspace - */ - public registerWorkspaceEvents(): void { - this.fileOpenEventRef = (file: any) => { - if (file instanceof TFile) { - this.handleFileOpen(file); - } - }; + /** + * 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.activeLeafChangeEventRef = (leaf: WorkspaceLeaf) => { + this.handleActiveLeafChange(leaf); + }; - this.layoutChangeEventRef = () => { - this.handleLayoutChange(); - }; + 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); - } + 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 apertura de archivo + */ + private handleFileOpen(file: TFile): void { + // Añade el botón de la barra de herramientas + this.toolbarIntegration.addToolbarButtonToActiveLeaf(); - /** - * 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(); - // } - } + // Actualiza estado + this.propagateNoteStatusChange(file); + } - /** - * Maneja y propaga el cambio de layout - */ - private handleLayoutChange(): void { - window.dispatchEvent(new CustomEvent('note-status:update-pane')); - } + /** + * Maneja cambio de hoja activa + */ + private handleActiveLeafChange(leaf: WorkspaceLeaf): void { + // Añade el botón de la barra de herramientas + this.toolbarIntegration.addToolbarButtonToActiveLeaf(); - /** - * 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); - } - } + //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/models/types.ts b/models/types.ts index 72b8808..03a7e1a 100644 --- a/models/types.ts +++ b/models/types.ts @@ -1,44 +1,76 @@ -import { TFile, View } from 'obsidian'; +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 + 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[]; + 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; + fileItems: Record< + string, + { + el?: HTMLElement; + file?: TFile; + titleEl?: HTMLElement; + selfEl?: HTMLElement; + } + >; } diff --git a/services/status-service.ts b/services/status-service.ts index c5a6e77..98000e5 100644 --- a/services/status-service.ts +++ b/services/status-service.ts @@ -1,388 +1,434 @@ -import { App, TFile, Editor, Notice } from 'obsidian'; -import { NoteStatusSettings, Status } from '../models/types'; -import { PREDEFINED_TEMPLATES } from '../constants/status-templates'; +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[] = []; + private app: App; + private settings: NoteStatusSettings; + private allStatuses: Status[] = []; - constructor(app: App, settings: NoteStatusSettings) { - this.app = app; - this.settings = settings; - this.updateAllStatuses(); - } + 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 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]; + /** + * 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; - } - } - } - } - } + // Add statuses from enabled templates if not using custom only + if (!this.settings.useCustomStatusesOnly) { + const templateStatuses = this.getTemplateStatuses(); - /** - * 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 : []); - } + for (const status of templateStatuses) { + const existingIndex = this.allStatuses.findIndex( + (s) => s.name.toLowerCase() === status.name.toLowerCase(), + ); - /** - * Get all available statuses (combined from templates and custom) - */ - public getAllStatuses(): Status[] { - return this.allStatuses; - } + 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; + } + } + } + } + } - /** - * 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); - } - } + /** + * 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 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 : '❓'; - } + /** + * Get all available statuses (combined from templates and custom) + */ + public getAllStatuses(): Status[] { + return this.allStatuses; + } - /** - * 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, - afterChange - } = 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 - } - })); - } + /** + * 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/views/status-pane-view/status-pane-view-controller.ts b/views/status-pane-view/status-pane-view-controller.ts index 19be0b0..49a9571 100644 --- a/views/status-pane-view/status-pane-view-controller.ts +++ b/views/status-pane-view/status-pane-view-controller.ts @@ -1,30 +1,30 @@ -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'; +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 searchQuery = ""; private paginationState = { itemsPerPage: 100, - currentPage: {} as Record + currentPage: {} as Record, }; static async open(app: App): Promise { - const existing = app.workspace.getLeavesOfType('status-pane')[0]; + 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 }); + await leaf.setViewState({ type: "status-pane", active: true }); } } @@ -36,11 +36,17 @@ export class StatusPaneViewController extends View { this.renderer = new StatusPaneView(this.statusService); } - getViewType(): string { return 'status-pane'; } + getViewType(): string { + return "status-pane"; + } - getDisplayText(): string { return 'Status pane'; } + getDisplayText(): string { + return "Status pane"; + } - getIcon(): string { return 'tag'; } + getIcon(): string { + return "tag"; + } async onOpen(): Promise { await this.setupPane(); @@ -51,51 +57,66 @@ export class StatusPaneViewController extends View { 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); + 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 - } + 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')); + 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'); - } + new Notice("Status pane refreshed"); + }, }); - const groupsContainer = containerEl.createDiv({ cls: 'note-status-groups-container' }); - const loadingIndicator = this.renderer.createLoadingIndicator(groupsContainer); + const groupsContainer = containerEl.createDiv({ + cls: "note-status-groups-container", + }); + const loadingIndicator = + this.renderer.createLoadingIndicator(groupsContainer); setTimeout(async () => { - await this.renderGroups(''); + await this.renderGroups(""); loadingIndicator.remove(); }, 10); } - private async renderGroups(searchQuery = ''): Promise { - const groupsContainerEl = this.containerEl.querySelector('.note-status-groups-container') as HTMLElement; + 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)); + this.renderer.createLoadingIndicator( + groupsContainerEl, + `Searching for "${searchQuery}"...`, + ); + await new Promise((resolve) => setTimeout(resolve, 0)); } else { groupsContainerEl.empty(); } @@ -113,19 +134,22 @@ export class StatusPaneViewController extends View { pagination: this.paginationState, callbacks: { onFileClick: (file) => { - this.app.workspace.openLinkText(file.path, file.path, true); + this.app.workspace.openLinkText( + file.path, + file.path, + true, + ); }, onStatusToggle: (status, collapsed) => { this.settings.collapsedStatuses[status] = collapsed; }, - onContextMenu: (e, file) => { - }, + onContextMenu: () => {}, onPageChange: (status, page) => { this.paginationState.currentPage[status] = page; this.renderGroups(this.searchQuery); - } - } - } + }, + }, + }, ); if (!hasGroups) { @@ -137,12 +161,12 @@ export class StatusPaneViewController extends View { this.settings.excludeUnknownStatus = false; await this.plugin.saveSettings(); this.renderGroups(searchQuery); - } + }, ); } } - private getFilteredStatusGroups(searchQuery = ''): Record { + private getFilteredStatusGroups(searchQuery = ""): Record { const rawGroups = this.statusService.groupFilesByStatus(searchQuery); const filteredGroups: Record = {}; @@ -157,12 +181,15 @@ export class StatusPaneViewController extends View { updateSettings(settings: NoteStatusSettings): void { this.settings = settings; - this.containerEl.toggleClass('note-status-compact-view', settings.compactView); + this.containerEl.toggleClass( + "note-status-compact-view", + settings.compactView, + ); this.renderGroups(this.searchQuery); } public update(): void { - // TODO: needs to be improved/fixed, every change + // 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 index a11486b..53a5304 100644 --- a/views/status-pane-view/status-pane-view.ts +++ b/views/status-pane-view/status-pane-view.ts @@ -1,87 +1,111 @@ -import { TFile, setIcon } from 'obsidian'; -import { StatusService } from '../../services/status-service'; +import { TFile, setIcon } from "obsidian"; +import { StatusService } from "../../services/status-service"; -export class StatusPaneView{ +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' }); + 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 searchContainer = header.createDiv({ + cls: "note-status-search search-input-container", + }); + const searchWrapper = searchContainer.createDiv({ + cls: "search-input-wrapper", }); - const clearBtn = searchWrapper.createEl('span', { cls: 'search-input-clear-button' }); - setIcon(clearBtn, 'x'); + const searchIcon = searchWrapper.createEl("span", { + cls: "search-input-icon", + }); + setIcon(searchIcon, "search"); - searchInput.addEventListener('input', () => { + 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.toggleClass("is-visible", !!searchInput.value); }); - clearBtn.addEventListener('click', () => { - searchInput.value = ''; - callbacks.onSearch(''); - clearBtn.toggleClass('is-visible', false); + 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' + const actions = header.createDiv({ + cls: "status-pane-actions-container", }); - 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' + 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(refreshBtn, 'refresh-cw'); - refreshBtn.addEventListener('click', callbacks.onRefresh); + 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: { - 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 - } - }): boolean { + 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)) { + if ( + files.length > 0 && + !(status === "unknown" && options.excludeUnknown) + ) { this.renderGroup(container, status, files, options); hasGroups = true; } @@ -94,40 +118,54 @@ export class StatusPaneView{ return true; } - private renderGroup(container: HTMLElement, status: string, files: TFile[], options: any): void { - const groupEl = container.createDiv({ cls: 'note-status-group nav-folder' }); - const titleEl = groupEl.createDiv({ cls: 'nav-folder-title' }); + 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'); + 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 titleContent = titleEl.createDiv({ + cls: "nav-folder-title-content", + }); const statusIcon = this.statusService.getStatusIcon(status); titleContent.createSpan({ text: `${status} ${statusIcon} (${files.length})`, - cls: `status-${status}` + cls: `status-${status}`, }); // Set collapse state if (isCollapsed) { - groupEl.addClass('note-status-is-collapsed'); + groupEl.addClass("note-status-is-collapsed"); } // Toggle collapse on click - titleEl.addEventListener('click', (e) => { + titleEl.addEventListener("click", (e) => { e.preventDefault(); - const newCollapsed = !groupEl.hasClass('note-status-is-collapsed'); - groupEl.toggleClass('note-status-is-collapsed', newCollapsed); + const newCollapsed = !groupEl.hasClass("note-status-is-collapsed"); + groupEl.toggleClass("note-status-is-collapsed", newCollapsed); collapseIcon.empty(); - setIcon(collapseIcon, newCollapsed ? 'chevron-right' : 'chevron-down'); + setIcon( + collapseIcon, + newCollapsed ? "chevron-right" : "chevron-down", + ); options.callbacks.onStatusToggle(status, newCollapsed); }); // Render content - const childrenEl = groupEl.createDiv({ cls: 'nav-folder-children' }); + const childrenEl = groupEl.createDiv({ cls: "nav-folder-children" }); // Pagination const currentPage = options.pagination.currentPage[status] || 0; @@ -137,56 +175,84 @@ export class StatusPaneView{ 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); + 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); + this.addPagination( + childrenEl, + status, + currentPage, + totalPages, + files.length, + options.callbacks, + ); } } - private renderFileItem(container: HTMLElement, file: TFile, status: string, isCompactView: boolean, callbacks: any): void { - const fileEl = container.createDiv({ cls: 'nav-file' }); - const fileTitleEl = fileEl.createDiv({ cls: 'nav-file-title' }); + 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'); + const fileIcon = fileTitleEl.createDiv({ cls: "nav-file-icon" }); + setIcon(fileIcon, "file"); } fileTitleEl.createSpan({ text: file.basename, - cls: 'nav-file-title-content' + cls: "nav-file-title-content", }); fileTitleEl.createSpan({ cls: `note-status-icon nav-file-tag status-${status}`, - text: this.statusService.getStatusIcon(status) + text: this.statusService.getStatusIcon(status), }); - fileEl.addEventListener('click', (e) => { + fileEl.addEventListener("click", (e) => { e.preventDefault(); callbacks.onFileClick(file); }); - fileEl.addEventListener('contextmenu', (e) => { + fileEl.addEventListener("contextmenu", (e) => { e.preventDefault(); callbacks.onContextMenu(e, file); }); } - private addPagination(container: HTMLElement, status: string, currentPage: number, totalPages: number, totalItems: number, callbacks: any): void { - const paginationEl = container.createDiv({ cls: 'note-status-pagination' }); + 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' + const prevButton = paginationEl.createEl("button", { + text: "Previous", + cls: "note-status-pagination-button", }); - prevButton.addEventListener('click', (e) => { + prevButton.addEventListener("click", (e) => { e.stopPropagation(); callbacks.onPageChange(status, currentPage - 1); }); @@ -194,24 +260,31 @@ export class StatusPaneView{ paginationEl.createSpan({ text: `Page ${currentPage + 1} of ${totalPages} (${totalItems} notes)`, - cls: 'note-status-pagination-info' + cls: "note-status-pagination-info", }); if (currentPage < totalPages - 1) { - const nextButton = paginationEl.createEl('button', { - text: 'Next', - cls: 'note-status-pagination-button' + const nextButton = paginationEl.createEl("button", { + text: "Next", + cls: "note-status-pagination-button", }); - nextButton.addEventListener('click', (e) => { + 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' }); + 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}"`; @@ -220,26 +293,28 @@ export class StatusPaneView{ if (excludeUnknown) { emptyMessage.createDiv({ - text: 'No notes with status found. Unassigned notes are currently hidden.', - cls: 'note-status-empty-message' + 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' + cls: "note-status-button-container", }); - const showUnknownBtn = btnContainer.createEl('button', { - text: 'Show unassigned notes', - cls: 'note-status-show-unassigned-button' + const showUnknownBtn = btnContainer.createEl("button", { + text: "Show unassigned notes", + cls: "note-status-show-unassigned-button", }); - showUnknownBtn.addEventListener('click', onShowUnassigned); + showUnknownBtn.addEventListener("click", onShowUnassigned); } } createLoadingIndicator(container: HTMLElement, text?: string): HTMLElement { - const loadingIndicator = container.createDiv({ cls: 'note-status-loading' }); - loadingIndicator.innerHTML = `${text || 'Loading notes...'}`; + const loadingIndicator = container.createDiv({ + cls: "note-status-loading", + }); + loadingIndicator.innerHTML = `${text || "Loading notes..."}`; return loadingIndicator; } }