┌   ┐
54
└   ┘

summaryrefslogtreecommitdiff
path: root/.obsidian/plugins/abacus-sorter/main.js
diff options
context:
space:
mode:
Diffstat (limited to '.obsidian/plugins/abacus-sorter/main.js')
-rw-r--r--.obsidian/plugins/abacus-sorter/main.js1077
1 files changed, 1077 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;