From 382ddf9b56d28add7508129170b9a7ca38928b21 Mon Sep 17 00:00:00 2001 From: Aleix Soler Date: Sat, 5 Apr 2025 11:56:52 +0200 Subject: [PATCH] refactor: folder structure and clean code --- constants/defaults.ts | 40 + constants/icons.ts | 24 + main.ts | 1326 ++++------------------------------ models/types.ts | 37 + services/status-service.ts | 169 +++++ services/style-service.ts | 99 +++ settings/settings-tab.ts | 239 ++++++ settings/status-pane-view.ts | 289 ++++++++ ui/context-menus.ts | 85 +++ ui/explorer.ts | 135 ++++ ui/modals.ts | 120 +++ ui/status-bar.ts | 107 +++ ui/status-dropdown.ts | 174 +++++ ui/status-pane-view.ts | 289 ++++++++ utils/dom-utils.ts | 119 +++ utils/file-utils.ts | 104 +++ 16 files changed, 2172 insertions(+), 1184 deletions(-) create mode 100644 constants/defaults.ts create mode 100644 constants/icons.ts create mode 100644 models/types.ts create mode 100644 services/status-service.ts create mode 100644 services/style-service.ts create mode 100644 settings/settings-tab.ts create mode 100644 settings/status-pane-view.ts create mode 100644 ui/context-menus.ts create mode 100644 ui/explorer.ts create mode 100644 ui/modals.ts create mode 100644 ui/status-bar.ts create mode 100644 ui/status-dropdown.ts create mode 100644 ui/status-pane-view.ts create mode 100644 utils/dom-utils.ts create mode 100644 utils/file-utils.ts diff --git a/constants/defaults.ts b/constants/defaults.ts new file mode 100644 index 0000000..7a2f61d --- /dev/null +++ b/constants/defaults.ts @@ -0,0 +1,40 @@ +import { NoteStatusSettings } from '../models/types'; + +/** + * Default plugin settings + */ +export const DEFAULT_SETTINGS: NoteStatusSettings = { + statusColors: { + active: 'var(--text-success)', + onHold: 'var(--text-warning)', + completed: 'var(--text-accent)', + dropped: 'var(--text-error)', + unknown: 'var(--text-muted)' + }, + showStatusDropdown: true, + showStatusBar: true, + dropdownPosition: 'top', + statusBarPosition: 'right', + autoHideStatusBar: false, + customStatuses: [ + { name: 'active', icon: '▶️' }, + { name: 'onHold', icon: '⏸️' }, + { name: 'completed', icon: '✅' }, + { name: 'dropped', icon: '❌' }, + { name: 'unknown', icon: '❓' } + ], + showStatusIconsInExplorer: true, + collapsedStatuses: {}, + compactView: false +}; + +/** + * Default colors in hexadecimal format for backup and reset + */ +export const DEFAULT_COLORS: Record = { + active: '#00ff00', // Green for success + onHold: '#ffaa00', // Orange for warning + completed: '#00aaff', // Blue for accent + dropped: '#ff0000', // Red for error + unknown: '#888888' // Gray for muted +}; diff --git a/constants/icons.ts b/constants/icons.ts new file mode 100644 index 0000000..6472992 --- /dev/null +++ b/constants/icons.ts @@ -0,0 +1,24 @@ +/** + * SVG icon definitions + */ +export const ICONS = { + statusPane: ` + + `, + search: ` + + + `, + clear: ` + + + `, + standardView: ``, + compactView: ``, + refresh: ``, + collapseDown: ``, + collapseRight: ``, + file: `` +}; diff --git a/main.ts b/main.ts index 93d1813..40265ae 100644 --- a/main.ts +++ b/main.ts @@ -1,391 +1,58 @@ -import { App, Editor, MarkdownView, Notice, Plugin, PluginSettingTab, Setting, TFile, Menu, Modal, View, WorkspaceLeaf, addIcon } from 'obsidian'; +import { Editor, MarkdownView, Notice, Plugin, TFile, addIcon, WorkspaceLeaf } from 'obsidian'; -// Define icons as constants -const ICONS = { - statusPane: ` - - `, - search: ` - - - `, - clear: ` - - - `, - standardView: ``, - compactView: ``, - refresh: ``, - collapseDown: ``, - collapseRight: ``, - file: `` -}; +// Import constants +import { ICONS } from './constants/icons'; +import { DEFAULT_SETTINGS, DEFAULT_COLORS } from './constants/defaults'; -// Add custom icons to Obsidian -addIcon('status-pane', ICONS.statusPane); +// Import interfaces and types +import { NoteStatusSettings } from './models/types'; -// TypeScript interfaces -interface Status { - name: string; - icon: string; -} +// Import services +import { StatusService } from './services/status-service'; +import { StyleService } from './services/style-service'; -interface NoteStatusSettings { - statusColors: Record; - showStatusDropdown: boolean; - showStatusBar: boolean; - dropdownPosition: 'top' | 'bottom'; - statusBarPosition: 'left' | 'right'; - autoHideStatusBar: boolean; - customStatuses: Status[]; - showStatusIconsInExplorer: boolean; - collapsedStatuses: Record; - compactView: boolean; -} -interface FileExplorerView extends View { - fileItems: Record; -} +// Import UI components +import { StatusBar } from './ui/status-bar'; +import { StatusDropdown } from './ui/status-dropdown'; +import { StatusPaneView } from './ui/status-pane-view'; +import { ExplorerIntegration } from './ui/explorer'; +import { BatchStatusModal } from './ui/modals'; +import { StatusContextMenu } from './ui/context-menus'; -const DEFAULT_SETTINGS: NoteStatusSettings = { - statusColors: { - active: 'var(--text-success)', - onHold: 'var(--text-warning)', - completed: 'var(--text-accent)', - dropped: 'var(--text-error)', - unknown: 'var(--text-muted)' - }, - showStatusDropdown: true, - showStatusBar: true, - dropdownPosition: 'top', - statusBarPosition: 'right', - autoHideStatusBar: false, - customStatuses: [ - { name: 'active', icon: '▶️' }, - { name: 'onHold', icon: '⏸️' }, - { name: 'completed', icon: '✅' }, - { name: 'dropped', icon: '❌' }, - { name: 'unknown', icon: '❓' } - ], - showStatusIconsInExplorer: true, - collapsedStatuses: {}, - compactView: false -}; - -// Default colors in hexadecimal format -const DEFAULT_COLORS: Record = { - active: '#00ff00', // Green for success - onHold: '#ffaa00', // Orange for warning - completed: '#00aaff', // Blue for accent - dropped: '#ff0000', // Red for error - unknown: '#888888' // Gray for muted -}; - -/** - * Status Pane View for managing note statuses - */ -class StatusPaneView extends View { - plugin: NoteStatus; - searchInput: HTMLInputElement | null = null; - - constructor(leaf: WorkspaceLeaf, plugin: NoteStatus) { - super(leaf); - this.plugin = plugin; - } - - getViewType() { return 'status-pane'; } - getDisplayText() { return 'Status Pane'; } - getIcon() { return 'status-pane'; } - - async onOpen() { - await this.setupPane(); - await this.renderGroups(''); - } - - async setupPane() { - const { containerEl } = this; - containerEl.empty(); - containerEl.addClass('note-status-pane', 'nav-files-container'); - - // Add a header container for better layout - const headerContainer = containerEl.createDiv({ cls: 'note-status-header' }); - - // Search container with search input - this.createSearchInput(headerContainer); - - // Actions toolbar (view toggle, refresh) - this.createActionToolbar(headerContainer); - - // Set initial compact view state - containerEl.toggleClass('compact-view', this.plugin.settings.compactView); - - // Add a container for the groups - containerEl.createDiv({ cls: 'note-status-groups-container' }); - } - - private createSearchInput(container: HTMLElement) { - const searchContainer = container.createDiv({ cls: 'note-status-search search-input-container' }); - const searchWrapper = searchContainer.createDiv({ cls: 'search-input-wrapper' }); - - // Search icon - searchWrapper.createEl('span', { cls: 'search-input-icon' }).innerHTML = ICONS.search; - - // Create the search input - this.searchInput = searchWrapper.createEl('input', { - type: 'text', - placeholder: 'Search notes...', - cls: 'note-status-search-input search-input' - }); - - // Add search event listener - this.searchInput.addEventListener('input', () => { - this.renderGroups(this.searchInput!.value.toLowerCase()); - this.toggleClearButton(); - }); - - // Clear search button (hidden by default) - const clearSearchBtn = searchWrapper.createEl('span', { cls: 'search-input-clear-button' }); - clearSearchBtn.innerHTML = ICONS.clear; - - clearSearchBtn.addEventListener('click', () => { - if (this.searchInput) { - this.searchInput.value = ''; - this.renderGroups(''); - this.toggleClearButton(); - } - }); - } - - private toggleClearButton() { - const clearBtn = this.containerEl.querySelector('.search-input-clear-button'); - if (clearBtn && this.searchInput) { - clearBtn.toggleClass('is-visible', !!this.searchInput.value); - } - } - - private createActionToolbar(container: HTMLElement) { - const actionsContainer = container.createDiv({ cls: 'status-pane-actions-container' }); - - // Toggle compact view button - const viewToggleButton = actionsContainer.createEl('button', { - type: 'button', - title: this.plugin.settings.compactView ? 'Switch to Standard View' : 'Switch to Compact View', - cls: 'note-status-view-toggle clickable-icon' - }); - - viewToggleButton.innerHTML = this.plugin.settings.compactView ? ICONS.compactView : ICONS.standardView; - - viewToggleButton.addEventListener('click', async () => { - this.plugin.settings.compactView = !this.plugin.settings.compactView; - viewToggleButton.title = this.plugin.settings.compactView ? 'Switch to Standard View' : 'Switch to Compact View'; - viewToggleButton.innerHTML = this.plugin.settings.compactView ? ICONS.compactView : ICONS.standardView; - - await this.plugin.saveSettings(); - this.containerEl.toggleClass('compact-view', this.plugin.settings.compactView); - await this.renderGroups(this.searchInput?.value.toLowerCase() || ''); - }); - - // Refresh button - const refreshButton = actionsContainer.createEl('button', { - type: 'button', - title: 'Refresh Statuses', - cls: 'note-status-actions-refresh clickable-icon' - }); - - refreshButton.innerHTML = ICONS.refresh; - - refreshButton.addEventListener('click', async () => { - await this.renderGroups(this.searchInput?.value.toLowerCase() || ''); - new Notice('Status pane refreshed'); - }); - } - - async renderGroups(searchQuery = '') { - const { containerEl } = this; - const groupsContainer = containerEl.querySelector('.note-status-groups-container'); - if (!groupsContainer) return; - - groupsContainer.empty(); - - // Group files by status - const statusGroups = this.groupFilesByStatus(searchQuery); - - // Render each status group - Object.entries(statusGroups).forEach(([status, files]) => { - if (files.length > 0) { - this.renderStatusGroup(groupsContainer as HTMLElement, status, files); - } - }); - } - - private groupFilesByStatus(searchQuery: string): Record { - const statusGroups: Record = {}; - - // Initialize groups for each status - this.plugin.settings.customStatuses.forEach(status => { - statusGroups[status.name] = []; - }); - - // Get all markdown files and filter by search query - const files = this.app.vault.getMarkdownFiles(); - - for (const file of files) { - if (!searchQuery || file.basename.toLowerCase().includes(searchQuery)) { - const status = this.plugin.getFileStatus(file); - statusGroups[status].push(file); - } - } - - return statusGroups; - } - - private renderStatusGroup(container: HTMLElement, status: string, files: TFile[]) { - const groupEl = container.createDiv({ cls: 'status-group nav-folder' }); - const titleEl = groupEl.createDiv({ cls: 'nav-folder-title' }); - - // Create a container for the collapse button and title - const collapseContainer = titleEl.createDiv({ cls: 'collapse-indicator' }); - collapseContainer.innerHTML = ICONS.collapseDown; - - // Create a container for the title content - const titleContentContainer = titleEl.createDiv({ cls: 'nav-folder-title-content' }); - - const statusIcon = this.plugin.getStatusIcon(status); - titleContentContainer.createSpan({ - text: `${status} ${statusIcon} (${files.length})`, - cls: `status-${status}` - }); - - // Handle collapsing/expanding behavior - titleEl.style.cursor = 'pointer'; - const isCollapsed = this.plugin.settings.collapsedStatuses[status] ?? false; - - if (isCollapsed) { - groupEl.addClass('is-collapsed'); - collapseContainer.innerHTML = ICONS.collapseRight; - } - - titleEl.addEventListener('click', (e) => { - e.preventDefault(); - const isCurrentlyCollapsed = groupEl.hasClass('is-collapsed'); - - // Toggle the collapsed state - if (isCurrentlyCollapsed) { - groupEl.removeClass('is-collapsed'); - collapseContainer.innerHTML = ICONS.collapseDown; - } else { - groupEl.addClass('is-collapsed'); - collapseContainer.innerHTML = ICONS.collapseRight; - } - - // Update the settings - this.plugin.settings.collapsedStatuses[status] = !isCurrentlyCollapsed; - this.plugin.saveSettings(); - }); - - // Create and populate child elements - const childrenEl = groupEl.createDiv({ cls: 'nav-folder-children' }); - - // Sort files by name - files.sort((a, b) => a.basename.localeCompare(b.basename)); - - // Create file list items - files.forEach(file => { - this.createFileListItem(childrenEl, file, status); - }); - } - - private createFileListItem(container: HTMLElement, file: TFile, status: string) { - const fileEl = container.createDiv({ cls: 'nav-file' }); - const fileTitleEl = fileEl.createDiv({ cls: 'nav-file-title' }); - - // Add file icon if in standard view - if (!this.plugin.settings.compactView) { - const fileIcon = fileTitleEl.createDiv({ cls: 'nav-file-icon' }); - fileIcon.innerHTML = ICONS.file; - } - - // Add file name - fileTitleEl.createSpan({ - text: file.basename, - cls: 'nav-file-title-content' - }); - - // Add status indicator - fileTitleEl.createSpan({ - cls: `note-status-icon nav-file-tag status-${status}`, - text: this.plugin.getStatusIcon(status) - }); - - // Add click handler to open the file - fileEl.addEventListener('click', (e) => { - e.preventDefault(); - this.app.workspace.openLinkText(file.path, file.path, true); - }); - - // Add context menu - fileEl.addEventListener('contextmenu', (e) => { - e.preventDefault(); - this.showFileContextMenu(e, file); - }); - } - - private showFileContextMenu(e: MouseEvent, file: TFile) { - const menu = new Menu(); - - // Add status change options - menu.addItem((item) => - item.setTitle('Change Status') - .setIcon('tag') - .onClick(() => { - this.plugin.showStatusContextMenu([file]); - }) - ); - - // Add open options - menu.addItem((item) => - item.setTitle('Open in New Tab') - .setIcon('lucide-external-link') - .onClick(() => { - this.app.workspace.openLinkText(file.path, file.path, 'tab'); - }) - ); - - menu.showAtMouseEvent(e); - } - - onClose() { - this.containerEl.empty(); - return Promise.resolve(); - } -} +// Import settings +import { NoteStatusSettingTab } from './settings/settings-tab'; /** * Main plugin class */ export default class NoteStatus extends Plugin { settings: NoteStatusSettings; - statusBarItem: HTMLElement; - currentStatus = 'unknown'; - statusDropdownContainer?: HTMLElement; + statusService: StatusService; + styleService: StyleService; + statusBar: StatusBar; + statusDropdown: StatusDropdown; + explorerIntegration: ExplorerIntegration; + statusContextMenu: StatusContextMenu; private statusPaneLeaf: WorkspaceLeaf | null = null; - private dynamicStyleEl?: HTMLStyleElement; async onload() { - // Load settings and set defaults + // Register custom icons + addIcon('status-pane', ICONS.statusPane); + + // Load settings await this.loadSettings(); - this.initializeDefaultColors(); - // Initialize dynamic styles - this.updateDynamicStyles(); + // Initialize services + this.statusService = new StatusService(this.app, this.settings); + this.styleService = new StyleService(this.settings); - // Register the status pane view + // Initialize UI components + this.statusBar = new StatusBar(this.addStatusBarItem(), this.settings, this.statusService); + this.statusDropdown = new StatusDropdown(this.app, this.settings, this.statusService); + this.explorerIntegration = new ExplorerIntegration(this.app, this.settings, this.statusService); + this.statusContextMenu = new StatusContextMenu(this.app, this.settings, this.statusService,); + + // Register status pane view this.registerView('status-pane', (leaf) => { this.statusPaneLeaf = leaf; return new StatusPaneView(leaf, this); @@ -396,9 +63,6 @@ export default class NoteStatus extends Plugin { this.openStatusPane(); }); - // Add status bar item - this.initializeStatusBar(); - // Register commands this.registerCommands(); @@ -413,36 +77,41 @@ export default class NoteStatus extends Plugin { // Small delay to ensure everything is loaded await new Promise(resolve => setTimeout(resolve, 500)); this.checkNoteStatus(); - this.updateStatusDropdown(); - this.updateAllFileExplorerIcons(); + this.statusDropdown.update(this.getCurrentStatus()); + this.explorerIntegration.updateAllFileExplorerIcons(); }); // Add settings tab this.addSettingTab(new NoteStatusSettingTab(this.app, this)); + + // Set up custom events + this.setupCustomEvents(); } - private initializeDefaultColors() { - // Ensure default colors are set for all statuses - for (const [status, color] of Object.entries(DEFAULT_COLORS)) { - if (!this.settings.statusColors[status]) { - this.settings.statusColors[status] = color; - } - } - } - - private initializeStatusBar() { - this.statusBarItem = this.addStatusBarItem(); - this.statusBarItem.addClass('note-status-bar'); - - // Add click handler to toggle status dropdown - this.statusBarItem.addEventListener('click', () => { - this.settings.showStatusDropdown = !this.settings.showStatusDropdown; - this.updateStatusDropdown(); - this.saveSettings(); - new Notice(`Status dropdown ${this.settings.showStatusDropdown ? 'shown' : 'hidden'}`); + private setupCustomEvents() { + // Listen for settings changes + window.addEventListener('note-status:settings-changed', async () => { + await this.saveSettings(); }); - this.updateStatusBar(); + // Listen for status changes + window.addEventListener('note-status:status-changed', (e: any) => { + const status = e.detail?.status || 'unknown'; + this.statusBar.update(status); + this.statusDropdown.update(status); + }); + + // Listen for refresh dropdown + window.addEventListener('note-status:refresh-dropdown', () => { + this.statusDropdown.render(); + }); + + // Listen for UI refresh + window.addEventListener('note-status:refresh-ui', () => { + this.checkNoteStatus(); + this.explorerIntegration.updateAllFileExplorerIcons(); + this.updateStatusPane(); + }); } private registerCommands() { @@ -468,7 +137,7 @@ export default class NoteStatus extends Plugin { id: 'insert-status-metadata', name: 'Insert Status Metadata', editorCallback: (editor: Editor, view: MarkdownView) => { - this.insertStatusMetadataInEditor(editor); + this.statusService.insertStatusMetadataInEditor(editor); } }); @@ -490,11 +159,11 @@ export default class NoteStatus extends Plugin { .setTitle('Change Status of Selected Files') .setIcon('tag') .onClick(() => { - const selectedFiles = this.getSelectedFiles(); + const selectedFiles = this.explorerIntegration.getSelectedFiles(); if (selectedFiles.length > 1) { - this.showBatchStatusContextMenu(selectedFiles); + this.statusContextMenu.showForFiles(selectedFiles); } else { - this.showBatchStatusContextMenu([file]); + this.statusContextMenu.showForFiles([file]); } }) ); @@ -512,7 +181,7 @@ export default class NoteStatus extends Plugin { .setTitle('Change Status of Selected Files') .setIcon('tag') .onClick(() => { - this.showBatchStatusContextMenu(mdFiles); + this.statusContextMenu.showForFiles(mdFiles); }) ); } @@ -527,7 +196,7 @@ export default class NoteStatus extends Plugin { item .setTitle('Change Note Status') .setIcon('tag') - .onClick(() => this.showStatusDropdown(editor, view)) + .onClick(() => this.statusDropdown.showInContextMenu(editor, view)) ); } }) @@ -538,7 +207,7 @@ export default class NoteStatus extends Plugin { // File open event this.registerEvent(this.app.workspace.on('file-open', () => { this.checkNoteStatus(); - this.updateStatusDropdown(); + this.statusDropdown.update(this.getCurrentStatus()); })); // Editor change event @@ -546,49 +215,66 @@ export default class NoteStatus extends Plugin { // Active leaf change event this.registerEvent(this.app.workspace.on('active-leaf-change', () => { - this.updateStatusDropdown(); + this.statusDropdown.update(this.getCurrentStatus()); this.updateStatusPane(); })); // File modification events this.registerEvent(this.app.vault.on('modify', (file) => { if (file instanceof TFile) { - this.updateFileExplorerIcons(file); + this.explorerIntegration.updateFileExplorerIcons(file); this.updateStatusPane(); } })); - // File creation event + // File creation, deletion, and rename events this.registerEvent(this.app.vault.on('create', () => { - this.updateAllFileExplorerIcons(); + this.explorerIntegration.updateAllFileExplorerIcons(); this.updateStatusPane(); })); - // File deletion event this.registerEvent(this.app.vault.on('delete', () => { - this.updateAllFileExplorerIcons(); + this.explorerIntegration.updateAllFileExplorerIcons(); this.updateStatusPane(); })); - // File rename event this.registerEvent(this.app.vault.on('rename', () => { - this.updateAllFileExplorerIcons(); + this.explorerIntegration.updateAllFileExplorerIcons(); this.updateStatusPane(); })); // Metadata change events this.registerEvent(this.app.metadataCache.on('changed', (file) => { this.checkNoteStatus(); - this.updateFileExplorerIcons(file); + this.explorerIntegration.updateFileExplorerIcons(file); this.updateStatusPane(); })); // Metadata resolved event this.registerEvent(this.app.metadataCache.on('resolved', () => { - this.updateAllFileExplorerIcons(); + this.explorerIntegration.updateAllFileExplorerIcons(); })); } + checkNoteStatus() { + const activeFile = this.app.workspace.getActiveFile(); + if (!activeFile || activeFile.extension !== 'md') { + this.statusBar.update('unknown'); + return; + } + + const status = this.statusService.getFileStatus(activeFile); + this.statusBar.update(status); + } + + getCurrentStatus(): string { + const activeFile = this.app.workspace.getActiveFile(); + if (!activeFile || activeFile.extension !== 'md') { + return 'unknown'; + } + return this.statusService.getFileStatus(activeFile); + } + async openStatusPane() { const existing = this.app.workspace.getLeavesOfType('status-pane')[0]; if (existing) { @@ -600,303 +286,12 @@ export default class NoteStatus extends Plugin { } } - getSelectedFiles(): TFile[] { - const fileExplorer = this.app.workspace.getLeavesOfType('file-explorer')[0]; - if (!fileExplorer || !fileExplorer.view) { - console.log('File explorer not found or no file items'); - return []; - } - - const fileExplorerView = fileExplorer.view as FileExplorerView; - if (!fileExplorerView.fileItems) { - console.log('No file items found'); - return []; - } - - const selectedFiles: TFile[] = []; - Object.entries(fileExplorerView.fileItems).forEach(([_, item]) => { - if (item.el?.classList.contains('is-selected') && item.file instanceof TFile && item.file.extension === 'md') { - selectedFiles.push(item.file); - } - }); - - return selectedFiles; + showBatchStatusModal() { + new BatchStatusModal(this.app, this.settings, this.statusService).open(); } showStatusContextMenu(files: TFile[]) { - this.showBatchStatusContextMenu(files); - } - - showBatchStatusContextMenu(files: TFile[]) { - const menu = new Menu(); - - this.settings.customStatuses - .filter(status => status.name !== 'unknown') // Exclude 'unknown' status - .forEach(status => { - menu.addItem((item) => - item - .setTitle(`${status.name} ${status.icon}`) - .setIcon('tag') - .onClick(async () => { - for (const file of files) { - await this.updateNoteStatus(status.name, file); - } - new Notice(`Updated status of ${files.length} file${files.length === 1 ? '' : 's'} to ${status.name}`); - }) - ); - }); - - menu.showAtMouseEvent(new MouseEvent('contextmenu')); - } - - async updateNoteStatus(newStatus: string, file?: TFile) { - const targetFile = file || this.app.workspace.getActiveFile(); - if (!targetFile || targetFile.extension !== 'md') return; - - const content = await this.app.vault.read(targetFile); - let newContent = content; - - // Handle frontmatter update - const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---\n?/); - if (frontmatterMatch) { - const frontmatter = frontmatterMatch[1]; - if (frontmatter.includes('status:')) { - newContent = content.replace( - /^---\n([\s\S]*?)status:\s*[^\n]+([\s\S]*?)\n---\n?/, - `---\n$1status: ${newStatus}$2\n---\n` - ); - } else { - newContent = content.replace( - /^---\n([\s\S]*?)\n---\n?/, - `---\n$1\nstatus: ${newStatus}\n---\n` - ); - } - } else { - newContent = `---\nstatus: ${newStatus}\n---\n${content.trim()}`; - } - - // Clean up excess newlines - newContent = newContent.replace(/\n{3,}/g, '\n\n'); - - // Update the file - await this.app.vault.modify(targetFile, newContent); - - // Update UI if this is the active file - if (targetFile === this.app.workspace.getActiveFile()) { - this.currentStatus = newStatus; - this.updateStatusBar(); - this.updateStatusDropdown(); - } - - this.updateFileExplorerIcons(targetFile); - this.updateStatusPane(); - } - - /** - * Get the status of a file from its metadata - */ - getFileStatus(file: TFile): string { - const cachedMetadata = this.app.metadataCache.getFileCache(file); - let status = 'unknown'; - - if (cachedMetadata?.frontmatter?.status) { - const frontmatterStatus = cachedMetadata.frontmatter.status.toLowerCase(); - const matchingStatus = this.settings.customStatuses.find(s => - s.name.toLowerCase() === frontmatterStatus); - - if (matchingStatus) status = matchingStatus.name; - } - - return status; - } - - checkNoteStatus() { - const activeFile = this.app.workspace.getActiveFile(); - if (!activeFile || activeFile.extension !== 'md') { - this.currentStatus = 'unknown'; - this.updateStatusBar(); - this.updateStatusDropdown(); - return; - } - - this.currentStatus = this.getFileStatus(activeFile); - this.updateStatusBar(); - this.updateStatusDropdown(); - this.updateFileExplorerIcons(activeFile); - } - - async showBatchStatusModal() { - const modal = new Modal(this.app); - modal.contentEl.createEl('h2', { text: 'Batch Update Note Status' }); - modal.contentEl.addClass('note-status-batch-modal'); - - // Create file selection with search filter - const searchContainer = modal.contentEl.createDiv({ cls: 'note-status-modal-search' }); - const searchInput = searchContainer.createEl('input', { - type: 'text', - placeholder: 'Filter files...', - cls: 'note-status-modal-search-input' - }); - - // File selection container - const fileSelectContainer = modal.contentEl.createDiv({ cls: 'note-status-file-select-container' }); - const fileSelect = fileSelectContainer.createEl('select', { - cls: 'note-status-file-select', - attr: { multiple: 'true', size: '10' } - }); - - // Get all markdown files and populate select - const mdFiles = this.app.vault.getMarkdownFiles(); - const populateFiles = (filter = '') => { - fileSelect.empty(); - mdFiles - .filter(file => !filter || file.path.toLowerCase().includes(filter.toLowerCase())) - .sort((a, b) => a.path.localeCompare(b.path)) - .forEach(file => { - const status = this.getFileStatus(file); - const option = fileSelect.createEl('option', { - text: `${file.path} ${this.getStatusIcon(status)}`, - value: file.path - }); - option.classList.add(`status-${status}`); - }); - }; - - populateFiles(); - - // Add search functionality - searchInput.addEventListener('input', () => { - populateFiles(searchInput.value); - }); - - // Status selection - const statusSelectContainer = modal.contentEl.createDiv({ cls: 'note-status-status-select-container' }); - const statusSelect = statusSelectContainer.createEl('select', { cls: 'note-status-status-select' }); - - // Add status options - this.settings.customStatuses - .filter(status => status.name !== 'unknown') - .forEach(status => { - const option = statusSelect.createEl('option', { - text: `${status.name} ${status.icon}`, - value: status.name - }); - option.classList.add(`status-${status.name}`); - }); - - // Add action buttons - const buttonContainer = modal.contentEl.createDiv({ cls: 'note-status-modal-buttons' }); - - // Select all button - const selectAllButton = buttonContainer.createEl('button', { - text: 'Select All', - cls: 'note-status-select-all' - }); - - selectAllButton.addEventListener('click', () => { - for (const option of Array.from(fileSelect.options)) { - option.selected = true; - } - }); - - // Apply button - const applyButton = buttonContainer.createEl('button', { - text: 'Apply Status', - cls: 'mod-cta' - }); - - applyButton.addEventListener('click', async () => { - const selectedFiles = Array.from(fileSelect.selectedOptions) - .map(opt => mdFiles.find(f => f.path === opt.value)) - .filter(Boolean) as TFile[]; - - if (selectedFiles.length === 0) { - new Notice('No files selected'); - return; - } - - const newStatus = statusSelect.value; - for (const file of selectedFiles) { - await this.updateNoteStatus(newStatus, file); - } - - new Notice(`Updated status of ${selectedFiles.length} note${selectedFiles.length !== 1 ? 's' : ''} to ${newStatus}`); - modal.close(); - }); - - modal.open(); - } - - private insertStatusMetadataInEditor(editor: Editor) { - const content = editor.getValue(); - const statusMetadata = 'status: unknown'; - - // Check if frontmatter exists - const frontMatterMatch = content.match(/^---\n([\s\S]+?)\n---/); - - if (frontMatterMatch) { - const frontMatter = frontMatterMatch[1]; - let updatedFrontMatter = frontMatter; - - // Check if status already exists in frontmatter - if (/^status:/.test(frontMatter)) { - updatedFrontMatter = frontMatter.replace(/^status: .*/m, statusMetadata); - } else { - updatedFrontMatter = `${frontMatter}\n${statusMetadata}`; - } - - const updatedContent = content.replace(/^---\n([\s\S]+?)\n---/, `---\n${updatedFrontMatter}\n---`); - editor.setValue(updatedContent); - } else { - // Create new frontmatter if it doesn't exist - const newFrontMatter = `---\n${statusMetadata}\n---\n${content}`; - editor.setValue(newFrontMatter); - } - } - - updateStatusBar() { - this.statusBarItem.empty(); - this.statusBarItem.removeClass('left', 'hidden', 'auto-hide', 'visible'); - this.statusBarItem.addClass('note-status-bar'); - - if (!this.settings.showStatusBar) { - this.statusBarItem.addClass('hidden'); - return; - } - - // Add left class if needed - if (this.settings.statusBarPosition === 'left') { - this.statusBarItem.addClass('left'); - } - - // Create status text - this.statusBarItem.createEl('span', { - text: `Status: ${this.currentStatus}`, - cls: `note-status-${this.currentStatus}` - }); - - // Create status icon - this.statusBarItem.createEl('span', { - text: this.getStatusIcon(this.currentStatus), - cls: `note-status-icon status-${this.currentStatus}` - }); - - // Handle auto-hide behavior - if (this.settings.autoHideStatusBar && this.currentStatus === 'unknown') { - this.statusBarItem.addClass('auto-hide'); - setTimeout(() => { - if (this.currentStatus === 'unknown' && this.settings.showStatusBar) { - this.statusBarItem.addClass('hidden'); - } - }, 500); - } else { - this.statusBarItem.addClass('visible'); - } - } - - getStatusIcon(status: string): string { - const customStatus = this.settings.customStatuses.find(s => s.name.toLowerCase() === status.toLowerCase()); - return customStatus ? customStatus.icon : '❓'; + this.statusContextMenu.showForFiles(files); } async updateStatusPane() { @@ -906,257 +301,6 @@ export default class NoteStatus extends Plugin { } } - showStatusDropdown(editor: Editor, view: MarkdownView) { - const menu = new Menu(); - - // Add status options to menu - this.settings.customStatuses - .filter(status => status.name !== 'unknown') - .forEach(status => { - menu.addItem((item) => - item - .setTitle(`${status.name} ${status.icon}`) - .setIcon('tag') - .onClick(async () => { - await this.updateNoteStatus(status.name); - }) - ); - }); - - // Position menu near cursor - const cursor = editor.getCursor('to'); - editor.posToOffset(cursor); - const editorEl = view.contentEl.querySelector('.cm-content'); - - if (editorEl) { - const rect = editorEl.getBoundingClientRect(); - menu.showAtPosition({ x: rect.left, y: rect.bottom }); - } else { - menu.showAtPosition({ x: 0, y: 0 }); - } - } - - updateStatusDropdown() { - // Remove existing dropdown if setting is turned off - if (!this.settings.showStatusDropdown) { - if (this.statusDropdownContainer) { - this.statusDropdownContainer.remove(); - this.statusDropdownContainer = undefined; - } - return; - } - - // Check for active markdown view - const view = this.app.workspace.getActiveViewOfType(MarkdownView); - if (!view) { - if (this.statusDropdownContainer) { - this.statusDropdownContainer.remove(); - this.statusDropdownContainer = undefined; - } - return; - } - - // Create or update the dropdown container - const contentEl = view.contentEl; - if (!this.statusDropdownContainer) { - this.statusDropdownContainer = this.settings.dropdownPosition === 'top' - ? contentEl.insertBefore(document.createElement('div'), contentEl.firstChild) - : contentEl.appendChild(document.createElement('div')); - - this.statusDropdownContainer.addClass('note-status-dropdown', this.settings.dropdownPosition); - } - - // Populate the dropdown - this.statusDropdownContainer.empty(); - - // Add label - this.statusDropdownContainer.createEl('span', { text: 'Status:', cls: 'note-status-label' }); - - // Add select element - const select = this.statusDropdownContainer.createEl('select', { cls: 'note-status-select dropdown' }); - - // Add status options (excluding 'unknown') - this.settings.customStatuses - .forEach(status => { - const option = select.createEl('option', { - text: `${status.name} ${status.icon}`, - value: status.name - }); - if (status.name === 'unknown') option.disabled = true; - - - if (status.name === this.currentStatus) option.selected = true; - }); - - // Add change event listener - select.addEventListener('change', async (e) => { - const newStatus = (e.target as HTMLSelectElement).value; - await this.updateNoteStatus(newStatus); - }); - - // Add hide button - const hideButton = this.statusDropdownContainer.createEl('button', { - text: 'Hide Bar', - cls: 'note-status-hide-button clickable-icon mod-cta' - }); - - hideButton.addEventListener('click', () => { - this.settings.showStatusDropdown = false; - this.updateStatusDropdown(); - this.saveSettings(); - new Notice('Status dropdown hidden'); - }); - } - - async updateFileExplorerIcons(file: TFile) { - if (!this.settings.showStatusIconsInExplorer || file.extension !== 'md') return; - - const status = this.getFileStatus(file); - const fileExplorer = this.app.workspace.getLeavesOfType('file-explorer')[0]; - - if (fileExplorer && fileExplorer.view) { - // Cast fileExplorer.view to FileExplorerView - const fileExplorerView = fileExplorer.view as FileExplorerView; - - if (fileExplorerView.fileItems) { - const fileItem = fileExplorerView.fileItems[file.path]; - - if (fileItem) { - const titleEl = fileItem.titleEl || fileItem.selfEl; - if (titleEl) { - // Remove existing icon if present - const existingIcon = titleEl.querySelector('.note-status-icon'); - if (existingIcon) existingIcon.remove(); - - // Add new icon - titleEl.createEl('span', { - cls: `note-status-icon nav-file-tag status-${status}`, - text: this.getStatusIcon(status) - }); - } - } - } - } - } - - updateAllFileExplorerIcons() { - // Remove all icons if setting is turned off - if (!this.settings.showStatusIconsInExplorer) { - this.removeAllFileExplorerIcons(); - return; - } - - // Update icons for all markdown files - const files = this.app.vault.getMarkdownFiles(); - files.forEach(file => this.updateFileExplorerIcons(file)); - } - - private removeAllFileExplorerIcons() { - const fileExplorer = this.app.workspace.getLeavesOfType('file-explorer')[0]; - if (fileExplorer && fileExplorer.view) { - // Cast fileExplorer.view to FileExplorerView - const fileExplorerView = fileExplorer.view as FileExplorerView; - - if (fileExplorerView.fileItems) { - Object.values(fileExplorerView.fileItems).forEach((fileItem) => { - const titleEl = fileItem.titleEl || fileItem.selfEl; - if (titleEl) { - const existingIcon = titleEl.querySelector('.note-status-icon'); - if (existingIcon) existingIcon.remove(); - } - }); - } - } - } - - onunload() { - // Clean up UI elements - this.statusBarItem.remove(); - - if (this.statusDropdownContainer) { - this.statusDropdownContainer.remove(); - } - - // Remove file explorer icons - this.removeAllFileExplorerIcons(); - - // Close status pane if open - const statusPane = this.app.workspace.getLeavesOfType('status-pane')[0]; - if (statusPane) statusPane.detach(); - - // Remove dynamic styles - if (this.dynamicStyleEl) { - this.dynamicStyleEl.remove(); - } - } - - async loadSettings() { - this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); - } - - private updateDynamicStyles() { - // Create style element if it doesn't exist - if (!this.dynamicStyleEl) { - this.dynamicStyleEl = document.createElement('style'); - document.head.appendChild(this.dynamicStyleEl); - } - - // Generate CSS for status colors - let css = ''; - for (const [status, color] of Object.entries(this.settings.statusColors)) { - css += ` - .status-${status} { - color: ${color} !important; - } - .note-status-bar .note-status-${status}, - .nav-file-title .note-status-${status} { - color: ${color} !important; - } - `; - } - - // Add modern styling for the batch modal - css += ` - .note-status-batch-modal { - max-width: 500px; - } - - .note-status-modal-search { - margin-bottom: 10px; - } - - .note-status-modal-search-input { - width: 100%; - padding: var(--input-padding); - border-radius: var(--input-radius); - } - - .note-status-file-select, - .note-status-status-select { - width: 100%; - margin-bottom: 10px; - border-radius: var(--input-radius); - } - - .note-status-modal-buttons { - display: flex; - justify-content: space-between; - margin-top: 15px; - } - `; - - this.dynamicStyleEl.textContent = css; - } - - async saveSettings() { - await this.saveData(this.settings); - this.updateDynamicStyles(); - this.updateStatusPane(); - this.updateStatusBar(); - this.updateStatusDropdown(); - this.updateAllFileExplorerIcons(); - } - async resetDefaultColors() { // Reset colors for default statuses const defaultStatuses = ['active', 'onHold', 'completed', 'dropped', 'unknown']; @@ -1169,241 +313,55 @@ export default class NoteStatus extends Plugin { await this.saveSettings(); } -} -/** - * Settings tab for the Note Status plugin - */ -class NoteStatusSettingTab extends PluginSettingTab { - plugin: NoteStatus; - - constructor(app: App, plugin: NoteStatus) { - super(app, plugin); - this.plugin = plugin; + async loadSettings() { + this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); + this.initializeDefaultColors(); } - display(): void { - const { containerEl } = this; - containerEl.empty(); + private initializeDefaultColors() { + // Ensure default colors are set for all statuses + for (const [status, color] of Object.entries(DEFAULT_COLORS)) { + if (!this.settings.statusColors[status]) { + this.settings.statusColors[status] = color; + } + } + } - // Header - containerEl.createEl('h2', { text: 'Note Status Settings' }); + async saveSettings() { + await this.saveData(this.settings); - // UI section - containerEl.createEl('h3', { text: 'UI Settings' }); + // Update services with new settings + this.statusService.updateSettings(this.settings); + this.styleService.updateSettings(this.settings); - // Status dropdown settings - new Setting(containerEl) - .setName('Show status dropdown') - .setDesc('Display status dropdown in notes') - .addToggle(toggle => toggle - .setValue(this.plugin.settings.showStatusDropdown) - .onChange(async (value) => { - this.plugin.settings.showStatusDropdown = value; - await this.plugin.saveSettings(); - })); + // Update UI components with new settings + this.statusBar.updateSettings(this.settings); + this.statusDropdown.updateSettings(this.settings); + this.explorerIntegration.updateSettings(this.settings); + this.statusContextMenu.updateSettings(this.settings); - new Setting(containerEl) - .setName('Dropdown position') - .setDesc('Where to place the status dropdown') - .addDropdown(dropdown => dropdown - .addOption('top', 'Top') - .addOption('bottom', 'Bottom') - .setValue(this.plugin.settings.dropdownPosition) - .onChange(async (value: 'top' | 'bottom') => { - this.plugin.settings.dropdownPosition = value; - await this.plugin.saveSettings(); - })); + // Update status pane if open + if (this.statusPaneLeaf && this.statusPaneLeaf.view instanceof StatusPaneView) { + (this.statusPaneLeaf.view as StatusPaneView).updateSettings(this.settings); + } + } - // Status bar settings - new Setting(containerEl) - .setName('Show status bar') - .setDesc('Display the status bar') - .addToggle(toggle => toggle - .setValue(this.plugin.settings.showStatusBar) - .onChange(async (value) => { - this.plugin.settings.showStatusBar = value; - await this.plugin.saveSettings(); - })); + onunload() { + // Clean up UI elements + this.statusBar.unload?.(); + this.statusDropdown.unload?.(); + this.explorerIntegration.unload?.(); + this.styleService.unload?.(); - new Setting(containerEl) - .setName('Status bar position') - .setDesc('Align the status bar text') - .addDropdown(dropdown => dropdown - .addOption('left', 'Left') - .addOption('right', 'Right') - .setValue(this.plugin.settings.statusBarPosition) - .onChange(async (value: 'left' | 'right') => { - this.plugin.settings.statusBarPosition = value; - await this.plugin.saveSettings(); - })); + // Close status pane if open + const statusPane = this.app.workspace.getLeavesOfType('status-pane')[0]; + if (statusPane) statusPane.detach(); - new Setting(containerEl) - .setName('Auto-hide status bar') - .setDesc('Hide the status bar when status is unknown') - .addToggle(toggle => toggle - .setValue(this.plugin.settings.autoHideStatusBar) - .onChange(async (value) => { - this.plugin.settings.autoHideStatusBar = value; - await this.plugin.saveSettings(); - })); - - // File explorer settings - new Setting(containerEl) - .setName('Show status icons in file explorer') - .setDesc('Display status icons in the file explorer') - .addToggle(toggle => toggle - .setValue(this.plugin.settings.showStatusIconsInExplorer) - .onChange(async (value) => { - this.plugin.settings.showStatusIconsInExplorer = value; - await this.plugin.saveSettings(); - })); - - // Compact view setting - new Setting(containerEl) - .setName('Default to compact view') - .setDesc('Start the status pane in compact view by default') - .addToggle(toggle => toggle - .setValue(this.plugin.settings.compactView || false) - .onChange(async (value) => { - this.plugin.settings.compactView = value; - await this.plugin.saveSettings(); - })); - - // Status management section - containerEl.createEl('h3', { text: 'Custom Statuses' }); - const statusList = containerEl.createDiv({ cls: 'custom-status-list' }); - - const renderStatuses = () => { - statusList.empty(); - - this.plugin.settings.customStatuses.forEach((status, index) => { - const setting = new Setting(statusList) - .setName(status.name) - .setClass('status-item'); - - // Name field - // Fixed name field to maintain focus after value changes - setting.addText(text => { - text.setPlaceholder('Status Name') - .setValue(status.name) - .onChange(async (value) => { - // Store current selection state and cursor position - const inputEl = text.inputEl; - const hadFocus = document.activeElement === inputEl; - const selectionStart = inputEl.selectionStart; - const selectionEnd = inputEl.selectionEnd; - - if (value && !this.plugin.settings.customStatuses.some(s => s.name === value && s !== status)) { - const oldName = status.name; - status.name = value; - - // Update color mapping - if (this.plugin.settings.statusColors[oldName]) { - this.plugin.settings.statusColors[value] = this.plugin.settings.statusColors[oldName]; - delete this.plugin.settings.statusColors[oldName]; - } - - await this.plugin.saveSettings(); - - // Use a small timeout to allow the UI to update - setTimeout(() => { - // Re-render statuses but restore focus and selection - renderStatuses(); - - // If the element had focus before, find it again in the new DOM and focus it - if (hadFocus) { - // Find the new input element for this status - // This depends on your DOM structure, you might need to adjust the selector - const newStatusItems = document.querySelectorAll('.status-item'); - - for (let i = 0; i < newStatusItems.length; i++) { - const statusItem = newStatusItems[i]; - const nameText = statusItem.querySelector('.setting-item-name'); - - if (nameText && nameText.textContent === value) { - const newInputEl = statusItem.querySelector('input'); - if (newInputEl) { - newInputEl.focus(); - // Restore cursor position - if (selectionStart !== null && selectionEnd !== null) { - newInputEl.setSelectionRange(selectionStart, selectionEnd); - } - break; - } - } - } - } - }, 10); - } - }); - return text; - }); - - // Icon field - setting.addText(text => text - .setPlaceholder('Icon') - .setValue(status.icon) - .onChange(async (value) => { - status.icon = value || '❓'; - await this.plugin.saveSettings(); - })); - - // Color picker - setting.addColorPicker(colorPicker => colorPicker - .setValue(this.plugin.settings.statusColors[status.name] || '#ffffff') - .onChange(async (value) => { - this.plugin.settings.statusColors[status.name] = value; - await this.plugin.saveSettings(); - })); - - // Remove button - setting.addButton(button => button - .setButtonText('Remove') - .setClass('status-remove-button') - .setWarning() - .onClick(async () => { - this.plugin.settings.customStatuses.splice(index, 1); - delete this.plugin.settings.statusColors[status.name]; - await this.plugin.saveSettings(); - renderStatuses(); - })); - }); - }; - - renderStatuses(); - - // Add new status - 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 () => { - const newStatus = { - name: `status${this.plugin.settings.customStatuses.length + 1}`, - icon: '⭐' - }; - - this.plugin.settings.customStatuses.push(newStatus); - this.plugin.settings.statusColors[newStatus.name] = '#ffffff'; // Initial white color - - await this.plugin.saveSettings(); - renderStatuses(); - })); - - // Reset colors - new Setting(containerEl) - .setName('Reset default status colors') - .setDesc('Restore the default colors for predefined statuses') - .addButton(button => button - .setButtonText('Reset Colors') - .setWarning() - .onClick(async () => { - await this.plugin.resetDefaultColors(); - renderStatuses(); - new Notice('Default status colors restored'); - })); + // Remove event listeners + window.removeEventListener('note-status:settings-changed', this.saveSettings); + window.removeEventListener('note-status:status-changed', this.checkNoteStatus); + window.removeEventListener('note-status:refresh-dropdown', this.statusDropdown.render); + window.removeEventListener('note-status:refresh-ui', this.checkNoteStatus); } } diff --git a/models/types.ts b/models/types.ts new file mode 100644 index 0000000..e78733f --- /dev/null +++ b/models/types.ts @@ -0,0 +1,37 @@ +import { TFile, View } from 'obsidian'; + +/** + * Defines a status with name and icon + */ +export interface Status { + name: string; + icon: string; +} + +/** + * Plugin settings interface + */ +export interface NoteStatusSettings { + statusColors: Record; + showStatusDropdown: boolean; + showStatusBar: boolean; + dropdownPosition: 'top' | 'bottom'; + statusBarPosition: 'left' | 'right'; + autoHideStatusBar: boolean; + customStatuses: Status[]; + showStatusIconsInExplorer: boolean; + collapsedStatuses: Record; + compactView: boolean; +} + +/** + * Extended interface for file explorer view + */ +export interface FileExplorerView extends View { + fileItems: Record; +} diff --git a/services/status-service.ts b/services/status-service.ts new file mode 100644 index 0000000..554e6b2 --- /dev/null +++ b/services/status-service.ts @@ -0,0 +1,169 @@ +import { App, TFile, Editor, Notice } from 'obsidian'; +import { NoteStatusSettings } from '../models/types'; + +/** + * Service for handling note status operations + */ +export class StatusService { + private app: App; + private settings: NoteStatusSettings; + + constructor(app: App, settings: NoteStatusSettings) { + this.app = app; + this.settings = settings; + } + + /** + * Updates the settings reference + */ + public updateSettings(settings: NoteStatusSettings): void { + this.settings = settings; + } + + /** + * Get the status of a file from its metadata + */ + public getFileStatus(file: TFile): string { + const cachedMetadata = this.app.metadataCache.getFileCache(file); + let status = 'unknown'; + + if (cachedMetadata?.frontmatter?.status) { + const frontmatterStatus = cachedMetadata.frontmatter.status.toLowerCase(); + const matchingStatus = this.settings.customStatuses.find(s => + s.name.toLowerCase() === frontmatterStatus); + + if (matchingStatus) status = matchingStatus.name; + } + + return status; + } + + /** + * Get the icon for a given status + */ + public getStatusIcon(status: string): string { + const customStatus = this.settings.customStatuses.find( + s => s.name.toLowerCase() === status.toLowerCase() + ); + return customStatus ? customStatus.icon : '❓'; + } + + /** + * Update the status of a note + */ + public async updateNoteStatus(newStatus: string, file?: TFile): Promise { + const targetFile = file || this.app.workspace.getActiveFile(); + if (!targetFile || targetFile.extension !== 'md') return; + + const content = await this.app.vault.read(targetFile); + let newContent = content; + + // Handle frontmatter update + const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---\n?/); + if (frontmatterMatch) { + const frontmatter = frontmatterMatch[1]; + if (frontmatter.includes('status:')) { + newContent = content.replace( + /^---\n([\s\S]*?)status:\s*[^\n]+([\s\S]*?)\n---\n?/, + `---\n$1status: ${newStatus}$2\n---\n` + ); + } else { + newContent = content.replace( + /^---\n([\s\S]*?)\n---\n?/, + `---\n$1\nstatus: ${newStatus}\n---\n` + ); + } + } else { + newContent = `---\nstatus: ${newStatus}\n---\n${content.trim()}`; + } + + // Clean up excess newlines + newContent = newContent.replace(/\n{3,}/g, '\n\n'); + + // Update the file + await this.app.vault.modify(targetFile, newContent); + } + + /** + * Batch update multiple files' statuses + */ + public async batchUpdateStatus(files: TFile[], newStatus: string): Promise { + if (files.length === 0) { + new Notice('No files selected'); + return; + } + + for (const file of files) { + await this.updateNoteStatus(newStatus, file); + } + + new Notice(`Updated status of ${files.length} file${files.length === 1 ? '' : 's'} to ${newStatus}`); + } + + /** + * Insert status metadata in the editor + */ + public insertStatusMetadataInEditor(editor: Editor): void { + const content = editor.getValue(); + const statusMetadata = 'status: unknown'; + + // Check if frontmatter exists + const frontMatterMatch = content.match(/^---\n([\s\S]+?)\n---/); + + if (frontMatterMatch) { + const frontMatter = frontMatterMatch[1]; + let updatedFrontMatter = frontMatter; + + // Check if status already exists in frontmatter + if (/^status:/.test(frontMatter)) { + updatedFrontMatter = frontMatter.replace(/^status: .*/m, statusMetadata); + } else { + updatedFrontMatter = `${frontMatter}\n${statusMetadata}`; + } + + const updatedContent = content.replace(/^---\n([\s\S]+?)\n---/, `---\n${updatedFrontMatter}\n---`); + editor.setValue(updatedContent); + } else { + // Create new frontmatter if it doesn't exist + const newFrontMatter = `---\n${statusMetadata}\n---\n${content}`; + editor.setValue(newFrontMatter); + } + } + + /** + * Get all markdown files with optional filtering + */ + public getMarkdownFiles(searchQuery = ''): TFile[] { + const files = this.app.vault.getMarkdownFiles(); + + if (!searchQuery) { + return files; + } + + return files.filter(file => + file.basename.toLowerCase().includes(searchQuery.toLowerCase()) + ); + } + + /** + * Group files by their status + */ + public groupFilesByStatus(searchQuery = ''): Record { + const statusGroups: Record = {}; + + // Initialize groups for each status + this.settings.customStatuses.forEach(status => { + statusGroups[status.name] = []; + }); + + // Get all markdown files and filter by search query + const files = this.getMarkdownFiles(searchQuery); + + for (const file of files) { + const status = this.getFileStatus(file); + statusGroups[status].push(file); + } + + return statusGroups; + } +} diff --git a/services/style-service.ts b/services/style-service.ts new file mode 100644 index 0000000..1e47a1a --- /dev/null +++ b/services/style-service.ts @@ -0,0 +1,99 @@ +import { NoteStatusSettings } from '../models/types'; + +/** + * Handles dynamic styling for the plugin + */ +export class StyleService { + private dynamicStyleEl?: HTMLStyleElement; + private settings: NoteStatusSettings; + + constructor(settings: NoteStatusSettings) { + this.settings = settings; + this.initializeDynamicStyles(); + } + + /** + * Updates the settings reference + */ + public updateSettings(settings: NoteStatusSettings): void { + this.settings = settings; + this.updateDynamicStyles(); + } + + /** + * Creates the style element if it doesn't exist + */ + private initializeDynamicStyles(): void { + if (!this.dynamicStyleEl) { + this.dynamicStyleEl = document.createElement('style'); + document.head.appendChild(this.dynamicStyleEl); + } + this.updateDynamicStyles(); + } + + /** + * Updates the dynamic styles based on current settings + */ + public updateDynamicStyles(): void { + if (!this.dynamicStyleEl) { + this.initializeDynamicStyles(); + return; + } + + // Generate CSS for status colors + let css = ''; + for (const [status, color] of Object.entries(this.settings.statusColors)) { + css += ` + .status-${status} { + color: ${color} !important; + } + .note-status-bar .note-status-${status}, + .nav-file-title .note-status-${status} { + color: ${color} !important; + } + `; + } + + // Add modern styling for the batch modal + css += ` + .note-status-batch-modal { + max-width: 500px; + } + + .note-status-modal-search { + margin-bottom: 10px; + } + + .note-status-modal-search-input { + width: 100%; + padding: var(--input-padding); + border-radius: var(--input-radius); + } + + .note-status-file-select, + .note-status-status-select { + width: 100%; + margin-bottom: 10px; + border-radius: var(--input-radius); + } + + .note-status-modal-buttons { + display: flex; + justify-content: space-between; + margin-top: 15px; + } + `; + + this.dynamicStyleEl.textContent = css; + } + + /** + * Cleans up styles when plugin is unloaded + */ + public unload(): void { + if (this.dynamicStyleEl) { + this.dynamicStyleEl.remove(); + this.dynamicStyleEl = undefined; + } + } +} diff --git a/settings/settings-tab.ts b/settings/settings-tab.ts new file mode 100644 index 0000000..011cf75 --- /dev/null +++ b/settings/settings-tab.ts @@ -0,0 +1,239 @@ +import { App, PluginSettingTab, Setting, Notice } from 'obsidian'; +import { Status } from '../models/types'; + +/** + * Settings tab for the Note Status plugin + */ +export class NoteStatusSettingTab extends PluginSettingTab { + plugin: any; + + constructor(app: App, plugin: any) { + super(app, plugin); + this.plugin = plugin; + } + + display(): void { + const { containerEl } = this; + containerEl.empty(); + + // Header + containerEl.createEl('h2', { text: 'Note Status Settings' }); + + // UI section + containerEl.createEl('h3', { text: 'UI Settings' }); + + // Status dropdown settings + new Setting(containerEl) + .setName('Show status dropdown') + .setDesc('Display status dropdown in notes') + .addToggle(toggle => toggle + .setValue(this.plugin.settings.showStatusDropdown) + .onChange(async (value) => { + this.plugin.settings.showStatusDropdown = value; + await this.plugin.saveSettings(); + })); + + new Setting(containerEl) + .setName('Dropdown position') + .setDesc('Where to place the status dropdown') + .addDropdown(dropdown => dropdown + .addOption('top', 'Top') + .addOption('bottom', 'Bottom') + .setValue(this.plugin.settings.dropdownPosition) + .onChange(async (value: 'top' | 'bottom') => { + this.plugin.settings.dropdownPosition = value; + await this.plugin.saveSettings(); + })); + + // Status bar settings + new Setting(containerEl) + .setName('Show status bar') + .setDesc('Display the status bar') + .addToggle(toggle => toggle + .setValue(this.plugin.settings.showStatusBar) + .onChange(async (value) => { + this.plugin.settings.showStatusBar = value; + await this.plugin.saveSettings(); + })); + + new Setting(containerEl) + .setName('Status bar position') + .setDesc('Align the status bar text') + .addDropdown(dropdown => dropdown + .addOption('left', 'Left') + .addOption('right', 'Right') + .setValue(this.plugin.settings.statusBarPosition) + .onChange(async (value: 'left' | 'right') => { + this.plugin.settings.statusBarPosition = value; + await this.plugin.saveSettings(); + })); + + new Setting(containerEl) + .setName('Auto-hide status bar') + .setDesc('Hide the status bar when status is unknown') + .addToggle(toggle => toggle + .setValue(this.plugin.settings.autoHideStatusBar) + .onChange(async (value) => { + this.plugin.settings.autoHideStatusBar = value; + await this.plugin.saveSettings(); + })); + + // File explorer settings + new Setting(containerEl) + .setName('Show status icons in file explorer') + .setDesc('Display status icons in the file explorer') + .addToggle(toggle => toggle + .setValue(this.plugin.settings.showStatusIconsInExplorer) + .onChange(async (value) => { + this.plugin.settings.showStatusIconsInExplorer = value; + await this.plugin.saveSettings(); + })); + + // Compact view setting + new Setting(containerEl) + .setName('Default to compact view') + .setDesc('Start the status pane in compact view by default') + .addToggle(toggle => toggle + .setValue(this.plugin.settings.compactView || false) + .onChange(async (value) => { + this.plugin.settings.compactView = value; + await this.plugin.saveSettings(); + })); + + // Status management section + containerEl.createEl('h3', { text: 'Custom Statuses' }); + const statusList = containerEl.createDiv({ cls: 'custom-status-list' }); + + const renderStatuses = () => { + statusList.empty(); + + this.plugin.settings.customStatuses.forEach((status: Status, index: number) => { + const setting = new Setting(statusList) + .setName(status.name) + .setClass('status-item'); + + // Name field + setting.addText(text => { + text.setPlaceholder('Status Name') + .setValue(status.name) + .onChange(async (value) => { + // Store current selection state and cursor position + const inputEl = text.inputEl; + const hadFocus = document.activeElement === inputEl; + const selectionStart = inputEl.selectionStart; + const selectionEnd = inputEl.selectionEnd; + + if (value && !this.plugin.settings.customStatuses.some( + (s: Status) => s.name === value && s !== status) + ) { + const oldName = status.name; + status.name = value; + + // Update color mapping + if (this.plugin.settings.statusColors[oldName]) { + this.plugin.settings.statusColors[value] = this.plugin.settings.statusColors[oldName]; + delete this.plugin.settings.statusColors[oldName]; + } + + await this.plugin.saveSettings(); + + // Use a small timeout to allow the UI to update + setTimeout(() => { + // Re-render statuses but restore focus and selection + renderStatuses(); + + // If the element had focus before, find it again in the new DOM and focus it + if (hadFocus) { + // Find the new input element for this status + const newStatusItems = document.querySelectorAll('.status-item'); + + for (let i = 0; i < newStatusItems.length; i++) { + const statusItem = newStatusItems[i]; + const nameText = statusItem.querySelector('.setting-item-name'); + + if (nameText && nameText.textContent === value) { + const newInputEl = statusItem.querySelector('input'); + if (newInputEl) { + newInputEl.focus(); + // Restore cursor position + if (selectionStart !== null && selectionEnd !== null) { + newInputEl.setSelectionRange(selectionStart, selectionEnd); + } + break; + } + } + } + } + }, 10); + } + }); + return text; + }); + + // Icon field + setting.addText(text => text + .setPlaceholder('Icon') + .setValue(status.icon) + .onChange(async (value) => { + status.icon = value || '❓'; + await this.plugin.saveSettings(); + })); + + // Color picker + setting.addColorPicker(colorPicker => colorPicker + .setValue(this.plugin.settings.statusColors[status.name] || '#ffffff') + .onChange(async (value) => { + this.plugin.settings.statusColors[status.name] = value; + await this.plugin.saveSettings(); + })); + + // Remove button + setting.addButton(button => button + .setButtonText('Remove') + .setClass('status-remove-button') + .setWarning() + .onClick(async () => { + this.plugin.settings.customStatuses.splice(index, 1); + delete this.plugin.settings.statusColors[status.name]; + await this.plugin.saveSettings(); + renderStatuses(); + })); + }); + }; + + renderStatuses(); + + // Add new status + 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 () => { + const newStatus = { + name: `status${this.plugin.settings.customStatuses.length + 1}`, + icon: '⭐' + }; + + this.plugin.settings.customStatuses.push(newStatus); + this.plugin.settings.statusColors[newStatus.name] = '#ffffff'; // Initial white color + + await this.plugin.saveSettings(); + renderStatuses(); + })); + + // Reset colors + new Setting(containerEl) + .setName('Reset default status colors') + .setDesc('Restore the default colors for predefined statuses') + .addButton(button => button + .setButtonText('Reset Colors') + .setWarning() + .onClick(async () => { + await this.plugin.resetDefaultColors(); + renderStatuses(); + new Notice('Default status colors restored'); + })); + } +} diff --git a/settings/status-pane-view.ts b/settings/status-pane-view.ts new file mode 100644 index 0000000..8cd32a4 --- /dev/null +++ b/settings/status-pane-view.ts @@ -0,0 +1,289 @@ +import { TFile, WorkspaceLeaf, View, Menu, Notice } from 'obsidian'; +import { NoteStatusSettings } from '../models/types'; +import { StatusService } from '../services/status-service'; +import { ICONS } from '../constants/icons'; + +/** + * Status Pane View for managing note statuses + */ +export class StatusPaneView extends View { + plugin: any; + searchInput: HTMLInputElement | null = null; + private settings: NoteStatusSettings; + private statusService: StatusService; + + constructor(leaf: WorkspaceLeaf, plugin: any) { + super(leaf); + this.plugin = plugin; + this.settings = plugin.settings; + this.statusService = plugin.statusService; + } + + getViewType(): string { + return 'status-pane'; + } + + getDisplayText(): string { + return 'Status Pane'; + } + + getIcon(): string { + return 'status-pane'; + } + + async onOpen(): Promise { + await this.setupPane(); + await this.renderGroups(''); + } + + async setupPane(): Promise { + const { containerEl } = this; + containerEl.empty(); + containerEl.addClass('note-status-pane', 'nav-files-container'); + + // Add a header container for better layout + const headerContainer = containerEl.createDiv({ cls: 'note-status-header' }); + + // Search container with search input + this.createSearchInput(headerContainer); + + // Actions toolbar (view toggle, refresh) + this.createActionToolbar(headerContainer); + + // Set initial compact view state + containerEl.toggleClass('compact-view', this.settings.compactView); + + // Add a container for the groups + containerEl.createDiv({ cls: 'note-status-groups-container' }); + } + + private createSearchInput(container: HTMLElement): void { + const searchContainer = container.createDiv({ cls: 'note-status-search search-input-container' }); + const searchWrapper = searchContainer.createDiv({ cls: 'search-input-wrapper' }); + + // Search icon + searchWrapper.createEl('span', { cls: 'search-input-icon' }).innerHTML = ICONS.search; + + // Create the search input + this.searchInput = searchWrapper.createEl('input', { + type: 'text', + placeholder: 'Search notes...', + cls: 'note-status-search-input search-input' + }); + + // Add search event listener + this.searchInput.addEventListener('input', () => { + this.renderGroups(this.searchInput!.value.toLowerCase()); + this.toggleClearButton(); + }); + + // Clear search button (hidden by default) + const clearSearchBtn = searchWrapper.createEl('span', { cls: 'search-input-clear-button' }); + clearSearchBtn.innerHTML = ICONS.clear; + + clearSearchBtn.addEventListener('click', () => { + if (this.searchInput) { + this.searchInput.value = ''; + this.renderGroups(''); + this.toggleClearButton(); + } + }); + } + + private toggleClearButton(): void { + const clearBtn = this.containerEl.querySelector('.search-input-clear-button'); + if (clearBtn && this.searchInput) { + clearBtn.toggleClass('is-visible', !!this.searchInput.value); + } + } + + private createActionToolbar(container: HTMLElement): void { + const actionsContainer = container.createDiv({ cls: 'status-pane-actions-container' }); + + // Toggle compact view button + const viewToggleButton = actionsContainer.createEl('button', { + type: 'button', + title: this.settings.compactView ? 'Switch to Standard View' : 'Switch to Compact View', + cls: 'note-status-view-toggle clickable-icon' + }); + + viewToggleButton.innerHTML = this.settings.compactView ? ICONS.standardView : ICONS.compactView; + + viewToggleButton.addEventListener('click', async () => { + this.settings.compactView = !this.settings.compactView; + viewToggleButton.title = this.settings.compactView ? 'Switch to Standard View' : 'Switch to Compact View'; + viewToggleButton.innerHTML = this.settings.compactView ? ICONS.standardView : ICONS.compactView; + + // Trigger settings update + window.dispatchEvent(new CustomEvent('note-status:settings-changed')); + + this.containerEl.toggleClass('compact-view', this.settings.compactView); + await this.renderGroups(this.searchInput?.value.toLowerCase() || ''); + }); + + // Refresh button + const refreshButton = actionsContainer.createEl('button', { + type: 'button', + title: 'Refresh Statuses', + cls: 'note-status-actions-refresh clickable-icon' + }); + + refreshButton.innerHTML = ICONS.refresh; + + refreshButton.addEventListener('click', async () => { + await this.renderGroups(this.searchInput?.value.toLowerCase() || ''); + new Notice('Status pane refreshed'); + }); + } + + async renderGroups(searchQuery = ''): Promise { + const { containerEl } = this; + const groupsContainer = containerEl.querySelector('.note-status-groups-container'); + if (!groupsContainer) return; + + groupsContainer.empty(); + + // Group files by status + const statusGroups = this.statusService.groupFilesByStatus(searchQuery); + + // Render each status group + Object.entries(statusGroups).forEach(([status, files]) => { + if (files.length > 0) { + this.renderStatusGroup(groupsContainer as HTMLElement, status, files); + } + }); + } + + private renderStatusGroup(container: HTMLElement, status: string, files: TFile[]): void { + const groupEl = container.createDiv({ cls: 'status-group nav-folder' }); + const titleEl = groupEl.createDiv({ cls: 'nav-folder-title' }); + + // Create a container for the collapse button and title + const collapseContainer = titleEl.createDiv({ cls: 'collapse-indicator' }); + collapseContainer.innerHTML = ICONS.collapseDown; + + // Create a container for the title content + const titleContentContainer = titleEl.createDiv({ cls: 'nav-folder-title-content' }); + + const statusIcon = this.statusService.getStatusIcon(status); + titleContentContainer.createSpan({ + text: `${status} ${statusIcon} (${files.length})`, + cls: `status-${status}` + }); + + // Handle collapsing/expanding behavior + titleEl.style.cursor = 'pointer'; + const isCollapsed = this.settings.collapsedStatuses[status] ?? false; + + if (isCollapsed) { + groupEl.addClass('is-collapsed'); + collapseContainer.innerHTML = ICONS.collapseRight; + } + + titleEl.addEventListener('click', (e) => { + e.preventDefault(); + const isCurrentlyCollapsed = groupEl.hasClass('is-collapsed'); + + // Toggle the collapsed state + if (isCurrentlyCollapsed) { + groupEl.removeClass('is-collapsed'); + collapseContainer.innerHTML = ICONS.collapseDown; + } else { + groupEl.addClass('is-collapsed'); + collapseContainer.innerHTML = ICONS.collapseRight; + } + + // Update the settings + this.settings.collapsedStatuses[status] = !isCurrentlyCollapsed; + + // Trigger settings save + window.dispatchEvent(new CustomEvent('note-status:settings-changed')); + }); + + // Create and populate child elements + const childrenEl = groupEl.createDiv({ cls: 'nav-folder-children' }); + + // Sort files by name + files.sort((a, b) => a.basename.localeCompare(b.basename)); + + // Create file list items + files.forEach(file => { + this.createFileListItem(childrenEl, file, status); + }); + } + + private createFileListItem(container: HTMLElement, file: TFile, status: string): void { + const fileEl = container.createDiv({ cls: 'nav-file' }); + const fileTitleEl = fileEl.createDiv({ cls: 'nav-file-title' }); + + // Add file icon if in standard view + if (!this.settings.compactView) { + const fileIcon = fileTitleEl.createDiv({ cls: 'nav-file-icon' }); + fileIcon.innerHTML = ICONS.file; + } + + // Add file name + fileTitleEl.createSpan({ + text: file.basename, + cls: 'nav-file-title-content' + }); + + // Add status indicator + fileTitleEl.createSpan({ + cls: `note-status-icon nav-file-tag status-${status}`, + text: this.statusService.getStatusIcon(status) + }); + + // Add click handler to open the file + fileEl.addEventListener('click', (e) => { + e.preventDefault(); + this.app.workspace.openLinkText(file.path, file.path, true); + }); + + // Add context menu + fileEl.addEventListener('contextmenu', (e) => { + e.preventDefault(); + this.showFileContextMenu(e, file); + }); + } + + private showFileContextMenu(e: MouseEvent, file: TFile): void { + const menu = new Menu(); + + // Add status change options + menu.addItem((item) => + item.setTitle('Change Status') + .setIcon('tag') + .onClick(() => { + this.plugin.showStatusContextMenu([file]); + }) + ); + + // Add open options + menu.addItem((item) => + item.setTitle('Open in New Tab') + .setIcon('lucide-external-link') + .onClick(() => { + this.app.workspace.openLinkText(file.path, file.path, 'tab'); + }) + ); + + menu.showAtMouseEvent(e); + } + + onClose(): Promise { + this.containerEl.empty(); + return Promise.resolve(); + } + + /** + * Update view when settings change + */ + updateSettings(settings: NoteStatusSettings): void { + this.settings = settings; + this.containerEl.toggleClass('compact-view', settings.compactView); + + // Refresh the view with the current search query + this.renderGroups(this.searchInput?.value.toLowerCase() || ''); + } +} diff --git a/ui/context-menus.ts b/ui/context-menus.ts new file mode 100644 index 0000000..f9b7ea8 --- /dev/null +++ b/ui/context-menus.ts @@ -0,0 +1,85 @@ +import { App, Menu, TFile } from 'obsidian'; +import { NoteStatusSettings } from '../models/types'; +import { StatusService } from '../services/status-service'; + +/** + * Handles context menu interactions for status changes + */ +export class StatusContextMenu { + private settings: NoteStatusSettings; + private statusService: StatusService; + public app: App; + + constructor(app: App, settings: NoteStatusSettings, statusService: StatusService) { + this.app = app; + this.settings = settings; + this.statusService = statusService; + } + + /** + * Updates settings reference + */ + public updateSettings(settings: NoteStatusSettings): void { + this.settings = settings; + } + + /** + * Shows the context menu for changing a status of one or more files + */ + public showForFiles(files: TFile[], position?: { x: number; y: number }): void { + const menu = new Menu(); + + // Add status options to menu + this.settings.customStatuses + .filter(status => status.name !== 'unknown') + .forEach(status => { + menu.addItem((item) => + item + .setTitle(`${status.name} ${status.icon}`) + .setIcon('tag') + .onClick(async () => { + await this.statusService.batchUpdateStatus(files, status.name); + + // Dispatch event for UI update + window.dispatchEvent(new CustomEvent('note-status:refresh-ui')); + }) + ); + }); + + // Show the menu + if (position) { + menu.showAtPosition(position); + } else { + // Use a default position at mouse event + menu.showAtMouseEvent(new MouseEvent('contextmenu')); + } + } + + /** + * Shows a context menu for a single file + */ + public showForFile(file: TFile, event: MouseEvent): void { + const menu = new Menu(); + + // Add status change options + menu.addItem((item) => + item.setTitle('Change Status') + .setIcon('tag') + .onClick(() => { + this.showForFiles([file]); + }) + ); + + // Add other file options + // Example: Open in new tab + menu.addItem((item) => + item.setTitle('Open in New Tab') + .setIcon('lucide-external-link') + .onClick(() => { + this.app.workspace.openLinkText(file.path, file.path, 'tab'); + }) + ); + + menu.showAtMouseEvent(event); + } +} diff --git a/ui/explorer.ts b/ui/explorer.ts new file mode 100644 index 0000000..8d60630 --- /dev/null +++ b/ui/explorer.ts @@ -0,0 +1,135 @@ +import { App, TFile } from 'obsidian'; +import { FileExplorerView, NoteStatusSettings } from '../models/types'; +import { StatusService } from '../services/status-service'; + +/** + * Handles file explorer integration for displaying status icons + */ +export class ExplorerIntegration { + private app: App; + private settings: NoteStatusSettings; + private statusService: StatusService; + + constructor(app: App, settings: NoteStatusSettings, statusService: StatusService) { + this.app = app; + this.settings = settings; + this.statusService = statusService; + } + + /** + * Update settings reference + */ + public updateSettings(settings: NoteStatusSettings): void { + this.settings = settings; + + // Update icons based on new settings + if (settings.showStatusIconsInExplorer) { + this.updateAllFileExplorerIcons(); + } else { + this.removeAllFileExplorerIcons(); + } + } + + /** + * Update a single file's icon in the file explorer + */ + public updateFileExplorerIcons(file: TFile): void { + if (!this.settings.showStatusIconsInExplorer || file.extension !== 'md') return; + + const status = this.statusService.getFileStatus(file); + const fileExplorer = this.app.workspace.getLeavesOfType('file-explorer')[0]; + + if (fileExplorer && fileExplorer.view) { + // Cast fileExplorer.view to FileExplorerView + const fileExplorerView = fileExplorer.view as FileExplorerView; + + if (fileExplorerView.fileItems) { + const fileItem = fileExplorerView.fileItems[file.path]; + + if (fileItem) { + const titleEl = fileItem.titleEl || fileItem.selfEl; + if (titleEl) { + // Remove existing icon if present + const existingIcon = titleEl.querySelector('.note-status-icon'); + if (existingIcon) existingIcon.remove(); + + // Add new icon + titleEl.createEl('span', { + cls: `note-status-icon nav-file-tag status-${status}`, + text: this.statusService.getStatusIcon(status) + }); + } + } + } + } + } + + /** + * Update all file icons in the explorer + */ + public updateAllFileExplorerIcons(): void { + // Remove all icons if setting is turned off + if (!this.settings.showStatusIconsInExplorer) { + this.removeAllFileExplorerIcons(); + return; + } + + // Update icons for all markdown files + const files = this.app.vault.getMarkdownFiles(); + files.forEach(file => this.updateFileExplorerIcons(file)); + } + + /** + * Remove all status icons from the file explorer + */ + public removeAllFileExplorerIcons(): void { + const fileExplorer = this.app.workspace.getLeavesOfType('file-explorer')[0]; + if (fileExplorer && fileExplorer.view) { + // Cast fileExplorer.view to FileExplorerView + const fileExplorerView = fileExplorer.view as FileExplorerView; + + if (fileExplorerView.fileItems) { + Object.values(fileExplorerView.fileItems).forEach((fileItem) => { + const titleEl = fileItem.titleEl || fileItem.selfEl; + if (titleEl) { + const existingIcon = titleEl.querySelector('.note-status-icon'); + if (existingIcon) existingIcon.remove(); + } + }); + } + } + } + + /** + * Get selected files from the file explorer + */ + public getSelectedFiles(): TFile[] { + const fileExplorer = this.app.workspace.getLeavesOfType('file-explorer')[0]; + if (!fileExplorer || !fileExplorer.view) { + console.log('File explorer not found or no file items'); + return []; + } + + const fileExplorerView = fileExplorer.view as FileExplorerView; + if (!fileExplorerView.fileItems) { + console.log('No file items found'); + return []; + } + + const selectedFiles: TFile[] = []; + Object.entries(fileExplorerView.fileItems).forEach(([_, item]) => { + if (item.el?.classList.contains('is-selected') && item.file instanceof TFile && item.file.extension === 'md') { + selectedFiles.push(item.file); + } + }); + + return selectedFiles; + } + + /** + * Clean up when plugin is unloaded + */ + public unload(): void { + this.removeAllFileExplorerIcons(); + } +} diff --git a/ui/modals.ts b/ui/modals.ts new file mode 100644 index 0000000..290f09a --- /dev/null +++ b/ui/modals.ts @@ -0,0 +1,120 @@ +import { App, Modal, TFile, Notice } from 'obsidian'; +import { NoteStatusSettings } from '../models/types'; +import { StatusService } from '../services/status-service'; + +/** + * Modal for batch updating statuses + */ +export class BatchStatusModal extends Modal { + settings: NoteStatusSettings; + statusService: StatusService; + app: App; + + constructor(app: App, settings: NoteStatusSettings, statusService: StatusService) { + super(app); + this.app = app; + this.settings = settings; + this.statusService = statusService; + } + + onOpen() { + const { contentEl } = this; + contentEl.createEl('h2', { text: 'Batch Update Note Status' }); + contentEl.addClass('note-status-batch-modal'); + + // Create file selection with search filter + const searchContainer = contentEl.createDiv({ cls: 'note-status-modal-search' }); + const searchInput = searchContainer.createEl('input', { + type: 'text', + placeholder: 'Filter files...', + cls: 'note-status-modal-search-input' + }); + + // File selection container + const fileSelectContainer = contentEl.createDiv({ cls: 'note-status-file-select-container' }); + const fileSelect = fileSelectContainer.createEl('select', { + cls: 'note-status-file-select', + attr: { multiple: 'true', size: '10' } + }); + + // Get all markdown files and populate select + const mdFiles = this.app.vault.getMarkdownFiles(); + const populateFiles = (filter = '') => { + fileSelect.empty(); + mdFiles + .filter(file => !filter || file.path.toLowerCase().includes(filter.toLowerCase())) + .sort((a, b) => a.path.localeCompare(b.path)) + .forEach(file => { + const status = this.statusService.getFileStatus(file); + const option = fileSelect.createEl('option', { + text: `${file.path} ${this.statusService.getStatusIcon(status)}`, + value: file.path + }); + option.classList.add(`status-${status}`); + }); + }; + + populateFiles(); + + // Add search functionality + searchInput.addEventListener('input', () => { + populateFiles(searchInput.value); + }); + + // Status selection + const statusSelectContainer = contentEl.createDiv({ cls: 'note-status-status-select-container' }); + const statusSelect = statusSelectContainer.createEl('select', { cls: 'note-status-status-select' }); + + // Add status options + this.settings.customStatuses + .filter(status => status.name !== 'unknown') + .forEach(status => { + const option = statusSelect.createEl('option', { + text: `${status.name} ${status.icon}`, + value: status.name + }); + option.classList.add(`status-${status.name}`); + }); + + // Add action buttons + const buttonContainer = contentEl.createDiv({ cls: 'note-status-modal-buttons' }); + + // Select all button + const selectAllButton = buttonContainer.createEl('button', { + text: 'Select All', + cls: 'note-status-select-all' + }); + + selectAllButton.addEventListener('click', () => { + for (const option of Array.from(fileSelect.options)) { + option.selected = true; + } + }); + + // Apply button + const applyButton = buttonContainer.createEl('button', { + text: 'Apply Status', + cls: 'mod-cta' + }); + + applyButton.addEventListener('click', async () => { + const selectedFiles = Array.from(fileSelect.selectedOptions) + .map(opt => mdFiles.find(f => f.path === opt.value)) + .filter(Boolean) as TFile[]; + + if (selectedFiles.length === 0) { + new Notice('No files selected'); + return; + } + + const newStatus = statusSelect.value; + await this.statusService.batchUpdateStatus(selectedFiles, newStatus); + this.close(); + }); + } + + onClose() { + const { contentEl } = this; + contentEl.empty(); + } +} diff --git a/ui/status-bar.ts b/ui/status-bar.ts new file mode 100644 index 0000000..3d8b41d --- /dev/null +++ b/ui/status-bar.ts @@ -0,0 +1,107 @@ +import { Notice } from 'obsidian'; +import { NoteStatusSettings } from '../models/types'; +import { StatusService } from '../services/status-service'; + +/** + * Handles the status bar functionality + */ +export class StatusBar { + private statusBarEl: HTMLElement; + private settings: NoteStatusSettings; + private statusService: StatusService; + private currentStatus = 'unknown'; + + constructor(statusBarEl: HTMLElement, settings: NoteStatusSettings, statusService: StatusService) { + this.statusBarEl = statusBarEl; + this.settings = settings; + this.statusService = statusService; + + // Add initial class + this.statusBarEl.addClass('note-status-bar'); + + // Add click handler + this.statusBarEl.addEventListener('click', this.handleClick.bind(this)); + + // Initial render + this.update('unknown'); + } + + /** + * Handle click on status bar + */ + private handleClick(): void { + this.settings.showStatusDropdown = !this.settings.showStatusDropdown; + + // Notify UI listeners through an event + window.dispatchEvent(new CustomEvent('note-status:refresh-dropdown')); + + new Notice(`Status dropdown ${this.settings.showStatusDropdown ? 'shown' : 'hidden'}`); + } + + /** + * Update the status bar with a new status + */ + public update(status: string): void { + this.currentStatus = status; + this.render(); + } + + /** + * Render the status bar based on current settings and status + */ + public render(): void { + this.statusBarEl.empty(); + this.statusBarEl.removeClass('left', 'hidden', 'auto-hide', 'visible'); + this.statusBarEl.addClass('note-status-bar'); + + if (!this.settings.showStatusBar) { + this.statusBarEl.addClass('hidden'); + return; + } + + // Add left class if needed + if (this.settings.statusBarPosition === 'left') { + this.statusBarEl.addClass('left'); + } + + // Create status text + this.statusBarEl.createEl('span', { + text: `Status: ${this.currentStatus}`, + cls: `note-status-${this.currentStatus}` + }); + + // Create status icon + this.statusBarEl.createEl('span', { + text: this.statusService.getStatusIcon(this.currentStatus), + cls: `note-status-icon status-${this.currentStatus}` + }); + + // Handle auto-hide behavior + if (this.settings.autoHideStatusBar && this.currentStatus === 'unknown') { + this.statusBarEl.addClass('auto-hide'); + setTimeout(() => { + if (this.currentStatus === 'unknown' && this.settings.showStatusBar) { + this.statusBarEl.addClass('hidden'); + } + }, 500); + } else { + this.statusBarEl.addClass('visible'); + } + } + + /** + * Update settings reference + */ + public updateSettings(settings: NoteStatusSettings): void { + this.settings = settings; + this.render(); + } + + /** + * Clean up when plugin is unloaded + */ + public unload(): void { + // Clean up any event listeners or DOM elements + this.statusBarEl.empty(); + } +} diff --git a/ui/status-dropdown.ts b/ui/status-dropdown.ts new file mode 100644 index 0000000..c60cec6 --- /dev/null +++ b/ui/status-dropdown.ts @@ -0,0 +1,174 @@ +import { MarkdownView, Notice, Editor, Menu } from 'obsidian'; +import { NoteStatusSettings } from '../models/types'; +import { StatusService } from '../services/status-service'; + +/** + * Handles the status dropdown functionality + */ +export class StatusDropdown { + private app: any; + private settings: NoteStatusSettings; + private statusService: StatusService; + private dropdownContainer?: HTMLElement; + private currentStatus = 'unknown'; + + constructor(app: any, settings: NoteStatusSettings, statusService: StatusService) { + this.app = app; + this.settings = settings; + this.statusService = statusService; + } + + /** + * Updates the dropdown UI based on current settings + */ + public update(currentStatus: string): void { + this.currentStatus = currentStatus; + this.render(); + } + + /** + * Updates settings reference + */ + public updateSettings(settings: NoteStatusSettings): void { + this.settings = settings; + this.render(); + } + + /** + * Render or remove the dropdown based on settings + */ + public render(): void { + // Remove existing dropdown if setting is turned off + if (!this.settings.showStatusDropdown) { + if (this.dropdownContainer) { + this.dropdownContainer.remove(); + this.dropdownContainer = undefined; + } + return; + } + + // Check for active markdown view + const view = this.app.workspace.getActiveViewOfType(MarkdownView); + if (!view) { + if (this.dropdownContainer) { + this.dropdownContainer.remove(); + this.dropdownContainer = undefined; + } + return; + } + + // Create or update the dropdown container + const contentEl = view.contentEl; + if (!this.dropdownContainer) { + this.dropdownContainer = this.settings.dropdownPosition === 'top' + ? contentEl.insertBefore(document.createElement('div'), contentEl.firstChild) + : contentEl.appendChild(document.createElement('div')); + + if (this.dropdownContainer) { + this.dropdownContainer.addClass('note-status-dropdown', this.settings.dropdownPosition); + } + } + + // Ensure dropdown container exists before continuing + if (!this.dropdownContainer) return; + + // Populate the dropdown + this.dropdownContainer.empty(); + + // Add label + this.dropdownContainer.createEl('span', { text: 'Status:', cls: 'note-status-label' }); + + // Add select element + const select = this.dropdownContainer.createEl('select', { cls: 'note-status-select dropdown' }); + + // Add status options (excluding 'unknown') + this.settings.customStatuses + .forEach(status => { + const option = select.createEl('option', { + text: `${status.name} ${status.icon}`, + value: status.name + }); + if (status.name === 'unknown') option.disabled = true; + + + if (status.name === this.currentStatus) option.selected = true; + }); + + // Add change event listener + select.addEventListener('change', async (e) => { + const newStatus = (e.target as HTMLSelectElement).value; + await this.statusService.updateNoteStatus(newStatus); + + // Dispatch event for UI update + window.dispatchEvent(new CustomEvent('note-status:status-changed', { + detail: { status: newStatus } + })); + }); + + // Add hide button + if (!this.dropdownContainer) return; + + const hideButton = this.dropdownContainer.createEl('button', { + text: 'Hide Bar', + cls: 'note-status-hide-button clickable-icon mod-cta' + }); + + hideButton.addEventListener('click', () => { + this.settings.showStatusDropdown = false; + this.render(); + + // Trigger settings save + window.dispatchEvent(new CustomEvent('note-status:settings-changed')); + + new Notice('Status dropdown hidden'); + }); + } + + /** + * Show status dropdown in context menu + */ + public showInContextMenu(editor: Editor, view: MarkdownView): void { + const menu = new Menu(); + + // Add status options to menu + this.settings.customStatuses + .filter(status => status.name !== 'unknown') + .forEach(status => { + menu.addItem((item) => + item + .setTitle(`${status.name} ${status.icon}`) + .setIcon('tag') + .onClick(async () => { + await this.statusService.updateNoteStatus(status.name); + + // Dispatch event for UI update + window.dispatchEvent(new CustomEvent('note-status:status-changed', { + detail: { status: status.name } + })); + }) + ); + }); + + // Position menu near cursor + const cursor = editor.getCursor('to'); + editor.posToOffset(cursor); + const editorEl = view.contentEl.querySelector('.cm-content'); + + if (editorEl) { + const rect = editorEl.getBoundingClientRect(); + menu.showAtPosition({ x: rect.left, y: rect.bottom }); + } else { + menu.showAtPosition({ x: 0, y: 0 }); + } + } + + /** + * Remove dropdown when plugin is unloaded + */ + public unload(): void { + if (this.dropdownContainer) { + this.dropdownContainer.remove(); + this.dropdownContainer = undefined; + } + } +} diff --git a/ui/status-pane-view.ts b/ui/status-pane-view.ts new file mode 100644 index 0000000..8cd32a4 --- /dev/null +++ b/ui/status-pane-view.ts @@ -0,0 +1,289 @@ +import { TFile, WorkspaceLeaf, View, Menu, Notice } from 'obsidian'; +import { NoteStatusSettings } from '../models/types'; +import { StatusService } from '../services/status-service'; +import { ICONS } from '../constants/icons'; + +/** + * Status Pane View for managing note statuses + */ +export class StatusPaneView extends View { + plugin: any; + searchInput: HTMLInputElement | null = null; + private settings: NoteStatusSettings; + private statusService: StatusService; + + constructor(leaf: WorkspaceLeaf, plugin: any) { + super(leaf); + this.plugin = plugin; + this.settings = plugin.settings; + this.statusService = plugin.statusService; + } + + getViewType(): string { + return 'status-pane'; + } + + getDisplayText(): string { + return 'Status Pane'; + } + + getIcon(): string { + return 'status-pane'; + } + + async onOpen(): Promise { + await this.setupPane(); + await this.renderGroups(''); + } + + async setupPane(): Promise { + const { containerEl } = this; + containerEl.empty(); + containerEl.addClass('note-status-pane', 'nav-files-container'); + + // Add a header container for better layout + const headerContainer = containerEl.createDiv({ cls: 'note-status-header' }); + + // Search container with search input + this.createSearchInput(headerContainer); + + // Actions toolbar (view toggle, refresh) + this.createActionToolbar(headerContainer); + + // Set initial compact view state + containerEl.toggleClass('compact-view', this.settings.compactView); + + // Add a container for the groups + containerEl.createDiv({ cls: 'note-status-groups-container' }); + } + + private createSearchInput(container: HTMLElement): void { + const searchContainer = container.createDiv({ cls: 'note-status-search search-input-container' }); + const searchWrapper = searchContainer.createDiv({ cls: 'search-input-wrapper' }); + + // Search icon + searchWrapper.createEl('span', { cls: 'search-input-icon' }).innerHTML = ICONS.search; + + // Create the search input + this.searchInput = searchWrapper.createEl('input', { + type: 'text', + placeholder: 'Search notes...', + cls: 'note-status-search-input search-input' + }); + + // Add search event listener + this.searchInput.addEventListener('input', () => { + this.renderGroups(this.searchInput!.value.toLowerCase()); + this.toggleClearButton(); + }); + + // Clear search button (hidden by default) + const clearSearchBtn = searchWrapper.createEl('span', { cls: 'search-input-clear-button' }); + clearSearchBtn.innerHTML = ICONS.clear; + + clearSearchBtn.addEventListener('click', () => { + if (this.searchInput) { + this.searchInput.value = ''; + this.renderGroups(''); + this.toggleClearButton(); + } + }); + } + + private toggleClearButton(): void { + const clearBtn = this.containerEl.querySelector('.search-input-clear-button'); + if (clearBtn && this.searchInput) { + clearBtn.toggleClass('is-visible', !!this.searchInput.value); + } + } + + private createActionToolbar(container: HTMLElement): void { + const actionsContainer = container.createDiv({ cls: 'status-pane-actions-container' }); + + // Toggle compact view button + const viewToggleButton = actionsContainer.createEl('button', { + type: 'button', + title: this.settings.compactView ? 'Switch to Standard View' : 'Switch to Compact View', + cls: 'note-status-view-toggle clickable-icon' + }); + + viewToggleButton.innerHTML = this.settings.compactView ? ICONS.standardView : ICONS.compactView; + + viewToggleButton.addEventListener('click', async () => { + this.settings.compactView = !this.settings.compactView; + viewToggleButton.title = this.settings.compactView ? 'Switch to Standard View' : 'Switch to Compact View'; + viewToggleButton.innerHTML = this.settings.compactView ? ICONS.standardView : ICONS.compactView; + + // Trigger settings update + window.dispatchEvent(new CustomEvent('note-status:settings-changed')); + + this.containerEl.toggleClass('compact-view', this.settings.compactView); + await this.renderGroups(this.searchInput?.value.toLowerCase() || ''); + }); + + // Refresh button + const refreshButton = actionsContainer.createEl('button', { + type: 'button', + title: 'Refresh Statuses', + cls: 'note-status-actions-refresh clickable-icon' + }); + + refreshButton.innerHTML = ICONS.refresh; + + refreshButton.addEventListener('click', async () => { + await this.renderGroups(this.searchInput?.value.toLowerCase() || ''); + new Notice('Status pane refreshed'); + }); + } + + async renderGroups(searchQuery = ''): Promise { + const { containerEl } = this; + const groupsContainer = containerEl.querySelector('.note-status-groups-container'); + if (!groupsContainer) return; + + groupsContainer.empty(); + + // Group files by status + const statusGroups = this.statusService.groupFilesByStatus(searchQuery); + + // Render each status group + Object.entries(statusGroups).forEach(([status, files]) => { + if (files.length > 0) { + this.renderStatusGroup(groupsContainer as HTMLElement, status, files); + } + }); + } + + private renderStatusGroup(container: HTMLElement, status: string, files: TFile[]): void { + const groupEl = container.createDiv({ cls: 'status-group nav-folder' }); + const titleEl = groupEl.createDiv({ cls: 'nav-folder-title' }); + + // Create a container for the collapse button and title + const collapseContainer = titleEl.createDiv({ cls: 'collapse-indicator' }); + collapseContainer.innerHTML = ICONS.collapseDown; + + // Create a container for the title content + const titleContentContainer = titleEl.createDiv({ cls: 'nav-folder-title-content' }); + + const statusIcon = this.statusService.getStatusIcon(status); + titleContentContainer.createSpan({ + text: `${status} ${statusIcon} (${files.length})`, + cls: `status-${status}` + }); + + // Handle collapsing/expanding behavior + titleEl.style.cursor = 'pointer'; + const isCollapsed = this.settings.collapsedStatuses[status] ?? false; + + if (isCollapsed) { + groupEl.addClass('is-collapsed'); + collapseContainer.innerHTML = ICONS.collapseRight; + } + + titleEl.addEventListener('click', (e) => { + e.preventDefault(); + const isCurrentlyCollapsed = groupEl.hasClass('is-collapsed'); + + // Toggle the collapsed state + if (isCurrentlyCollapsed) { + groupEl.removeClass('is-collapsed'); + collapseContainer.innerHTML = ICONS.collapseDown; + } else { + groupEl.addClass('is-collapsed'); + collapseContainer.innerHTML = ICONS.collapseRight; + } + + // Update the settings + this.settings.collapsedStatuses[status] = !isCurrentlyCollapsed; + + // Trigger settings save + window.dispatchEvent(new CustomEvent('note-status:settings-changed')); + }); + + // Create and populate child elements + const childrenEl = groupEl.createDiv({ cls: 'nav-folder-children' }); + + // Sort files by name + files.sort((a, b) => a.basename.localeCompare(b.basename)); + + // Create file list items + files.forEach(file => { + this.createFileListItem(childrenEl, file, status); + }); + } + + private createFileListItem(container: HTMLElement, file: TFile, status: string): void { + const fileEl = container.createDiv({ cls: 'nav-file' }); + const fileTitleEl = fileEl.createDiv({ cls: 'nav-file-title' }); + + // Add file icon if in standard view + if (!this.settings.compactView) { + const fileIcon = fileTitleEl.createDiv({ cls: 'nav-file-icon' }); + fileIcon.innerHTML = ICONS.file; + } + + // Add file name + fileTitleEl.createSpan({ + text: file.basename, + cls: 'nav-file-title-content' + }); + + // Add status indicator + fileTitleEl.createSpan({ + cls: `note-status-icon nav-file-tag status-${status}`, + text: this.statusService.getStatusIcon(status) + }); + + // Add click handler to open the file + fileEl.addEventListener('click', (e) => { + e.preventDefault(); + this.app.workspace.openLinkText(file.path, file.path, true); + }); + + // Add context menu + fileEl.addEventListener('contextmenu', (e) => { + e.preventDefault(); + this.showFileContextMenu(e, file); + }); + } + + private showFileContextMenu(e: MouseEvent, file: TFile): void { + const menu = new Menu(); + + // Add status change options + menu.addItem((item) => + item.setTitle('Change Status') + .setIcon('tag') + .onClick(() => { + this.plugin.showStatusContextMenu([file]); + }) + ); + + // Add open options + menu.addItem((item) => + item.setTitle('Open in New Tab') + .setIcon('lucide-external-link') + .onClick(() => { + this.app.workspace.openLinkText(file.path, file.path, 'tab'); + }) + ); + + menu.showAtMouseEvent(e); + } + + onClose(): Promise { + this.containerEl.empty(); + return Promise.resolve(); + } + + /** + * Update view when settings change + */ + updateSettings(settings: NoteStatusSettings): void { + this.settings = settings; + this.containerEl.toggleClass('compact-view', settings.compactView); + + // Refresh the view with the current search query + this.renderGroups(this.searchInput?.value.toLowerCase() || ''); + } +} diff --git a/utils/dom-utils.ts b/utils/dom-utils.ts new file mode 100644 index 0000000..f72cd94 --- /dev/null +++ b/utils/dom-utils.ts @@ -0,0 +1,119 @@ +/** + * Utility functions for DOM manipulation + */ + +/** + * Creates a button element with given properties + */ +export function createButton( + container: HTMLElement, + text: string, + cls: string, + clickHandler: (e: MouseEvent) => void, + icon?: string +): HTMLButtonElement { + const button = container.createEl('button', { + text: text, + cls: cls + }); + + if (icon) { + button.innerHTML = icon; + } + + button.addEventListener('click', clickHandler); + return button; +} + +/** + * Creates a text input with label + */ +export function createLabeledInput( + container: HTMLElement, + id: string, + labelText: string, + placeholder: string, + initialValue: string, + changeHandler: (value: string) => void +): HTMLInputElement { + const wrapper = container.createDiv({ cls: 'setting-item' }); + + wrapper.createEl('label', { + text: labelText, + attr: { for: id } + }); + + const input = wrapper.createEl('input', { + type: 'text', + placeholder: placeholder, + value: initialValue, + attr: { id: id } + }); + + input.addEventListener('change', () => { + changeHandler(input.value); + }); + + return input; +} + +/** + * Creates a dropdown select element + */ +export function createSelect( + container: HTMLElement, + options: { value: string; text: string; selected?: boolean }[], + changeHandler: (value: string) => void, + cls?: string +): HTMLSelectElement { + const select = container.createEl('select', { cls: cls }); + + options.forEach(option => { + const optionEl = select.createEl('option', { + text: option.text, + value: option.value + }); + + if (option.selected) { + optionEl.selected = true; + } + }); + + select.addEventListener('change', (e) => { + changeHandler((e.target as HTMLSelectElement).value); + }); + + return select; +} + +/** + * Adds a toggle switch with label + */ +export function createToggle( + container: HTMLElement, + labelText: string, + initialValue: boolean, + changeHandler: (value: boolean) => void +): HTMLElement { + const wrapper = container.createDiv({ cls: 'setting-item-toggle' }); + + // Create label + wrapper.createEl('span', { text: labelText, cls: 'setting-item-label' }); + + // Create toggle component + const toggleContainer = wrapper.createDiv({ cls: 'setting-item-control' }); + const toggle = toggleContainer.createEl('div', { cls: 'checkbox-container' }); + + // Create the actual input + const input = toggle.createEl('input', { + type: 'checkbox', + cls: 'checkbox' + }); + input.checked = initialValue; + + input.addEventListener('change', () => { + changeHandler(input.checked); + }); + + return wrapper; +} diff --git a/utils/file-utils.ts b/utils/file-utils.ts new file mode 100644 index 0000000..e97f9de --- /dev/null +++ b/utils/file-utils.ts @@ -0,0 +1,104 @@ +import { App, TFile } from 'obsidian'; + +/** + * Utility functions for file operations + */ + +/** + * Checks if a file is a markdown file + */ +export function isMarkdownFile(file: TFile): boolean { + return file && file.extension === 'md'; +} + +/** + * Gets the frontmatter from file content + */ +export function extractFrontmatter(content: string): { + hasFrontMatter: boolean; + frontMatter?: string; + content: string; +} { + const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---\n?/); + + if (frontmatterMatch) { + return { + hasFrontMatter: true, + frontMatter: frontmatterMatch[1], + content: content.slice(frontmatterMatch[0].length) + }; + } + + return { + hasFrontMatter: false, + content + }; +} + +/** + * Updates a property in frontmatter + */ +export function updateFrontmatterProperty( + content: string, + property: string, + value: string +): string { + const { hasFrontMatter, frontMatter, content: mainContent } = extractFrontmatter(content); + + if (hasFrontMatter && frontMatter) { + // Check if property already exists in frontmatter + if (frontMatter.includes(`${property}:`)) { + // Replace existing property + const updatedFrontMatter = frontMatter.replace( + new RegExp(`${property}:\\s*[^\\n]+`, 'm'), + `${property}: ${value}` + ); + return `---\n${updatedFrontMatter}\n---\n${mainContent}`; + } else { + // Add new property to existing frontmatter + return `---\n${frontMatter}\n${property}: ${value}\n---\n${mainContent}`; + } + } else { + // Create new frontmatter + return `---\n${property}: ${value}\n---\n\n${content}`; + } +} + +/** + * Reads frontmatter property from file + */ +export async function getFrontmatterProperty( + app: App, + file: TFile, + property: string +): Promise { + const cache = app.metadataCache.getFileCache(file); + + if (cache?.frontmatter && property in cache.frontmatter) { + return cache.frontmatter[property]; + } + + return null; +} + +/** + * Safely updates a file's frontmatter + */ +export async function updateFileFrontmatter( + app: App, + file: TFile, + property: string, + value: string +): Promise { + if (!isMarkdownFile(file)) { + return; + } + + const content = await app.vault.read(file); + const newContent = updateFrontmatterProperty(content, property, value); + + // Only write if content has changed + if (newContent !== content) { + await app.vault.modify(file, newContent); + } +}