mirror of
https://github.com/devonthesofa/obsidian-note-status.git
synced 2026-07-22 05:45:04 +00:00
refactor: folder structure and clean code
This commit is contained in:
parent
d4385234a0
commit
382ddf9b56
16 changed files with 2172 additions and 1184 deletions
40
constants/defaults.ts
Normal file
40
constants/defaults.ts
Normal file
|
|
@ -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<string, string> = {
|
||||
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
|
||||
};
|
||||
24
constants/icons.ts
Normal file
24
constants/icons.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
/**
|
||||
* SVG icon definitions
|
||||
*/
|
||||
export const ICONS = {
|
||||
statusPane: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M3 3h18v18H3V3zm2 2v14h14V5H5zm2 2h10v2H7V7zm0 4h10v2H7v-2zm0 4h10v2H7v-2z"/>
|
||||
</svg>`,
|
||||
search: `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="search-icon">
|
||||
<circle cx="11" cy="11" r="8"></circle>
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
||||
</svg>`,
|
||||
clear: `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>`,
|
||||
standardView: `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"></line><line x1="8" y1="12" x2="21" y2="12"></line><line x1="8" y1="18" x2="21" y2="18"></line><line x1="3" y1="6" x2="3.01" y2="6"></line><line x1="3" y1="12" x2="3.01" y2="12"></line><line x1="3" y1="18" x2="3.01" y2="18"></line></svg>`,
|
||||
compactView: `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect><line x1="9" y1="3" x2="9" y2="21"></line></svg>`,
|
||||
refresh: `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.5 2v6h-6M21.34 15.57a10 10 0 1 1-.57-8.38"/></svg>`,
|
||||
collapseDown: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg>`,
|
||||
collapseRight: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6"/></svg>`,
|
||||
file: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"></path><polyline points="14 2 14 8 20 8"></polyline></svg>`
|
||||
};
|
||||
37
models/types.ts
Normal file
37
models/types.ts
Normal file
|
|
@ -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<string, string>;
|
||||
showStatusDropdown: boolean;
|
||||
showStatusBar: boolean;
|
||||
dropdownPosition: 'top' | 'bottom';
|
||||
statusBarPosition: 'left' | 'right';
|
||||
autoHideStatusBar: boolean;
|
||||
customStatuses: Status[];
|
||||
showStatusIconsInExplorer: boolean;
|
||||
collapsedStatuses: Record<string, boolean>;
|
||||
compactView: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extended interface for file explorer view
|
||||
*/
|
||||
export interface FileExplorerView extends View {
|
||||
fileItems: Record<string, {
|
||||
el?: HTMLElement;
|
||||
file?: TFile;
|
||||
titleEl?: HTMLElement;
|
||||
selfEl?: HTMLElement;
|
||||
}>;
|
||||
}
|
||||
169
services/status-service.ts
Normal file
169
services/status-service.ts
Normal file
|
|
@ -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<void> {
|
||||
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<void> {
|
||||
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<string, TFile[]> {
|
||||
const statusGroups: Record<string, TFile[]> = {};
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
99
services/style-service.ts
Normal file
99
services/style-service.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
239
settings/settings-tab.ts
Normal file
239
settings/settings-tab.ts
Normal file
|
|
@ -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');
|
||||
}));
|
||||
}
|
||||
}
|
||||
289
settings/status-pane-view.ts
Normal file
289
settings/status-pane-view.ts
Normal file
|
|
@ -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<void> {
|
||||
await this.setupPane();
|
||||
await this.renderGroups('');
|
||||
}
|
||||
|
||||
async setupPane(): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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() || '');
|
||||
}
|
||||
}
|
||||
85
ui/context-menus.ts
Normal file
85
ui/context-menus.ts
Normal file
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
135
ui/explorer.ts
Normal file
135
ui/explorer.ts
Normal file
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
120
ui/modals.ts
Normal file
120
ui/modals.ts
Normal file
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
107
ui/status-bar.ts
Normal file
107
ui/status-bar.ts
Normal file
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
174
ui/status-dropdown.ts
Normal file
174
ui/status-dropdown.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
289
ui/status-pane-view.ts
Normal file
289
ui/status-pane-view.ts
Normal file
|
|
@ -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<void> {
|
||||
await this.setupPane();
|
||||
await this.renderGroups('');
|
||||
}
|
||||
|
||||
async setupPane(): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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() || '');
|
||||
}
|
||||
}
|
||||
119
utils/dom-utils.ts
Normal file
119
utils/dom-utils.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
104
utils/file-utils.ts
Normal file
104
utils/file-utils.ts
Normal file
|
|
@ -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<string | null> {
|
||||
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<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue