diff options
Diffstat (limited to '.obsidian/plugins')
| -rw-r--r-- | .obsidian/plugins/abacus-sorter/main.js | 1077 | ||||
| -rw-r--r-- | .obsidian/plugins/abacus-sorter/manifest.json | 9 | ||||
| -rw-r--r-- | .obsidian/plugins/abacus-sorter/styles.css | 139 |
3 files changed, 1225 insertions, 0 deletions
diff --git a/.obsidian/plugins/abacus-sorter/main.js b/.obsidian/plugins/abacus-sorter/main.js new file mode 100644 index 0000000..966e1a3 --- /dev/null +++ b/.obsidian/plugins/abacus-sorter/main.js @@ -0,0 +1,1077 @@ +/* +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; diff --git a/.obsidian/plugins/abacus-sorter/manifest.json b/.obsidian/plugins/abacus-sorter/manifest.json new file mode 100644 index 0000000..1d35c98 --- /dev/null +++ b/.obsidian/plugins/abacus-sorter/manifest.json @@ -0,0 +1,9 @@ +{ + "id": "abacus-sorter", + "name": "Abacus Custom Sorter", + "version": "1.0.0", + "minAppVersion": "1.7.2", + "description": "Config-driven custom sorting of files and folders in File Explorer, with manual drag-style reordering and sort specification support.", + "author": "Conway", + "isDesktopOnly": false +} diff --git a/.obsidian/plugins/abacus-sorter/styles.css b/.obsidian/plugins/abacus-sorter/styles.css new file mode 100644 index 0000000..599055a --- /dev/null +++ b/.obsidian/plugins/abacus-sorter/styles.css @@ -0,0 +1,139 @@ +/* Abacus Custom Sorter */ + +/* Ribbon icon when suspended */ +.abacus-ribbon-suspended { + opacity: 0.4; +} + +/* Manual Order Modal */ +.abacus-manual-order-modal { + max-width: 500px; +} + +.abacus-modal-subtitle { + color: var(--text-muted); + margin-bottom: 4px; +} + +.abacus-modal-hint { + color: var(--text-faint); + font-size: var(--font-smallest); + margin-bottom: 12px; +} + +.abacus-order-list { + max-height: 400px; + overflow-y: auto; + border: 1px solid var(--background-modifier-border); + border-radius: 6px; + margin-bottom: 12px; +} + +.abacus-order-row { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + border-bottom: 1px solid var(--background-modifier-border); + cursor: grab; + transition: background-color 0.1s; +} + +.abacus-order-row:last-child { + border-bottom: none; +} + +.abacus-order-row:hover { + background-color: var(--background-modifier-hover); +} + +.abacus-order-row.abacus-dragging { + opacity: 0.4; +} + +.abacus-order-row.abacus-drag-over { + background-color: var(--interactive-accent); + color: var(--text-on-accent); +} + +.abacus-drag-handle { + color: var(--text-faint); + cursor: grab; + user-select: none; + font-size: 14px; + flex-shrink: 0; +} + +.abacus-order-index { + color: var(--text-muted); + font-size: var(--font-smallest); + min-width: 20px; + text-align: right; + flex-shrink: 0; +} + +.abacus-order-name { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.abacus-order-buttons { + display: flex; + gap: 2px; + flex-shrink: 0; +} + +.abacus-order-btn { + background: none; + border: none; + color: var(--text-muted); + cursor: pointer; + padding: 2px 4px; + font-size: 10px; + border-radius: 3px; +} + +.abacus-order-btn:hover { + background-color: var(--background-modifier-hover); + color: var(--text-normal); +} + +.abacus-modal-buttons { + display: flex; + gap: 8px; + justify-content: flex-end; +} + +/* Quick Sort Modal */ +.abacus-quick-sort-modal { + max-width: 350px; +} + +.abacus-quick-sort-list { + display: flex; + flex-direction: column; + gap: 4px; + margin-bottom: 12px; +} + +.abacus-quick-sort-option { + text-align: left; + padding: 8px 12px; + border: 1px solid var(--background-modifier-border); + border-radius: 6px; + background: var(--background-secondary); + cursor: pointer; + transition: background-color 0.1s; +} + +.abacus-quick-sort-option:hover { + background-color: var(--interactive-accent); + color: var(--text-on-accent); +} + +.abacus-quick-sort-cancel { + margin-top: 4px; + align-self: flex-end; +} |
