/* Abacus Custom Sorter. It's a config-driven custom file explorer sorter for Obsidian. Inspired by SebastianMC's Custom File Explorer sorting. This plugin however does not use a bookmark dependency. */ var obsidian = require('obsidian'); // Default Settings const DEFAULT_SETTINGS = { suspended: false, sortSpecFile: '', statusBarEnabled: true, notificationsEnabled: true, contextMenuEnabled: true, delayForInitialApplication: 0, manualOrder: {} // { folderPath: [itemName, itemName, ...] } }; // Sort Spec Parser const SORT_ORDERS = { 'a-z': { key: 'alpha', reverse: false, caseSensitive: false }, 'A-Z': { key: 'alpha', reverse: false, caseSensitive: true }, 'z-a': { key: 'alpha', reverse: true, caseSensitive: false }, 'Z-A': { key: 'alpha', reverse: true, caseSensitive: true }, 'modified-new': { key: 'mtime', reverse: false }, 'modified-old': { key: 'mtime', reverse: true }, 'created-new': { key: 'ctime', reverse: false }, 'created-old': { key: 'ctime', reverse: true }, 'natural-a-z': { key: 'natural', reverse: false }, 'natural-z-a': { key: 'natural', reverse: true }, 'files-first': { key: 'type', filesFirst: true }, 'folders-first': { key: 'type', filesFirst: false }, 'by-metadata': { key: 'metadata' }, 'manual': { key: 'manual' }, }; const MATCH_TYPES = { 'exact': 'exact', 'prefix': 'prefix', 'suffix': 'suffix', 'regex': 'regex', 'with-metadata': 'metadata', 'files': 'files', 'folders': 'folders', 'all': 'all', }; function naturalCompare(a, b) { return a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' }); } function parseSortSpec(text) { if (!text || !text.trim()) return null; const lines = text.split('\n'); const specs = {}; let currentFolder = null; let currentGroups = []; let currentGroup = null; function finishFolder() { if (currentFolder !== null) { if (currentGroup) currentGroups.push(currentGroup); specs[currentFolder] = { groups: currentGroups }; currentGroups = []; currentGroup = null; } } for (let rawLine of lines) { const line = rawLine.trim(); if (!line || line.startsWith('//') || line.startsWith('#')) continue; // Folder target if (line.startsWith('target-folder:')) { finishFolder(); currentFolder = line.slice('target-folder:'.length).trim(); if (currentFolder === '/') currentFolder = '/'; continue; } // Wildcard folder target if (line.startsWith('target-folder-wildcard:')) { finishFolder(); currentFolder = line.slice('target-folder-wildcard:'.length).trim(); continue; } if (currentFolder === null) continue; // Sort order line if (line.startsWith('>') || line.startsWith('< ')) { const orderStr = line.replace(/^[><]\s*/, '').trim(); const parts = orderStr.split(/\s+/); const orderName = parts[0]; const order = SORT_ORDERS[orderName]; if (order && currentGroup) { currentGroup.order = { ...order }; if (order.key === 'metadata' && parts.length > 1) { currentGroup.order.metadataField = parts[1]; if (parts.includes('numeric')) currentGroup.order.numeric = true; } } else if (order && currentGroups.length === 0 && !currentGroup) { // Default order for the folder if (!specs[currentFolder]) specs[currentFolder] = { groups: [] }; specs[currentFolder].defaultOrder = { ...order }; if (order.key === 'metadata' && parts.length > 1) { specs[currentFolder].defaultOrder.metadataField = parts[1]; if (parts.includes('numeric')) specs[currentFolder].defaultOrder.numeric = true; } } continue; } // Separator if (line === '---' || line === '***') { if (currentGroup) { currentGroups.push(currentGroup); currentGroup = null; } continue; } // Match rule let matched = false; for (const [keyword, type] of Object.entries(MATCH_TYPES)) { if (line.startsWith(keyword + ':') || line === keyword) { if (currentGroup) currentGroups.push(currentGroup); const value = line.includes(':') ? line.slice(keyword.length + 1).trim() : ''; currentGroup = { matchType: type, matchValue: value, order: null }; if (type === 'regex' && value) { try { currentGroup.matchRegex = new RegExp(value); } catch(e) { /* skip invalid regex */ } } matched = true; break; } } if (matched) continue; // If nothing else matched, treat as a group label or exact match shorthand if (currentGroup) currentGroups.push(currentGroup); currentGroup = { matchType: 'exact', matchValue: line, order: null }; } finishFolder(); return Object.keys(specs).length > 0 ? specs : null; } // Items from getSortedFolderItems may be FileExplorerItem (with .file) // or raw TAbstractFile objects, depending on Obsidian version. This helper normalizes. function getItemFile(item) { return item && item.file ? item.file : item; } function getItemName(item) { const f = getItemFile(item); return f ? (f.name || '') : ''; } function itemMatchesGroup(item, group, app) { const f = getItemFile(item); const name = f ? (f.name || '') : ''; const basename = f ? (f.basename || name.replace(/\.[^.]+$/, '')) : ''; switch (group.matchType) { case 'exact': return name === group.matchValue || basename === group.matchValue; case 'prefix': return name.startsWith(group.matchValue) || basename.startsWith(group.matchValue); case 'suffix': return name.endsWith(group.matchValue) || basename.endsWith(group.matchValue); case 'regex': return group.matchRegex ? group.matchRegex.test(name) || group.matchRegex.test(basename) : false; case 'metadata': { if (!f || !f.path) return false; const cache = app.metadataCache.getCache(f.path); if (!cache || !cache.frontmatter) return false; return cache.frontmatter[group.matchValue] !== undefined; } case 'files': return !!(f && f.children === undefined); case 'folders': return !!(f && f.children !== undefined); case 'all': return true; default: return false; } } // Sorting Engine function getMetadataValue(item, field, app) { const f = getItemFile(item); if (!f || !f.path) return null; const cache = app.metadataCache.getCache(f.path); if (!cache || !cache.frontmatter) return null; return cache.frontmatter[field] ?? null; } function makeComparator(order, app, manualOrderList) { if (!order) return null; switch (order.key) { case 'alpha': return (a, b) => { const an = getItemName(a); const bn = getItemName(b); const cmp = order.caseSensitive ? an.localeCompare(bn) : an.toLowerCase().localeCompare(bn.toLowerCase()); return order.reverse ? -cmp : cmp; }; case 'natural': return (a, b) => { const an = getItemName(a); const bn = getItemName(b); const cmp = naturalCompare(an, bn); return order.reverse ? -cmp : cmp; }; case 'mtime': return (a, b) => { const af = getItemFile(a); const bf = getItemFile(b); const at = af && af.stat ? af.stat.mtime : 0; const bt = bf && bf.stat ? bf.stat.mtime : 0; return order.reverse ? at - bt : bt - at; }; case 'ctime': return (a, b) => { const af = getItemFile(a); const bf = getItemFile(b); const at = af && af.stat ? af.stat.ctime : 0; const bt = bf && bf.stat ? bf.stat.ctime : 0; return order.reverse ? at - bt : bt - at; }; case 'type': return (a, b) => { const af = getItemFile(a); const bf = getItemFile(b); const aIsFolder = !!(af && af.children !== undefined); const bIsFolder = !!(bf && bf.children !== undefined); if (aIsFolder === bIsFolder) return 0; if (order.filesFirst) return aIsFolder ? 1 : -1; return aIsFolder ? -1 : 1; }; case 'metadata': return (a, b) => { let av = getMetadataValue(a, order.metadataField, app); let bv = getMetadataValue(b, order.metadataField, app); if (av === null && bv === null) return 0; if (av === null) return 1; if (bv === null) return -1; if (order.numeric) { av = parseFloat(av); bv = parseFloat(bv); if (isNaN(av)) return 1; if (isNaN(bv)) return -1; return av - bv; } return String(av).localeCompare(String(bv)); }; case 'manual': return (a, b) => { if (!manualOrderList) return 0; const an = getItemName(a); const bn = getItemName(b); const ai = manualOrderList.indexOf(an); const bi = manualOrderList.indexOf(bn); if (ai === -1 && bi === -1) return 0; if (ai === -1) return 1; if (bi === -1) return -1; return ai - bi; }; default: return null; } } function sortItems(items, spec, app, manualOrderList) { if (!spec || !spec.groups || spec.groups.length === 0) { // No groups, just apply default order if present if (spec && spec.defaultOrder) { const cmp = makeComparator(spec.defaultOrder, app, manualOrderList); if (cmp) items.sort(cmp); } return items; } // Assign items to groups const buckets = spec.groups.map(() => []); const unmatched = []; for (const item of items) { let placed = false; for (let g = 0; g < spec.groups.length; g++) { if (itemMatchesGroup(item, spec.groups[g], app)) { buckets[g].push(item); placed = true; break; } } if (!placed) unmatched.push(item); } // Sort each bucket const defaultCmp = spec.defaultOrder ? makeComparator(spec.defaultOrder, app, manualOrderList) : null; for (let g = 0; g < spec.groups.length; g++) { const group = spec.groups[g]; const cmp = group.order ? makeComparator(group.order, app, manualOrderList) : defaultCmp; if (cmp) buckets[g].sort(cmp); } if (defaultCmp) unmatched.sort(defaultCmp); // Reassemble const result = []; for (const bucket of buckets) result.push(...bucket); result.push(...unmatched); return result; } // Manual Order Sorting (standalone, no spec)─ function applyManualOrder(items, manualOrderList) { if (!manualOrderList || manualOrderList.length === 0) return items; const ordered = []; const unordered = []; for (const item of items) { const name = getItemName(item); const idx = manualOrderList.indexOf(name); if (idx !== -1) { ordered[idx] = item; } else { unordered.push(item); } } // Filter out sparse gaps and append unordered return ordered.filter(Boolean).concat(unordered); } // File Explorer Patcher─ function getFileExplorerLeaf(app) { const leaves = app.workspace.getLeavesOfType('file-explorer'); return leaves.length > 0 ? leaves[0] : null; } function getFileExplorer(app) { const leaf = getFileExplorerLeaf(app); return leaf ? leaf.view : null; } /** * Monkey-patch a single method on an object, preserving the original. * Returns an uninstall function that restores the original. */ function monkeyPatch(obj, methodName, wrapper) { const original = obj[methodName]; const hasOwn = obj.hasOwnProperty(methodName); // The wrapper receives the original function as its first arg obj[methodName] = function(...args) { return wrapper.call(this, original.bind(this), ...args); }; // Return uninstaller return function uninstall() { if (hasOwn) { obj[methodName] = original; } else { delete obj[methodName]; } }; } function patchFileExplorer(plugin) { const explorer = getFileExplorer(plugin.app); if (!explorer) { console.warn('Abacus: File explorer view not found'); return false; } plugin.fileExplorer = explorer; // Verify the view has getSortedFolderItems and requestSort if (typeof explorer.getSortedFolderItems !== 'function') { console.warn('Abacus: getSortedFolderItems not found on file explorer view'); return false; } if (typeof explorer.requestSort !== 'function') { console.warn('Abacus: requestSort not found on file explorer view'); } if (plugin.unpatchFn) return true; // already patched // Determine where to patch: prefer prototype, fall back to instance const proto = explorer.constructor.prototype; const target = (typeof proto.getSortedFolderItems === 'function') ? proto : explorer; console.log('Abacus: Patching getSortedFolderItems on', target === proto ? 'prototype' : 'instance'); // Patch getSortedFolderItems - this is called by Obsidian to get the ordered // list of children (FileExplorerItem[]) for each folder in the file explorer plugin.unpatchFn = monkeyPatch( target, 'getSortedFolderItems', function(originalFn, folder) { // Always call original first to get Obsidian's default sorted items const defaultResult = originalFn(folder); if (plugin.settings.suspended) return defaultResult; const folderPath = folder ? folder.path : ''; if (!defaultResult || defaultResult.length === 0) return defaultResult; // Check for sort spec const spec = plugin.getSpecForFolder(folderPath); const manualOrderList = plugin.settings.manualOrder[folderPath] || null; if (spec) { return sortItems([...defaultResult], spec, plugin.app, manualOrderList); } else if (manualOrderList && manualOrderList.length > 0) { return applyManualOrder([...defaultResult], manualOrderList); } return defaultResult; } ); console.log('Abacus: File explorer patched successfully'); return true; } function unpatchFileExplorer(plugin) { if (plugin.unpatchFn) { plugin.unpatchFn(); plugin.unpatchFn = null; } } // Settings Tab─ class AbacusSettingTab extends obsidian.PluginSettingTab { constructor(app, plugin) { super(app, plugin); this.plugin = plugin; } display() { const { containerEl } = this; containerEl.empty(); containerEl.createEl('h2', { text: 'Abacus Custom Sorter' }); new obsidian.Setting(containerEl) .setName('Suspend sorting') .setDesc('Temporarily disable custom sorting and revert to Obsidian defaults.') .addToggle(toggle => toggle .setValue(this.plugin.settings.suspended) .onChange(async (value) => { this.plugin.settings.suspended = value; await this.plugin.saveSettings(); this.plugin.refreshExplorer(); this.plugin.updateStatusBar(); })); new obsidian.Setting(containerEl) .setName('Sort specification file') .setDesc('Path to a sort spec file (relative to vault root). Leave empty for none. Example: .obsidian/sort-spec.md') .addText(text => text .setPlaceholder('.obsidian/sort-spec.md') .setValue(this.plugin.settings.sortSpecFile) .onChange(async (value) => { this.plugin.settings.sortSpecFile = value.trim(); await this.plugin.saveSettings(); await this.plugin.loadSortSpec(); this.plugin.refreshExplorer(); })); new obsidian.Setting(containerEl) .setName('Status bar') .setDesc('Show sort status in the status bar.') .addToggle(toggle => toggle .setValue(this.plugin.settings.statusBarEnabled) .onChange(async (value) => { this.plugin.settings.statusBarEnabled = value; await this.plugin.saveSettings(); this.plugin.updateStatusBar(); })); new obsidian.Setting(containerEl) .setName('Notifications') .setDesc('Show notices when sorting state changes.') .addToggle(toggle => toggle .setValue(this.plugin.settings.notificationsEnabled) .onChange(async (value) => { this.plugin.settings.notificationsEnabled = value; await this.plugin.saveSettings(); })); new obsidian.Setting(containerEl) .setName('Context menu') .setDesc('Show sorting options in file explorer context menu.') .addToggle(toggle => toggle .setValue(this.plugin.settings.contextMenuEnabled) .onChange(async (value) => { this.plugin.settings.contextMenuEnabled = value; await this.plugin.saveSettings(); })); new obsidian.Setting(containerEl) .setName('Initial delay (ms)') .setDesc('Delay before applying sort on startup. Increase if sorting fails to apply.') .addText(text => text .setValue(String(this.plugin.settings.delayForInitialApplication)) .onChange(async (value) => { const num = parseInt(value, 10); if (!isNaN(num) && num >= 0) { this.plugin.settings.delayForInitialApplication = num; await this.plugin.saveSettings(); } })); // Manual order management containerEl.createEl('h3', { text: 'Manual Order' }); const manualKeys = Object.keys(this.plugin.settings.manualOrder); if (manualKeys.length === 0) { containerEl.createEl('p', { text: 'No manual orders defined. Right-click a folder in the file explorer to set manual ordering.', cls: 'setting-item-description' }); } else { for (const folder of manualKeys.sort()) { const count = this.plugin.settings.manualOrder[folder].length; new obsidian.Setting(containerEl) .setName(folder || '/ (vault root)') .setDesc(`${count} item(s) ordered`) .addButton(btn => btn .setButtonText('Clear') .setWarning() .onClick(async () => { delete this.plugin.settings.manualOrder[folder]; await this.plugin.saveSettings(); this.plugin.refreshExplorer(); this.display(); // refresh settings view })); } } // Sort spec viewer if (this.plugin.sortSpecs) { containerEl.createEl('h3', { text: 'Active Sort Spec' }); const specFolders = Object.keys(this.plugin.sortSpecs); containerEl.createEl('p', { text: `${specFolders.length} folder rule(s) loaded from spec file.`, cls: 'setting-item-description' }); for (const f of specFolders.sort()) { const groupCount = this.plugin.sortSpecs[f].groups ? this.plugin.sortSpecs[f].groups.length : 0; containerEl.createEl('p', { text: ` ${f || '/'}: ${groupCount} group(s)`, cls: 'setting-item-description' }); } } } } // Manual Order Modal─ class ManualOrderModal extends obsidian.Modal { constructor(app, plugin, folderPath, items) { super(app); this.plugin = plugin; this.folderPath = folderPath; this.items = items.map(i => getItemName(i)); this.orderList = [...this.items]; // If there's an existing manual order, apply it const existing = plugin.settings.manualOrder[folderPath]; if (existing && existing.length > 0) { const ordered = []; const rest = []; for (const name of existing) { if (this.items.includes(name)) ordered.push(name); } for (const name of this.items) { if (!ordered.includes(name)) rest.push(name); } this.orderList = ordered.concat(rest); } } onOpen() { const { contentEl } = this; contentEl.empty(); contentEl.addClass('abacus-manual-order-modal'); contentEl.createEl('h2', { text: 'Manual Sort Order' }); contentEl.createEl('p', { text: `Folder: ${this.folderPath || '/ (vault root)'}`, cls: 'abacus-modal-subtitle' }); contentEl.createEl('p', { text: 'Drag items to reorder, or use the arrow buttons.', cls: 'abacus-modal-hint' }); this.listEl = contentEl.createEl('div', { cls: 'abacus-order-list' }); this.renderList(); const btnRow = contentEl.createEl('div', { cls: 'abacus-modal-buttons' }); const saveBtn = btnRow.createEl('button', { text: 'Save Order', cls: 'mod-cta' }); saveBtn.addEventListener('click', async () => { this.plugin.settings.manualOrder[this.folderPath] = [...this.orderList]; await this.plugin.saveSettings(); this.plugin.refreshExplorer(); if (this.plugin.settings.notificationsEnabled) { new obsidian.Notice(`Abacus: Manual order saved for ${this.folderPath || '/'}`); } this.close(); }); const clearBtn = btnRow.createEl('button', { text: 'Clear Order' }); clearBtn.addEventListener('click', async () => { delete this.plugin.settings.manualOrder[this.folderPath]; await this.plugin.saveSettings(); this.plugin.refreshExplorer(); if (this.plugin.settings.notificationsEnabled) { new obsidian.Notice(`Abacus: Manual order cleared for ${this.folderPath || '/'}`); } this.close(); }); const cancelBtn = btnRow.createEl('button', { text: 'Cancel' }); cancelBtn.addEventListener('click', () => this.close()); } renderList() { this.listEl.empty(); for (let i = 0; i < this.orderList.length; i++) { const row = this.listEl.createEl('div', { cls: 'abacus-order-row' }); row.setAttribute('draggable', 'true'); row.dataset.index = String(i); // Drag handle const handle = row.createEl('span', { cls: 'abacus-drag-handle', text: '⠿' }); // Index number row.createEl('span', { cls: 'abacus-order-index', text: String(i + 1) }); // Item name row.createEl('span', { cls: 'abacus-order-name', text: this.orderList[i] }); // Up/down buttons const btnContainer = row.createEl('span', { cls: 'abacus-order-buttons' }); if (i > 0) { const upBtn = btnContainer.createEl('button', { cls: 'abacus-order-btn', text: '\u25B2' }); upBtn.addEventListener('click', () => this.moveItem(i, i - 1)); } if (i < this.orderList.length - 1) { const downBtn = btnContainer.createEl('button', { cls: 'abacus-order-btn', text: '\u25BC' }); downBtn.addEventListener('click', () => this.moveItem(i, i + 1)); } // Drag events row.addEventListener('dragstart', (e) => { e.dataTransfer.setData('text/plain', String(i)); row.addClass('abacus-dragging'); }); row.addEventListener('dragend', () => { row.removeClass('abacus-dragging'); }); row.addEventListener('dragover', (e) => { e.preventDefault(); row.addClass('abacus-drag-over'); }); row.addEventListener('dragleave', () => { row.removeClass('abacus-drag-over'); }); row.addEventListener('drop', (e) => { e.preventDefault(); row.removeClass('abacus-drag-over'); const fromIndex = parseInt(e.dataTransfer.getData('text/plain'), 10); if (!isNaN(fromIndex) && fromIndex !== i) { this.moveItem(fromIndex, i); } }); } } moveItem(from, to) { const item = this.orderList.splice(from, 1)[0]; this.orderList.splice(to, 0, item); this.renderList(); } onClose() { this.contentEl.empty(); } } // Quick Sort Modal (apply a preset sort to a folder) class QuickSortModal extends obsidian.Modal { constructor(app, plugin, folderPath, items) { super(app); this.plugin = plugin; this.folderPath = folderPath; this.items = items; } onOpen() { const { contentEl } = this; contentEl.empty(); contentEl.addClass('abacus-quick-sort-modal'); contentEl.createEl('h2', { text: 'Quick Sort' }); contentEl.createEl('p', { text: `Apply a sort order to: ${this.folderPath || '/ (vault root)'}`, cls: 'abacus-modal-subtitle' }); const options = [ ['A \u2192 Z (natural)', 'natural-a-z'], ['Z \u2192 A (natural)', 'natural-z-a'], ['A \u2192 Z (strict)', 'a-z'], ['Z \u2192 A (strict)', 'z-a'], ['Newest modified first', 'modified-new'], ['Oldest modified first', 'modified-old'], ['Newest created first', 'created-new'], ['Oldest created first', 'created-old'], ['Folders first', 'folders-first'], ['Files first', 'files-first'], ]; const list = contentEl.createEl('div', { cls: 'abacus-quick-sort-list' }); for (const [label, orderKey] of options) { const btn = list.createEl('button', { text: label, cls: 'abacus-quick-sort-option' }); btn.addEventListener('click', async () => { const order = SORT_ORDERS[orderKey]; if (order) { const cmp = makeComparator(order, this.app, null); if (cmp) { const sorted = [...this.items].sort((a, b) => { return cmp(a, b); }); const names = sorted.map(i => getItemName(i)); this.plugin.settings.manualOrder[this.folderPath] = names; await this.plugin.saveSettings(); this.plugin.refreshExplorer(); if (this.plugin.settings.notificationsEnabled) { new obsidian.Notice(`Abacus: Sorted ${this.folderPath || '/'} by ${label}`); } } } this.close(); }); } const cancelBtn = contentEl.createEl('button', { text: 'Cancel', cls: 'abacus-quick-sort-cancel' }); cancelBtn.addEventListener('click', () => this.close()); } onClose() { this.contentEl.empty(); } } // Main Plugin Class class AbacusSorterPlugin extends obsidian.Plugin { async onload() { await this.loadSettings(); this.sortSpecs = null; this.originalSort = null; this.fileExplorer = null; // Add settings tab this.addSettingTab(new AbacusSettingTab(this.app, this)); // Ribbon icon this.ribbonIconEl = this.addRibbonIcon('arrow-up-down', 'Abacus Custom Sorter', () => { this.settings.suspended = !this.settings.suspended; this.saveSettings(); this.refreshExplorer(); this.updateStatusBar(); this.updateRibbonIcon(); if (this.settings.notificationsEnabled) { new obsidian.Notice(`Abacus: Sorting ${this.settings.suspended ? 'suspended' : 'active'}`); } }); this.updateRibbonIcon(); // Status bar this.statusBarEl = this.addStatusBarItem(); this.updateStatusBar(); // Context menu this.registerEvent( this.app.workspace.on('file-menu', (menu, file, source) => { if (!this.settings.contextMenuEnabled) return; if (source !== 'file-explorer-context-menu') return; const folderPath = file.children !== undefined ? file.path : (file.parent ? file.parent.path : ''); menu.addSeparator(); menu.addItem((item) => { item.setTitle('Abacus: Set manual order...') .setIcon('arrow-up-down') .onClick(() => { this.openManualOrderModal(folderPath); }); }); menu.addItem((item) => { item.setTitle('Abacus: Quick sort...') .setIcon('sort-asc') .onClick(() => { this.openQuickSortModal(folderPath); }); }); if (this.settings.manualOrder[folderPath]) { menu.addItem((item) => { item.setTitle('Abacus: Clear manual order') .setIcon('x') .onClick(async () => { delete this.settings.manualOrder[folderPath]; await this.saveSettings(); this.refreshExplorer(); if (this.settings.notificationsEnabled) { new obsidian.Notice(`Abacus: Manual order cleared for ${folderPath || '/'}`); } }); }); } menu.addItem((item) => { item.setTitle(this.settings.suspended ? 'Abacus: Resume sorting' : 'Abacus: Suspend sorting') .setIcon(this.settings.suspended ? 'play' : 'pause') .onClick(async () => { this.settings.suspended = !this.settings.suspended; await this.saveSettings(); this.refreshExplorer(); this.updateStatusBar(); this.updateRibbonIcon(); }); }); }) ); // Wait for layout ready, then patch this.app.workspace.onLayoutReady(() => { const delay = this.settings.delayForInitialApplication || 0; if (delay > 0) { setTimeout(() => this.initializeSorting(), delay); } else { this.initializeSorting(); } }); // Watch for file changes to refresh this.registerEvent(this.app.vault.on('rename', () => this.debouncedRefresh())); this.registerEvent(this.app.vault.on('create', () => this.debouncedRefresh())); this.registerEvent(this.app.vault.on('delete', () => this.debouncedRefresh())); // Commands this.addCommand({ id: 'toggle-sorting', name: 'Toggle custom sorting', callback: () => { this.settings.suspended = !this.settings.suspended; this.saveSettings(); this.refreshExplorer(); this.updateStatusBar(); this.updateRibbonIcon(); if (this.settings.notificationsEnabled) { new obsidian.Notice(`Abacus: Sorting ${this.settings.suspended ? 'suspended' : 'active'}`); } } }); this.addCommand({ id: 'reload-sort-spec', name: 'Reload sort specification', callback: async () => { await this.loadSortSpec(); this.refreshExplorer(); new obsidian.Notice('Abacus: Sort spec reloaded'); } }); } async onunload() { unpatchFileExplorer(this); // Trigger a re-sort so Obsidian reverts to its default order const explorer = getFileExplorer(this.app); if (explorer && typeof explorer.requestSort === 'function') { explorer.requestSort(); } } async loadSettings() { this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); // Ensure manualOrder exists if (!this.settings.manualOrder) this.settings.manualOrder = {}; } async saveSettings() { await this.saveData(this.settings); } async initializeSorting() { await this.loadSortSpec(); const patched = patchFileExplorer(this); if (patched) { this.refreshExplorer(); if (this.settings.notificationsEnabled) { new obsidian.Notice('Abacus Custom Sorter: Active'); } } else { // Retry once after a short delay setTimeout(() => { const retry = patchFileExplorer(this); if (retry) { this.refreshExplorer(); if (this.settings.notificationsEnabled) { new obsidian.Notice('Abacus Custom Sorter: Active'); } } }, 1000); } } async loadSortSpec() { this.sortSpecs = null; const specPath = this.settings.sortSpecFile; if (!specPath) return; try { const file = this.app.vault.getAbstractFileByPath(specPath); if (file && file instanceof obsidian.TFile) { const content = await this.app.vault.cachedRead(file); this.sortSpecs = parseSortSpec(content); } } catch (e) { console.error('Abacus: Failed to load sort spec:', e); } } getSpecForFolder(folderPath) { if (!this.sortSpecs) return null; // Exact match if (this.sortSpecs[folderPath]) return this.sortSpecs[folderPath]; // Check wildcards: try parent paths with /* const parts = folderPath.split('/'); for (let i = parts.length - 1; i >= 0; i--) { const partial = parts.slice(0, i).join('/') + '/*'; if (this.sortSpecs[partial]) return this.sortSpecs[partial]; } // Global wildcard if (this.sortSpecs['/*']) return this.sortSpecs['/*']; if (this.sortSpecs['*']) return this.sortSpecs['*']; return null; } openManualOrderModal(folderPath) { // Get the TFolder and its children directly from the vault const folder = this.app.vault.getAbstractFileByPath(folderPath); if (!folder || !folder.children) { new obsidian.Notice('Abacus: Folder not found'); return; } // Build item wrappers that match what getSortedFolderItems returns // Each needs a .file property pointing to the TAbstractFile const items = folder.children.map(child => ({ file: child })); if (items.length === 0) { new obsidian.Notice('Abacus: No items found in folder'); return; } // Sort by current display order (natural sort as baseline) items.sort((a, b) => naturalCompare(getItemName(a), getItemName(b))); new ManualOrderModal(this.app, this, folderPath, items).open(); } openQuickSortModal(folderPath) { const folder = this.app.vault.getAbstractFileByPath(folderPath); if (!folder || !folder.children) { new obsidian.Notice('Abacus: Folder not found'); return; } const items = folder.children.map(child => ({ file: child })); if (items.length === 0) { new obsidian.Notice('Abacus: No items found in folder'); return; } new QuickSortModal(this.app, this, folderPath, items).open(); } refreshExplorer() { // requestSort() tells the file explorer to re-call getSortedFolderItems // for all visible folders, which triggers our patched version const explorer = getFileExplorer(this.app); if (explorer && typeof explorer.requestSort === 'function') { explorer.requestSort(); } } _refreshTimeout = null; debouncedRefresh() { if (this._refreshTimeout) clearTimeout(this._refreshTimeout); this._refreshTimeout = setTimeout(() => { this.refreshExplorer(); }, 300); } updateStatusBar() { if (!this.statusBarEl) return; if (!this.settings.statusBarEnabled) { this.statusBarEl.setText(''); return; } const manualCount = Object.keys(this.settings.manualOrder).length; const specCount = this.sortSpecs ? Object.keys(this.sortSpecs).length : 0; const state = this.settings.suspended ? 'OFF' : 'ON'; let text = `Abacus: ${state}`; if (!this.settings.suspended && (manualCount > 0 || specCount > 0)) { const parts = []; if (specCount > 0) parts.push(`${specCount} spec`); if (manualCount > 0) parts.push(`${manualCount} manual`); text += ` (${parts.join(', ')})`; } this.statusBarEl.setText(text); } updateRibbonIcon() { if (!this.ribbonIconEl) return; if (this.settings.suspended) { this.ribbonIconEl.addClass('abacus-ribbon-suspended'); this.ribbonIconEl.setAttribute('aria-label', 'Abacus Custom Sorter (suspended)'); } else { this.ribbonIconEl.removeClass('abacus-ribbon-suspended'); this.ribbonIconEl.setAttribute('aria-label', 'Abacus Custom Sorter (active)'); } } } module.exports = AbacusSorterPlugin;