/* Abacus Sorter. Config-driven custom sorting for Obsidian's File Explorer. Manual ordering is stored internally (no bookmarks dependency). */ var obsidian = require('obsidian'); const DEFAULT_SETTINGS = { suspended: false, sortSpecFile: '', statusBarEnabled: true, notificationsEnabled: 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; if (line.startsWith('target-folder:')) { finishFolder(); currentFolder = line.slice('target-folder:'.length).trim(); continue; } 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 = getItemName(item); 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 cmp = naturalCompare(getItemName(a), getItemName(b)); 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 ai = manualOrderList.indexOf(getItemName(a)); const bi = manualOrderList.indexOf(getItemName(b)); 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) { 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); 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 idx = manualOrderList.indexOf(getItemName(item)); 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 getFileExplorer(app) { const leaves = app.workspace.getLeavesOfType('file-explorer'); return leaves.length > 0 ? leaves[0].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 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; } if (typeof explorer.getSortedFolderItems !== 'function') { console.warn('Abacus: getSortedFolderItems not found on file explorer view'); return false; } if (plugin.unpatchFn) return true; // already patched // Prefer patching the prototype, fall back to the instance const proto = explorer.constructor.prototype; const target = (typeof proto.getSortedFolderItems === 'function') ? proto : explorer; // getSortedFolderItems 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) { const defaultResult = originalFn(folder); if (plugin.settings.suspended) return defaultResult; if (!defaultResult || defaultResult.length === 0) return defaultResult; const folderPath = folder ? folder.path : ''; 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; } ); 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' }); 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('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(); })); } } // 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 = existing.filter(name => this.items.includes(name)); const rest = this.items.filter(name => !ordered.includes(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(); this.plugin.notify(`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(); this.plugin.notify(`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); row.createEl('span', { cls: 'abacus-drag-handle', text: '⠿' }); row.createEl('span', { cls: 'abacus-order-index', text: String(i + 1) }); row.createEl('span', { cls: 'abacus-order-name', text: this.orderList[i] }); const btnContainer = row.createEl('span', { cls: 'abacus-order-buttons' }); if (i > 0) { const upBtn = btnContainer.createEl('button', { cls: 'abacus-order-btn', text: '▲' }); upBtn.addEventListener('click', () => this.moveItem(i, i - 1)); } if (i < this.orderList.length - 1) { const downBtn = btnContainer.createEl('button', { cls: 'abacus-order-btn', text: '▼' }); downBtn.addEventListener('click', () => this.moveItem(i, i + 1)); } 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(); } } // Main Plugin Class class AbacusSorterPlugin extends obsidian.Plugin { async onload() { await this.loadSettings(); this.sortSpecs = null; this.addSettingTab(new AbacusSettingTab(this.app, this)); this.ribbonIconEl = this.addRibbonIcon('arrow-up-down', 'Abacus', () => this.toggleSuspended()); this.updateRibbonIcon(); this.statusBarEl = this.addStatusBarItem(); this.updateStatusBar(); this.registerEvent( this.app.workspace.on('file-menu', (menu, file, source) => { 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)); }); }) ); // 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())); this.addCommand({ id: 'toggle-sorting', name: 'Toggle custom sorting', callback: () => this.toggleSuspended() }); this.addCommand({ id: 'reload-sort-spec', name: 'Reload sort specification', callback: async () => { await this.loadSortSpec(); this.refreshExplorer(); new obsidian.Notice('Abacus: Sort spec reloaded'); } }); } onunload() { unpatchFileExplorer(this); // Trigger a re-sort so Obsidian reverts to its default order this.refreshExplorer(); } async loadSettings() { this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); if (!this.settings.manualOrder) this.settings.manualOrder = {}; } async saveSettings() { await this.saveData(this.settings); } notify(message) { if (this.settings.notificationsEnabled) new obsidian.Notice(message); } toggleSuspended() { this.settings.suspended = !this.settings.suspended; this.saveSettings(); this.refreshExplorer(); this.updateStatusBar(); this.updateRibbonIcon(); this.notify(`Abacus: Sorting ${this.settings.suspended ? 'suspended' : 'active'}`); } async initializeSorting() { await this.loadSortSpec(); if (patchFileExplorer(this)) { this.refreshExplorer(); this.notify('Abacus: Active'); return; } // Retry once after a short delay setTimeout(() => { if (patchFileExplorer(this)) { this.refreshExplorer(); this.notify('Abacus: 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; if (this.sortSpecs[folderPath]) return this.sortSpecs[folderPath]; // 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]; } if (this.sortSpecs['/*']) return this.sortSpecs['/*']; if (this.sortSpecs['*']) return this.sortSpecs['*']; return null; } openManualOrderModal(folderPath) { const folder = this.app.vault.getAbstractFileByPath(folderPath); if (!folder || !folder.children) { new obsidian.Notice('Abacus: Folder not found'); return; } // Wrap children the way getSortedFolderItems returns them: { file } 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(); } 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; let text = `Abacus: ${this.settings.suspended ? 'OFF' : 'ON'}`; 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 (suspended)'); } else { this.ribbonIconEl.removeClass('abacus-ribbon-suspended'); this.ribbonIconEl.setAttribute('aria-label', 'Abacus (active)'); } } } module.exports = AbacusSorterPlugin;