├── .npmrc ├── inspect-config.mjs ├── .editorconfig ├── manifest.json ├── .gitignore ├── tsconfig.json ├── versions.json ├── version-bump.mjs ├── package.json ├── src ├── ui │ ├── conversion-progress.css │ ├── path-suggest.ts │ ├── view-state.ts │ ├── modals │ │ ├── manage-groups-modal.ts │ │ ├── manage-sorting-modal.ts │ │ └── create-edit-group-modal.ts │ ├── column │ │ └── column-renderer.ts │ ├── context-menu.ts │ ├── dnd │ │ └── drag-manager.ts │ ├── settings-tab.ts │ ├── icon-modal.ts │ ├── abstract-folder-view-toolbar.ts │ ├── tree │ │ └── tree-renderer.ts │ └── modals.ts ├── types.ts ├── settings.ts ├── utils │ ├── virtualization.ts │ ├── tree-utils.ts │ └── file-operations.ts ├── metrics-manager.ts └── file-reveal-manager.ts ├── esbuild.config.mjs ├── eslint.config.mjs ├── generate_template.py ├── main.ts ├── AGENTS.md ├── README.md ├── CHANGELOG.md └── TECHNICAL_CHANGELOG.md /.npmrc: -------------------------------------------------------------------------------- 1 | tag-version-prefix="" -------------------------------------------------------------------------------- /inspect-config.mjs: -------------------------------------------------------------------------------- 1 | import obsidianmd from "eslint-plugin-obsidianmd"; 2 | console.log(JSON.stringify(obsidianmd.configs.recommended, null, 2)); -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # top-most EditorConfig file 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | insert_final_newline = true 8 | indent_style = tab 9 | indent_size = 4 10 | tab_width = 4 11 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "abstract-folder", 3 | "name": "Abstract Folder", 4 | "version": "1.6.2", 5 | "minAppVersion": "1.9.10", 6 | "description": "Manage your vault with dynamic, virtual folders for flexible note organization.", 7 | "author": "Erfan Rahmani", 8 | "isDesktopOnly": true, 9 | "fundingUrl": { 10 | "PayPal": "https://www.paypal.com/paypalme/airfunn", 11 | "Wise": "https://wise.com/pay/me/erfanr47" 12 | } 13 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # vscode 2 | .vscode 3 | 4 | # Intellij 5 | *.iml 6 | .idea 7 | 8 | # npm 9 | node_modules 10 | 11 | # Don't include the compiled main.js file in the repo. 12 | # They should be uploaded to GitHub releases instead. 13 | main.js 14 | 15 | # Exclude sourcemaps 16 | *.map 17 | 18 | # obsidian 19 | data.json 20 | 21 | # Exclude macOS Finder (System Explorer) View States 22 | .DS_Store 23 | 24 | 25 | # Test scripts 26 | generate_md.py -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "inlineSourceMap": true, 5 | "inlineSources": true, 6 | "module": "ESNext", 7 | "target": "ES6", 8 | "allowJs": true, 9 | "noImplicitAny": true, 10 | "moduleResolution": "node", 11 | "importHelpers": true, 12 | "isolatedModules": true, 13 | "strictNullChecks": true, 14 | "lib": [ 15 | "DOM", 16 | "ES5", 17 | "ES6", 18 | "ES7" 19 | ] 20 | }, 21 | "include": [ 22 | "**/*.ts" 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "1.0.0": "1.9.10", 3 | "1.0.1": "1.9.10", 4 | "1.0.2": "1.9.10", 5 | "1.1.0": "1.9.10", 6 | "1.1.1": "1.9.10", 7 | "1.2.0": "1.9.10", 8 | "1.2.1": "1.9.10", 9 | "1.2.2": "1.9.10", 10 | "1.2.3": "1.9.10", 11 | "1.2.4": "1.9.10", 12 | "1.2.5": "1.9.10", 13 | "1.2.6": "1.9.10", 14 | "1.2.7": "1.9.10", 15 | "1.3.0": "1.9.10", 16 | "1.3.1": "1.9.10", 17 | "1.3.2": "1.9.10", 18 | "1.3.3": "1.9.10", 19 | "1.3.4": "1.9.10", 20 | "1.3.5": "1.9.10", 21 | "1.3.6": "1.9.10", 22 | "1.3.7": "1.9.10", 23 | "1.3.8": "1.9.10", 24 | "1.3.9": "1.9.10", 25 | "1.4.0": "1.9.10", 26 | "1.4.1": "1.9.10", 27 | "1.5.0": "1.9.10", 28 | "1.6.0": "1.9.10", 29 | "1.6.1": "1.9.10", 30 | "1.6.2": "1.9.10" 31 | } 32 | -------------------------------------------------------------------------------- /version-bump.mjs: -------------------------------------------------------------------------------- 1 | import { readFileSync, writeFileSync } from "fs"; 2 | 3 | const targetVersion = process.env.npm_package_version; 4 | 5 | // read minAppVersion from manifest.json and bump version to target version 6 | const manifest = JSON.parse(readFileSync("manifest.json", "utf8")); 7 | const { minAppVersion } = manifest; 8 | manifest.version = targetVersion; 9 | writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t")); 10 | 11 | // update versions.json with target version and minAppVersion from manifest.json 12 | // but only if the target version is not already in versions.json 13 | const versions = JSON.parse(readFileSync('versions.json', 'utf8')); 14 | if (!Object.values(versions).includes(minAppVersion)) { 15 | versions[targetVersion] = minAppVersion; 16 | writeFileSync('versions.json', JSON.stringify(versions, null, '\t')); 17 | } 18 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "obsidian-abstract-folder", 3 | "version": "1.6.2", 4 | "description": "Manage your vault with dynamic, virtual folders for flexible note organization.", 5 | "main": "main.js", 6 | "scripts": { 7 | "dev": "node esbuild.config.mjs", 8 | "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", 9 | "version": "node version-bump.mjs && git add manifest.json versions.json" 10 | }, 11 | "keywords": [], 12 | "author": "Erfan Rahmani", 13 | "license": "GPL-3.0", 14 | "devDependencies": { 15 | "@types/node": "^16.11.6", 16 | "@eslint/js": "^9.39.1", 17 | "@typescript-eslint/eslint-plugin": "^8.48.1", 18 | "@typescript-eslint/parser": "^8.48.1", 19 | "builtin-modules": "3.3.0", 20 | "esbuild": "0.17.3", 21 | "eslint": "^9.39.1", 22 | "eslint-plugin-obsidianmd": "^0.1.9", 23 | "globals": "^15.0.0", 24 | "obsidian": "latest", 25 | "tslib": "2.4.0", 26 | "typescript": "^5.0.0", 27 | "typescript-eslint": "^8.48.1" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/ui/conversion-progress.css: -------------------------------------------------------------------------------- 1 | .abstract-folder-conversion-progress { 2 | display: flex; 3 | flex-direction: column; 4 | align-items: center; 5 | justify-content: center; 6 | height: 100%; 7 | padding: 20px; 8 | text-align: center; 9 | } 10 | 11 | .abstract-folder-conversion-progress .conversion-title { 12 | font-size: 1.2em; 13 | font-weight: bold; 14 | margin-bottom: 15px; 15 | } 16 | 17 | .abstract-folder-conversion-progress .conversion-message { 18 | margin-bottom: 20px; 19 | color: var(--text-muted); 20 | } 21 | 22 | .abstract-folder-conversion-progress .conversion-progress-bar-container { 23 | width: 100%; 24 | height: 10px; 25 | background-color: var(--background-modifier-border); 26 | border-radius: 5px; 27 | overflow: hidden; 28 | margin-bottom: 10px; 29 | } 30 | 31 | .abstract-folder-conversion-progress .conversion-progress-bar-fill { 32 | height: 100%; 33 | background-color: var(--interactive-accent); 34 | width: 0%; 35 | transition: width 0.3s ease-out; 36 | } 37 | 38 | .abstract-folder-conversion-progress .conversion-stats { 39 | font-size: 0.9em; 40 | color: var(--text-faint); 41 | } -------------------------------------------------------------------------------- /esbuild.config.mjs: -------------------------------------------------------------------------------- 1 | import esbuild from "esbuild"; 2 | import process from "process"; 3 | import builtins from "builtin-modules"; 4 | 5 | const banner = 6 | `/* 7 | THIS IS A GENERATED/BUNDLED FILE BY ESBUILD 8 | if you want to view the source, please visit the github repository of this plugin 9 | */ 10 | `; 11 | 12 | const prod = (process.argv[2] === "production"); 13 | 14 | const context = await esbuild.context({ 15 | banner: { 16 | js: banner, 17 | }, 18 | entryPoints: ["main.ts"], 19 | bundle: true, 20 | external: [ 21 | "obsidian", 22 | "electron", 23 | "@codemirror/autocomplete", 24 | "@codemirror/collab", 25 | "@codemirror/commands", 26 | "@codemirror/language", 27 | "@codemirror/lint", 28 | "@codemirror/search", 29 | "@codemirror/state", 30 | "@codemirror/view", 31 | "@lezer/common", 32 | "@lezer/highlight", 33 | "@lezer/lr", 34 | ...builtins], 35 | format: "cjs", 36 | target: "es2018", 37 | logLevel: "info", 38 | sourcemap: prod ? false : "inline", 39 | treeShaking: true, 40 | outfile: "main.js", 41 | minify: prod, 42 | }); 43 | 44 | if (prod) { 45 | await context.rebuild(); 46 | process.exit(0); 47 | } else { 48 | await context.watch(); 49 | } 50 | -------------------------------------------------------------------------------- /src/ui/path-suggest.ts: -------------------------------------------------------------------------------- 1 | import { App, AbstractInputSuggest } from "obsidian"; 2 | 3 | export class PathSuggest extends AbstractInputSuggest { 4 | constructor(app: App, private inputEl: HTMLInputElement) { 5 | super(app, inputEl); 6 | } 7 | 8 | getSuggestions(inputStr: string): string[] { 9 | const abstractFolderPaths: string[] = []; // In a real plugin, you might get these from your indexer 10 | // For now, let's suggest all files and folders in the vault 11 | const files = this.app.vault.getAllLoadedFiles(); 12 | for (const file of files) { 13 | abstractFolderPaths.push(file.path); 14 | } 15 | 16 | const lowerCaseInputStr = inputStr.toLowerCase(); 17 | return abstractFolderPaths.filter(path => 18 | path.toLowerCase().includes(lowerCaseInputStr) 19 | ); 20 | } 21 | 22 | renderSuggestion(value: string, el: HTMLElement): void { 23 | el.setText(value); 24 | } 25 | 26 | selectSuggestion(value: string, evt: MouseEvent | KeyboardEvent): void { 27 | this.inputEl.value = value; 28 | this.inputEl.trigger("input"); 29 | this.close(); 30 | } 31 | } -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import globals from "globals"; 2 | import pluginJs from "@eslint/js"; 3 | import tseslint from "typescript-eslint"; 4 | import obsidianmd from "eslint-plugin-obsidianmd"; 5 | 6 | export default tseslint.config( 7 | { 8 | ignores: [ 9 | "node_modules/", 10 | "main.js", 11 | "dist/", 12 | "coverage/" 13 | ] 14 | }, 15 | 16 | // 1. Basic JavaScript recommendations 17 | pluginJs.configs.recommended, 18 | 19 | // 2. TypeScript Type-Checked recommendations (Required for "Promises must be awaited") 20 | ...tseslint.configs.recommendedTypeChecked, 21 | 22 | // 3. Obsidian Recommended Config (Correct placement: Top-level, not inside 'rules') 23 | ...obsidianmd.configs.recommended, 24 | 25 | { 26 | files: ["**/*.ts"], 27 | languageOptions: { 28 | parser: tseslint.parser, 29 | parserOptions: { 30 | project: ["./tsconfig.json"], 31 | tsconfigRootDir: import.meta.dirname, 32 | }, 33 | globals: { 34 | ...globals.browser, 35 | ...globals.node, 36 | }, 37 | }, 38 | rules: { 39 | // Restore your preferences 40 | "no-unused-vars": "off", 41 | "@typescript-eslint/no-unused-vars": ["error", { "args": "none" }], 42 | "@typescript-eslint/ban-ts-comment": "off", 43 | "no-prototype-builtins": "off", 44 | "@typescript-eslint/no-empty-function": "off", 45 | 46 | // Explicitly allow 'any' if you want (Note: The bot complains if you disable this rule using comments, 47 | // but globally disabling it here might hide errors locally that the bot will see) 48 | // "@typescript-eslint/no-explicit-any": "off", 49 | }, 50 | }, 51 | { 52 | // Disable type-checked rules for JS files to avoid parser errors 53 | files: ["**/*.js", "**/*.mjs"], 54 | extends: [tseslint.configs.disableTypeChecked], 55 | languageOptions: { 56 | sourceType: "module", 57 | globals: globals.browser 58 | }, 59 | } 60 | ); -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { TFile } from "obsidian"; 2 | 3 | // Extend the App interface to include the 'commands' property, 4 | // which is available in Obsidian's internal API but might not be in default types. 5 | declare module "obsidian" { 6 | interface App { 7 | commands: { 8 | executeCommandById(commandId: string): void; 9 | }; 10 | } 11 | } 12 | 13 | export const HIDDEN_FOLDER_ID = "abstract-hidden-root"; // Unique ID for the special "Hidden" folder 14 | 15 | export interface AbstractFolderFrontmatter { 16 | children?: string[]; 17 | icon?: string; 18 | [key: string]: unknown; // Allow other properties 19 | } 20 | 21 | export interface ParentChildMap { 22 | [parentPath: string]: Set; // Parent path -> Set of child paths 23 | } 24 | 25 | export interface FileGraph { 26 | parentToChildren: ParentChildMap; 27 | childToParents: Map>; // Child path -> Set of parent paths (for easier updates) 28 | allFiles: Set; // All files encountered (parents or children) 29 | roots: Set; // Root files (those without parents in the graph) 30 | } 31 | 32 | export interface FolderNode { 33 | file: TFile | null; // The file itself, null for root "folder" nodes that don't correspond to a file 34 | path: string; // The path of the file or logical folder 35 | children: FolderNode[]; 36 | isFolder: boolean; 37 | icon?: string; // Optional icon or emoji from frontmatter 38 | isHidden?: boolean; // Whether this node should be considered "hidden" from the main tree 39 | } 40 | 41 | export interface Group { 42 | id: string; 43 | name: string; 44 | parentFolders: string[]; // Paths of parent folders to display 45 | sort?: SortConfig; 46 | } 47 | 48 | export interface NodeMetrics { 49 | thermal: number; 50 | lastInteraction: number; // Timestamp 51 | gravity: number; // Recursive descendant count (Payload) 52 | rot: number; // Inactivity * Complexity 53 | complexity: number; // Direct or recursive child count used for rot 54 | } 55 | 56 | export type SortBy = 'name' | 'mtime' | 'thermal' | 'rot' | 'gravity'; 57 | 58 | export interface SortConfig { 59 | sortBy: SortBy; 60 | sortOrder: 'asc' | 'desc'; 61 | } 62 | 63 | export type Cycle = string[]; // Represents a cycle as an array of file paths -------------------------------------------------------------------------------- /generate_template.py: -------------------------------------------------------------------------------- 1 | 2 | import os 3 | 4 | def create_template(base_path): 5 | """ 6 | Creates a structured template folder with university, work, and personal notes. 7 | """ 8 | template = { 9 | "University": { 10 | "Semester 1": { 11 | "Computer Science 101": ["Syllabus.md", "Notes.md", "Assignment_1.md"], 12 | "Mathematics": ["Calculus_Notes.md", "Formula_Sheet.md"], 13 | "Physics": ["Lab_Report.md"] 14 | }, 15 | "Resources": ["Library_Links.md", "Student_Handbook.md"] 16 | }, 17 | "Work": { 18 | "Projects": { 19 | "Alpha": ["Spec.md", "Timeline.md"], 20 | "Beta": ["Feedback.md"] 21 | }, 22 | "Meetings": ["Weekly_Sync.md", "One_on_One.md"], 23 | "Admin": ["Timesheet.md", "Expenses.md"] 24 | }, 25 | "Notes": { 26 | "Reading List": ["Books_to_Read.md", "Articles.md"], 27 | "Ideas": ["App_Idea.md", "Blog_Posts.md"], 28 | "Journal": ["2024-01-01.md"] 29 | }, 30 | "Archive": {} 31 | } 32 | 33 | def build_structure(current_path, structure): 34 | for name, content in structure.items(): 35 | path = os.path.join(current_path, name) 36 | if isinstance(content, dict): 37 | os.makedirs(path, exist_ok=True) 38 | print(f"Created folder: {path}") 39 | build_structure(path, content) 40 | elif isinstance(content, list): 41 | os.makedirs(path, exist_ok=True) 42 | print(f"Created folder: {path}") 43 | for file_name in content: 44 | file_path = os.path.join(path, file_name) 45 | with open(file_path, "w") as f: 46 | f.write(f"# {file_name.replace('.md', '').replace('_', ' ')}\n\nTemplate content for {file_name}.") 47 | print(f"Created file: {file_path}") 48 | 49 | print(f"Starting template generation in {base_path}...") 50 | build_structure(base_path, template) 51 | print("Finished generating template.") 52 | 53 | if __name__ == "__main__": 54 | current_script_dir = os.path.dirname(os.path.abspath(__file__)) 55 | # Target is the root of the vault (3 levels up from plugins/abstract-folder) 56 | target_vault_dir = os.path.abspath(os.path.join(current_script_dir, "../../..")) 57 | 58 | # We'll put the template in a "Template" subfolder to avoid cluttering the root directly 59 | template_root = os.path.join(target_vault_dir, "Template_Vault") 60 | 61 | create_template(template_root) 62 | -------------------------------------------------------------------------------- /src/settings.ts: -------------------------------------------------------------------------------- 1 | import { Group, SortConfig } from "./types"; 2 | 3 | export interface AbstractFolderPluginSettings { 4 | propertyName: string; // The frontmatter property key used to define parent notes (child-defined parent) 5 | childrenPropertyName: string; // The frontmatter property key used by a parent to define its children (parent-defined children) 6 | showAliases: boolean; // Whether to show aliases instead of file names in the view 7 | autoExpandParents: boolean; // Whether to expand parent folders when revealing the active file 8 | autoExpandChildren: boolean; // Whether to expand all children folders when a file is opened 9 | startupOpen: boolean; // Whether to open the view on plugin load 10 | openSide: 'left' | 'right'; // Which side panel to open the view in 11 | showRibbonIcon: boolean; // Whether to display the ribbon icon 12 | enableRainbowIndents: boolean; // Whether to enable rainbow indentation guides 13 | rainbowPalette: 'classic' | 'pastel' | 'neon'; // The color palette for rainbow indents 14 | enablePerItemRainbowColors: boolean; // Whether to use varied colors for indentation guides of sibling items 15 | viewStyle: 'tree' | 'column'; // New: Tree or Column view 16 | rememberExpanded: boolean; // Whether to remember expanded/collapsed state of folders 17 | expandedFolders: string[]; // List of paths of currently expanded folders 18 | excludedPaths: string[]; // Paths to exclude from the abstract folder view (e.g. export folders) 19 | groups: Group[]; // New: List of defined groups 20 | activeGroupId: string | null; // New: ID of the currently active group, or null if no group is active 21 | expandTargetFolderOnDrop: boolean; // Whether to expand the target folder after a drag-and-drop operation 22 | metrics: Record; // Path -> Metrics (persisted) 23 | defaultSort: SortConfig; // Default sort configuration for the main view 24 | } 25 | 26 | export const DEFAULT_SETTINGS: AbstractFolderPluginSettings = { 27 | propertyName: 'parent', 28 | childrenPropertyName: 'children', // Default to 'children' 29 | showAliases: true, 30 | autoExpandParents: true, 31 | autoExpandChildren: false, 32 | startupOpen: false, 33 | openSide: 'left', 34 | showRibbonIcon: true, // Default to true 35 | enableRainbowIndents: true, 36 | rainbowPalette: 'classic', 37 | enablePerItemRainbowColors: false, // Default to false 38 | viewStyle: 'tree', 39 | rememberExpanded: false, 40 | expandedFolders: [], 41 | excludedPaths: [], 42 | groups: [], 43 | activeGroupId: null, 44 | expandTargetFolderOnDrop: true, // Default to true for now 45 | metrics: {}, 46 | defaultSort: { sortBy: 'name', sortOrder: 'asc' }, 47 | }; -------------------------------------------------------------------------------- /src/ui/view-state.ts: -------------------------------------------------------------------------------- 1 | import { AbstractFolderPluginSettings } from '../settings'; 2 | import AbstractFolderPlugin from 'main'; // Import the plugin class 3 | 4 | export class ViewState { 5 | private settings: AbstractFolderPluginSettings; 6 | private plugin: AbstractFolderPlugin; 7 | 8 | public sortOrder: 'asc' | 'desc'; 9 | public sortBy: 'name' | 'mtime' | 'thermal' | 'rot' | 'gravity'; 10 | public selectionPath: string[]; 11 | public multiSelectedPaths: Set; 12 | 13 | constructor(settings: AbstractFolderPluginSettings, plugin: AbstractFolderPlugin) { 14 | this.settings = settings; 15 | this.plugin = plugin; 16 | this.sortOrder = 'asc'; 17 | this.sortBy = 'name'; 18 | this.selectionPath = []; 19 | this.multiSelectedPaths = new Set(); 20 | 21 | // Initialize sort from settings 22 | this.initializeSort(); 23 | } 24 | 25 | initializeSort() { 26 | let sortConfig = this.settings.defaultSort; 27 | if (this.settings.activeGroupId) { 28 | const activeGroup = this.settings.groups.find(g => g.id === this.settings.activeGroupId); 29 | if (activeGroup && activeGroup.sort) { 30 | sortConfig = activeGroup.sort; 31 | } 32 | } 33 | this.sortBy = sortConfig.sortBy; 34 | this.sortOrder = sortConfig.sortOrder; 35 | } 36 | 37 | setSort(sortBy: 'name' | 'mtime' | 'thermal' | 'rot' | 'gravity', sortOrder: 'asc' | 'desc') { 38 | this.sortBy = sortBy; 39 | this.sortOrder = sortOrder; 40 | this.plugin.app.workspace.trigger('abstract-folder:graph-updated'); // Trigger re-render 41 | } 42 | 43 | toggleViewStyle() { 44 | this.settings.viewStyle = this.settings.viewStyle === 'tree' ? 'column' : 'tree'; 45 | this.plugin.saveSettings().catch(console.error); // Save settings via the plugin instance 46 | this.plugin.app.workspace.trigger('abstract-folder:view-style-changed'); // Trigger re-render and button update 47 | } 48 | 49 | clearMultiSelection() { 50 | if (this.multiSelectedPaths.size > 0) { 51 | this.multiSelectedPaths.clear(); 52 | this.plugin.app.workspace.trigger('abstract-folder:graph-updated'); // Re-render to clear selection 53 | } 54 | } 55 | 56 | toggleMultiSelect(path: string) { 57 | if (this.multiSelectedPaths.has(path)) { 58 | this.multiSelectedPaths.delete(path); 59 | } else { 60 | this.multiSelectedPaths.add(path); 61 | } 62 | this.plugin.app.workspace.trigger('abstract-folder:graph-updated'); // Re-render to show selection 63 | } 64 | 65 | 66 | resetSelectionPath() { 67 | this.selectionPath = []; 68 | this.plugin.app.workspace.trigger('abstract-folder:graph-updated'); // Re-render 69 | } 70 | } -------------------------------------------------------------------------------- /src/utils/virtualization.ts: -------------------------------------------------------------------------------- 1 | import { App } from "obsidian"; 2 | import { FileGraph, FolderNode, HIDDEN_FOLDER_ID, Group } from "../types"; 3 | import { createFolderNode, resolveGroupRoots } from "./tree-utils"; 4 | 5 | export interface FlatItem { 6 | node: FolderNode; 7 | depth: number; 8 | parentPath: string | null; 9 | } 10 | 11 | export function flattenTree( 12 | nodes: FolderNode[], 13 | expandedFolders: Set, 14 | depth: number = 0, 15 | parentPath: string | null = null, 16 | result: FlatItem[] = [] 17 | ): FlatItem[] { 18 | for (const node of nodes) { 19 | result.push({ node, depth, parentPath }); 20 | if (node.isFolder && expandedFolders.has(node.path)) { 21 | flattenTree(node.children, expandedFolders, depth + 1, node.path, result); 22 | } 23 | } 24 | return result; 25 | } 26 | 27 | export function generateFlatItemsFromGraph( 28 | app: App, 29 | graph: FileGraph, 30 | expandedFolders: Set, 31 | sortComparator: (a: FolderNode, b: FolderNode) => number, 32 | activeGroup?: Group 33 | ): FlatItem[] { 34 | const flatItems: FlatItem[] = []; 35 | const parentToChildren = graph.parentToChildren; 36 | 37 | // 1. Identify Roots 38 | let rootPaths: string[] = []; 39 | if (activeGroup) { 40 | // Use shared logic for group roots 41 | rootPaths = resolveGroupRoots(app, graph, activeGroup); 42 | } else { 43 | // Default logic: use graph roots 44 | rootPaths = Array.from(graph.roots); 45 | 46 | // Ensure HIDDEN_FOLDER_ID is included if it has children 47 | if (graph.parentToChildren[HIDDEN_FOLDER_ID] && graph.parentToChildren[HIDDEN_FOLDER_ID].size > 0) { 48 | if (!rootPaths.includes(HIDDEN_FOLDER_ID)) { 49 | rootPaths.push(HIDDEN_FOLDER_ID); 50 | } 51 | } 52 | } 53 | 54 | // 2. Create and Sort Root Nodes 55 | const rootNodes: FolderNode[] = []; 56 | for (const path of rootPaths) { 57 | const node = createFolderNode(app, path, graph); 58 | if (node) rootNodes.push(node); 59 | } 60 | rootNodes.sort(sortComparator); 61 | 62 | // 3. Traverse Depth-First 63 | for (const node of rootNodes) { 64 | traverse(node, 0, null); 65 | } 66 | 67 | function traverse(node: FolderNode, depth: number, parentPath: string | null) { 68 | flatItems.push({ node, depth, parentPath }); 69 | 70 | // Lazy Recursion: Only if expanded 71 | if (node.isFolder && expandedFolders.has(node.path)) { 72 | const childrenPaths = parentToChildren[node.path]; 73 | if (childrenPaths && childrenPaths.size > 0) { 74 | const childNodes: FolderNode[] = []; 75 | for (const childPath of childrenPaths) { 76 | const childNode = createFolderNode(app, childPath, graph); 77 | if (childNode) childNodes.push(childNode); 78 | } 79 | childNodes.sort(sortComparator); 80 | 81 | for (const child of childNodes) { 82 | traverse(child, depth + 1, node.path); 83 | } 84 | } 85 | } 86 | } 87 | 88 | return flatItems; 89 | } 90 | -------------------------------------------------------------------------------- /src/ui/modals/manage-groups-modal.ts: -------------------------------------------------------------------------------- 1 | import { App, Modal, Setting } from "obsidian"; 2 | import { AbstractFolderPluginSettings } from "../../settings"; 3 | import { Group } from "../../types"; 4 | import { CreateEditGroupModal } from "./create-edit-group-modal"; 5 | 6 | export class ManageGroupsModal extends Modal { 7 | private settings: AbstractFolderPluginSettings; 8 | private onSave: (groups: Group[], activeGroupId: string | null) => void; 9 | private groups: Group[]; 10 | private activeGroupId: string | null; 11 | 12 | constructor(app: App, settings: AbstractFolderPluginSettings, onSave: (groups: Group[], activeGroupId: string | null) => void) { 13 | super(app); 14 | this.settings = settings; 15 | this.onSave = onSave; 16 | this.groups = [...settings.groups]; // Work on a copy 17 | this.activeGroupId = settings.activeGroupId; 18 | } 19 | 20 | onOpen() { 21 | const { contentEl } = this; 22 | contentEl.empty(); 23 | contentEl.createEl("h2", { text: "Manage groups" }); 24 | 25 | this.renderGroupList(contentEl); 26 | 27 | new Setting(contentEl) 28 | .addButton(button => button 29 | .setButtonText("Add new group") 30 | .setCta() 31 | .onClick(() => { 32 | this.createGroup(); 33 | })) 34 | .addButton(button => button 35 | .setButtonText("Close") 36 | .onClick(() => { 37 | this.close(); 38 | })); 39 | } 40 | 41 | renderGroupList(containerEl: HTMLElement) { 42 | const groupsContainer = containerEl.createDiv({ cls: "abstract-folder-groups-container" }); 43 | if (this.groups.length === 0) { 44 | groupsContainer.createEl("p", { text: "No groups defined yet." }); 45 | return; 46 | } 47 | 48 | this.groups.forEach((group, index) => { 49 | const groupSetting = new Setting(groupsContainer) 50 | .setName(group.name) 51 | .setDesc(`Folders: ${group.parentFolders.join(", ")}`); 52 | 53 | groupSetting.addToggle(toggle => toggle 54 | .setValue(this.activeGroupId === group.id) 55 | .setTooltip("Set as active group") 56 | .onChange(value => { 57 | this.activeGroupId = value ? group.id : null; 58 | this.saveAndRerender(); 59 | })); 60 | 61 | groupSetting.addButton(button => button 62 | .setIcon("edit") 63 | .setTooltip("Edit group") 64 | .onClick(() => { 65 | this.editGroup(group); 66 | })); 67 | 68 | groupSetting.addButton(button => button 69 | .setIcon("trash") 70 | .setTooltip("Delete group") 71 | .onClick(() => { 72 | this.deleteGroup(index); 73 | })); 74 | }); 75 | } 76 | 77 | createGroup() { 78 | new CreateEditGroupModal(this.app, this.settings, null, (newGroup) => { 79 | this.groups.push(newGroup); 80 | this.saveAndRerender(); 81 | }).open(); 82 | } 83 | 84 | editGroup(group: Group) { 85 | new CreateEditGroupModal(this.app, this.settings, group, (updatedGroup) => { 86 | const index = this.groups.findIndex(g => g.id === updatedGroup.id); 87 | if (index !== -1) { 88 | this.groups[index] = updatedGroup; 89 | this.saveAndRerender(); 90 | } 91 | }).open(); 92 | } 93 | 94 | deleteGroup(index: number) { 95 | const groupToDelete = this.groups[index]; 96 | if (this.activeGroupId === groupToDelete.id) { 97 | this.activeGroupId = null; // Clear active group if deleted 98 | } 99 | this.groups.splice(index, 1); 100 | this.saveAndRerender(); 101 | } 102 | 103 | private saveAndRerender() { 104 | this.onSave(this.groups, this.activeGroupId); 105 | this.close(); 106 | this.open(); 107 | } 108 | 109 | onClose() { 110 | const { contentEl } = this; 111 | contentEl.empty(); 112 | } 113 | } -------------------------------------------------------------------------------- /src/ui/modals/manage-sorting-modal.ts: -------------------------------------------------------------------------------- 1 | import { App, Modal, Setting } from "obsidian"; 2 | import { AbstractFolderPluginSettings } from "../../settings"; 3 | import { Group, SortBy, SortConfig } from "../../types"; 4 | 5 | export class ManageSortingModal extends Modal { 6 | private settings: AbstractFolderPluginSettings; 7 | private onSave: (updatedSettings: AbstractFolderPluginSettings) => void; 8 | private groups: Group[]; 9 | private defaultSort: SortConfig; 10 | 11 | constructor(app: App, settings: AbstractFolderPluginSettings, onSave: (updatedSettings: AbstractFolderPluginSettings) => void) { 12 | super(app); 13 | this.settings = settings; 14 | this.onSave = onSave; 15 | this.groups = JSON.parse(JSON.stringify(settings.groups)) as Group[]; // Work on a deep copy 16 | this.defaultSort = { ...settings.defaultSort }; // Work on a copy 17 | } 18 | 19 | onOpen() { 20 | const { contentEl } = this; 21 | contentEl.empty(); 22 | contentEl.createEl("h2", { text: "Manage default sorting" }); 23 | contentEl.createEl("p", { text: "Set the default sorting options for the main view and for each group. These settings will be applied when you switch to the group or reset the view." }); 24 | 25 | // Main Default View Sorting 26 | contentEl.createEl("h3", { text: "Default view" }); 27 | const defaultViewContainer = contentEl.createDiv({ cls: "abstract-folder-sort-container" }); 28 | this.createSortSetting(defaultViewContainer, "Default view", this.defaultSort, (newSort) => { 29 | this.defaultSort = newSort; 30 | }); 31 | 32 | contentEl.createEl("hr"); 33 | 34 | // Groups Sorting 35 | contentEl.createEl("h3", { text: "Groups" }); 36 | const groupsContainer = contentEl.createDiv({ cls: "abstract-folder-sort-container" }); 37 | 38 | if (this.groups.length === 0) { 39 | groupsContainer.createEl("p", { text: "No groups defined." }); 40 | } else { 41 | this.groups.forEach((group) => { 42 | // Initialize group sort if it doesn't exist, defaulting to 'name' 'asc' 43 | if (!group.sort) { 44 | group.sort = { sortBy: 'name', sortOrder: 'asc' }; 45 | } 46 | 47 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 48 | this.createSortSetting(groupsContainer, group.name, group.sort, (newSort) => { 49 | group.sort = newSort; 50 | }); 51 | }); 52 | } 53 | 54 | new Setting(contentEl) 55 | .addButton(button => button 56 | .setButtonText("Save") 57 | .setCta() 58 | .onClick(() => { 59 | this.saveSettings(); 60 | })) 61 | .addButton(button => button 62 | .setButtonText("Cancel") 63 | .onClick(() => { 64 | this.close(); 65 | })); 66 | } 67 | 68 | createSortSetting(container: HTMLElement, name: string, currentSort: SortConfig, onChange: (sort: SortConfig) => void) { 69 | new Setting(container) 70 | .setName(name) 71 | .setDesc("Sort by") 72 | .addDropdown(dropdown => dropdown 73 | .addOption("name", "Name") 74 | .addOption("mtime", "Modified time") 75 | .addOption("thermal", "Thermal") 76 | .addOption("rot", "Stale rot") 77 | .addOption("gravity", "Gravity") 78 | .setValue(currentSort.sortBy) 79 | .onChange((value) => { 80 | currentSort.sortBy = value as SortBy; 81 | onChange(currentSort); 82 | }) 83 | ) 84 | .addDropdown(dropdown => dropdown 85 | .addOption("asc", "Ascending") 86 | .addOption("desc", "Descending") 87 | .setValue(currentSort.sortOrder) 88 | .onChange((value) => { 89 | currentSort.sortOrder = value as 'asc' | 'desc'; 90 | onChange(currentSort); 91 | }) 92 | ); 93 | } 94 | 95 | saveSettings() { 96 | // Update the main settings object with the local changes IN PLACE to preserve reference 97 | this.settings.groups = this.groups; 98 | this.settings.defaultSort = this.defaultSort; 99 | 100 | this.onSave(this.settings); 101 | this.close(); 102 | } 103 | 104 | onClose() { 105 | const { contentEl } = this; 106 | contentEl.empty(); 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/metrics-manager.ts: -------------------------------------------------------------------------------- 1 | import { App, TFile } from "obsidian"; 2 | import { FolderIndexer } from "./indexer"; 3 | import { NodeMetrics } from "./types"; 4 | import AbstractFolderPlugin from "../main"; 5 | 6 | export class MetricsManager { 7 | private app: App; 8 | private indexer: FolderIndexer; 9 | private plugin: AbstractFolderPlugin; 10 | private metrics: Map = new Map(); 11 | 12 | constructor(app: App, indexer: FolderIndexer, plugin: AbstractFolderPlugin) { 13 | this.app = app; 14 | this.indexer = indexer; 15 | this.plugin = plugin; 16 | this.loadPersistedMetrics(); 17 | } 18 | 19 | private loadPersistedMetrics() { 20 | const persisted = this.plugin.settings.metrics || {}; 21 | for (const [path, data] of Object.entries(persisted)) { 22 | this.metrics.set(path, { 23 | thermal: (data.thermal as number | undefined) || ((data as unknown as Record).hotness) || 0, 24 | lastInteraction: data.lastInteraction, 25 | gravity: 0, 26 | rot: 0, 27 | complexity: 0 28 | }); 29 | } 30 | } 31 | 32 | public async saveMetrics() { 33 | const toPersist: Record = {}; 34 | this.metrics.forEach((m, path) => { 35 | if (m.thermal > 0.01) { // Only persist if it has significant heat 36 | toPersist[path] = { 37 | thermal: m.thermal, 38 | lastInteraction: m.lastInteraction 39 | }; 40 | } 41 | }); 42 | this.plugin.settings.metrics = toPersist; 43 | await this.plugin.saveSettings(); 44 | } 45 | 46 | public getMetrics(path: string): NodeMetrics { 47 | let m = this.metrics.get(path); 48 | if (!m) { 49 | m = { thermal: 0, lastInteraction: Date.now(), gravity: 0, rot: 0, complexity: 0 }; 50 | this.metrics.set(path, m); 51 | } 52 | return m; 53 | } 54 | 55 | /** 56 | * Hotness Logic: Exponential decay. 57 | * Decay by 20% every 24 hours. 58 | * Formula: score * (0.8 ^ (hours_passed / 24)) 59 | */ 60 | public applyDecay() { 61 | const now = Date.now(); 62 | const decayRate = 0.8; 63 | const msPerDay = 24 * 60 * 60 * 1000; 64 | 65 | this.metrics.forEach((m, path) => { 66 | const daysPassed = (now - m.lastInteraction) / msPerDay; 67 | if (daysPassed > 0) { 68 | m.thermal = m.thermal * Math.pow(decayRate, daysPassed); 69 | m.lastInteraction = now; 70 | } 71 | }); 72 | } 73 | 74 | public onInteraction(path: string) { 75 | this.applyDecay(); 76 | const m = this.getMetrics(path); 77 | m.thermal += 1; 78 | m.lastInteraction = Date.now(); 79 | void this.saveMetrics(); 80 | } 81 | 82 | /** 83 | * Recalculate graph-based metrics: Payload and Rot. 84 | */ 85 | public calculateGraphMetrics() { 86 | const graph = this.indexer.getGraph(); 87 | const memoGravity = new Map(); 88 | 89 | const getGravity = (path: string): number => { 90 | if (memoGravity.has(path)) return memoGravity.get(path)!; 91 | 92 | const children = graph.parentToChildren[path]; 93 | let count = 0; 94 | if (children) { 95 | children.forEach(childPath => { 96 | count += 1 + getGravity(childPath); 97 | }); 98 | } 99 | memoGravity.set(path, count); 100 | return count; 101 | }; 102 | 103 | const now = Date.now(); 104 | const msPerDay = 24 * 60 * 60 * 1000; 105 | 106 | graph.allFiles.forEach(path => { 107 | const m = this.getMetrics(path); 108 | const gravity = getGravity(path); 109 | const directChildren = graph.parentToChildren[path]?.size || 0; 110 | 111 | m.gravity = gravity; 112 | m.complexity = directChildren; 113 | 114 | const file = this.app.vault.getAbstractFileByPath(path); 115 | if (file instanceof TFile) { 116 | const daysSinceEdit = (now - file.stat.mtime) / msPerDay; 117 | // Rot = Inactivity (days) * Complexity (number of abstract children) 118 | // We'll use direct children for complexity as per "abstract logic" prompt 119 | m.rot = daysSinceEdit * directChildren; 120 | } else { 121 | m.rot = 0; 122 | } 123 | }); 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/file-reveal-manager.ts: -------------------------------------------------------------------------------- 1 | import { App, TFile } from "obsidian"; 2 | import { AbstractFolderPluginSettings } from "./settings"; 3 | import { ViewState } from "./ui/view-state"; 4 | import { ColumnRenderer } from "./ui/column/column-renderer"; 5 | import { FolderIndexer } from "./indexer"; 6 | import AbstractFolderPlugin from "../main"; 7 | 8 | export class FileRevealManager { 9 | private app: App; 10 | private settings: AbstractFolderPluginSettings; 11 | private contentEl: HTMLElement; 12 | private viewState: ViewState; 13 | private indexer: FolderIndexer; 14 | private columnRenderer: ColumnRenderer; 15 | private renderView: () => void; 16 | private plugin: AbstractFolderPlugin; 17 | 18 | constructor( 19 | app: App, 20 | settings: AbstractFolderPluginSettings, 21 | contentEl: HTMLElement, 22 | viewState: ViewState, 23 | indexer: FolderIndexer, 24 | columnRenderer: ColumnRenderer, 25 | renderViewCallback: () => void, 26 | plugin: AbstractFolderPlugin 27 | ) { 28 | this.app = app; 29 | this.settings = settings; 30 | this.contentEl = contentEl; 31 | this.viewState = viewState; 32 | this.indexer = indexer; 33 | this.columnRenderer = columnRenderer; 34 | this.renderView = renderViewCallback; 35 | this.plugin = plugin; 36 | } 37 | 38 | public onFileOpen = (file: TFile | null) => { 39 | if (!file) return; 40 | this.revealFile(file.path, this.settings.autoExpandParents); 41 | } 42 | 43 | public revealFile(filePath: string, expandParents: boolean = true) { 44 | if (this.settings.viewStyle === 'tree') { 45 | const fileNodeEls = this.contentEl.querySelectorAll(`.abstract-folder-item[data-path="${filePath}"]`); 46 | 47 | let hasExpandedOneParentChain = false; // Flag to ensure parent expansion happens only once across all paths 48 | 49 | fileNodeEls.forEach(itemEl => { 50 | if (expandParents && this.settings.autoExpandParents && !hasExpandedOneParentChain) { 51 | let currentEl = itemEl.parentElement; 52 | while (currentEl) { 53 | if (currentEl.classList.contains("abstract-folder-children")) { 54 | const parentItem = currentEl.parentElement; 55 | if (parentItem) { 56 | if (parentItem.hasClass("is-collapsed")) { 57 | parentItem.removeClass("is-collapsed"); 58 | if (this.settings.rememberExpanded) { 59 | const parentPath = parentItem.dataset.path; 60 | if (parentPath && !this.settings.expandedFolders.includes(parentPath)) { 61 | this.settings.expandedFolders.push(parentPath); 62 | this.plugin.saveSettings().catch(console.error); 63 | } 64 | } 65 | } 66 | currentEl = parentItem.parentElement; 67 | } else { 68 | break; 69 | } 70 | } else if (currentEl.classList.contains("abstract-folder-tree")) { 71 | break; 72 | } else { 73 | currentEl = currentEl.parentElement; 74 | } 75 | } 76 | hasExpandedOneParentChain = true; 77 | } 78 | 79 | itemEl.scrollIntoView({ block: "nearest", behavior: "smooth" }); 80 | 81 | // First, remove 'is-active' from any elements that are currently active but do not match the filePath 82 | this.contentEl.querySelectorAll(".abstract-folder-item-self.is-active").forEach(el => { 83 | const parentItem = el.closest(".abstract-folder-item"); 84 | if (parentItem instanceof HTMLElement && parentItem.dataset.path !== filePath) { 85 | el.removeClass("is-active"); 86 | } 87 | }); 88 | 89 | // Then, ensure all instances of the *current* active file (filePath) are highlighted 90 | const selfElToHighlight = itemEl.querySelector(".abstract-folder-item-self"); 91 | if (selfElToHighlight) { 92 | selfElToHighlight.addClass("is-active"); 93 | } 94 | }); 95 | } else if (this.settings.viewStyle === 'column') { 96 | // Column view always needs to 'expand' to the active file's path 97 | const isPathAlreadySelected = this.viewState.selectionPath.includes(filePath); 98 | 99 | if (!isPathAlreadySelected) { 100 | const pathSegments = this.indexer.getPathToRoot(filePath); 101 | this.viewState.selectionPath = pathSegments; 102 | } 103 | this.columnRenderer.setSelectionPath(this.viewState.selectionPath); 104 | this.renderView(); 105 | this.contentEl.querySelector(".abstract-folder-column:last-child")?.scrollIntoView({ block: "end", behavior: "smooth" }); 106 | } 107 | } 108 | } -------------------------------------------------------------------------------- /src/ui/modals/create-edit-group-modal.ts: -------------------------------------------------------------------------------- 1 | import { App, Modal, Setting, Notice, normalizePath } from "obsidian"; 2 | import { AbstractFolderPluginSettings } from "../../settings"; 3 | import { Group } from "../../types"; 4 | import { PathInputSuggest } from "../settings-tab"; 5 | 6 | export class CreateEditGroupModal extends Modal { 7 | private settings: AbstractFolderPluginSettings; 8 | private existingGroup: Group | null; 9 | private onSubmit: (group: Group) => void; 10 | 11 | private groupId: string; 12 | private groupName: string; 13 | private parentFolders: string[]; 14 | private newParentFolderInput: HTMLInputElement | null = null; 15 | 16 | constructor(app: App, settings: AbstractFolderPluginSettings, existingGroup: Group | null, onSubmit: (group: Group) => void) { 17 | super(app); 18 | this.settings = settings; 19 | this.existingGroup = existingGroup; 20 | this.onSubmit = onSubmit; 21 | 22 | if (existingGroup) { 23 | this.groupId = existingGroup.id; 24 | this.groupName = existingGroup.name; 25 | this.parentFolders = [...existingGroup.parentFolders]; 26 | } else { 27 | this.groupId = this.generateId(); 28 | this.groupName = ""; 29 | this.parentFolders = []; 30 | } 31 | } 32 | 33 | onOpen() { 34 | const { contentEl } = this; 35 | contentEl.empty(); 36 | contentEl.createEl("h2", { text: this.existingGroup ? "Edit Group" : "Create New Group" }); 37 | 38 | new Setting(contentEl) 39 | .setName("Group name") 40 | .addText(text => text 41 | .setPlaceholder("Example: work projects") 42 | .setValue(this.groupName) 43 | .onChange(value => this.groupName = value)); 44 | 45 | this.renderParentFolders(contentEl); 46 | 47 | new Setting(contentEl) 48 | .addButton(button => button 49 | .setButtonText(this.existingGroup ? "Save Group" : "Create Group") 50 | .setCta() 51 | .onClick(() => this.submit())) 52 | .addButton(button => button 53 | .setButtonText("Cancel") 54 | .onClick(() => this.close())); 55 | } 56 | 57 | renderParentFolders(containerEl: HTMLElement) { 58 | containerEl.createEl("h3", { text: "Included parent folders" }); 59 | const folderListEl = containerEl.createDiv({ cls: "abstract-folder-group-folders" }); 60 | 61 | if (this.parentFolders.length === 0) { 62 | folderListEl.createEl("p", { text: "No parent folders added yet." }); 63 | } else { 64 | this.parentFolders.forEach((folder, index) => { 65 | new Setting(folderListEl) 66 | .setClass("abstract-folder-group-folder-item") 67 | .addText(text => text 68 | .setValue(folder) 69 | .setDisabled(true)) 70 | .addButton(button => button 71 | .setIcon("trash") 72 | .setTooltip("Remove folder") 73 | .onClick(() => { 74 | this.parentFolders.splice(index, 1); 75 | this.close(); this.open(); // Re-render the modal to update the list 76 | })); 77 | }); 78 | } 79 | 80 | new Setting(containerEl) 81 | .setName("Add parent folder") 82 | .setDesc("Enter the full path of a folder to include (e.g., 'Projects/Work').") 83 | .addText(text => { 84 | this.newParentFolderInput = text.inputEl; 85 | new PathInputSuggest(this.app, text.inputEl); // Use PathInputSuggest 86 | text.setPlaceholder("Folder path") 87 | .onChange(value => { 88 | // No direct update here, wait for add button or enter 89 | }); 90 | text.inputEl.addEventListener("keydown", (e) => { 91 | if (e.key === "Enter") { 92 | this.addParentFolder(text.getValue()); 93 | text.setValue(""); // Clear input after adding 94 | } 95 | }); 96 | }) 97 | .addButton(button => button 98 | .setIcon("plus") 99 | .setTooltip("Add folder") 100 | .onClick(() => { 101 | if (this.newParentFolderInput) { 102 | this.addParentFolder(this.newParentFolderInput.value); 103 | this.newParentFolderInput.value = ""; // Clear input after adding 104 | } 105 | })); 106 | } 107 | 108 | addParentFolder(folderPath: string) { 109 | const normalizedPath = normalizePath(folderPath).trim(); 110 | if (normalizedPath && !this.parentFolders.includes(normalizedPath)) { 111 | this.parentFolders.push(normalizedPath); 112 | this.close(); this.open(); // Re-render to show the new folder 113 | } else if (this.parentFolders.includes(normalizedPath)) { 114 | new Notice("This folder is already in the list."); 115 | } 116 | } 117 | 118 | submit() { 119 | if (!this.groupName) { 120 | new Notice("Group name cannot be empty."); 121 | return; 122 | } 123 | 124 | // Attempt to add any pending path in the input field before submitting 125 | if (this.newParentFolderInput && this.newParentFolderInput.value) { 126 | // Temporarily store the value to avoid issues with this.close()/this.open() 127 | const pendingPath = this.newParentFolderInput.value; 128 | this.addParentFolder(pendingPath); 129 | } 130 | 131 | const group: Group = { 132 | id: this.groupId, 133 | name: this.groupName, 134 | parentFolders: this.parentFolders, 135 | }; 136 | this.onSubmit(group); 137 | this.close(); 138 | } 139 | 140 | generateId(): string { 141 | return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15); 142 | } 143 | 144 | onClose() { 145 | const { contentEl } = this; 146 | contentEl.empty(); 147 | } 148 | } -------------------------------------------------------------------------------- /src/utils/tree-utils.ts: -------------------------------------------------------------------------------- 1 | import { App, TFile } from "obsidian"; 2 | import { FileGraph, FolderNode, HIDDEN_FOLDER_ID, Group } from "../types"; 3 | 4 | /** 5 | * Creates a single FolderNode from a path. 6 | * Returns null if the path does not represent a valid node in our graph context. 7 | */ 8 | export function createFolderNode(app: App, path: string, graph: FileGraph): FolderNode | null { 9 | if (path === HIDDEN_FOLDER_ID) { 10 | return { 11 | file: null, 12 | path: path, 13 | children: [], 14 | isFolder: true, 15 | isHidden: true 16 | }; 17 | } 18 | 19 | const file = app.vault.getAbstractFileByPath(path); 20 | const parentToChildren = graph.parentToChildren; 21 | 22 | // Determine if it is a folder (has children in graph) 23 | const hasChildren = parentToChildren[path] && parentToChildren[path].size > 0; 24 | 25 | // A node is a folder only if it has children or is the hidden root 26 | const isFolder = hasChildren || path === HIDDEN_FOLDER_ID; 27 | 28 | // If it's not a valid file and not a known parent, skip it. 29 | // In Abstract Folder, usually nodes are TFiles or the Hidden folder. 30 | if (!file && !hasChildren) { 31 | return null; 32 | } 33 | 34 | let icon: string | undefined; 35 | if (file instanceof TFile) { 36 | const cache = app.metadataCache.getFileCache(file); 37 | // Fix for "Unsafe assignment of an `any` value" 38 | icon = cache?.frontmatter?.icon as string | undefined; 39 | } 40 | 41 | return { 42 | file: file instanceof TFile ? file : null, 43 | path: path, 44 | children: [], // Children are not populated initially 45 | isFolder: isFolder, 46 | icon: icon, 47 | isHidden: path === HIDDEN_FOLDER_ID 48 | }; 49 | } 50 | 51 | /** 52 | * Resolves the root paths for a specific group. 53 | */ 54 | export function resolveGroupRoots(app: App, graph: FileGraph, group: Group): string[] { 55 | const explicitPaths = group.parentFolders; 56 | const processedRoots = new Set(); 57 | 58 | for (const includedPath of explicitPaths) { 59 | // Logic: check path, then folder note, then sibling note 60 | let targetPath = includedPath; 61 | let file = app.vault.getAbstractFileByPath(targetPath); 62 | 63 | if (!file) { 64 | const folderName = includedPath.split('/').pop(); 65 | if (folderName) { 66 | const insideNotePath = `${includedPath}/${folderName}.md`; 67 | if (app.vault.getAbstractFileByPath(insideNotePath)) { 68 | targetPath = insideNotePath; 69 | } 70 | } 71 | } 72 | 73 | if (!app.vault.getAbstractFileByPath(targetPath)) { 74 | if (!targetPath.endsWith('.md')) { 75 | const siblingNotePath = `${targetPath}.md`; 76 | if (app.vault.getAbstractFileByPath(siblingNotePath)) { 77 | targetPath = siblingNotePath; 78 | } 79 | } 80 | } 81 | 82 | // Verify it exists in graph or vault 83 | if (graph.allFiles.has(targetPath) || app.vault.getAbstractFileByPath(targetPath)) { 84 | processedRoots.add(targetPath); 85 | } 86 | } 87 | return Array.from(processedRoots); 88 | } 89 | 90 | /** 91 | * Builds a full tree of FolderNodes. 92 | * This is used for Column View and Legacy Tree View. 93 | */ 94 | export function buildFolderTree( 95 | app: App, 96 | graph: FileGraph, 97 | sortComparator: (a: FolderNode, b: FolderNode) => number 98 | ): FolderNode[] { 99 | const allFilePaths = graph.allFiles; 100 | const parentToChildren = graph.parentToChildren; 101 | const childToParents = graph.childToParents; 102 | 103 | const nodesMap = new Map(); 104 | 105 | // Create all possible nodes using the shared helper 106 | allFilePaths.forEach(path => { 107 | const node = createFolderNode(app, path, graph); 108 | if (node) { 109 | nodesMap.set(path, node); 110 | } 111 | }); 112 | 113 | // We need to re-identify hidden status because createFolderNode only sets it for HIDDEN_FOLDER_ID 114 | const hiddenNodes = new Set(); 115 | 116 | const identifyHiddenChildren = (nodePath: string) => { 117 | if (hiddenNodes.has(nodePath)) return; 118 | hiddenNodes.add(nodePath); 119 | 120 | const children = parentToChildren[nodePath]; 121 | if (children) { 122 | children.forEach(childPath => identifyHiddenChildren(childPath)); 123 | } 124 | }; 125 | 126 | if (parentToChildren[HIDDEN_FOLDER_ID]) { 127 | parentToChildren[HIDDEN_FOLDER_ID].forEach(childPath => { 128 | const childNode = nodesMap.get(childPath); 129 | if (childNode) { 130 | childNode.isHidden = true; 131 | identifyHiddenChildren(childPath); 132 | } 133 | }); 134 | } 135 | 136 | // Build parent-child relationships 137 | for (const parentPath in parentToChildren) { 138 | parentToChildren[parentPath].forEach(childPath => { 139 | const parentNode = nodesMap.get(parentPath); 140 | const childNode = nodesMap.get(childPath); 141 | 142 | if (parentNode && childNode) { 143 | // Determine if we should add this child (handling hidden logic) 144 | if (parentPath === HIDDEN_FOLDER_ID || !hiddenNodes.has(childPath)) { 145 | parentNode.children.push(childNode); 146 | } 147 | } 148 | }); 149 | } 150 | 151 | // Sort children 152 | nodesMap.forEach(node => { 153 | node.children.sort(sortComparator); 154 | }); 155 | 156 | // Identify root nodes 157 | const rootPaths = new Set(allFilePaths); 158 | childToParents.forEach((_, childPath) => { 159 | if (rootPaths.has(childPath) && !hiddenNodes.has(childPath)) { 160 | rootPaths.delete(childPath); 161 | } 162 | }); 163 | 164 | const sortedRootNodes: FolderNode[] = []; 165 | 166 | // Add Hidden folder if applicable 167 | const hiddenFolderNode = nodesMap.get(HIDDEN_FOLDER_ID); 168 | if (hiddenFolderNode && hiddenFolderNode.children.length > 0) { 169 | sortedRootNodes.push(hiddenFolderNode); 170 | } 171 | 172 | // Add other roots 173 | rootPaths.forEach(path => { 174 | const node = nodesMap.get(path); 175 | if (node && !hiddenNodes.has(node.path) && node.path !== HIDDEN_FOLDER_ID) { 176 | sortedRootNodes.push(node); 177 | } 178 | }); 179 | 180 | sortedRootNodes.sort(sortComparator); 181 | return sortedRootNodes; 182 | } 183 | -------------------------------------------------------------------------------- /src/ui/column/column-renderer.ts: -------------------------------------------------------------------------------- 1 | import { App, setIcon } from "obsidian"; 2 | import { FolderNode, HIDDEN_FOLDER_ID } from "../../types"; 3 | import { AbstractFolderPluginSettings } from "../../settings"; 4 | import AbstractFolderPlugin from "../../../main"; 5 | import { ContextMenuHandler } from "../context-menu"; 6 | import { FolderIndexer } from "src/indexer"; 7 | import { DragManager } from "../dnd/drag-manager"; 8 | 9 | export class ColumnRenderer { 10 | private app: App; 11 | private settings: AbstractFolderPluginSettings; 12 | private plugin: AbstractFolderPlugin; 13 | public selectionPath: string[]; // Made public so it can be updated directly from view.ts 14 | private multiSelectedPaths: Set; 15 | private getDisplayName: (node: FolderNode) => string; 16 | private handleColumnNodeClick: (node: FolderNode, depth: number, event?: MouseEvent) => void; 17 | private contextMenuHandler: ContextMenuHandler; 18 | private dragManager: DragManager; 19 | private getContentEl: () => HTMLElement; 20 | 21 | constructor( 22 | app: App, 23 | settings: AbstractFolderPluginSettings, 24 | plugin: AbstractFolderPlugin, 25 | selectionPath: string[], 26 | multiSelectedPaths: Set, 27 | getDisplayName: (node: FolderNode) => string, 28 | handleColumnNodeClick: (node: FolderNode, depth: number, event?: MouseEvent) => void, 29 | indexer: FolderIndexer, 30 | dragManager: DragManager, 31 | getContentEl: () => HTMLElement 32 | ) { 33 | this.app = app; 34 | this.settings = settings; 35 | this.plugin = plugin; 36 | this.selectionPath = selectionPath; 37 | this.multiSelectedPaths = multiSelectedPaths; 38 | this.getDisplayName = getDisplayName; 39 | this.handleColumnNodeClick = handleColumnNodeClick; 40 | this.contextMenuHandler = new ContextMenuHandler(app, settings, plugin, indexer); 41 | this.dragManager = dragManager; 42 | this.getContentEl = getContentEl; 43 | } 44 | 45 | // New method to update the selection path 46 | public setSelectionPath(path: string[]) { 47 | this.selectionPath = path; 48 | } 49 | 50 | renderColumn(nodes: FolderNode[], parentEl: HTMLElement, depth: number, selectedParentPath?: string) { 51 | const columnEl = parentEl.createDiv({ cls: "abstract-folder-column", attr: { 'data-depth': depth } }); 52 | if (selectedParentPath) { 53 | columnEl.dataset.parentPath = selectedParentPath; 54 | } 55 | 56 | nodes.forEach(node => { 57 | this.renderColumnNode(node, columnEl, depth, selectedParentPath || ""); 58 | }); 59 | } 60 | 61 | private renderColumnNode(node: FolderNode, parentEl: HTMLElement, depth: number, parentPath: string) { 62 | // TEMPORARY DEBUG: Trace why files are treated as folders 63 | const isFolder = node.isFolder && node.children.length > 0; 64 | // Debug logging removed 65 | // if (node.path.includes('file_') && isFolder) { 66 | // console.debug(`[ColumnRenderer] rendering ${node.path}: isFolder=${node.isFolder}, childrenCount=${node.children.length}, childrenNames=${node.children.map(c => c.path).join(', ')}`); 67 | // } else { 68 | // console.debug(`[ColumnRenderer] rendering ${node.path}: isFolder=${node.isFolder}, childrenCount=${node.children.length}`); 69 | // } 70 | 71 | const activeFile = this.app.workspace.getActiveFile(); 72 | const itemEl = parentEl.createDiv({ 73 | cls: "abstract-folder-item", 74 | attr: { 'data-path': node.path } 75 | }); 76 | itemEl.draggable = true; 77 | 78 | itemEl.addEventListener("dragstart", (e) => this.dragManager.handleDragStart(e, node, parentPath, this.multiSelectedPaths)); 79 | itemEl.addEventListener("dragover", (e) => this.dragManager.handleDragOver(e, node)); 80 | itemEl.addEventListener("dragleave", (e) => this.dragManager.handleDragLeave(e)); 81 | itemEl.addEventListener("drop", (e) => { 82 | this.dragManager.handleDrop(e, node).catch(console.error); 83 | }); 84 | 85 | if (isFolder) { 86 | itemEl.addClass("is-folder"); 87 | } else { 88 | itemEl.addClass("is-file"); 89 | } 90 | 91 | const selfEl = itemEl.createDiv({ cls: "abstract-folder-item-self" }); 92 | 93 | if (activeFile && activeFile.path === node.path) { 94 | selfEl.addClass("is-active"); 95 | } 96 | 97 | const selectionIndex = this.selectionPath.indexOf(node.path); 98 | if (selectionIndex > -1) { 99 | if (selectionIndex === this.selectionPath.length - 1) { 100 | selfEl.addClass("is-selected-in-column"); 101 | } else { 102 | selfEl.addClass("is-ancestor-of-selected"); 103 | } 104 | } 105 | 106 | if (this.multiSelectedPaths.has(node.path)) { 107 | selfEl.addClass("is-multi-selected"); 108 | } 109 | 110 | let iconToUse = node.icon; 111 | if (node.path === HIDDEN_FOLDER_ID && !iconToUse) { 112 | iconToUse = "eye-off"; 113 | } 114 | 115 | if (iconToUse) { 116 | const iconContainerEl = selfEl.createDiv({ cls: "abstract-folder-item-icon" }); 117 | setIcon(iconContainerEl, iconToUse); 118 | if (!iconContainerEl.querySelector('svg')) { 119 | iconContainerEl.setText(iconToUse); 120 | } 121 | } 122 | 123 | const innerEl = selfEl.createDiv({ cls: "abstract-folder-item-inner" }); 124 | innerEl.setText(this.getDisplayName(node)); 125 | 126 | if (node.file && node.path !== HIDDEN_FOLDER_ID && node.file.extension !== 'md') { 127 | const fileTypeTag = selfEl.createDiv({ cls: "abstract-folder-file-tag" }); 128 | fileTypeTag.setText(node.file.extension.toUpperCase()); 129 | } 130 | 131 | const parentCount = this.plugin.indexer.getGraph().childToParents.get(node.path)?.size || 0; 132 | const childCount = node.children.length; 133 | 134 | // Only show folder indicator if it's truly a folder with children in our graph 135 | if (node.isFolder && childCount > 0) { 136 | const folderIndicator = selfEl.createDiv({ cls: "abstract-folder-folder-indicator" }); 137 | setIcon(folderIndicator, "chevron-right"); 138 | } 139 | 140 | if (parentCount > 1) { 141 | const multiParentIndicator = selfEl.createSpan({ cls: "abstract-folder-multi-parent-indicator" }); 142 | setIcon(multiParentIndicator, "git-branch-plus"); 143 | multiParentIndicator.ariaLabel = `${parentCount} parents`; 144 | multiParentIndicator.title = `${parentCount} parents`; 145 | } 146 | 147 | selfEl.addEventListener("click", (e) => { 148 | e.stopPropagation(); 149 | this.handleColumnNodeClick(node, depth, e); 150 | }); 151 | 152 | if (node.file) { 153 | selfEl.addEventListener("contextmenu", (e) => { 154 | e.preventDefault(); 155 | this.contextMenuHandler.showContextMenu(e, node, this.multiSelectedPaths); 156 | }); 157 | } 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /main.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file main.ts 3 | * @description Plugin entry point for Abstract Folder. 4 | * @author Erfan Rahmani 5 | * @license GPL-3.0 6 | * @copyright 2025 Erfan Rahmani 7 | */ 8 | 9 | import { Plugin, WorkspaceLeaf, Notice } from 'obsidian'; 10 | import { AbstractFolderPluginSettings, DEFAULT_SETTINGS } from './src/settings'; 11 | import { FolderIndexer } from './src/indexer'; 12 | import { MetricsManager } from './src/metrics-manager'; 13 | import { AbstractFolderView, VIEW_TYPE_ABSTRACT_FOLDER } from './src/view'; 14 | import { CreateAbstractChildModal, ParentPickerModal, ChildFileType, FolderSelectionModal, ConversionOptionsModal, DestinationPickerModal, NewFolderNameModal, SimulationModal, ScopeSelectionModal } from './src/ui/modals'; 15 | import { ManageGroupsModal } from './src/ui/modals/manage-groups-modal'; 16 | import { AbstractFolderSettingTab } from './src/ui/settings-tab'; 17 | import { createAbstractChildFile } from './src/utils/file-operations'; 18 | import { convertFoldersToPluginFormat, generateFolderStructurePlan, executeFolderGeneration } from './src/utils/conversion'; 19 | import { TFolder, TFile } from 'obsidian'; 20 | import { Group } from './src/types'; 21 | 22 | export default class AbstractFolderPlugin extends Plugin { 23 | settings: AbstractFolderPluginSettings; 24 | indexer: FolderIndexer; 25 | metricsManager: MetricsManager; 26 | ribbonIconEl: HTMLElement | null = null; 27 | 28 | async onload() { 29 | await this.loadSettings(); 30 | 31 | this.indexer = new FolderIndexer(this.app, this.settings, this); 32 | this.metricsManager = new MetricsManager(this.app, this.indexer, this); 33 | this.indexer.initializeIndexer(); 34 | 35 | this.registerView( 36 | VIEW_TYPE_ABSTRACT_FOLDER, 37 | (leaf) => new AbstractFolderView(leaf, this.indexer, this.settings, this, this.metricsManager) 38 | ); 39 | 40 | this.updateRibbonIconVisibility(); 41 | 42 | this.addCommand({ 43 | id: "open-view", 44 | name: "Open view", 45 | callback: () => { 46 | this.activateView().catch(console.error); 47 | }, 48 | }); 49 | 50 | this.addCommand({ 51 | id: "create-child", 52 | name: "Create abstract child", 53 | callback: () => { 54 | new CreateAbstractChildModal(this.app, this.settings, (childName: string, childType: ChildFileType) => { 55 | new ParentPickerModal(this.app, (parentFile) => { 56 | createAbstractChildFile(this.app, this.settings, childName, parentFile, childType) 57 | .catch(console.error); 58 | }).open(); 59 | }).open(); 60 | }, 61 | }); 62 | 63 | this.addCommand({ 64 | id: "manage-groups", 65 | name: "Manage groups", 66 | callback: () => { 67 | new ManageGroupsModal(this.app, this.settings, (updatedGroups: Group[], activeGroupId: string | null) => { 68 | this.settings.groups = updatedGroups; 69 | this.settings.activeGroupId = activeGroupId; 70 | this.saveSettings().then(() => { 71 | this.app.workspace.trigger('abstract-folder:group-changed'); 72 | }).catch(console.error); 73 | }).open(); 74 | }, 75 | }); 76 | 77 | this.addCommand({ 78 | id: "clear-active-group", 79 | name: "Clear active group", 80 | callback: () => { 81 | if (this.settings.activeGroupId) { 82 | this.settings.activeGroupId = null; 83 | this.saveSettings().then(() => { 84 | new Notice("Active group cleared."); 85 | this.app.workspace.trigger('abstract-folder:group-changed'); 86 | }).catch(console.error); 87 | } else { 88 | new Notice("No active group to clear."); 89 | } 90 | }, 91 | }); 92 | 93 | this.addCommand({ 94 | id: "convert-folder-to-plugin", 95 | name: "Convert folder structure to plugin format", 96 | callback: () => { 97 | new FolderSelectionModal(this.app, (folder: TFolder) => { 98 | new ConversionOptionsModal(this.app, folder, (options) => { 99 | convertFoldersToPluginFormat(this.app, this.settings, folder, options) 100 | .catch(console.error); 101 | }).open(); 102 | }).open(); 103 | } 104 | }); 105 | this.addCommand({ 106 | id: "create-folders-from-plugin", 107 | name: "Create folder structure from plugin format", 108 | callback: () => { 109 | new ScopeSelectionModal(this.app, (scope) => { 110 | new DestinationPickerModal(this.app, (parentFolder: TFolder) => { 111 | new NewFolderNameModal(this.app, parentFolder, (destinationPath: string, placeIndexFileInside: boolean) => { 112 | // Automatically add the export folder to excluded paths if not already present 113 | if (!this.settings.excludedPaths.includes(destinationPath)) { 114 | this.settings.excludedPaths.push(destinationPath); 115 | this.saveSettings().then(() => { 116 | this.indexer.updateSettings(this.settings); 117 | }).catch(console.error); 118 | } 119 | 120 | const rootScope = (scope instanceof TFile) ? scope : undefined; 121 | const plan = generateFolderStructurePlan(this.app, this.settings, this.indexer, destinationPath, placeIndexFileInside, rootScope); 122 | new SimulationModal(this.app, plan.conflicts, (resolvedConflicts) => { 123 | executeFolderGeneration(this.app, plan).catch((error) => console.error(error)); 124 | }).open(); 125 | }).open(); 126 | }).open(); 127 | }).open(); 128 | } 129 | }); 130 | this.addSettingTab(new AbstractFolderSettingTab(this.app, this)); 131 | 132 | this.app.workspace.onLayoutReady(() => { 133 | this.metricsManager.applyDecay(); 134 | this.indexer.rebuildGraphAndTriggerUpdate(); 135 | if (this.settings.startupOpen) { 136 | this.activateView().catch(console.error); 137 | } 138 | }); 139 | } 140 | 141 | onunload() { 142 | this.indexer.onunload(); 143 | void this.metricsManager.saveMetrics(); 144 | if (this.ribbonIconEl) { 145 | this.ribbonIconEl.remove(); 146 | this.ribbonIconEl = null; 147 | } 148 | } 149 | 150 | async activateView() { 151 | let leaf: WorkspaceLeaf | null = null; 152 | const leaves = this.app.workspace.getLeavesOfType(VIEW_TYPE_ABSTRACT_FOLDER); 153 | 154 | if (leaves.length > 0) { 155 | // If a leaf of our view type already exists, use it 156 | leaf = leaves[0]; 157 | } else { 158 | // No existing leaf found, create a new one 159 | const side = this.settings.openSide; 160 | if (side === 'left') { 161 | leaf = this.app.workspace.getLeftLeaf(false); 162 | if (!leaf) { 163 | leaf = this.app.workspace.getLeftLeaf(true); 164 | } 165 | } else { // right 166 | leaf = this.app.workspace.getRightLeaf(false); 167 | if (!leaf) { 168 | leaf = this.app.workspace.getRightLeaf(true); 169 | } 170 | } 171 | } 172 | 173 | if (leaf) { 174 | await leaf.setViewState({ 175 | type: VIEW_TYPE_ABSTRACT_FOLDER, 176 | active: true, 177 | }); 178 | if (leaf instanceof WorkspaceLeaf) { 179 | void this.app.workspace.revealLeaf(leaf); 180 | } 181 | } 182 | } 183 | 184 | async loadSettings() { 185 | this.settings = Object.assign({}, DEFAULT_SETTINGS, (await this.loadData()) as AbstractFolderPluginSettings); 186 | } 187 | 188 | async saveSettings() { 189 | await this.saveData(this.settings); 190 | this.updateRibbonIconVisibility(); 191 | } 192 | 193 | updateRibbonIconVisibility() { 194 | if (this.settings.showRibbonIcon) { 195 | if (!this.ribbonIconEl) { 196 | this.ribbonIconEl = this.addRibbonIcon("folder-tree", "Open abstract folders", () => { 197 | this.activateView().catch(console.error); 198 | }); 199 | } 200 | } else { 201 | if (this.ribbonIconEl) { 202 | this.ribbonIconEl.remove(); 203 | this.ribbonIconEl = null; 204 | } 205 | } 206 | } 207 | } 208 | -------------------------------------------------------------------------------- /src/ui/context-menu.ts: -------------------------------------------------------------------------------- 1 | import { App, Menu, TFile } from "obsidian"; 2 | import { FolderNode } from "../types"; 3 | import { AbstractFolderPluginSettings } from "../settings"; 4 | import AbstractFolderPlugin from "../../main"; 5 | import { BatchDeleteConfirmModal, CreateAbstractChildModal, ChildFileType, DeleteConfirmModal } from './modals'; 6 | import { IconModal } from './icon-modal'; 7 | import { updateFileIcon, toggleHiddenStatus, createAbstractChildFile, deleteAbstractFile } from '../utils/file-operations'; 8 | import { FolderIndexer } from "../indexer"; 9 | 10 | export class ContextMenuHandler { 11 | constructor( 12 | private app: App, 13 | private settings: AbstractFolderPluginSettings, 14 | private plugin: AbstractFolderPlugin, 15 | private indexer: FolderIndexer 16 | ) {} 17 | 18 | showContextMenu(event: MouseEvent, node: FolderNode, multiSelectedPaths: Set) { 19 | const menu = new Menu(); 20 | 21 | if (multiSelectedPaths.size > 1 && multiSelectedPaths.has(node.path)) { 22 | this.addMultiSelectItems(menu, multiSelectedPaths); 23 | } else { 24 | this.addSingleItemItems(menu, node, multiSelectedPaths); 25 | } 26 | 27 | menu.showAtPosition({ x: event.clientX, y: event.clientY }); 28 | } 29 | 30 | showBackgroundMenu(event: MouseEvent) { 31 | const menu = new Menu(); 32 | this.addCreationItems(menu); 33 | menu.showAtPosition({ x: event.clientX, y: event.clientY }); 34 | } 35 | 36 | private addMultiSelectItems(menu: Menu, multiSelectedPaths: Set) { 37 | const selectedFiles: TFile[] = []; 38 | multiSelectedPaths.forEach(path => { 39 | const abstractFile = this.app.vault.getAbstractFileByPath(path); 40 | if (abstractFile instanceof TFile) { 41 | selectedFiles.push(abstractFile); 42 | } 43 | }); 44 | 45 | this.app.workspace.trigger("files-menu", menu, selectedFiles, "abstract-folder-view"); 46 | 47 | menu.addSeparator(); 48 | menu.addItem((item) => 49 | item 50 | .setTitle(`Delete ${selectedFiles.length} items`) 51 | .setIcon("trash") 52 | .onClick(() => { 53 | new BatchDeleteConfirmModal(this.app, selectedFiles, (deleteChildren: boolean) => { 54 | const deletePromises = selectedFiles.map(file => 55 | deleteAbstractFile(this.app, file, deleteChildren, this.indexer) 56 | ); 57 | Promise.all(deletePromises).then(() => { 58 | multiSelectedPaths.clear(); 59 | this.plugin.app.workspace.trigger('abstract-folder:graph-updated'); 60 | }).catch(console.error); 61 | }).open(); 62 | }) 63 | ); 64 | } 65 | 66 | private addSingleItemItems(menu: Menu, node: FolderNode, multiSelectedPaths: Set) { 67 | if (!node.file) return; 68 | 69 | if (!multiSelectedPaths.has(node.path) && multiSelectedPaths.size > 0) { 70 | multiSelectedPaths.clear(); 71 | this.plugin.app.workspace.trigger('abstract-folder:graph-updated'); 72 | } 73 | 74 | this.addFileSpecificActions(menu, node.file); 75 | 76 | menu.addSeparator(); 77 | menu.addItem((item) => 78 | item 79 | .setTitle("Delete file") 80 | .setIcon("trash") 81 | .onClick(() => { 82 | new DeleteConfirmModal(this.app, node.file!, (deleteChildren: boolean) => { 83 | deleteAbstractFile(this.app, node.file!, deleteChildren, this.indexer) 84 | .catch(console.error); 85 | }).open(); 86 | }) 87 | ); 88 | this.app.workspace.trigger("file-menu", menu, node.file, "abstract-folder-view"); 89 | } 90 | 91 | private addCreationItems(menu: Menu, parentFile: TFile | null = null) { 92 | menu.addItem((item) => 93 | item 94 | .setTitle(parentFile ? "Create abstract child note" : "Create new root note") 95 | .setIcon("file-plus") 96 | .onClick(() => { 97 | new CreateAbstractChildModal(this.app, this.settings, (childName: string, childType: ChildFileType) => { 98 | createAbstractChildFile(this.app, this.settings, childName, parentFile, childType) 99 | .catch(console.error); 100 | }, 'note').open(); 101 | }) 102 | ); 103 | 104 | menu.addItem((item) => 105 | item 106 | .setTitle(parentFile ? "Create abstract canvas child" : "Create new root canvas") 107 | .setIcon("layout-dashboard") 108 | .onClick(() => { 109 | new CreateAbstractChildModal(this.app, this.settings, (childName: string, childType: ChildFileType) => { 110 | createAbstractChildFile(this.app, this.settings, childName, parentFile, childType) 111 | .catch(console.error); 112 | }, 'canvas').open(); 113 | }) 114 | ); 115 | 116 | menu.addItem((item) => 117 | item 118 | .setTitle(parentFile ? "Create abstract bases child" : "Create new root base") 119 | .setIcon("database") 120 | .onClick(() => { 121 | new CreateAbstractChildModal(this.app, this.settings, (childName: string, childType: ChildFileType) => { 122 | createAbstractChildFile(this.app, this.settings, childName, parentFile, childType) 123 | .catch(console.error); 124 | }, 'base').open(); 125 | }) 126 | ); 127 | 128 | } 129 | 130 | private addFileSpecificActions(menu: Menu, file: TFile) { 131 | menu.addItem((item) => 132 | item 133 | .setTitle("Open in new tab") 134 | .setIcon("file-plus") 135 | .onClick(() => { 136 | this.app.workspace.getLeaf('tab').openFile(file).catch(console.error); 137 | }) 138 | ); 139 | 140 | menu.addItem((item) => 141 | item 142 | .setTitle("Open to the right") 143 | .setIcon("separator-vertical") 144 | .onClick(() => { 145 | this.app.workspace.getLeaf('split').openFile(file).catch(console.error); 146 | }) 147 | ); 148 | 149 | menu.addItem((item) => 150 | item 151 | .setTitle("Open in new window") 152 | .setIcon("popout") 153 | .onClick(() => { 154 | this.app.workspace.getLeaf('window').openFile(file).catch(console.error); 155 | }) 156 | ); 157 | 158 | menu.addSeparator(); 159 | 160 | const fileCache = this.app.metadataCache.getFileCache(file); 161 | const parentProperty = fileCache?.frontmatter?.[this.settings.propertyName] as string | string[] | undefined; 162 | let isCurrentlyHidden = false; 163 | if (parentProperty) { 164 | const parentLinks = Array.isArray(parentProperty) ? parentProperty : [parentProperty]; 165 | isCurrentlyHidden = parentLinks.some((p: string) => p.toLowerCase().trim() === 'hidden'); 166 | } 167 | 168 | if (isCurrentlyHidden) { 169 | menu.addItem((item) => 170 | item 171 | .setTitle("Unhide abstract note") 172 | .setIcon("eye") 173 | .onClick(() => { 174 | toggleHiddenStatus(this.app, file, this.settings).catch(console.error); 175 | }) 176 | ); 177 | } else { 178 | menu.addItem((item) => 179 | item 180 | .setTitle("Hide abstract note") 181 | .setIcon("eye-off") 182 | .onClick(() => { 183 | toggleHiddenStatus(this.app, file, this.settings).catch(console.error); 184 | }) 185 | ); 186 | } 187 | 188 | menu.addItem((item) => 189 | item 190 | .setTitle("Set/change icon") 191 | .setIcon("image") 192 | .onClick(() => { 193 | const currentIcon = this.app.metadataCache.getFileCache(file)?.frontmatter?.icon as string | undefined; 194 | new IconModal(this.app, (result) => { 195 | updateFileIcon(this.app, file, result).catch(console.error); 196 | }, currentIcon as string || "").open(); 197 | }) 198 | ); 199 | 200 | this.addCreationItems(menu, file); 201 | } 202 | } -------------------------------------------------------------------------------- /src/ui/dnd/drag-manager.ts: -------------------------------------------------------------------------------- 1 | import { App, TFile } from "obsidian"; 2 | import { AbstractFolderPluginSettings } from "../../settings"; 3 | import { moveFiles } from "../../utils/file-operations"; 4 | import { FolderNode } from "../../types"; 5 | import { FolderIndexer } from "../../indexer"; 6 | import { AbstractFolderView } from "../../view"; // Import AbstractFolderView 7 | 8 | export interface DragData { 9 | sourcePaths: string[]; 10 | sourceParentPath: string; 11 | isCopy: boolean; // Add isCopy flag 12 | } 13 | 14 | export class DragManager { 15 | private app: App; 16 | private settings: AbstractFolderPluginSettings; 17 | private indexer: FolderIndexer; 18 | private view: AbstractFolderView; // Add view instance 19 | private dragData: DragData | null = null; 20 | private currentDragTarget: HTMLElement | null = null; 21 | 22 | constructor(app: App, settings: AbstractFolderPluginSettings, indexer: FolderIndexer, view: AbstractFolderView) { 23 | this.app = app; 24 | this.settings = settings; 25 | this.indexer = indexer; 26 | this.view = view; // Assign view instance 27 | } 28 | 29 | public handleDragStart(event: DragEvent, node: FolderNode, parentPath: string, multiSelectedPaths: Set) { 30 | if (!event.dataTransfer) return; 31 | 32 | event.stopPropagation(); // Prevent bubbling to parent folder items 33 | 34 | // Attach dragend listener to cleanup if drop doesn't fire (e.g. invalid drop target) 35 | const el = event.currentTarget as HTMLElement; 36 | el.addEventListener("dragend", (e) => this.handleDragEnd(e), { once: true }); 37 | 38 | const sourcePaths = multiSelectedPaths.has(node.path) 39 | ? Array.from(multiSelectedPaths) 40 | : [node.path]; 41 | 42 | this.dragData = { 43 | sourcePaths: sourcePaths, 44 | sourceParentPath: parentPath, 45 | isCopy: false // Will be determined dynamically during dragover/drop 46 | }; 47 | // Set drag image/effect to allow both move and copy 48 | event.dataTransfer.effectAllowed = "copyMove"; 49 | 50 | // We can put the first path in text/plain for external apps, though mostly internal 51 | event.dataTransfer.setData("text/plain", node.path); 52 | 53 | // Add a custom class to the ghost image if needed, or just let browser handle it 54 | const dragIcon = document.body.createDiv({ 55 | cls: "abstract-folder-ghost-drag-image", 56 | text: `Moving ${sourcePaths.length} item(s)` 57 | }); 58 | event.dataTransfer.setDragImage(dragIcon, 0, 0); 59 | setTimeout(() => dragIcon.remove(), 0); 60 | } 61 | 62 | public handleDragEnd(event: DragEvent) { 63 | this.cleanup(); 64 | } 65 | 66 | private cleanup() { 67 | if (this.currentDragTarget) { 68 | this.currentDragTarget.removeClass("abstract-folder-drag-over"); 69 | this.currentDragTarget.removeClass("abstract-folder-drag-invalid"); 70 | this.currentDragTarget = null; 71 | } 72 | this.dragData = null; 73 | } 74 | 75 | public handleDragOver(event: DragEvent, targetNode: FolderNode | null) { 76 | if (!this.dragData) { 77 | return; 78 | } 79 | 80 | event.stopPropagation(); // Ensure we only highlight the specific item dragged over 81 | 82 | const targetEl = event.currentTarget as HTMLElement; 83 | 84 | // Track current target for cleanup 85 | if (this.currentDragTarget && this.currentDragTarget !== targetEl) { 86 | this.currentDragTarget.removeClass("abstract-folder-drag-over"); 87 | this.currentDragTarget.removeClass("abstract-folder-drag-invalid"); 88 | } 89 | this.currentDragTarget = targetEl; 90 | 91 | let isValid = true; 92 | const targetPath = targetNode?.path ?? ""; // Use empty string for root target 93 | 94 | // Validation 1: Self drop (if target is a specific node) 95 | if (targetNode && this.dragData.sourcePaths.includes(targetNode.path)) { 96 | isValid = false; 97 | } 98 | 99 | // Validation 2: Non-MD target (if target is a specific node) 100 | if (isValid && targetNode?.file && targetNode.file.extension !== 'md') { 101 | isValid = false; 102 | } 103 | 104 | // Validation 3: Circular dependency (if target is a specific node) 105 | if (isValid && targetNode) { 106 | for (const sourcePath of this.dragData.sourcePaths) { 107 | if (this.isDescendant(sourcePath, targetPath)) { 108 | isValid = false; 109 | break; 110 | } 111 | } 112 | } 113 | 114 | const isCopyOperation = event.ctrlKey || event.altKey || event.metaKey; 115 | 116 | if (isValid) { 117 | event.preventDefault(); // Necessary to allow dropping 118 | event.dataTransfer!.dropEffect = isCopyOperation ? "copy" : "move"; 119 | targetEl.addClass("abstract-folder-drag-over"); 120 | targetEl.removeClass("abstract-folder-drag-invalid"); 121 | } else { 122 | // Show invalid visual feedback 123 | event.preventDefault(); // Allow processing to show feedback 124 | event.dataTransfer!.dropEffect = "none"; 125 | targetEl.addClass("abstract-folder-drag-invalid"); 126 | targetEl.removeClass("abstract-folder-drag-over"); 127 | } 128 | } 129 | 130 | public handleDragLeave(event: DragEvent) { 131 | const targetEl = event.currentTarget as HTMLElement; 132 | targetEl.removeClass("abstract-folder-drag-over"); 133 | targetEl.removeClass("abstract-folder-drag-invalid"); 134 | 135 | if (this.currentDragTarget === targetEl) { 136 | this.currentDragTarget = null; 137 | } 138 | } 139 | 140 | public async handleDrop(event: DragEvent, targetNode: FolderNode | null) { 141 | const targetEl = event.currentTarget as HTMLElement; 142 | targetEl.removeClass("abstract-folder-drag-over"); 143 | targetEl.removeClass("abstract-folder-drag-invalid"); 144 | this.currentDragTarget = null; 145 | 146 | if (!this.dragData) return; 147 | 148 | event.preventDefault(); 149 | event.stopPropagation(); 150 | 151 | const isCopyOperation = event.ctrlKey || event.altKey || event.metaKey; 152 | try { 153 | const { sourcePaths, sourceParentPath } = this.dragData; 154 | const targetPath = targetNode?.path ?? ""; // Use empty string for root target 155 | 156 | // Validation: Don't drop into self 157 | if (sourcePaths.includes(targetPath)) return; 158 | 159 | // Validation: Don't drop into immediate parent (no-op for move, but allowed for copy) 160 | if (!isCopyOperation && targetPath === sourceParentPath) return; 161 | 162 | // Validation: Target must be MD or virtual (if target is a specific node) 163 | if (targetNode?.file && targetNode.file.extension !== 'md') { 164 | return; 165 | } 166 | 167 | const filesToMove: TFile[] = []; 168 | for (const path of sourcePaths) { 169 | const file = this.app.vault.getAbstractFileByPath(path); 170 | if (file instanceof TFile) { 171 | filesToMove.push(file); 172 | } 173 | } 174 | 175 | if (filesToMove.length > 0) { 176 | await moveFiles(this.app, this.settings, filesToMove, targetPath, sourceParentPath, this.indexer, isCopyOperation); 177 | 178 | // Expand target folder if setting is enabled and target is a folder 179 | if (this.settings.expandTargetFolderOnDrop && targetNode?.isFolder) { 180 | this.view.expandFolderByPath(targetNode.path).catch(console.error); 181 | } 182 | } 183 | } finally { 184 | // Ensure cleanup happens even if early returns occur 185 | this.dragData = null; 186 | } 187 | } 188 | 189 | private isDescendant(potentialAncestorPath: string, potentialDescendantPath: string): boolean { 190 | // Simple BFS to check if potentialDescendantPath is reachable from potentialAncestorPath 191 | // using the graph in Indexer 192 | 193 | if (potentialAncestorPath === potentialDescendantPath) return true; 194 | 195 | const graph = this.indexer.getGraph(); 196 | const children = graph.parentToChildren[potentialAncestorPath]; 197 | 198 | if (!children) return false; 199 | 200 | if (children.has(potentialDescendantPath)) return true; 201 | 202 | for (const childPath of children) { 203 | if (this.isDescendant(childPath, potentialDescendantPath)) { 204 | return true; 205 | } 206 | } 207 | 208 | return false; 209 | } 210 | } -------------------------------------------------------------------------------- /src/ui/settings-tab.ts: -------------------------------------------------------------------------------- 1 | import { App, PluginSettingTab, Setting, AbstractInputSuggest, normalizePath } from 'obsidian'; 2 | import AbstractFolderPlugin from '../../main'; // Adjust path if necessary 3 | 4 | // Helper for path suggestions 5 | export class PathInputSuggest extends AbstractInputSuggest { 6 | constructor(app: App, private inputEl: HTMLInputElement) { 7 | super(app, inputEl); 8 | } 9 | 10 | getSuggestions(inputStr: string): string[] { 11 | const files = this.app.vault.getAllLoadedFiles(); 12 | const paths: string[] = []; 13 | for (const file of files) { 14 | paths.push(file.path); 15 | } 16 | 17 | const lowerCaseInputStr = inputStr.toLowerCase(); 18 | return paths.filter(path => 19 | path.toLowerCase().includes(lowerCaseInputStr) 20 | ); 21 | } 22 | 23 | renderSuggestion(value: string, el: HTMLElement): void { 24 | el.setText(value); 25 | } 26 | 27 | selectSuggestion(value: string, evt: MouseEvent | KeyboardEvent): void { 28 | this.inputEl.value = value; 29 | this.inputEl.trigger("input"); 30 | this.close(); 31 | } 32 | } 33 | 34 | export class AbstractFolderSettingTab extends PluginSettingTab { 35 | plugin: AbstractFolderPlugin; 36 | 37 | constructor(app: App, plugin: AbstractFolderPlugin) { 38 | super(app, plugin); 39 | this.plugin = plugin; 40 | } 41 | 42 | display(): void { 43 | const { containerEl } = this; 44 | 45 | containerEl.empty(); 46 | 47 | this.renderExcludedPaths(containerEl); 48 | 49 | new Setting(containerEl) 50 | .setName("Parent property name") 51 | .setDesc("The frontmatter property key used to define parent notes (e.g., 'parent' or 'folder'). This setting is case-sensitive, so ensure your frontmatter property name matches the casing exactly.") 52 | .addText((text) => 53 | text 54 | .setPlaceholder("Example: parent") 55 | .setValue(this.plugin.settings.propertyName) 56 | .onChange(async (value) => { 57 | this.plugin.settings.propertyName = value; 58 | await this.plugin.saveSettings(); 59 | this.plugin.indexer.updateSettings(this.plugin.settings); // Notify indexer of setting change 60 | }) 61 | ); 62 | 63 | new Setting(containerEl) 64 | .setName("Children property name") 65 | .setDesc("The frontmatter property key used by a parent to define its children (e.g., 'children' or 'sub_notes'). This setting is case-sensitive, so ensure your frontmatter property name matches the casing exactly.") 66 | .addText((text) => 67 | text 68 | .setPlaceholder("Example: children") 69 | .setValue(this.plugin.settings.childrenPropertyName) 70 | .onChange(async (value) => { 71 | this.plugin.settings.childrenPropertyName = value; 72 | await this.plugin.saveSettings(); 73 | this.plugin.indexer.updateSettings(this.plugin.settings); // Notify indexer of setting change 74 | }) 75 | ); 76 | 77 | new Setting(containerEl) 78 | .setName("Show aliases") 79 | .setDesc("Use the first alias as the display name in the abstract folders view if available.") 80 | .addToggle((toggle) => 81 | toggle 82 | .setValue(this.plugin.settings.showAliases) 83 | .onChange(async (value) => { 84 | this.plugin.settings.showAliases = value; 85 | await this.plugin.saveSettings(); 86 | this.plugin.indexer.updateSettings(this.plugin.settings); 87 | }) 88 | ); 89 | 90 | new Setting(containerEl) 91 | .setName("Expand parent folders for active file") 92 | .setDesc("Automatically expand all parent folders in the tree view to reveal the active file's location. This ensures that even if a file has multiple parents, all ancestors will be expanded. The active file will always be highlighted.") 93 | .addToggle((toggle) => 94 | toggle 95 | .setValue(this.plugin.settings.autoExpandParents) 96 | .onChange(async (value) => { 97 | this.plugin.settings.autoExpandParents = value; 98 | await this.plugin.saveSettings(); 99 | // No indexer update needed, fileRevealManager uses settings directly 100 | }) 101 | ); 102 | 103 | new Setting(containerEl) 104 | .setName("Expand children when opening a file") 105 | .setDesc("If enabled, when you open a file, its direct children folders will be expanded in the tree view.") 106 | .addToggle((toggle) => 107 | toggle 108 | .setValue(this.plugin.settings.autoExpandChildren) 109 | .onChange(async (value) => { 110 | this.plugin.settings.autoExpandChildren = value; 111 | await this.plugin.saveSettings(); 112 | }) 113 | ); 114 | 115 | new Setting(containerEl) 116 | .setName("Expand target folder on drag & drop") 117 | .setDesc("If enabled, the target folder will automatically expand when an item is dropped into it.") 118 | .addToggle((toggle) => 119 | toggle 120 | .setValue(this.plugin.settings.expandTargetFolderOnDrop) 121 | .onChange(async (value) => { 122 | this.plugin.settings.expandTargetFolderOnDrop = value; 123 | await this.plugin.saveSettings(); 124 | }) 125 | ); 126 | 127 | new Setting(containerEl) 128 | .setName("Remember expanded folders") 129 | .setDesc("Keep folders expanded even when switching views or restarting Obsidian.") 130 | .addToggle((toggle) => 131 | toggle 132 | .setValue(this.plugin.settings.rememberExpanded) 133 | .onChange(async (value) => { 134 | this.plugin.settings.rememberExpanded = value; 135 | if (!value) { 136 | this.plugin.settings.expandedFolders = []; 137 | } 138 | await this.plugin.saveSettings(); 139 | }) 140 | ); 141 | 142 | new Setting(containerEl) 143 | .setName("Open on startup") 144 | .setDesc("Automatically open the abstract folders view when Obsidian starts.") 145 | .addToggle((toggle) => 146 | toggle 147 | .setValue(this.plugin.settings.startupOpen) 148 | .onChange(async (value) => { 149 | this.plugin.settings.startupOpen = value; 150 | await this.plugin.saveSettings(); 151 | }) 152 | ); 153 | 154 | new Setting(containerEl) 155 | .setName("Open position") 156 | .setDesc("Which side sidebar to open the view in.") 157 | .addDropdown((dropdown) => 158 | dropdown 159 | .addOption("left", "Left") 160 | .addOption("right", "Right") 161 | .setValue(this.plugin.settings.openSide) 162 | .onChange(async (value: 'left' | 'right') => { 163 | this.plugin.settings.openSide = value; 164 | await this.plugin.saveSettings(); 165 | }) 166 | ); 167 | 168 | new Setting(containerEl) 169 | .setName("Show ribbon icon") 170 | .setDesc("Toggle the visibility of the abstract folders icon in the left ribbon.") 171 | .addToggle((toggle) => 172 | toggle 173 | .setValue(this.plugin.settings.showRibbonIcon) 174 | .onChange(async (value) => { 175 | this.plugin.settings.showRibbonIcon = value; 176 | await this.plugin.saveSettings(); 177 | // The main plugin class's saveSettings will call updateRibbonIconVisibility 178 | }) 179 | ); 180 | 181 | new Setting(containerEl) 182 | .setName("Visual") 183 | .setHeading(); 184 | 185 | new Setting(containerEl) 186 | .setName("Enable rainbow indents") 187 | .setDesc("Color the indentation lines to visually distinguish tree depth.") 188 | .addToggle((toggle) => 189 | toggle 190 | .setValue(this.plugin.settings.enableRainbowIndents) 191 | .onChange(async (value) => { 192 | this.plugin.settings.enableRainbowIndents = value; 193 | await this.plugin.saveSettings(); 194 | // Trigger view refresh to apply new styling 195 | this.plugin.indexer.updateSettings(this.plugin.settings); 196 | }) 197 | ); 198 | 199 | new Setting(containerEl) 200 | .setName("Rainbow indent palette") 201 | .setDesc("Choose the color palette for rainbow indentation guides.") 202 | .addDropdown((dropdown) => 203 | dropdown 204 | .addOption("classic", "Classic") 205 | .addOption("pastel", "Pastel") 206 | .addOption("neon", "Neon") 207 | .setValue(this.plugin.settings.rainbowPalette) 208 | .onChange(async (value: 'classic' | 'pastel' | 'neon') => { 209 | this.plugin.settings.rainbowPalette = value; 210 | await this.plugin.saveSettings(); 211 | // Trigger view refresh to apply new styling 212 | this.plugin.indexer.updateSettings(this.plugin.settings); 213 | }) 214 | ); 215 | 216 | new Setting(containerEl) 217 | .setName("Rainbow indent - varied item colors") 218 | .setDesc("If enabled, sibling items at the same indentation level will use different colors from the palette, making them easier to distinguish. If disabled, all items at the same depth will share the same color.") 219 | .addToggle((toggle) => 220 | toggle 221 | .setValue(this.plugin.settings.enablePerItemRainbowColors) 222 | .onChange(async (value) => { 223 | this.plugin.settings.enablePerItemRainbowColors = value; 224 | await this.plugin.saveSettings(); 225 | // Trigger view refresh to apply new styling 226 | this.plugin.indexer.updateSettings(this.plugin.settings); 227 | }) 228 | ); 229 | } 230 | 231 | private renderExcludedPaths(containerEl: HTMLElement): void { 232 | new Setting(containerEl) 233 | .setName("Excluded paths") 234 | .setDesc("Paths to exclude from the abstract folders view.") 235 | .setHeading(); 236 | 237 | const excludedPathsContainer = containerEl.createDiv({ cls: "abstract-folder-excluded-paths-container" }); 238 | this.plugin.settings.excludedPaths.forEach((path, index) => { 239 | new Setting(excludedPathsContainer) 240 | .addText(text => { 241 | text.setPlaceholder("Path to exclude"); 242 | text.setValue(path); 243 | new PathInputSuggest(this.app, text.inputEl); 244 | text.onChange(async (value) => { 245 | this.plugin.settings.excludedPaths[index] = normalizePath(value); 246 | await this.plugin.saveSettings(); 247 | this.plugin.indexer.updateSettings(this.plugin.settings); 248 | }); 249 | }) 250 | .addButton(button => button 251 | .setButtonText("Remove") 252 | .setIcon("trash") 253 | .onClick(async () => { 254 | this.plugin.settings.excludedPaths.splice(index, 1); 255 | await this.plugin.saveSettings(); 256 | this.plugin.indexer.updateSettings(this.plugin.settings); 257 | this.display(); // Re-render to update the list 258 | })); 259 | }); 260 | 261 | new Setting(containerEl) 262 | .addButton(button => button 263 | .setButtonText("Add new excluded path") 264 | .setCta() 265 | .onClick(async () => { 266 | this.plugin.settings.excludedPaths.push(normalizePath("")); // Add an empty path for the new input, normalized 267 | await this.plugin.saveSettings(); 268 | this.display(); // Re-render to show the new input field 269 | })); 270 | } 271 | } -------------------------------------------------------------------------------- /AGENTS.md: -------------------------------------------------------------------------------- 1 | # Obsidian community plugin 2 | 3 | ## Project overview 4 | 5 | - Target: Obsidian Community Plugin (TypeScript → bundled JavaScript). 6 | - Entry point: `main.ts` compiled to `main.js` and loaded by Obsidian. 7 | - Required release artifacts: `main.js`, `manifest.json`, and optional `styles.css`. 8 | 9 | ## Environment & tooling 10 | 11 | - Node.js: use current LTS (Node 18+ recommended). 12 | - **Package manager: npm** (required for this sample - `package.json` defines npm scripts and dependencies). 13 | - **Bundler: esbuild** (required for this sample - `esbuild.config.mjs` and build scripts depend on it). Alternative bundlers like Rollup or webpack are acceptable for other projects if they bundle all external dependencies into `main.js`. 14 | - Types: `obsidian` type definitions. 15 | 16 | **Note**: This sample project has specific technical dependencies on npm and esbuild. If you're creating a plugin from scratch, you can choose different tools, but you'll need to replace the build configuration accordingly. 17 | 18 | ### Install 19 | 20 | ```bash 21 | npm install 22 | ``` 23 | 24 | ### Dev (watch) 25 | 26 | ```bash 27 | npm run dev 28 | ``` 29 | 30 | ### Production build 31 | 32 | ```bash 33 | npm run build 34 | ``` 35 | 36 | ## Linting 37 | 38 | - To use eslint install eslint from terminal: `npm install -g eslint` 39 | - To use eslint to analyze this project use this command: `eslint main.ts` 40 | - eslint will then create a report with suggestions for code improvement by file and line number. 41 | - If your source code is in a folder, such as `src`, you can use eslint with this command to analyze all files in that folder: `eslint ./src/` 42 | 43 | ## File & folder conventions 44 | 45 | - **Organize code into multiple files**: Split functionality across separate modules rather than putting everything in `main.ts`. 46 | - Source lives in `src/`. Keep `main.ts` small and focused on plugin lifecycle (loading, unloading, registering commands). 47 | - **Example file structure**: 48 | ``` 49 | src/ 50 | main.ts # Plugin entry point, lifecycle management 51 | settings.ts # Settings interface and defaults 52 | commands/ # Command implementations 53 | command1.ts 54 | command2.ts 55 | ui/ # UI components, modals, views 56 | modal.ts 57 | view.ts 58 | utils/ # Utility functions, helpers 59 | helpers.ts 60 | constants.ts 61 | types.ts # TypeScript interfaces and types 62 | ``` 63 | - **Do not commit build artifacts**: Never commit `node_modules/`, `main.js`, or other generated files to version control. 64 | - Keep the plugin small. Avoid large dependencies. Prefer browser-compatible packages. 65 | - Generated output should be placed at the plugin root or `dist/` depending on your build setup. Release artifacts must end up at the top level of the plugin folder in the vault (`main.js`, `manifest.json`, `styles.css`). 66 | 67 | ## Manifest rules (`manifest.json`) 68 | 69 | - Must include (non-exhaustive): 70 | - `id` (plugin ID; for local dev it should match the folder name) 71 | - `name` 72 | - `version` (Semantic Versioning `x.y.z`) 73 | - `minAppVersion` 74 | - `description` 75 | - `isDesktopOnly` (boolean) 76 | - Optional: `author`, `authorUrl`, `fundingUrl` (string or map) 77 | - Never change `id` after release. Treat it as stable API. 78 | - Keep `minAppVersion` accurate when using newer APIs. 79 | - Canonical requirements are coded here: https://github.com/obsidianmd/obsidian-releases/blob/master/.github/workflows/validate-plugin-entry.yml 80 | 81 | ## Testing 82 | 83 | - Manual install for testing: copy `main.js`, `manifest.json`, `styles.css` (if any) to: 84 | ``` 85 | /.obsidian/plugins// 86 | ``` 87 | - Reload Obsidian and enable the plugin in **Settings → Community plugins**. 88 | 89 | ## Commands & settings 90 | 91 | - Any user-facing commands should be added via `this.addCommand(...)`. 92 | - If the plugin has configuration, provide a settings tab and sensible defaults. 93 | - Persist settings using `this.loadData()` / `this.saveData()`. 94 | - Use stable command IDs; avoid renaming once released. 95 | 96 | ## Versioning & releases 97 | 98 | - Bump `version` in `manifest.json` (SemVer) and update `versions.json` to map plugin version → minimum app version. 99 | - Create a GitHub release whose tag exactly matches `manifest.json`'s `version`. Do not use a leading `v`. 100 | - Attach `manifest.json`, `main.js`, and `styles.css` (if present) to the release as individual assets. 101 | - After the initial release, follow the process to add/update your plugin in the community catalog as required. 102 | 103 | ## Security, privacy, and compliance 104 | 105 | Follow Obsidian's **Developer Policies** and **Plugin Guidelines**. In particular: 106 | 107 | - Default to local/offline operation. Only make network requests when essential to the feature. 108 | - No hidden telemetry. If you collect optional analytics or call third-party services, require explicit opt-in and document clearly in `README.md` and in settings. 109 | - Never execute remote code, fetch and eval scripts, or auto-update plugin code outside of normal releases. 110 | - Minimize scope: read/write only what's necessary inside the vault. Do not access files outside the vault. 111 | - Clearly disclose any external services used, data sent, and risks. 112 | - Respect user privacy. Do not collect vault contents, filenames, or personal information unless absolutely necessary and explicitly consented. 113 | - Avoid deceptive patterns, ads, or spammy notifications. 114 | - Register and clean up all DOM, app, and interval listeners using the provided `register*` helpers so the plugin unloads safely. 115 | 116 | ## UX & copy guidelines (for UI text, commands, settings) 117 | 118 | - Prefer sentence case for headings, buttons, and titles. 119 | - Use clear, action-oriented imperatives in step-by-step copy. 120 | - Use **bold** to indicate literal UI labels. Prefer "select" for interactions. 121 | - Use arrow notation for navigation: **Settings → Community plugins**. 122 | - Keep in-app strings short, consistent, and free of jargon. 123 | 124 | ## Performance 125 | 126 | - Keep startup light. Defer heavy work until needed. 127 | - Avoid long-running tasks during `onload`; use lazy initialization. 128 | - Batch disk access and avoid excessive vault scans. 129 | - Debounce/throttle expensive operations in response to file system events. 130 | 131 | ## Coding conventions 132 | 133 | - TypeScript with `"strict": true` preferred. 134 | - **Keep `main.ts` minimal**: Focus only on plugin lifecycle (onload, onunload, addCommand calls). Delegate all feature logic to separate modules. 135 | - **Split large files**: If any file exceeds ~200-300 lines, consider breaking it into smaller, focused modules. 136 | - **Use clear module boundaries**: Each file should have a single, well-defined responsibility. 137 | - Bundle everything into `main.js` (no unbundled runtime deps). 138 | - Avoid Node/Electron APIs if you want mobile compatibility; set `isDesktopOnly` accordingly. 139 | - Prefer `async/await` over promise chains; handle errors gracefully. 140 | 141 | ## Mobile 142 | 143 | - Where feasible, test on iOS and Android. 144 | - Don't assume desktop-only behavior unless `isDesktopOnly` is `true`. 145 | - Avoid large in-memory structures; be mindful of memory and storage constraints. 146 | 147 | ## Agent do/don't 148 | 149 | **Do** 150 | - Add commands with stable IDs (don't rename once released). 151 | - Provide defaults and validation in settings. 152 | - Write idempotent code paths so reload/unload doesn't leak listeners or intervals. 153 | - Use `this.register*` helpers for everything that needs cleanup. 154 | 155 | **Don't** 156 | - Introduce network calls without an obvious user-facing reason and documentation. 157 | - Ship features that require cloud services without clear disclosure and explicit opt-in. 158 | - Store or transmit vault contents unless essential and consented. 159 | 160 | ## Common tasks 161 | 162 | ### Organize code across multiple files 163 | 164 | **main.ts** (minimal, lifecycle only): 165 | ```ts 166 | import { Plugin } from "obsidian"; 167 | import { MySettings, DEFAULT_SETTINGS } from "./settings"; 168 | import { registerCommands } from "./commands"; 169 | 170 | export default class MyPlugin extends Plugin { 171 | settings: MySettings; 172 | 173 | async onload() { 174 | this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); 175 | registerCommands(this); 176 | } 177 | } 178 | ``` 179 | 180 | **settings.ts**: 181 | ```ts 182 | export interface MySettings { 183 | enabled: boolean; 184 | apiKey: string; 185 | } 186 | 187 | export const DEFAULT_SETTINGS: MySettings = { 188 | enabled: true, 189 | apiKey: "", 190 | }; 191 | ``` 192 | 193 | **commands/index.ts**: 194 | ```ts 195 | import { Plugin } from "obsidian"; 196 | import { doSomething } from "./my-command"; 197 | 198 | export function registerCommands(plugin: Plugin) { 199 | plugin.addCommand({ 200 | id: "do-something", 201 | name: "Do something", 202 | callback: () => doSomething(plugin), 203 | }); 204 | } 205 | ``` 206 | 207 | ### Add a command 208 | 209 | ```ts 210 | this.addCommand({ 211 | id: "your-command-id", 212 | name: "Do the thing", 213 | callback: () => this.doTheThing(), 214 | }); 215 | ``` 216 | 217 | ### Persist settings 218 | 219 | ```ts 220 | interface MySettings { enabled: boolean } 221 | const DEFAULT_SETTINGS: MySettings = { enabled: true }; 222 | 223 | async onload() { 224 | this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); 225 | await this.saveData(this.settings); 226 | } 227 | ``` 228 | 229 | ### Register listeners safely 230 | 231 | ```ts 232 | this.registerEvent(this.app.workspace.on("file-open", f => { /* ... */ })); 233 | this.registerDomEvent(window, "resize", () => { /* ... */ }); 234 | this.registerInterval(window.setInterval(() => { /* ... */ }, 1000)); 235 | ``` 236 | 237 | ## Troubleshooting 238 | 239 | - Plugin doesn't load after build: ensure `main.js` and `manifest.json` are at the top level of the plugin folder under `/.obsidian/plugins//`. 240 | - Build issues: if `main.js` is missing, run `npm run build` or `npm run dev` to compile your TypeScript source code. 241 | - Commands not appearing: verify `addCommand` runs after `onload` and IDs are unique. 242 | - Settings not persisting: ensure `loadData`/`saveData` are awaited and you re-render the UI after changes. 243 | - Mobile-only issues: confirm you're not using desktop-only APIs; check `isDesktopOnly` and adjust. 244 | 245 | ## References 246 | 247 | - Obsidian sample plugin: https://github.com/obsidianmd/obsidian-sample-plugin 248 | - API documentation: https://docs.obsidian.md 249 | - Developer policies: https://docs.obsidian.md/Developer+policies 250 | - Plugin guidelines: https://docs.obsidian.md/Plugins/Releasing/Plugin+guidelines 251 | - Style guide: https://help.obsidian.md/style-guide 252 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Abstract Folder 2 | 3 | **Organize your files virtually, independent of their physical location.** 4 | 5 | 6 | You can support me if you find this useful :) 7 | 8 | [![Donate via PayPal](https://img.shields.io/badge/Donate-PayPal-00457C?style=for-the-badge&logo=paypal)](https://www.paypal.com/paypalme/airfunn) 9 | [![Donate via Wise](https://img.shields.io/badge/Donate-Wise-00BF8D?style=for-the-badge&logo=wise)](https://wise.com/pay/me/erfanr47) 10 | 11 | ## Further Reading 12 | 13 | * **The Folders Fail: A PKM Solution** - [Read the article here](https://erfanrahmani.com/folders-fail-pkm-solution/) 14 | 15 | ## The Problem 16 | 17 | Standard folders are rigid. A file usually belongs to only one folder in your system, but conceptually, it might belong to three different projects. 18 | 19 | ## The Solution 20 | 21 | **Abstract Folder** creates a "Virtual File Explorer" inside Obsidian. You define the folder structure using links in your Frontmatter. 22 | 23 | * **One File, Multiple Folders:** A single file can appear in "Project A," "Team Meetings," and "Archives" simultaneously without duplicating the actual file. 24 | * **Files *are* Folders:** Any file can act as a parent folder for other files. 25 | * **No Physical Moving:** Reorganize your entire vault hierarchy by editing text. Your actual file system structure remains untouched. 26 | 27 | ----- 28 | 29 | ## Visual Demonstration 30 | 31 |

32 | 33 | ### 1. The Conversion Command 34 | The command palette option to automatically convert your physical folder structure into Abstract Folders, and vice versa, exporting from Abstract Folders into physical folders. 35 | ![Conversion edited](https://github.com/user-attachments/assets/2dda076f-242c-41a4-b267-b5df0877a319) 36 | 37 |
38 | 39 | ### 2. Multi-Parenting Example 40 | A single note appears under multiple different "parent" folders in the Abstract Folder view. 41 | ![Multi Parent Edited](https://github.com/user-attachments/assets/495cfee8-e647-4725-b785-ebe1b1629d63) 42 | 43 |
44 | 45 | ### 3. Drag and Drop Functionality 46 | Quickly move and reorganize your abstract files directly within the view. 47 | ![AF Drag n drop Edited](https://github.com/user-attachments/assets/9cb8b9f1-9ad1-41eb-b69a-91c82b706449) 48 | 49 |
50 | 51 | ### 4. Custom Groups View 52 | Using groups to filter and manage a subset of your abstract folders. 53 | ![AF ACTIVE GROUP Edited](https://github.com/user-attachments/assets/b1b2e1c2-e0fd-41c8-966a-08bab3ee5f1d) 54 | 55 |

56 | 57 | ----- 58 | 59 | ## Quick Start 60 | 61 | 1. **Install the plugin** (see [Installation](#installation)). 62 | 2. **Convert existing folders (optional but recommended):** Run the command **"Abstract Folder: Convert folder structure to plugin format"** from the Command Palette (`Ctrl/Cmd + P`). This will automatically add `parent` properties to your notes, mirroring your current physical folder structure as abstract folders. 63 | 3. **Open the Abstract Folder view:** Run the command **"Abstract Folder: Open Abstract Folder View"**. 64 | 4. **Define relationships:** 65 | * **Child points to Parent:** In any note's frontmatter, add a `parent` property as a list (e.g., `parent: ["[[Parent Note Name]]"]`). If you use the conversion command (step 2), this will be set up automatically. **Tip: You can click the property icon to change the property type to list** 66 | * **Parent lists Children:** In a parent note's frontmatter, add `children: ["[[Child Note 1]]", "[[Child Note 2]]"]`. 67 | * For more details and examples, see [Usage](#usage). 68 | 5. **Explore and manage:** Use the virtual file explorer to navigate your newly defined abstract hierarchy. For available actions, refer to the [Commands](#commands) section. 69 | 70 | ----- 71 | 72 | ## Key Features 73 | 74 | * **Virtual Hierarchy:** Create deep nesting and folder structures entirely via metadata. 75 | * **Drag & Drop:** Reorganize your abstract folders naturally. Drag to move, or hold `Ctrl`/`Cmd` to copy (add to a second parent). 76 | * **Multi-Parenting:** Assign a file to multiple "parents" using the `parent` property. It will appear in all of them in the tree view. 77 | * **Parent-Defined Children:** Use the `children` property to manually list files that belong to a parent file. 78 | * **Non-Markdown Support:** Using the "Parent-Defined Children" feature, you can organize files that don't have frontmatter (like Canvas, Excalidraw, Images, or PDFs) into your abstract folders. 79 | * **Custom Views:** Browse your files using a Tree view, Column view, or Groups. 80 | * **Migration Tools:** One-click tools to convert your physical folder structure to Abstract Folders (and vice-versa). 81 | * **Advanced Sorting:** Organize your view using smart metrics like "Thermal," "Stale Rot," and "Gravity." 82 | * **Hotness (Thermal):** Surface notes you are actively working on using exponential decay logic. 83 | 84 | ## Usage 85 | 86 | ### 1\. The Basic Method (Child points to Parent) 87 | 88 | This is the most common method. Inside a file, add a `parent` link in the Frontmatter. 89 | 90 | **File:** `My File.md` 91 | 92 | ```yaml 93 | --- 94 | parent: "[[Project Alpha]]" 95 | --- 96 | ``` 97 | 98 | *Result:* `My File` will appear inside `Project Alpha` in the Abstract Folder view. 99 | 100 | **Multiple Parents:** 101 | 102 | ```yaml 103 | --- 104 | parent: 105 | - "[[Project Alpha]]" 106 | - "[[Daily Log]]" 107 | --- 108 | ``` 109 | 110 | *Result:* `My File` appears inside **both** folders. 111 | 112 | ### 2\. The Advanced Method (Parent lists Children) 113 | 114 | Use this for files that don't have frontmatter (like Canvas, Excalidraw, or PDFs), or if you prefer to organize from the top down. You list the child files inside the parent file. 115 | 116 | **File:** `Project Alpha.md` 117 | 118 | ```yaml 119 | --- 120 | children: 121 | - "[[Brainstorming.canvas]]" 122 | - "[[Diagram.excalidraw]]" 123 | - "[[Meeting Recording.mp3]]" 124 | --- 125 | ``` 126 | 127 | ### 3\. Drag and Drop 128 | 129 | You can reorganize your structure directly in the view. 130 | 131 | * **Move (Default):** Dragging a file from Folder A to Folder B will *move* it (remove it from A, add it to B). 132 | * **Copy (Add Parent):** Holding `Ctrl` (Windows/Linux) or `Cmd` (macOS) while dragging will *copy* the file (keep it in A, and *also* add it to B). This is how you create multi-parent setups quickly. 133 | * **Non-Markdown Files:** Dragging images or PDFs works too! The plugin will automatically update the `children` list of the target parent folder. 134 | 135 | ### 4\. Advanced Sorting 136 | 137 | Organize your knowledge map using abstract logic that mirrors how you actually interact with your notes. Unlike simple alphabetical sorting, these metrics help you surface what matters right now. 138 | 139 | #### The Metrics 140 | 141 | * **The "Thermal" Sort (Focus Logic):** Identifies which part of your vault is currently "active" (The "Hotness"). It uses an exponential decay formula (20% every 24 hours) based on recency and interaction frequency. Scores increase when a note is opened or when its abstract structure changes. 142 | * **The "Stale Rot" Sort (Cleanup Logic):** Identifies abandoned ideas. It calculates a score by multiplying the inactivity period (days since last edit) by the total number of abstract children (complexity). High scores represent large, complex structures you haven't touched in months. 143 | * **The "Gravity" Sort (Recursive Density):** Identifies the biggest hubs in your vault. It recursively counts all descendants for each abstract folder, placing the "heaviest" branches at the top. 144 | 145 | #### Understanding Thermal (Hotness) vs. Rot 146 | 147 | While both involve "recency," they measure different aspects of your knowledge map: 148 | 149 | * **Thermal (Hotness) rewards *Activity*:** A single note you just opened is "Hot," even if it has no children. It's about what you are thinking about *right now*. 150 | * **Rot highlights *Neglect*:** A massive project folder with 50 notes that you haven't touched in 3 months has high "Rot." It's about large structures you've *forgotten about*. 151 | 152 | Sorting by **Thermal (Descending)** shows your current focus. Sorting by **Stale Rot (Descending)** shows you where it's time to clean up or archive. 153 | 154 | ----- 155 | 156 | ## Commands 157 | 158 | Access these via the Command Palette (`Ctrl/Cmd + P`): 159 | 160 | * **Abstract Folder: Open Abstract Folder View** 161 | Opens the virtual tree view in your sidebar. 162 | * **Abstract Folder: Create Abstract Child** 163 | Creates a new file and automatically links it as a child of the currently selected abstract folder. 164 | * **Abstract Folder: Manage Groups** 165 | Opens the menu to create, edit, or delete folder groups. 166 | * **Abstract Folder: Clear Active Group** 167 | Removes the current group filter to show all abstract folders. 168 | * **Abstract Folder: Convert folder structure to plugin format** 169 | Scans your physical folders and adds `parent` frontmatter links to replicate the structure virtually. 170 | * **Abstract Folder: Create folder structure from plugin format** 171 | Reorganizes your physical file system to match your abstract hierarchy. 172 | 173 | ----- 174 | 175 | ## Settings 176 | 177 | Customize the plugin behavior in **Settings → Abstract Folder**. 178 | 179 | ### General Configuration 180 | 181 | * **Property Name:** The frontmatter key used to define parents (default: `parent`). *Case-sensitive.* 182 | * **Children Property Name:** The frontmatter key used to define children (default: `children`). 183 | * **Show Aliases:** If enabled, the tree view will display the file's first alias instead of the filename. 184 | * **Excluded Paths:** A list of file paths to hide from the abstract view. 185 | 186 | ### View Behavior 187 | 188 | * **Auto Reveal:** Automatically expands the folder tree to highlight the file you are currently editing. 189 | * **Remember Expanded Folders:** Keeps folders open in the tree view even after restarting Obsidian (default: `false`). 190 | * **Open on Startup:** Automatically opens the Abstract Folder view when you launch Obsidian. 191 | * **Open Position:** Choose whether the view opens in the `left` or `right` sidebar. 192 | * **Show Ribbon Icon:** Toggles the visibility of the icon in the left ribbon. 193 | 194 | ### Visuals (Rainbow Indents) 195 | 196 | * **Enable Rainbow Indents:** Colors the indentation lines to visually distinguish tree depth. 197 | * **Rainbow Palette:** Select the color scheme for indentations (`classic`, `pastel`, or `neon`). 198 | * **Per-Item Colors:** If enabled, sibling items at the same depth will use different colors. If disabled, all items at the same depth share the same color. 199 | 200 | ----- 201 | 202 | ## Installation 203 | 204 | This plugin is not yet available in the official Obsidian Community Plugins list. You must install it manually. 205 | 206 | 1. Go to the [GitHub Repository](https://github.com/RahmaniErfan/abstract-folder) and find the latest **Release** on the right sidebar. 207 | 2. Download these three files: `main.js`, `manifest.json`, and `styles.css`. 208 | 3. Navigate to your Obsidian Vault folder on your computer. 209 | 4. Open the hidden `.obsidian` folder, then open the `plugins` folder inside it. 210 | * *(Note: On macOS, press `Cmd + Shift + .` to toggle hidden files. On Windows, go to View -> Show -> Hidden items).* 211 | 5. Create a new folder named `abstract-folder`. 212 | 6. Paste the three downloaded files (`main.js`, `manifest.json`, `styles.css`) into this new folder. 213 | 7. Restart Obsidian, go to **Settings → Community Plugins**, and enable **Abstract Folder**. 214 | 215 | ----- 216 | 217 | ### Privacy 218 | 219 | This plugin works 100% locally. It makes no network requests and moves no physical files unless you explicitly use the "Create folder structure from plugin format" command. 220 | 221 | ----- 222 | -------------------------------------------------------------------------------- /src/ui/icon-modal.ts: -------------------------------------------------------------------------------- 1 | import { App, Modal, Setting, setIcon } from "obsidian"; 2 | 3 | // A more comprehensive list of common Lucide icons, including variations 4 | const COMMON_OBSIDIAN_ICONS = [ 5 | "a-arrow-down", "a-arrow-up", "a-large-small", "accessibility", "activity", "air-vent", "airplay", 6 | "alarm-clock", "alarm-clock-check", "alarm-clock-minus", "alarm-clock-off", "alarm-clock-plus", 7 | "alarm-smoke", "album", "align-center", "align-center-horizontal", "align-center-vertical", 8 | "align-end-horizontal", "align-end-vertical", "align-horizontal-distribute-center", 9 | "align-horizontal-distribute-end", "align-horizontal-distribute-start", "align-horizontal-justify-center", 10 | "align-horizontal-justify-end", "align-horizontal-justify-start", "align-horizontal-space-around", 11 | "align-horizontal-space-between", "align-justify", "align-left", "align-right", "align-start-horizontal", 12 | "align-start-vertical", "align-vertical-distribute-center", "align-vertical-distribute-end", 13 | "align-vertical-distribute-start", "align-vertical-justify-center", "align-vertical-justify-end", 14 | "align-vertical-justify-start", "align-vertical-space-around", "align-vertical-space-between", 15 | "ambulance", "ampersand", "ampersands", "amphora", "anchor", "angry", "annoyed", "antenna", "anvil", 16 | "aperture", "app-window", "app-window-mac", "apple", "archive", "archive-restore", "archive-x", 17 | "armchair", "arrow-big-down", "arrow-big-down-dash", "arrow-big-left", "arrow-big-left-dash", 18 | "arrow-big-right", "arrow-big-right-dash", "arrow-big-up", "arrow-big-up-dash", "arrow-down", 19 | "arrow-down-from-line", "arrow-down-left", "arrow-down-narrow-wide", "arrow-down-right", 20 | "arrow-down-to-dot", "arrow-down-to-line", "arrow-down-up", "arrow-down-wide-narrow", "arrow-left", 21 | "arrow-left-from-line", "arrow-left-right", "arrow-left-to-line", "arrow-right", 22 | "arrow-right-from-line", "arrow-right-left", "arrow-right-to-line", "arrow-up", "arrow-up-from-dot", 23 | "arrow-up-from-line", "arrow-up-left", "arrow-up-narrow-wide", "arrow-up-right", "arrow-up-to-line", 24 | "arrow-up-wide-narrow", "arrows-up-from-line", "asterisk", "at-sign", "atom", "audio-lines", 25 | "audio-waveform", "award", "axe", "axis-3d", "baby", "backpack", "badge", "badge-alert", "badge-cent", 26 | "badge-check", "badge-dollar-sign", "badge-euro", "badge-indian-rupee", "badge-info", 27 | "badge-japanese-yen", "badge-minus", "badge-percent", "badge-plus", "badge-pound-sterling", 28 | "badge-russian-ruble", "badge-swiss-franc", "badge-x", "baggage-claim", "ban", "banana", "bandage", 29 | "banknote", "barcode", "bar-chart", 30 | "bar-chart-big", "bar-chart-decreasing", "bar-chart-increasing", "bar-chart-stacked", "baseline", "bath", 31 | "battery", "battery-charging", "battery-full", "battery-low", "battery-medium", "battery-warning", 32 | "beaker", "bean", "bean-off", "bed", "bed-double", "bed-single", "beef", "beer", 33 | "beer-off", "bell", "bell-dot", "bell-electric", "bell-minus", "bell-off", "bell-plus", "bell-ring", 34 | "between-horizontal-end", "between-horizontal-start", "between-vertical-end", "between-vertical-start", 35 | "biceps-flexed", "bike", "binary", "binoculars", "biohazard", "bird", "bitcoin", "blend", 36 | "blinds", "blocks", "bluetooth", "bluetooth-connected", "bluetooth-off", "bluetooth-searching", "bold", 37 | "bolt", "bomb", "bone", "book", "book-a", "book-audio", "book-check", "book-copy", 38 | "book-dashed", "book-down", "book-headphones", "book-heart", "book-image", "book-key", "book-lock", 39 | "book-marked", "book-minus", "book-open", "book-open-check", "book-open-text", "book-plus", 40 | "book-text", "book-type", "book-up", "book-up-2", "book-user", "book-x", "bookmark", 41 | "bookmark-check", "bookmark-minus", "bookmark-plus", "bookmark-x", "boom-box", "bot", 42 | "bot-message-square", "bot-off", "box", "boxes", "box-select", "braces", "brackets", "brain", 43 | "brain-circuit", "brain-cog", "brick-wall", "briefcase", 44 | "briefcase-business", "briefcase-conveyor-belt", "briefcase-medical", "bring-to-front", "brush", 45 | "bug", "bug-off", "bug-play", "building", "building-2", "bus", "bus-front", 46 | "cable", "cable-car", "cake", "cake-slice", "calculator", "calendar", "calendar-arrow-down", 47 | "calendar-arrow-up", "calendar-check", "calendar-check-2", "calendar-clock", "calendar-cog", 48 | "calendar-days", "calendar-fold", "calendar-heart", "calendar-minus", "calendar-minus-2", "calendar-off", 49 | "calendar-plus", "calendar-plus-2", "calendar-range", 50 | "calendar-search", "calendar-sync", "calendar-x", "camera", "camera-off", "candy", "candy-cane", 51 | "candy-off", "cannabis", "captions", "captions-off", "car", "car-front", "car-taxi-front", "caravan", 52 | "carrot", "case-lower", "case-sensitive", "case-upper", "cassette-tape", "cast", "castle", "cat", "cctv", 53 | "chart-area", "chart-bar", "chart-bar-big", "chart-bar-decreasing", "chart-bar-increasing", 54 | "chart-bar-stacked", "chart-candlestick", "chart-column", "chart-column-big", "chart-column-decreasing", 55 | "chart-column-increasing", "chart-column-stacked", "chart-gantt", "chart-line", "chart-network", 56 | "chart-no-axes-column", "chart-no-axes-column-decreasing", "chart-no-axes-column-increasing", 57 | "chart-no-axes-combined", "chart-no-axes-gantt", "chart-pie", "chart-scatter", "chart-spline", "check", 58 | "check-check", "check-circle", "check-square", "chef-hat", "cherry", "chevron-down", "chevron-left", 59 | "chevron-right", "chevron-up", "chevrons-down", "chevrons-left", "chevrons-right", "chevrons-up", "circle", 60 | "circle-dot", "circle-off", "clipboard", "cloud", "cloud-drizzle", "cloud-fog", "cloud-lightning", 61 | "cloud-moon", "cloud-off", "cloud-rain", "cloud-snow", "cloud-sun", "code", "cog", "columns", 62 | "compass", "copy", "coffee", "cookie", "cpu", "credit-card", "crop", "crosshair", "crown", "cup-soda", "database", 63 | "database-backup", "database-zap", "diamond", "dice-1", "dice-2", "dice-3", "dice-4", "dice-5", "dice-6", 64 | "dollar-sign", "download", "droplet", "droplets", "dribbble", "edit", "eraser", "expand", "eye", "eye-off", 65 | "facebook", "fast-forward", "feather", "figma", "file", "file-archive", "file-audio", "file-code", 66 | "file-image", "file-minus", "file-plus", "file-question", "file-search", "file-text", "file-video", 67 | "file-warning", "filter", "fingerprint", "flask-conical", "folder", "folder-check", "folder-dot", 68 | "folder-minus", "folder-open", "folder-plus", "folder-symlink", "folder-tree", "folder-x", "framer", 69 | "fullscreen", "gear", "gem", "gift", "git-branch", "git-commit", "git-merge", 70 | "git-pull-request", "gitlab", "globe", "globe-lock", "grip-horizontal", "grip-vertical", "hard-drive", 71 | "hard-drive-download", "hard-drive-upload", "hash", "headphones", "heart", "hexagon", "home", "hourglass", 72 | "ice-cream", "image", "inbox", "info", "instagram", "italic", "key", "keyboard", "laptop", "layout-grid", 73 | "layout-list", "layout-template", "line-chart", "link", "linkedin", "list", "list-ordered", "list-todo", 74 | "lock", "lock-open", "log-in", "log-out", "mail", "mail-open", "map", "map-pin", "maximize", 75 | "message-circle", "message-square", "mic", "microscope", "minimize", "minus", "minus-circle", 76 | "minus-square", "monitor", "moon", "moon-star", "mouse", "mouse-pointer", "move", "navigation", "octagon", 77 | "package", "palette", "paperclip", "pause", "pencil", "pen-tool", "pentagon", "person-standing", 78 | "pie-chart", "pilcrow", "pizza", "plane", "play", "plus", "plus-circle", "plus-square", "pointer", 79 | "printer", "quote", "refresh-ccw", "repeat", "rewind", "rotate-ccw", "rotate-cw", "route", "rows", "ruler", 80 | "scatter-chart", "scissors", "search", "send", "server", "settings", "share", "shield", "shield-off", 81 | "shopping-bag", "shopping-cart", "shuffle", "skip-back", "skip-forward", "slack", "sliders", "smartphone", 82 | "sparkles", "speech", "square", "square-dot", "strikethrough", "star", "star-half", "star-off", "sun", 83 | "sunrise", "sunset", "syringe", "table", "tablet", "tag", "tags", "target", "terminal", 84 | "terminal-square", "test-tube", "thermometer-snowflake", "train", "trending-down", "trending-up", 85 | "triangle", "trophy", "truck", "twitch", "twitter", "type", "underline", "unlock", "upload", "user", 86 | "user-check", "user-circle", "user-cog", "user-minus", "user-plus", "user-square", "user-x", "users", 87 | "video", "volume", "volume-1", "volume-2", "volume-x", "wallet", "watch", "wifi", "wifi-off", "wind", 88 | "wine", "x", "x-circle", "x-square", "youtube", "zap", "zoom-in", "zoom-out" 89 | ]; 90 | 91 | export class IconModal extends Modal { 92 | result: string; 93 | onSubmit: (result: string) => void; 94 | private searchInput: string; 95 | private iconGrid: HTMLElement; 96 | 97 | constructor(app: App, onSubmit: (result: string) => void, initialValue = "") { 98 | super(app); 99 | this.onSubmit = onSubmit; 100 | this.result = initialValue; 101 | this.searchInput = ""; 102 | } 103 | 104 | onOpen() { 105 | const { contentEl } = this; 106 | contentEl.empty(); 107 | contentEl.addClass("abstract-folder-icon-modal"); 108 | 109 | contentEl.createEl("h2", { text: "Set note icon/emoji" }); 110 | 111 | // Combined input for search and custom icons/emojis 112 | new Setting(contentEl) 113 | .setName("Search icons or enter custom icon/emoji") 114 | .setDesc("Enter an Obsidian icon ID (e.g., 'star', 'lucide-file'), any emoji (e.g., '📝'), or filter the list below. The field's content will be saved as the icon.") 115 | .addText((text) => 116 | text 117 | .setPlaceholder("Example: star, folder-tree, 📝") 118 | .setValue(this.result) // Use 'result' as the primary value for direct input 119 | .onChange((value) => { 120 | this.result = value; // Update result directly 121 | this.searchInput = value.toLowerCase(); // Also update search input for filtering 122 | this.renderIconGrid(); 123 | }) 124 | ); 125 | 126 | // Icon Grid Container 127 | this.iconGrid = contentEl.createDiv({ cls: "abstract-folder-icon-grid" }); 128 | this.renderIconGrid(); // Initial render of icons 129 | 130 | // Action buttons 131 | new Setting(contentEl) 132 | .addButton((btn) => 133 | btn 134 | .setButtonText("Set icon") 135 | .setCta() 136 | .onClick(() => { 137 | this.close(); 138 | this.onSubmit(this.result); 139 | }) 140 | ) 141 | .addButton((btn) => 142 | btn 143 | .setButtonText("Remove icon") 144 | .onClick(() => { 145 | this.result = ""; // Clear the icon 146 | this.close(); 147 | this.onSubmit(this.result); 148 | }) 149 | ) 150 | .addButton((btn) => 151 | btn 152 | .setButtonText("Cancel") 153 | .onClick(() => { 154 | this.close(); 155 | }) 156 | ); 157 | } 158 | 159 | onClose() { 160 | const { contentEl } = this; 161 | contentEl.empty(); 162 | } 163 | 164 | private renderIconGrid() { 165 | this.iconGrid.empty(); 166 | const filteredIcons = COMMON_OBSIDIAN_ICONS.filter(iconName => 167 | iconName.includes(this.searchInput) 168 | ); 169 | 170 | if (filteredIcons.length === 0) { 171 | this.iconGrid.createEl("div", { text: "No matching icons found.", cls: "abstract-folder-no-icons" }); 172 | return; 173 | } 174 | 175 | filteredIcons.forEach(iconName => { 176 | const iconEl = this.iconGrid.createDiv({ cls: "abstract-folder-grid-item" }); 177 | if (this.result === iconName) { 178 | iconEl.addClass("is-active"); 179 | } 180 | setIcon(iconEl, iconName); 181 | // Removed: iconEl.createEl("span", { text: iconName, cls: "abstract-folder-icon-name" }); 182 | iconEl.title = iconName; // Keep title for hover tooltip 183 | 184 | iconEl.addEventListener("click", () => { 185 | this.result = iconName; 186 | this.onSubmit(this.result); 187 | this.close(); 188 | }); 189 | }); 190 | } 191 | } -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## Version 1.6.1 4 | 5 | **Features:** 6 | * **Conversion Progress Tracking**: Added a visual progress tracker when converting large folders to the abstract structure, providing real-time feedback and preventing UI freezes. 7 | 8 | **Fixes:** 9 | * **UI Stability during Conversion**: Refactored the folder conversion process to yield to the main thread, ensuring the application remains responsive even when processing thousands of files. 10 | * **Post-Conversion Glitches**: Fixed an issue where files would seemingly disappear or the view would turn white after conversion by robustly re-initializing virtual scroll components and allowing the indexer time to settle. 11 | 12 | ## Version 1.6.0 13 | 14 | **Features:** 15 | * **Default Sorting Management**: Introduced a new "Manage default sorting" modal accessible from the sort menu. This allows you to set a persistent default sort order for the main view and for each individual group. 16 | * **Per-Group Sorting**: Groups can now have their own independent sort preferences, which are automatically applied when you switch to that group. 17 | 18 | ## Version 1.5.0 19 | 20 | **Features:** 21 | * **Advanced Sorting (Thermal, Stale Rot, Gravity)**: Introduced a suite of smart sorting methods that reflect real-world note interaction patterns. 22 | * **Thermal (Hotness)**: Surfaces active notes using an exponential decay logic (20% per day). Heat increases when notes are opened or modified. 23 | * **Stale Rot**: Identifies abandoned complex ideas by multiplying inactivity duration by the number of children. 24 | * **Gravity (Payload)**: Linear sorting based on recursive descendant count, highlighting the densest hubs in your vault. 25 | * **Smart Icons**: Added descriptive icons (flame, skull, weight) to the sort menu for better visual clarity. 26 | 27 | ## Version 1.4.1 28 | 29 | **Fixes:** 30 | * **Incremental Graph Updates**: Fixed a bug where removing a recursive parent link (a file being its own parent) did not correctly restore the file to the view. This was due to the incremental update logic using cached relationship data instead of the latest state during removal checks. 31 | * **View Switching**: Fixed an issue where the Abstract Tree view would disappear after switching from Column View (Miller View). This was caused by the view container being incorrectly emptied, destroying stable DOM elements required for virtual scrolling. 32 | 33 | ## Version 1.4.0 34 | 35 | **Performance Optimization (Major):** 36 | * **Incremental Graph Updates**: The plugin now intelligently updates only the parts of the abstract folder structure that have changed when you edit file frontmatter, rather than rebuilding the entire structure. This reduces processing time from ~500ms to ~2ms for large vaults (35k+ files), eliminating lag during editing. 37 | * **Lazy Loading & Virtual Scrolling**: The Abstract Folder view now uses lazy loading and virtual scrolling, enabling it to handle massive vaults with tens of thousands of files instantly without UI freezing or crashing. 38 | * **Optimized Rendering**: Opening and closing folders in the tree view is now near-instantaneous due to improved caching and DOM diffing. 39 | 40 | **Improvements:** 41 | * **Loading State**: Added a visual "Loading abstract structure..." indicator to provide feedback during initial startup or graph rebuilds, replacing the confusing "No folders found" message. 42 | * **Code Cleanup**: Removed internal benchmark logging for a cleaner console experience. 43 | 44 | ## Synced Folder Branch (Experimental) 45 | 46 | **Features:** 47 | * **Synced Folders**: You can now link an abstract folder to a physical folder on your disk. Files created in or moved to the physical folder will automatically be linked to the abstract folder, and vice-versa. 48 | * **Auto-Linking**: Moving files into a synced physical folder automatically adds them to the abstract folder. 49 | * **Non-Markdown Support**: Works with all file types, including Canvas files and images. 50 | 51 | **Improvements:** 52 | * **Refined UI Input**: The file input suggestions in `CreateSyncedFolderModal` and `CreateEditGroupModal` now correctly display only files when selecting abstract parents, improving clarity and usability. 53 | 54 | **Fixes:** 55 | * **Ambiguity Resolution**: Synced folders now use full-path links when auto-linking files. This prevents issues where duplicate filenames (e.g., in the root and a subfolder) could cause the abstract view to open the wrong file. 56 | * **Renaming Stability**: Fixed an issue where renaming files during a move (or Obsidian's auto-renaming) could cause them to be unlinked from the abstract folder. 57 | * **Reliable Linking**: Improved the way links are created for non-markdown files to ensure they persist correctly even when files are moved around. 58 | 59 | ## Version 1.3.9 60 | 61 | **Code Quality & Maintenance:** 62 | * **ESLint Compliance**: Resolved all ESLint issues reported by the automated plugin scan, including fixing floating promises, removing unnecessary `eslint-disable` comments, and ensuring type safety with explicit casting and improved interfaces. 63 | * **UI Text Improvements**: Updated various UI labels and descriptions to strictly follow sentence case guidelines (e.g., "Sort by name (ascending)", "Example: star, folder-tree"). 64 | * **Performance & Stability**: Fixed potential issues with unhandled promises in drag-and-drop handlers and view activation. 65 | 66 | ## Version 1.3.7 67 | 68 | **Fixes:** 69 | * **Conversion adding unidirectional relationship for md files**: Fixed an issue where converted markdown files were being bi-directionally linked in both parent and child frontmatter, instead of just the child's `parent` property. 70 | * **Empty Group View**: Fixed a bug where creating a group using a folder path (e.g., `University`) would result in an empty view if the abstract folder was defined by a file (e.g., `University.md`). The view now correctly resolves the underlying file for the folder path. 71 | 72 | ## Version 1.3.6 73 | 74 | **Features:** 75 | * **Additive Drag-and-Drop (Ctrl/Alt + Drag)**: Holding `Ctrl` or `Alt` during a drag-and-drop operation will now perform an "additive" action instead of a move. 76 | * For Markdown files, the dropped file will add the target folder as an additional parent, without removing existing parent links. 77 | * For non-Markdown files, the dropped file will be added to the target parent's `children` property, without being removed from its original parent. This allows a file to exist under multiple abstract folders. 78 | 79 | ## Version 1.3.0 80 | 81 | ## Version 1.3.1 82 | 83 | **Fixes:** 84 | * **File creation not reflected**: Implemented a `vault.on('create')` event listener to ensure that manually added files or screenshots are immediately reflected in the abstract folder view. 85 | 86 | ## Version 1.3.0 87 | 88 | **Features:** 89 | * **Drag-and-Drop File Management**: Implemented drag-and-drop functionality for abstract folders, allowing intuitive reorganization of notes. 90 | 91 | **Fixes:** 92 | * **Parent Inversion during Drag**: Resolved an issue where dragging a child note could unintentionally re-parent an ancestor due to event bubbling. This was fixed by preventing event propagation during drag start. 93 | * **Duplicate Parent Links**: Fixed a bug where files appeared under multiple parents after drag-and-drop if the original parent link was not precisely matched. The logic now uses Obsidian's `metadataCache` to robustly resolve and remove old parent links. 94 | * **Persisting Drag Outline**: Corrected an issue where the drag-and-drop visual outline would remain on invalid drop targets. Cleanup logic was enhanced to ensure all drag feedback is removed reliably. 95 | * **UI Flicker on Drag**: Addressed visual flickering during drag operations by switching from CSS `border` to `outline` for drag feedback, preventing layout shifts. 96 | * **Overly Bright Invalid Drag Feedback**: Adjusted the visual feedback for invalid drop targets to use a softer, less intrusive red color. 97 | 98 | **Improvements:** 99 | * **Drag-and-Drop Validation**: Enhanced validation to prevent dropping files into non-Markdown files and to disallow circular dependencies. 100 | 101 | ## Version 1.2.7 102 | * **Abstract Child Creation for Canvas/Bases**: Resolved an issue where creating abstract child files for Canvas (.canvas) and Bases (.base) resulted in root files or JSON parsing errors. This was fixed by no longer attempting to add frontmatter to these JSON-based files. Instead, when a parent file (which must be a Markdown note) is specified, the new Canvas or Base file is added to the parent's `children` frontmatter property, including its full filename and extension (e.g., `[[my_canvas.canvas]]`), allowing the plugin's indexer to correctly establish the parent-child relationship. 103 | 104 | ## Version 1.2.6 105 | 106 | **Fixes:** 107 | * **Multi-Parent Auto-Expansion**: Resolved an issue where typing in a note with multiple abstract parents would cause all parent chains to expand simultaneously. This was fixed by modifying the `revealFile` function in `src/file-reveal-manager.ts` to ensure parent expansion is applied only to the first DOM element representing the active file, preventing unintended multiple expansions. 108 | 109 | ## Version 1.2.5 110 | 111 | **Features:** 112 | * **Auto Expand Children Toggle**: Implemented toggle-like behavior for auto-expanding children. Clicking a parent file or folder when 'Auto expand children' is enabled will now expand it if collapsed, or collapse it if expanded. Files will still open regardless of expansion state. 113 | 114 | ## Version 1.2.4 115 | 116 | **Features:** 117 | * **Auto Expand Children**: Added a new setting to automatically expand direct child folders when a parent file is opened in the tree view. 118 | 119 | ## Version 1.2.3 120 | 121 | **Fixes:** 122 | * **Settings Duplication**: Removed duplicate "Rainbow indent - varied item colors" setting from the settings tab. 123 | 124 | ## Version 1.2.2 125 | 126 | **Features:** 127 | * **Toggle Parent Folder Expansion**: Introduced a new setting to allow users to disable automatic expansion of parent folders when revealing the active file. 128 | 129 | **Improvements:** 130 | * **Streamlined File Revelation**: Removed the "Auto reveal active files" setting, making highlighting the active file a default behavior. 131 | 132 | ## Version 1.2.1 133 | 134 | **Fixes:** 135 | * **PDF Opening**: Resolved issue where PDFs would open in a split view instead of the current active pane. 136 | 137 | ## Version 1.2.0 138 | 139 | **Features:** 140 | * **Recursive Delete**: Added "Delete children" functionality to single and batch file deletion modals, enabling recursive deletion of associated child notes and folders. 141 | 142 | **Improvements:** 143 | * **Optimized Graph Updates**: Refined graph update triggers for improved robustness during file deletion operations. 144 | 145 | ## Version 1.1.1 146 | 147 | **Fixes:** 148 | * **Trailing Spaces in Filenames**: Resolved issues with links to and creation of files with trailing spaces in their names. 149 | 150 | ## Version 1.1.0 151 | 152 | **Features:** 153 | * **Group View**: Added a "Group View" to organize abstract folders into custom groups. 154 | * **Group Management**: Added commands for creating, editing, and assigning notes to groups. 155 | * **Cascade Delete Child References**: Automatically removes references to deleted child files from parent frontmatter. 156 | * **Abstract Child Management**: 157 | * **Alias Quoting**: Ensures aliases in new abstract child files are quoted (e.g., `aliases: - "1"`) to prevent warnings. 158 | * **Unidirectional Parent Linking**: Only the new child file is updated with a parent reference. Parent files no longer automatically link to new children. 159 | 160 | **Improvements:** 161 | * **View Overhaul**: Abstract folder view refactored with a new header for better user experience. 162 | * **Column View Styles**: Improved styling for column view, with clearer indicators for selected items, ancestors, and abstract folders. 163 | * **README Refactor**: The `README.md` has been refactored to make it more straightforward and easier to understand. 164 | * **Multi-Parent Indicator**: Added a visual indicator for notes with multiple parents. 165 | * **File Type Tags**: Added subtle tags to differentiate file types in the view. 166 | * **Modal Styling**: Improved styling for plugin modals (e.g., conflict resolution, icon selection). 167 | 168 | **Refactoring & Code Quality:** 169 | * **Modularization**: Core view logic (`src/view.ts`) split into smaller modules and UI components. 170 | * **File Relocation**: Utility files (`conversion.ts`, `file-operations.ts`, `tree-utils.ts`) moved to `src/utils`. 171 | * **CSS Management**: `styles.css` is now treated as a build artifact. -------------------------------------------------------------------------------- /src/ui/abstract-folder-view-toolbar.ts: -------------------------------------------------------------------------------- 1 | import { App, setIcon, Menu } from "obsidian"; 2 | import { AbstractFolderPluginSettings } from "../settings"; 3 | import AbstractFolderPlugin from "../../main"; 4 | import { CreateAbstractChildModal, ChildFileType } from './modals'; 5 | import { ManageGroupsModal } from './modals/manage-groups-modal'; 6 | import { ManageSortingModal } from './modals/manage-sorting-modal'; 7 | import { ViewState } from './view-state'; 8 | import { createAbstractChildFile } from '../utils/file-operations'; 9 | import { Group } from "../types"; 10 | 11 | export class AbstractFolderViewToolbar { 12 | private app: App; 13 | private settings: AbstractFolderPluginSettings; 14 | private plugin: AbstractFolderPlugin; 15 | private viewState: ViewState; 16 | 17 | private viewStyleToggleAction: HTMLElement | undefined; 18 | private expandAllAction: HTMLElement | undefined; 19 | private collapseAllAction: HTMLElement | undefined; 20 | 21 | // Callbacks provided by AbstractFolderView to interact with it 22 | private addAction: (icon: string, title: string, onclick: (evt: MouseEvent) => void) => HTMLElement; 23 | private renderView: () => void; 24 | private expandAllView: () => void; // New callback 25 | private collapseAllView: () => void; // New callback 26 | 27 | 28 | constructor( 29 | app: App, 30 | settings: AbstractFolderPluginSettings, 31 | plugin: AbstractFolderPlugin, 32 | viewState: ViewState, 33 | addActionCallback: (icon: string, title: string, onclick: (evt: MouseEvent) => void) => HTMLElement, 34 | renderViewCallback: () => void, 35 | expandAllViewCallback: () => void, 36 | collapseAllViewCallback: () => void, 37 | ) { 38 | this.app = app; 39 | this.settings = settings; 40 | this.plugin = plugin; 41 | this.viewState = viewState; 42 | this.addAction = addActionCallback; 43 | this.renderView = renderViewCallback; 44 | this.expandAllView = expandAllViewCallback; 45 | this.collapseAllView = collapseAllViewCallback; 46 | } 47 | 48 | public setupToolbarActions(): void { 49 | this.addAction("file-plus", "Create new root note", () => { 50 | new CreateAbstractChildModal(this.app, this.settings, (childName: string, childType: ChildFileType) => { 51 | createAbstractChildFile(this.app, this.settings, childName, null, childType).catch(console.error); 52 | }, 'note').open(); 53 | }); 54 | 55 | this.addAction("group", "Select group", (evt: MouseEvent) => this.showGroupMenu(evt)); 56 | 57 | this.addAction("arrow-up-down", "Sort order", (evt: MouseEvent) => this.showSortMenu(evt)); 58 | this.expandAllAction = this.addAction("chevrons-up-down", "Expand all folders", () => this.expandAllView()); 59 | this.collapseAllAction = this.addAction("chevrons-down-up", "Collapse all folders", () => this.collapseAllView()); 60 | 61 | this.addAction("lucide-folder-sync", "Convert folder structure", (evt: MouseEvent) => this.showConversionMenu(evt)); 62 | 63 | this.viewStyleToggleAction = this.addAction("list", "Switch view style", () => this.viewState.toggleViewStyle()); 64 | this.updateViewStyleToggleButton(); 65 | this.updateButtonStates(); 66 | } 67 | 68 | public updateButtonStates(): void { 69 | const isTreeView = this.settings.viewStyle === 'tree'; 70 | if (this.expandAllAction) { 71 | this.expandAllAction.ariaDisabled = String(!isTreeView); 72 | this.expandAllAction.toggleClass('is-disabled', !isTreeView); 73 | } 74 | if (this.collapseAllAction) { 75 | this.collapseAllAction.ariaDisabled = String(!isTreeView); 76 | this.collapseAllAction.toggleClass('is-disabled', !isTreeView); 77 | } 78 | } 79 | 80 | public updateViewStyleToggleButton(): void { 81 | if (!this.viewStyleToggleAction) return; 82 | const isColumnView = this.settings.viewStyle === 'column'; 83 | setIcon(this.viewStyleToggleAction, isColumnView ? "folder-tree" : "rows-2"); 84 | this.viewStyleToggleAction.ariaLabel = isColumnView ? "Switch to tree view" : "Switch to column view"; 85 | this.viewStyleToggleAction.title = isColumnView ? "Switch to tree view" : "Switch to column view"; 86 | } 87 | 88 | private showSortMenu(event: MouseEvent): void { 89 | const menu = new Menu(); 90 | 91 | menu.addItem((item) => 92 | item 93 | .setTitle("Manage default sorting") 94 | .setIcon("gear") 95 | .onClick(() => { 96 | new ManageSortingModal(this.app, this.settings, (updatedSettings) => { 97 | this.plugin.settings = updatedSettings; 98 | this.plugin.saveSettings().then(() => { 99 | // Determine which sort config to apply based on active group 100 | let sortConfig = this.plugin.settings.defaultSort; 101 | if (this.plugin.settings.activeGroupId) { 102 | const activeGroup = this.plugin.settings.groups.find(g => g.id === this.plugin.settings.activeGroupId); 103 | if (activeGroup && activeGroup.sort) { 104 | sortConfig = activeGroup.sort; 105 | } 106 | } 107 | // Apply the sort config 108 | this.viewState.setSort(sortConfig.sortBy, sortConfig.sortOrder); 109 | }).catch(console.error); 110 | }).open(); 111 | }) 112 | ); 113 | menu.addSeparator(); 114 | 115 | menu.addItem((item) => 116 | item 117 | .setTitle("Sort by name (ascending)") 118 | .setIcon(this.viewState.sortBy === 'name' && this.viewState.sortOrder === 'asc' ? "check" : "sort-asc") 119 | .onClick(() => this.viewState.setSort('name', 'asc')) 120 | ); 121 | menu.addItem((item) => 122 | item 123 | .setTitle("Sort by name (descending)") 124 | .setIcon(this.viewState.sortBy === 'name' && this.viewState.sortOrder === 'desc' ? "check" : "sort-desc") 125 | .onClick(() => this.viewState.setSort('name', 'desc')) 126 | ); 127 | menu.addSeparator(); 128 | menu.addItem((item) => 129 | item 130 | .setTitle("Sort by modified (old to new)") 131 | .setIcon(this.viewState.sortBy === 'mtime' && this.viewState.sortOrder === 'asc' ? "check" : "sort-asc") 132 | .onClick(() => this.viewState.setSort('mtime', 'asc')) 133 | ); 134 | menu.addItem((item) => 135 | item 136 | .setTitle("Sort by modified (new to old)") 137 | .setIcon(this.viewState.sortBy === 'mtime' && this.viewState.sortOrder === 'desc' ? "check" : "sort-desc") 138 | .onClick(() => this.viewState.setSort('mtime', 'desc')) 139 | ); 140 | menu.addSeparator(); 141 | menu.addItem((item) => 142 | item 143 | .setTitle("Sort by thermal (most to least)") 144 | .setIcon(this.viewState.sortBy === 'thermal' && this.viewState.sortOrder === 'desc' ? "check" : "flame") 145 | .onClick(() => this.viewState.setSort('thermal', 'desc')) 146 | ); 147 | menu.addItem((item) => 148 | item 149 | .setTitle("Sort by thermal (least to most)") 150 | .setIcon(this.viewState.sortBy === 'thermal' && this.viewState.sortOrder === 'asc' ? "check" : "flame") 151 | .onClick(() => this.viewState.setSort('thermal', 'asc')) 152 | ); 153 | menu.addSeparator(); 154 | menu.addItem((item) => 155 | item 156 | .setTitle("Sort by stale rot (most to least)") 157 | .setIcon(this.viewState.sortBy === 'rot' && this.viewState.sortOrder === 'desc' ? "check" : "skull") 158 | .onClick(() => this.viewState.setSort('rot', 'desc')) 159 | ); 160 | menu.addItem((item) => 161 | item 162 | .setTitle("Sort by stale rot (least to most)") 163 | .setIcon(this.viewState.sortBy === 'rot' && this.viewState.sortOrder === 'asc' ? "check" : "skull") 164 | .onClick(() => this.viewState.setSort('rot', 'asc')) 165 | ); 166 | menu.addSeparator(); 167 | menu.addItem((item) => 168 | item 169 | .setTitle("Sort by gravity (heaviest to lightest)") 170 | .setIcon(this.viewState.sortBy === 'gravity' && this.viewState.sortOrder === 'desc' ? "check" : "weight") 171 | .onClick(() => this.viewState.setSort('gravity', 'desc')) 172 | ); 173 | menu.addItem((item) => 174 | item 175 | .setTitle("Sort by gravity (lightest to heaviest)") 176 | .setIcon(this.viewState.sortBy === 'gravity' && this.viewState.sortOrder === 'asc' ? "check" : "weight") 177 | .onClick(() => this.viewState.setSort('gravity', 'asc')) 178 | ); 179 | menu.showAtMouseEvent(event); 180 | } 181 | 182 | private showGroupMenu(event: MouseEvent): void { 183 | const menu = new Menu(); 184 | 185 | if (this.settings.groups.length === 0) { 186 | menu.addItem(item => item.setTitle("No groups defined").setDisabled(true)); 187 | } else { 188 | this.settings.groups.forEach((group: Group) => { // Explicitly typed group 189 | menu.addItem(item => 190 | item.setTitle(group.name) 191 | .setIcon(this.settings.activeGroupId === group.id ? "check" : "group") 192 | .onClick(async () => { 193 | this.settings.activeGroupId = group.id; 194 | await this.plugin.saveSettings(); 195 | 196 | // Apply group default sort if available 197 | if (group.sort) { 198 | this.viewState.setSort(group.sort.sortBy, group.sort.sortOrder); 199 | } 200 | 201 | this.renderView(); // Trigger re-render of the view 202 | }) 203 | ); 204 | }); 205 | menu.addSeparator(); 206 | } 207 | 208 | menu.addItem(item => 209 | item.setTitle("Manage groups") 210 | .setIcon("gear") 211 | .onClick(() => { 212 | new ManageGroupsModal(this.app, this.settings, (updatedGroups: Group[], activeGroupId: string | null) => { 213 | this.plugin.settings.groups = updatedGroups; 214 | this.plugin.settings.activeGroupId = activeGroupId; 215 | this.plugin.saveSettings().then(() => { 216 | this.plugin.app.workspace.trigger('abstract-folder:group-changed'); 217 | }).catch(console.error); 218 | }).open(); 219 | }) 220 | ); 221 | 222 | menu.addItem(item => 223 | item.setTitle("Clear active group") 224 | .setIcon(this.settings.activeGroupId === null ? "check" : "cross") 225 | .onClick(async () => { 226 | this.settings.activeGroupId = null; 227 | await this.plugin.saveSettings(); 228 | 229 | // Revert to default sort 230 | const defaultSort = this.plugin.settings.defaultSort; 231 | this.viewState.setSort(defaultSort.sortBy, defaultSort.sortOrder); 232 | 233 | this.renderView(); // Trigger re-render of the view 234 | }) 235 | ); 236 | 237 | menu.showAtMouseEvent(event); 238 | } 239 | 240 | private showConversionMenu(event: MouseEvent): void { 241 | const menu = new Menu(); 242 | 243 | menu.addItem((item) => 244 | item 245 | .setTitle("Convert physical folder to abstract folder") 246 | .setIcon("folder-symlink") 247 | .onClick(() => { 248 | this.plugin.app.commands.executeCommandById("abstract-folder:convert-folder-to-plugin"); 249 | }) 250 | ); 251 | 252 | menu.addItem((item) => 253 | item 254 | .setTitle("Create folder structure from plugin format") 255 | .setIcon("folder-plus") 256 | .onClick(() => { 257 | this.plugin.app.commands.executeCommandById("abstract-folder:create-folders-from-plugin"); 258 | }) 259 | ); 260 | 261 | menu.showAtMouseEvent(event); 262 | } 263 | } 264 | -------------------------------------------------------------------------------- /TECHNICAL_CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Technical Changelog - Abstract Folder Plugin v1.6.0 2 | 3 | ## Features 4 | 5 | ### Default Sorting Management 6 | 7 | * **Data Structure Updates**: 8 | * Updated `AbstractFolderPluginSettings` to include `defaultSort: SortConfig`. 9 | * Updated `Group` interface to include optional `sort?: SortConfig`. 10 | * Defined `SortConfig` as `{ sortBy: SortBy, sortOrder: 'asc' | 'desc' }`. 11 | * **UI Implementation**: 12 | * Created `ManageSortingModal` to allow users to configure default sorting for the main view and all defined groups. 13 | * Added entry point in `AbstractFolderViewToolbar`. 14 | * **State Management**: 15 | * Updated `ViewState` to initialize `sortBy` and `sortOrder` from settings, respecting active group overrides. 16 | * Ensured group switching triggers a re-evaluation of the sort configuration. 17 | 18 | # Technical Changelog - Abstract Folder Plugin v1.5.0 19 | 20 | ## Features 21 | 22 | ### Metrics-Based Sorting (`src/metrics-manager.ts`) 23 | 24 | * **Thermal Scoring**: 25 | * Implemented a decay-based activity tracking system. Scores increase on `file-open` and graph updates. 26 | * **Formula**: `score * (0.8 ^ days_since_last_interaction)`. 27 | * **Persistence**: Activity timestamps and scores are persisted in `data.json` for consistency across restarts. 28 | * **Stale Rot Calculation**: 29 | * Identifies neglected nodes by calculating `Inactivity (Days) * Complexity (Direct Child Count)`. 30 | * **Gravity Calculation**: 31 | * Optimized recursive descendant counting using memoization to determine branch density in the abstract graph. 32 | 33 | ### UI & State Integration 34 | 35 | * **Sort Options**: Expanded `SortBy` type to include `thermal`, `rot`, and `gravity`. 36 | * **Toolbar Enhancements**: Integrated new sort modes into `AbstractFolderViewToolbar` with dedicated Lucide icons. 37 | 38 | # Technical Changelog - Abstract Folder Plugin v1.4.1 39 | 40 | ## Bug Fixes 41 | 42 | ### Incremental Indexer Reliability 43 | 44 | * **Relationship State Synchronization**: 45 | * **Problem**: In `updateFileIncremental`, the map tracking file relationships (`this.fileRelationships`) was updated *after* attempting to remove old relationships. For self-referencing links (A->A), `removeRelationshipFromGraphStructure(A, A)` would check the map, find that A still defines the relationship (using the old state), and skip removal. This left "phantom" links in the graph, preventing files from returning to a Root state when the self-reference was removed. 46 | * **Solution**: Moved `this.fileRelationships.set(file.path, newRelationships)` to before the removal loops. This ensures that the safety checks in `removeRelationshipFromGraphStructure` (which verify if any file *still* defines the link) correctly use the *new* intended state of the file being updated. 47 | 48 | ### View Stability 49 | 50 | * **Virtual Scroll Persistence**: 51 | * **Problem**: `renderColumnView` called `this.contentEl.empty()`, which destroyed the `abstract-folder-virtual-wrapper` and its associated containers (`virtualContainer`, `virtualSpacer`) created in `onOpen`. When switching back to Tree View, the renderer attempted to update these now-detached DOM elements, resulting in an empty view. 52 | * **Solution**: Removed the destructive `empty()` call from `renderColumnView`. View cleanup is now centralized in `renderView`, which selectively removes only non-static elements, preserving the virtual scroll infrastructure across view transitions. 53 | 54 | # Technical Changelog - Abstract Folder Plugin v1.4.0 55 | 56 | ## Performance Optimization & Virtualization 57 | 58 | This release focuses on resolving critical performance bottlenecks for large vaults (35,000+ files), transforming O(N) operations into O(1) or O(log N) where possible. 59 | 60 | ### Architectural Decisions & Implementation Details: 61 | 62 | 1. **Incremental Graph Indexing (`src/indexer.ts`)**: 63 | * **Problem**: Every file modification triggered a `buildGraph()` call, which iterated over all 35k files to reconstruct the entire parent-child graph. This took ~500ms per keystroke/save, causing noticeable editor lag. 64 | * **Solution**: Implemented `updateFileIncremental`. The indexer now tracks relationships defined *by* each file (`fileRelationships` map). When a file changes: 65 | 1. It retrieves the previous relationships defined by that file. 66 | 2. It calculates the new relationships from the updated frontmatter. 67 | 3. It removes the old links and adds the new ones in the global graph (O(1) set operations). 68 | 4. It updates the "Root" status only for the affected parent/child nodes. 69 | * **Result**: Graph update time reduced from ~500ms to ~2ms. 70 | 71 | 2. **Lazy Rendering & Virtualization (`src/view.ts`, `src/utils/virtualization.ts`)**: 72 | * **Problem**: The `AbstractFolderView` attempted to render DOM nodes for the entire tree structure at once. With 35k files, this caused the UI to freeze for seconds or crash due to DOM overload. 73 | * **Solution**: 74 | * **Lazy Generation**: The full tree structure is no longer built. Instead, `generateFlatItemsFromGraph` creates a flat list of *only* the currently visible (expanded) nodes. 75 | * **Virtual Scrolling**: A custom virtual scroller was implemented. It calculates which items are currently visible in the viewport and renders only those (plus a small buffer), recycling DOM nodes as the user scrolls. 76 | * **Occlusion Culling**: Items outside the viewport are removed from the DOM. 77 | * **Result**: Initial render and scroll performance are now independent of total vault size, handling 35k+ files smoothly. 78 | 79 | 3. **Code Modularization (`src/utils/tree-utils.ts`)**: 80 | * **Refactor**: Extracted core node creation logic (`createFolderNode`, `resolveGroupRoots`) into a shared utility to eliminate code duplication between the new Lazy Renderer and the existing Column Renderer. 81 | * **Type Safety**: Fixed implicit `any` typing issues in frontmatter access during node creation. 82 | 83 | 4. **UX Improvements**: 84 | * **Loading State**: Introduced a distinct `isLoading` state in the view, triggered by a new `abstract-folder:graph-build-start` event. This prevents the "No abstract folders found" empty state from flashing during graph rebuilds. 85 | 86 | # Synced Folder Branch (Experimental) 87 | 88 | ## Ambiguity Resolution in Synced Folders 89 | 90 | * **Ambiguous Link Resolution**: 91 | * **Problem**: Short links (basename only) generated by `SyncManager` became ambiguous when duplicate filenames existed (e.g., `Untitled.md` in root vs `Untitled/Untitled.md`). This caused the abstract view to resolve the link to the "shortest path" (often the root file), opening the wrong file. 92 | * **Solution**: The `SyncManager` now generates links using the **full vault-relative path** (e.g., `[[Folder/File.canvas]]`) for **all** files (Markdown and non-Markdown). This forces explicit resolution to the correct file regardless of duplicates elsewhere in the vault. 93 | 94 | ## UI Input Refinement 95 | 96 | * **Refined UI Input**: The file input suggestions (`FileInputSuggest`) in `CreateSyncedFolderModal` and `CreateEditGroupModal` were refined to display only files when selecting abstract parents, improving clarity and usability. 97 | 98 | ## Synced Folder Implementation 99 | 100 | This release introduces the "Synced Folder" feature, allowing users to bind an abstract folder to a physical folder on the file system. This required significant additions to the indexing and event handling logic. 101 | 102 | ### Architectural Decisions & Implementation Details: 103 | 104 | 1. **SyncManager (`src/sync-manager.ts`)**: 105 | * **Decision**: A dedicated `SyncManager` class was created to handle the logic for two-way synchronization (currently primarily Physical -> Abstract). 106 | * **Implementation**: This manager listens to `vault.on('create')` and `vault.on('rename')` events. When a file is created in or moved to a folder that is mapped to an abstract parent, the manager automatically links the file to that parent. 107 | * **Challenge (Race Conditions)**: A critical issue arose when multiple files were moved simultaneously (e.g., drag-and-drop of multiple items) or when Obsidian renamed a file immediately after a move (e.g., `Untitled` -> `Untitled 1`). This caused concurrent `processFrontMatter` calls on the parent file, leading to overwrites where some files were lost from the `children` list. 108 | * **Solution (Debounced Batching)**: Implemented a debounced queue system in `SyncManager`. Updates to the parent file's frontmatter are queued and applied in a single batch operation after a short delay (300ms). This ensures all new children are added atomically, resolving the race condition. 109 | 110 | 2. **Ambiguous Link Resolution (Initial partial fix)**: 111 | * **Challenge**: When a file was moved into a synced folder and renamed by Obsidian (due to naming collisions), the link update process sometimes became confused, leading to broken or missing links in the abstract parent. 112 | * **Solution**: Initial implementation for non-markdown files used full paths. (Note: Fully standardized for all files in v1.4.1). 113 | 114 | 3. **Folder Indexer Updates (`src/indexer.ts`)**: 115 | * **Implementation**: Updated `FolderIndexer` to map physical folder paths to their corresponding abstract parent files (`physicalToAbstractMap`). This map is used by `SyncManager` to quickly look up the target abstract parent for any given file event. 116 | * **Fallback Resolution**: Enhanced `resolveLinkToPath` to include a fallback mechanism. If `metadataCache` fails to resolve a link (which can happen with non-markdown files or immediately after creation), the indexer now performs a direct name search to locate the file, ensuring robust graph building. 117 | 118 | # Technical Changelog - Abstract Folder Plugin v1.3.0 119 | 120 | ## Drag-and-Drop Functionality Implementation 121 | 122 | This release introduces comprehensive drag-and-drop capabilities for abstract folders, addressing several functional and UI challenges to provide a fluid user experience. 123 | 124 | ### Architectural Decisions & Implementation Details: 125 | 126 | 1. **Centralized Drag Management (`src/ui/dnd/drag-manager.ts`)**: 127 | * **Decision**: To encapsulate all drag-and-drop event handling logic, a dedicated `DragManager` class was created. This approach promotes separation of concerns, keeping the rendering components (`TreeRenderer`, `ColumnRenderer`) focused solely on presentation. 128 | * **Implementation**: `DragManager` registers and handles `dragstart`, `dragover`, `dragleave`, and `drop` DOM events. It maintains internal state (`dragData`, `currentDragTarget`) to track the ongoing drag operation and manage visual feedback. 129 | * **Challenge**: Initial implementations faced issues with event bubbling, particularly `dragstart`, leading to incorrect `sourceParentPath` identification when dragging nested items. 130 | * **Solution**: `event.stopPropagation()` was strategically applied in `handleDragStart` to ensure that only the intended draggable item's data is captured, preventing accidental re-parenting of ancestor folders. 131 | 132 | 2. **Enhanced File Operations (`src/utils/file-operations.ts`)**: 133 | * **Decision**: The core logic for modifying file frontmatter based on drag-and-drop operations was centralized in the `moveFiles` function. This function differentiates between Markdown and non-Markdown files, adhering to the plugin's "Child-Defined" (for Markdown) and "Parent-Defined" (for non-Markdown) parentage rules. 134 | * **Implementation**: 135 | * **Markdown Files**: The `moveFiles` function directly updates the `parent` frontmatter property of dragged Markdown files. 136 | * **Non-Markdown Files**: For non-Markdown files (e.g., `.canvas`, images), the `children` frontmatter property of both the source and target Markdown parent files are updated. 137 | * **Challenge**: Accurately removing the old parent link from a child's frontmatter proved difficult due to variations in how Obsidian links are stored (e.g., `[[Note]]`, `[[Note|Alias]]`, `[[Path/To/Note]]`). Simple string matching was insufficient and led to duplicate parent entries. 138 | * **Solution**: Leveraging Obsidian's `app.metadataCache.getFirstLinkpathDest` was crucial. This API robustly resolves a link string to its corresponding file, allowing precise comparison and removal of the correct parent link, preventing "files appearing under both parents." 139 | 140 | 3. **UI Integration and Visual Feedback**: 141 | * **Decision**: Provide clear and non-disruptive visual feedback during drag operations. 142 | * **Implementation**: 143 | * `abstract-folder-drag-over` and `abstract-folder-drag-invalid` CSS classes were introduced. 144 | * **Challenge**: Initially, using `border` for drag feedback caused layout flickering, as it altered the element's box model dimensions. Additionally, the default error red for invalid drops was too jarring. 145 | * **Solution**: The CSS was refactored to use `outline` with `outline-offset: -2px` instead of `border`. `outline` does not affect layout, eliminating the flicker. The invalid drop background was softened using `rgba(var(--color-red-rgb), 0.15)` for a more subtle visual cue. 146 | * **Challenge**: Ensuring drag feedback was always cleaned up, especially after invalid drops or drag cancellations. 147 | * **Solution**: `DragManager` was enhanced with a `currentDragTarget` tracker and a `dragend` event listener. A `try...finally` block in `handleDrop` guarantees `dragData` and visual styles are reset irrespective of the drop outcome. 148 | 149 | ### Key Learnings: 150 | 151 | * **Obsidian API Nuances**: Understanding the specific behaviors of Obsidian's `metadataCache` and event system was critical for handling complex interactions like multi-parented notes and DOM events. 152 | * **Robustness in Data Handling**: Anticipating variations in data (e.g., link formats in frontmatter) and using platform-specific APIs for resolution significantly improves reliability. 153 | * **Subtle UI/UX**: Small details in visual feedback (like using `outline` over `border` and carefully chosen colors/opacities) contribute significantly to a polished user experience. -------------------------------------------------------------------------------- /src/ui/tree/tree-renderer.ts: -------------------------------------------------------------------------------- 1 | import { App, setIcon } from "obsidian"; 2 | import { FolderNode, HIDDEN_FOLDER_ID } from "../../types"; 3 | import { AbstractFolderPluginSettings } from "../../settings"; 4 | import AbstractFolderPlugin from "../../../main"; 5 | import { ContextMenuHandler } from "../context-menu"; 6 | import { FolderIndexer } from "../../indexer"; 7 | import { DragManager } from "../dnd/drag-manager"; 8 | import { FlatItem } from "../../utils/virtualization"; 9 | 10 | function stringToNumberHash(str: string): number { 11 | let hash = 0; 12 | for (let i = 0; i < str.length; i++) { 13 | hash = str.charCodeAt(i) + ((hash << 5) - hash); 14 | } 15 | return hash; 16 | } 17 | 18 | export class TreeRenderer { 19 | private app: App; 20 | private settings: AbstractFolderPluginSettings; 21 | private plugin: AbstractFolderPlugin; 22 | private multiSelectedPaths: Set; 23 | private getDisplayName: (node: FolderNode) => string; 24 | private toggleCollapse: (itemEl: HTMLElement, path: string) => Promise; 25 | private contextMenuHandler: ContextMenuHandler; 26 | private dragManager: DragManager; 27 | 28 | constructor( 29 | app: App, 30 | settings: AbstractFolderPluginSettings, 31 | plugin: AbstractFolderPlugin, 32 | multiSelectedPaths: Set, 33 | getDisplayName: (node: FolderNode) => string, 34 | toggleCollapse: (itemEl: HTMLElement, path: string) => Promise, 35 | indexer: FolderIndexer, // Add indexer here 36 | dragManager: DragManager 37 | ) { 38 | this.app = app; 39 | this.settings = settings; 40 | this.plugin = plugin; 41 | this.multiSelectedPaths = multiSelectedPaths; 42 | this.getDisplayName = getDisplayName; 43 | this.toggleCollapse = toggleCollapse; 44 | this.contextMenuHandler = new ContextMenuHandler(app, settings, plugin, indexer); 45 | this.dragManager = dragManager; 46 | } 47 | 48 | renderTreeNode(node: FolderNode, parentEl: HTMLElement, ancestors: Set, depth: number, parentPath: string | null) { 49 | const activeFile = this.app.workspace.getActiveFile(); 50 | // Only prevent rendering for folder loops, not for files that can appear in multiple abstract folders. 51 | if (node.isFolder && ancestors.has(node.path)) { 52 | return; 53 | } 54 | const currentDepth = depth + 1; 55 | 56 | const itemEl = parentEl.createDiv({ cls: "abstract-folder-item" }); 57 | itemEl.dataset.path = node.path; 58 | itemEl.dataset.depth = String(depth); 59 | // @ts-ignore 60 | itemEl._folderNode = node; 61 | // @ts-ignore 62 | itemEl._ancestors = ancestors; 63 | 64 | itemEl.draggable = true; 65 | 66 | itemEl.addEventListener("dragstart", (e) => this.dragManager.handleDragStart(e, node, parentPath || "", this.multiSelectedPaths)); 67 | itemEl.addEventListener("dragover", (e) => this.dragManager.handleDragOver(e, node)); 68 | itemEl.addEventListener("dragleave", (e) => this.dragManager.handleDragLeave(e)); 69 | itemEl.addEventListener("drop", (e) => { 70 | this.dragManager.handleDrop(e, node).catch(console.error); 71 | }); 72 | 73 | if (node.isFolder) { 74 | itemEl.addClass("is-folder"); 75 | if (this.settings.rememberExpanded && this.settings.expandedFolders.includes(node.path)) { 76 | // Expanded 77 | } else { 78 | itemEl.addClass("is-collapsed"); 79 | } 80 | } else { 81 | itemEl.addClass("is-file"); 82 | } 83 | 84 | const selfEl = itemEl.createDiv({ cls: "abstract-folder-item-self" }); 85 | 86 | if (activeFile && activeFile.path === node.path) { 87 | selfEl.addClass("is-active"); 88 | } 89 | 90 | if (this.multiSelectedPaths.has(node.path)) { 91 | selfEl.addClass("is-multi-selected"); 92 | } 93 | 94 | if (node.isFolder) { 95 | const iconEl = selfEl.createDiv({ cls: "abstract-folder-collapse-icon" }); 96 | setIcon(iconEl, "chevron-right"); 97 | 98 | iconEl.addEventListener("click", (e) => { 99 | e.stopPropagation(); 100 | this.toggleCollapse(itemEl, node.path).catch(console.error); 101 | }); 102 | } 103 | 104 | let iconToUse = node.icon; 105 | if (node.path === HIDDEN_FOLDER_ID && !iconToUse) { 106 | iconToUse = "eye-off"; 107 | } 108 | 109 | if (iconToUse) { 110 | const iconContainerEl = selfEl.createDiv({ cls: "abstract-folder-item-icon" }); 111 | setIcon(iconContainerEl, iconToUse); 112 | if (!iconContainerEl.querySelector('svg')) { 113 | iconContainerEl.setText(iconToUse); 114 | } 115 | } 116 | 117 | const innerEl = selfEl.createDiv({ cls: "abstract-folder-item-inner" }); 118 | innerEl.setText(this.getDisplayName(node)); 119 | 120 | if (node.file && node.path !== HIDDEN_FOLDER_ID && node.file.extension !== 'md') { 121 | const fileTypeTag = selfEl.createDiv({ cls: "abstract-folder-file-tag" }); 122 | fileTypeTag.setText(node.file.extension.toUpperCase()); 123 | } 124 | 125 | selfEl.addEventListener("click", (e) => { 126 | e.stopPropagation(); 127 | this.handleNodeClick(node, e).catch(console.error); 128 | }); 129 | 130 | if (node.file) { 131 | selfEl.addEventListener("contextmenu", (e) => { 132 | e.stopPropagation(); 133 | e.preventDefault(); 134 | this.contextMenuHandler.showContextMenu(e, node, this.multiSelectedPaths); 135 | }); 136 | } 137 | 138 | if (node.isFolder) { 139 | const childrenEl = itemEl.createDiv({ cls: "abstract-folder-children" }); 140 | if (this.settings.enableRainbowIndents) { 141 | childrenEl.addClass("rainbow-indent"); 142 | let colorIndex: number; 143 | if (this.settings.enablePerItemRainbowColors) { 144 | // Use depth + a hash of the path for varied colors within the same depth 145 | colorIndex = Math.abs(((currentDepth - 1) + stringToNumberHash(node.path)) % 10); // Use 10 colors 146 | } else { 147 | // Use only depth for consistent colors at each level 148 | colorIndex = (currentDepth - 1) % 10; // Use 10 colors 149 | } 150 | childrenEl.addClass(`rainbow-indent-${colorIndex}`); 151 | childrenEl.addClass(`${this.settings.rainbowPalette}-palette`); 152 | } 153 | 154 | // Lazy Rendering: Only render children if expanded 155 | if (this.settings.expandedFolders.includes(node.path)) { 156 | if (node.children.length > 0) { 157 | const newAncestors = new Set(ancestors).add(node.path); 158 | node.children.forEach(child => this.renderTreeNode(child, childrenEl, newAncestors, currentDepth, node.path)); 159 | } 160 | } 161 | } 162 | } 163 | 164 | public renderChildren(itemEl: HTMLElement) { 165 | // @ts-ignore 166 | const node = itemEl._folderNode as FolderNode; 167 | // @ts-ignore 168 | const ancestors = itemEl._ancestors as Set; 169 | const depth = parseInt(itemEl.dataset.depth || "0"); 170 | 171 | if (!node || !node.isFolder) return; 172 | 173 | const childrenEl = itemEl.querySelector('.abstract-folder-children') as HTMLElement; 174 | if (!childrenEl) return; 175 | 176 | // If already rendered, skip 177 | if (childrenEl.childElementCount > 0) return; 178 | 179 | const currentDepth = depth + 1; 180 | const newAncestors = new Set(ancestors).add(node.path); 181 | 182 | node.children.forEach(child => this.renderTreeNode(child, childrenEl, newAncestors, currentDepth, node.path)); 183 | } 184 | 185 | public renderFlatItem(item: FlatItem, container: HTMLElement | DocumentFragment, top: number) { 186 | const node = item.node; 187 | const depth = item.depth; 188 | const activeFile = this.app.workspace.getActiveFile(); 189 | 190 | const itemEl = container.createDiv({ cls: "abstract-folder-item abstract-folder-virtual-item" }); 191 | itemEl.style.setProperty('top', `${top}px`); 192 | 193 | // Render Indent Guides 194 | if (this.settings.enableRainbowIndents && depth > 0) { 195 | const guidesContainer = itemEl.createDiv({ cls: "abstract-folder-indent-guides" }); 196 | // Static styles moved to CSS class .abstract-folder-indent-guides 197 | 198 | for (let i = 0; i < depth; i++) { 199 | const guide = guidesContainer.createDiv({ cls: "abstract-folder-indent-guide" }); 200 | // Dynamic style for indentation position 201 | guide.style.setProperty('left', `${6 + (i * 20)}px`); 202 | 203 | guide.addClass("rainbow-indent"); 204 | guide.addClass(`${this.settings.rainbowPalette}-palette`); 205 | 206 | // Color Logic (Depth based) 207 | const colorIndex = i % 10; 208 | guide.addClass(`rainbow-indent-${colorIndex}`); 209 | } 210 | } 211 | 212 | itemEl.dataset.path = node.path; 213 | itemEl.dataset.depth = String(depth); 214 | // @ts-ignore 215 | itemEl._folderNode = node; 216 | 217 | itemEl.draggable = true; 218 | 219 | itemEl.addEventListener("dragstart", (e) => this.dragManager.handleDragStart(e, node, item.parentPath || "", this.multiSelectedPaths)); 220 | itemEl.addEventListener("dragover", (e) => this.dragManager.handleDragOver(e, node)); 221 | itemEl.addEventListener("dragleave", (e) => this.dragManager.handleDragLeave(e)); 222 | itemEl.addEventListener("drop", (e) => { 223 | this.dragManager.handleDrop(e, node).catch(console.error); 224 | }); 225 | 226 | if (node.isFolder) { 227 | itemEl.addClass("is-folder"); 228 | if (this.settings.expandedFolders.includes(node.path)) { 229 | // Expanded 230 | } else { 231 | itemEl.addClass("is-collapsed"); 232 | } 233 | } else { 234 | itemEl.addClass("is-file"); 235 | } 236 | 237 | const selfEl = itemEl.createDiv({ cls: "abstract-folder-item-self" }); 238 | // Virtual Indentation 239 | selfEl.style.setProperty('padding-left', `${6 + (depth * 20)}px`); // 6px base + 20px per level 240 | 241 | if (activeFile && activeFile.path === node.path) { 242 | selfEl.addClass("is-active"); 243 | } 244 | 245 | if (this.multiSelectedPaths.has(node.path)) { 246 | selfEl.addClass("is-multi-selected"); 247 | } 248 | 249 | if (node.isFolder) { 250 | const iconEl = selfEl.createDiv({ cls: "abstract-folder-collapse-icon" }); 251 | setIcon(iconEl, "chevron-right"); 252 | 253 | iconEl.addEventListener("click", (e) => { 254 | e.stopPropagation(); 255 | // In virtual view, toggleCollapse should just update state and trigger re-render 256 | // We pass itemEl, but view needs to know to handle it differently? 257 | // Actually, toggleCollapse in view.ts now calls renderChildren. 258 | // We need to override that behavior for virtual view. 259 | // Or simply: the view handles the click event? 260 | this.toggleCollapse(itemEl, node.path).catch(console.error); 261 | }); 262 | } 263 | 264 | let iconToUse = node.icon; 265 | if (node.path === HIDDEN_FOLDER_ID && !iconToUse) { 266 | iconToUse = "eye-off"; 267 | } 268 | 269 | if (iconToUse) { 270 | const iconContainerEl = selfEl.createDiv({ cls: "abstract-folder-item-icon" }); 271 | setIcon(iconContainerEl, iconToUse); 272 | if (!iconContainerEl.querySelector('svg')) { 273 | iconContainerEl.setText(iconToUse); 274 | } 275 | } 276 | 277 | const innerEl = selfEl.createDiv({ cls: "abstract-folder-item-inner" }); 278 | innerEl.setText(this.getDisplayName(node)); 279 | 280 | if (node.file && node.path !== HIDDEN_FOLDER_ID && node.file.extension !== 'md') { 281 | const fileTypeTag = selfEl.createDiv({ cls: "abstract-folder-file-tag" }); 282 | fileTypeTag.setText(node.file.extension.toUpperCase()); 283 | } 284 | 285 | selfEl.addEventListener("click", (e) => { 286 | e.stopPropagation(); 287 | this.handleNodeClick(node, e).catch(console.error); 288 | }); 289 | 290 | if (node.file) { 291 | selfEl.addEventListener("contextmenu", (e) => { 292 | e.stopPropagation(); 293 | e.preventDefault(); 294 | this.contextMenuHandler.showContextMenu(e, node, this.multiSelectedPaths); 295 | }); 296 | } 297 | } 298 | 299 | private async handleNodeClick(node: FolderNode, e: MouseEvent) { 300 | const isMultiSelectModifier = e.altKey || e.ctrlKey || e.metaKey; 301 | 302 | if (isMultiSelectModifier) { 303 | if (this.multiSelectedPaths.size === 0) { 304 | const activeFile = this.app.workspace.getActiveFile(); 305 | if (activeFile) { 306 | this.multiSelectedPaths.add(activeFile.path); 307 | } 308 | } 309 | 310 | if (this.multiSelectedPaths.has(node.path)) { 311 | this.multiSelectedPaths.delete(node.path); 312 | } else { 313 | this.multiSelectedPaths.add(node.path); 314 | } 315 | this.plugin.app.workspace.trigger('abstract-folder:graph-updated'); // Re-render to show selection 316 | return; 317 | } 318 | 319 | if (this.multiSelectedPaths.size > 0) { 320 | this.multiSelectedPaths.clear(); 321 | this.plugin.app.workspace.trigger('abstract-folder:graph-updated'); // Re-render to clear selection 322 | } 323 | 324 | if (node.file) { 325 | const fileExists = this.app.vault.getAbstractFileByPath(node.file.path); 326 | if (fileExists) { 327 | this.app.workspace.getLeaf(false).openFile(node.file).catch(console.error); 328 | 329 | // If this file also has children and autoExpandChildren is enabled, toggle its expanded state 330 | if (this.settings.autoExpandChildren && node.children.length > 0) { 331 | const selfEl = e.currentTarget as HTMLElement; 332 | const itemEl = selfEl.parentElement; // The .abstract-folder-item 333 | if (itemEl) { 334 | await this.toggleCollapse(itemEl, node.path); 335 | } 336 | } 337 | } 338 | } else if (node.isFolder) { 339 | const selfEl = e.currentTarget as HTMLElement; 340 | const itemEl = selfEl.parentElement; 341 | 342 | if (itemEl) { 343 | await this.toggleCollapse(itemEl, node.path); 344 | } 345 | } 346 | } 347 | } -------------------------------------------------------------------------------- /src/utils/file-operations.ts: -------------------------------------------------------------------------------- 1 | import { App, TFile, Notice, TFolder } from "obsidian"; 2 | import { AbstractFolderPluginSettings } from "../settings"; 3 | import { ChildFileType } from "../ui/modals"; 4 | import { FolderIndexer } from "../indexer"; 5 | import { AbstractFolderFrontmatter } from "../types"; 6 | 7 | /** 8 | * Creates a new abstract child file (note, canvas, or base) with appropriate frontmatter and content. 9 | * @param app The Obsidian App instance. 10 | * @param settings The plugin settings. 11 | * @param childName The name of the new child file. 12 | * @param parentFile The parent TFile, if creating a child for an existing parent. 13 | * @param childType The type of child file to create ('note', 'canvas', 'base'). 14 | */ 15 | export async function createAbstractChildFile(app: App, settings: AbstractFolderPluginSettings, childName: string, parentFile: TFile | null, childType: ChildFileType) { 16 | let fileExtension: string; 17 | let initialContent: string; 18 | 19 | switch (childType) { 20 | case 'note': 21 | fileExtension = '.md'; 22 | if (parentFile) { 23 | const parentBaseName = parentFile.basename; 24 | const cleanParentName = parentBaseName.replace(/"/g, ''); 25 | initialContent = `--- 26 | ${settings.propertyName}: "[[${cleanParentName}]]" 27 | aliases: 28 | - "${childName}" 29 | --- 30 | `; 31 | } else { 32 | initialContent = `--- 33 | aliases: 34 | - "${childName}" 35 | --- 36 | `; 37 | } 38 | break; 39 | case 'canvas': 40 | fileExtension = '.canvas'; 41 | initialContent = `{ 42 | "nodes": [], 43 | "edges": [] 44 | }`; 45 | break; 46 | case 'base': 47 | fileExtension = '.base'; 48 | initialContent = `{}`; 49 | break; 50 | default: 51 | new Notice(`Unsupported child type: ${childType as string}`); 52 | return; 53 | } 54 | 55 | const safeChildName = childName.replace(/[\\/:*?"<>|]/g, ""); 56 | let fileName = `${safeChildName}${fileExtension}`; 57 | let counter = 0; 58 | while (app.vault.getAbstractFileByPath(fileName)) { 59 | counter++; 60 | fileName = `${safeChildName} ${counter}${fileExtension}`; 61 | } 62 | 63 | try { 64 | const file = await app.vault.create(fileName, initialContent); 65 | new Notice(`Created: ${fileName}`); 66 | 67 | if (fileExtension !== '.md' && parentFile && parentFile.extension === 'md') { 68 | await app.fileManager.processFrontMatter(parentFile, (frontmatter: AbstractFolderFrontmatter) => { 69 | const childrenPropertyName = settings.childrenPropertyName; 70 | const rawChildren = frontmatter[childrenPropertyName]; 71 | let childrenArray: string[] = []; 72 | 73 | if (typeof rawChildren === 'string') { 74 | childrenArray = [rawChildren]; 75 | } else if (Array.isArray(rawChildren)) { 76 | childrenArray = rawChildren as string[]; 77 | } 78 | 79 | const newChildLink = `[[${file.name}]]`; // Link to the new file, including extension 80 | if (!childrenArray.includes(newChildLink)) { 81 | childrenArray.push(newChildLink); 82 | } 83 | 84 | frontmatter[childrenPropertyName] = childrenArray.length === 1 ? childrenArray[0] : childrenArray; 85 | }); 86 | } 87 | 88 | app.workspace.getLeaf(true).openFile(file).catch(console.error); 89 | app.workspace.trigger('abstract-folder:graph-updated'); 90 | } catch (error) { 91 | new Notice(`Failed to create file: ${error}`); 92 | console.error(error); 93 | } 94 | } 95 | 96 | /** 97 | * Deletes an abstract file, with an option to recursively delete its children. 98 | * @param app The Obsidian App instance. 99 | * @param file The TFile to delete. 100 | * @param deleteChildren If true, recursively deletes all children of this file. 101 | * @param indexer The FolderIndexer instance to query the graph. 102 | */ 103 | export async function deleteAbstractFile(app: App, file: TFile, deleteChildren: boolean, indexer: FolderIndexer) { 104 | try { 105 | if (deleteChildren) { 106 | const graph = indexer.getGraph(); 107 | const childrenPaths = graph.parentToChildren[file.path]; 108 | 109 | if (childrenPaths && childrenPaths.size > 0) { 110 | for (const childPath of childrenPaths) { 111 | const childAbstractFile = app.vault.getAbstractFileByPath(childPath); 112 | if (childAbstractFile instanceof TFile) { 113 | await deleteAbstractFile(app, childAbstractFile, deleteChildren, indexer); 114 | } else if (childAbstractFile instanceof TFolder) { 115 | await deleteFolderRecursive(app, childAbstractFile, deleteChildren, indexer); 116 | } 117 | } 118 | } 119 | } 120 | await app.fileManager.trashFile(file); 121 | new Notice(`Deleted file: ${file.name}`); 122 | } catch (error) { 123 | const errorMessage = error instanceof Error ? error.message : String(error); 124 | new Notice(`Failed to delete file ${file.name}: ${errorMessage}`); 125 | console.error(`Error deleting file ${file.name}:`, error); 126 | } 127 | } 128 | 129 | /** 130 | * Recursively deletes a folder and its contents. 131 | * This is a helper for deleteAbstractFile when a child is a folder. 132 | * @param app The Obsidian App instance. 133 | * @param folder The TFolder to delete. 134 | * @param deleteChildren If true, recursively deletes all children of this folder (passed through). 135 | * @param indexer The FolderIndexer instance to query the graph. 136 | */ 137 | async function deleteFolderRecursive(app: App, folder: TFolder, deleteChildren: boolean, indexer: FolderIndexer) { 138 | try { 139 | for (const child of folder.children) { 140 | if (child instanceof TFile) { 141 | await deleteAbstractFile(app, child, deleteChildren, indexer); 142 | } else if (child instanceof TFolder) { 143 | await deleteFolderRecursive(app, child, deleteChildren, indexer); 144 | } 145 | } 146 | await app.fileManager.trashFile(folder); 147 | new Notice(`Deleted folder: ${folder.name}`); 148 | } catch (error) { 149 | const errorMessage = error instanceof Error ? error.message : String(error); 150 | new Notice(`Failed to delete folder ${folder.name}: ${errorMessage}`); 151 | console.error(`Error deleting folder ${folder.name}:`, error); 152 | } 153 | } 154 | 155 | /** 156 | * Updates the 'icon' frontmatter property of a given file. 157 | * @param app The Obsidian App instance. 158 | * @param file The TFile to update. 159 | * @param iconName The name of the icon to set, or empty string to remove. 160 | */ 161 | export async function updateFileIcon(app: App, file: TFile, iconName: string) { 162 | await app.fileManager.processFrontMatter(file, (frontmatter: AbstractFolderFrontmatter) => { 163 | if (iconName) { 164 | frontmatter.icon = iconName; 165 | } else { 166 | delete frontmatter.icon; 167 | } 168 | }); 169 | app.workspace.trigger('abstract-folder:graph-updated'); 170 | } 171 | 172 | /** 173 | * Toggles the 'hidden' status of a note by adding/removing 'hidden' from its parent property. 174 | * @param app The Obsidian App instance. 175 | * @param file The TFile to update. 176 | * @param settings The plugin settings to get the propertyName. 177 | */ 178 | export async function toggleHiddenStatus(app: App, file: TFile, settings: AbstractFolderPluginSettings) { 179 | await app.fileManager.processFrontMatter(file, (frontmatter: AbstractFolderFrontmatter) => { 180 | const primaryPropertyName = settings.propertyName; 181 | const rawParents = frontmatter[primaryPropertyName]; 182 | let parentLinks: string[] = []; 183 | 184 | if (typeof rawParents === 'string') { 185 | parentLinks = [rawParents]; 186 | } else if (Array.isArray(rawParents)) { 187 | parentLinks = rawParents as string[]; 188 | } 189 | 190 | const isCurrentlyHidden = parentLinks.some((p: string) => p.toLowerCase().trim() === 'hidden'); 191 | 192 | if (isCurrentlyHidden) { 193 | const newParents = parentLinks.filter((p: string) => p.toLowerCase().trim() !== 'hidden'); 194 | 195 | if (newParents.length > 0) { 196 | frontmatter[primaryPropertyName] = newParents.length === 1 ? newParents[0] : newParents; 197 | } else { 198 | delete frontmatter[primaryPropertyName]; 199 | } 200 | new Notice(`Unhid: ${file.basename}`); 201 | } else { 202 | if (!parentLinks.some((p: string) => p.toLowerCase().trim() === 'hidden')) { 203 | parentLinks.push('hidden'); 204 | } 205 | frontmatter[primaryPropertyName] = parentLinks.length === 1 ? parentLinks[0] : parentLinks; 206 | new Notice(`Hid: ${file.basename}`); 207 | } 208 | }); 209 | app.workspace.trigger('abstract-folder:graph-updated'); 210 | } 211 | 212 | /** 213 | * Moves files to a new abstract parent folder. 214 | * Handles the logic for MD files (modifying child's parent property) 215 | * and Non-MD files (modifying parent's children property). 216 | * 217 | * @param app The Obsidian App instance. 218 | * @param settings The plugin settings. 219 | * @param files The list of files to move. 220 | * @param targetParentPath The path of the destination abstract folder (parent). 221 | * @param sourceParentPath The path of the source abstract folder (parent) where the drag started. 222 | */ 223 | export async function moveFiles( 224 | app: App, 225 | settings: AbstractFolderPluginSettings, 226 | files: TFile[], 227 | targetParentPath: string, 228 | sourceParentPath: string | null, 229 | indexer: FolderIndexer, 230 | isCopy: boolean 231 | ) { 232 | const targetParentFile = app.vault.getAbstractFileByPath(targetParentPath); 233 | 234 | // Validation: If target is a file, it MUST be a Markdown file to act as a parent 235 | if (targetParentFile instanceof TFile && targetParentFile.extension !== 'md') { 236 | new Notice("Target must be a Markdown file to contain other files."); 237 | return; 238 | } 239 | 240 | // Group files by type 241 | const mdFiles: TFile[] = []; 242 | const nonMdFiles: TFile[] = []; 243 | 244 | for (const file of files) { 245 | if (file.extension === 'md') { 246 | mdFiles.push(file); 247 | } else { 248 | nonMdFiles.push(file); 249 | } 250 | } 251 | 252 | for (const file of mdFiles) { 253 | await app.fileManager.processFrontMatter(file, (frontmatter: AbstractFolderFrontmatter) => { 254 | const parentPropertyName = settings.propertyName; 255 | const rawParents = frontmatter[parentPropertyName]; 256 | let parentLinks: string[] = []; 257 | 258 | if (typeof rawParents === 'string') { 259 | parentLinks = [rawParents]; 260 | } else if (Array.isArray(rawParents)) { 261 | parentLinks = rawParents as string[]; 262 | } 263 | 264 | if (sourceParentPath && !isCopy) { 265 | const sourceParentFile = app.vault.getAbstractFileByPath(sourceParentPath); 266 | if (sourceParentFile) { 267 | parentLinks = parentLinks.filter(link => { 268 | let cleanLink = link.replace(/^["']+|["']+$|^\s+|[\s]+$/g, ''); 269 | cleanLink = cleanLink.replace(/\[\[|\]\]/g, ''); 270 | cleanLink = cleanLink.split('|')[0]; 271 | cleanLink = cleanLink.trim(); 272 | 273 | const linkTargetFile = app.metadataCache.getFirstLinkpathDest(cleanLink, file.path); 274 | 275 | let isMatch = false; 276 | if (linkTargetFile) { 277 | isMatch = linkTargetFile.path === sourceParentFile.path; 278 | } else { 279 | const sourceName = sourceParentFile.name; 280 | const sourceBasename = (sourceParentFile instanceof TFile) ? sourceParentFile.basename : sourceParentFile.name; 281 | isMatch = cleanLink === sourceName || cleanLink === sourceBasename; 282 | } 283 | 284 | return !isMatch; 285 | }); 286 | } 287 | } 288 | 289 | // Add the new target parent link 290 | if (targetParentFile) { 291 | const targetName = (targetParentFile instanceof TFile) ? targetParentFile.basename : targetParentFile.name; 292 | const newLink = `[[${targetName}]]`; 293 | if (!parentLinks.includes(newLink)) { 294 | parentLinks.push(newLink); 295 | } 296 | } 297 | 298 | // Save back 299 | if (parentLinks.length === 0) { 300 | delete frontmatter[parentPropertyName]; 301 | } else if (parentLinks.length === 1) { 302 | frontmatter[parentPropertyName] = parentLinks[0]; 303 | } else { 304 | frontmatter[parentPropertyName] = parentLinks; 305 | } 306 | }); 307 | } 308 | 309 | if (sourceParentPath && nonMdFiles.length > 0 && !isCopy) { 310 | const sourceParentFile = app.vault.getAbstractFileByPath(sourceParentPath); 311 | if (sourceParentFile instanceof TFile && sourceParentFile.extension === 'md') { 312 | await app.fileManager.processFrontMatter(sourceParentFile, (frontmatter: AbstractFolderFrontmatter) => { 313 | const childrenProp = settings.childrenPropertyName; 314 | const rawChildren = frontmatter[childrenProp]; 315 | if (!rawChildren) return; 316 | 317 | let childrenList: string[] = []; 318 | if (Array.isArray(rawChildren)) { 319 | childrenList = rawChildren as string[]; 320 | } else if (typeof rawChildren === 'string') { 321 | childrenList = [rawChildren]; 322 | } 323 | 324 | childrenList = childrenList.filter(link => { 325 | return !nonMdFiles.some(movedFile => 326 | link.includes(movedFile.name) 327 | ); 328 | }); 329 | 330 | if (childrenList.length === 0) delete frontmatter[childrenProp]; 331 | else frontmatter[childrenProp] = childrenList.length === 1 ? childrenList[0] : childrenList; 332 | }); 333 | } 334 | } 335 | 336 | if (targetParentFile instanceof TFile && targetParentFile.extension === 'md' && nonMdFiles.length > 0) { 337 | await app.fileManager.processFrontMatter(targetParentFile, (frontmatter: AbstractFolderFrontmatter) => { 338 | const childrenProp = settings.childrenPropertyName; 339 | const rawChildren = frontmatter[childrenProp] || []; 340 | let childrenList: string[] = []; 341 | if (Array.isArray(rawChildren)) { 342 | childrenList = rawChildren as string[]; 343 | } else if (typeof rawChildren === 'string') { 344 | childrenList = [rawChildren]; 345 | } 346 | 347 | for (const file of nonMdFiles) { 348 | const newLink = `[[${file.name}]]`; 349 | if (!childrenList.includes(newLink)) { 350 | childrenList.push(newLink); 351 | } 352 | } 353 | 354 | frontmatter[childrenProp] = childrenList.length === 1 ? childrenList[0] : childrenList; 355 | }); 356 | } else if (nonMdFiles.length > 0 && (!targetParentFile || !(targetParentFile instanceof TFile) || targetParentFile.extension !== 'md')) { 357 | new Notice(`Cannot move ${nonMdFiles.length} non-markdown files: Target parent must be a Markdown file.`); 358 | } 359 | 360 | // Trigger update 361 | app.workspace.trigger('abstract-folder:graph-updated'); 362 | } -------------------------------------------------------------------------------- /src/ui/modals.ts: -------------------------------------------------------------------------------- 1 | import { App, Modal, Setting, TFile, TFolder, Notice, FuzzySuggestModal, normalizePath } from "obsidian"; 2 | import { ConversionOptions, FileConflict } from "../utils/conversion"; 3 | import { AbstractFolderPluginSettings } from "../settings"; 4 | 5 | export class ParentPickerModal extends FuzzySuggestModal { 6 | private onChoose: (file: TFile) => void; 7 | 8 | constructor(app: App, onChoose: (file: TFile) => void) { 9 | super(app); 10 | this.onChoose = onChoose; 11 | this.setPlaceholder("Select parent note"); 12 | } 13 | 14 | getItems(): TFile[] { 15 | return this.app.vault.getMarkdownFiles(); 16 | } 17 | 18 | getItemText(file: TFile): string { 19 | return file.path; 20 | } 21 | 22 | onChooseItem(file: TFile, evt: MouseEvent | KeyboardEvent) { 23 | this.onChoose(file); 24 | } 25 | } 26 | 27 | export type ChildFileType = 'note' | 'canvas' | 'base'; 28 | 29 | export class CreateAbstractChildModal extends Modal { 30 | private settings: AbstractFolderPluginSettings; 31 | private childName = ""; 32 | private childType: ChildFileType; 33 | private onSubmit: (childName: string, childType: ChildFileType) => void; 34 | 35 | constructor(app: App, settings: AbstractFolderPluginSettings, onSubmit: (childName: string, childType: ChildFileType) => void, initialChildType: ChildFileType = 'note') { 36 | super(app); 37 | this.settings = settings; 38 | this.onSubmit = onSubmit; 39 | this.childType = initialChildType; 40 | } 41 | 42 | onOpen() { 43 | const { contentEl } = this; 44 | contentEl.createEl("h2", { text: "Create abstract child" }); 45 | 46 | new Setting(contentEl) 47 | .setName("Child name") 48 | .setDesc("The name for the new file (e.g. 'meeting notes', 'project board').") 49 | .addText((text) => { 50 | text.inputEl.focus(); 51 | text.onChange((value) => { 52 | this.childName = value; 53 | }); 54 | text.inputEl.addEventListener("keydown", (e) => { 55 | if (e.key === "Enter") { 56 | this.submit(); 57 | } 58 | }); 59 | }); 60 | 61 | new Setting(contentEl) 62 | .setName("Child type") 63 | .setDesc("Select the type of file to create.") 64 | .addDropdown((dropdown) => { 65 | dropdown.addOption('note', 'Markdown note'); 66 | dropdown.addOption('canvas', 'Canvas'); 67 | dropdown.addOption('base', 'Bases'); 68 | dropdown.setValue(this.childType); 69 | dropdown.onChange((value: ChildFileType) => { 70 | this.childType = value; 71 | }); 72 | }); 73 | 74 | new Setting(contentEl) 75 | .addButton((btn) => 76 | btn 77 | .setButtonText("Create") 78 | .setCta() 79 | .onClick(() => { 80 | this.submit(); 81 | }) 82 | ); 83 | } 84 | 85 | private submit() { 86 | if (!this.childName) { 87 | new Notice("Child name is required."); 88 | return; 89 | } 90 | this.close(); 91 | this.onSubmit(this.childName, this.childType); 92 | } 93 | 94 | onClose() { 95 | const { contentEl } = this; 96 | contentEl.empty(); 97 | } 98 | } 99 | 100 | 101 | export class RenameModal extends Modal { 102 | private file: TFile; 103 | private newName: string; 104 | 105 | constructor(app: App, file: TFile) { 106 | super(app); 107 | this.file = file; 108 | this.newName = file.basename; 109 | } 110 | 111 | onOpen() { 112 | const { contentEl } = this; 113 | contentEl.createEl("h2", { text: "Rename file" }); 114 | 115 | new Setting(contentEl) 116 | .setName("New name") 117 | .addText((text) => { 118 | text.setValue(this.newName); 119 | text.inputEl.focus(); 120 | text.inputEl.select(); 121 | text.onChange((value) => { 122 | this.newName = value; 123 | }); 124 | text.inputEl.addEventListener("keydown", (e) => { 125 | if (e.key === "Enter") { 126 | this.submit().catch((error) => console.error(error)); 127 | } 128 | }); 129 | }); 130 | 131 | new Setting(contentEl) 132 | .addButton((btn) => 133 | btn 134 | .setButtonText("Rename") 135 | .setCta() 136 | .onClick(() => { 137 | this.submit().catch((error) => console.error(error)); 138 | }) 139 | ); 140 | } 141 | 142 | private async submit() { 143 | if (!this.newName) { 144 | new Notice("Name cannot be empty."); 145 | return; 146 | } 147 | 148 | if (this.newName === this.file.basename) { 149 | this.close(); 150 | return; 151 | } 152 | 153 | const parentPath = this.file.parent?.path || ""; 154 | // Handle root directory where parent.path is '/' 155 | const directory = parentPath === "/" ? "" : parentPath; 156 | const newPath = (directory ? directory + "/" : "") + this.newName + "." + this.file.extension; 157 | 158 | try { 159 | await this.app.fileManager.renameFile(this.file, newPath); 160 | // new Notice(`Renamed to ${this.newName}`); // Obsidian usually shows a notice or updates UI automatically 161 | this.close(); 162 | } catch (error) { 163 | new Notice(`Failed to rename: ${error}`); 164 | console.error(error); 165 | } 166 | } 167 | 168 | onClose() { 169 | const { contentEl } = this; 170 | contentEl.empty(); 171 | } 172 | } 173 | 174 | export class DeleteConfirmModal extends Modal { 175 | private file: TFile; 176 | private onConfirm: (deleteChildren: boolean) => void; 177 | private deleteChildren: boolean = true; 178 | private isMarkdownFile: boolean; 179 | 180 | constructor(app: App, file: TFile, onConfirm: (deleteChildren: boolean) => void) { 181 | super(app); 182 | this.file = file; 183 | this.onConfirm = onConfirm; 184 | this.isMarkdownFile = file.extension === 'md'; 185 | } 186 | 187 | onOpen() { 188 | const { contentEl } = this; 189 | contentEl.createEl("h2", { text: "Delete file" }); 190 | contentEl.createEl("p", { text: `Are you sure you want to delete "${this.file.name}"?` }); 191 | 192 | // Only show the option to delete children if the file is a markdown file, as only markdown files can be abstract parents. 193 | if (this.isMarkdownFile) { 194 | new Setting(contentEl) 195 | .setName("Delete children as well?") 196 | .setDesc("If enabled, all notes and folders directly linked as children to this file will also be deleted.") 197 | .addToggle(toggle => toggle 198 | .setValue(this.deleteChildren) 199 | .onChange(value => this.deleteChildren = value)); 200 | } else { 201 | // If it's a non-markdown file, this option doesn't make sense, so ensure deleteChildren is false. 202 | this.deleteChildren = false; 203 | } 204 | 205 | const buttonContainer = contentEl.createDiv({ cls: "modal-button-container" }); 206 | 207 | const deleteButton = buttonContainer.createEl("button", { text: "Delete", cls: "mod-warning" }); 208 | deleteButton.addEventListener("click", () => { 209 | this.onConfirm(this.deleteChildren); 210 | this.close(); 211 | }); 212 | 213 | const cancelButton = buttonContainer.createEl("button", { text: "Cancel" }); 214 | cancelButton.addEventListener("click", () => { 215 | this.close(); 216 | }); 217 | } 218 | 219 | onClose() { 220 | const { contentEl } = this; 221 | contentEl.empty(); 222 | } 223 | } 224 | 225 | export class BatchDeleteConfirmModal extends Modal { 226 | private files: TFile[]; 227 | private onConfirm: (deleteChildren: boolean) => void; 228 | private deleteChildren: boolean = true; 229 | private hasMarkdownFiles: boolean; 230 | 231 | constructor(app: App, files: TFile[], onConfirm: (deleteChildren: boolean) => void) { 232 | super(app); 233 | this.files = files; 234 | this.onConfirm = onConfirm; 235 | this.hasMarkdownFiles = files.some(file => file.extension === 'md'); 236 | } 237 | 238 | onOpen() { 239 | const { contentEl } = this; 240 | contentEl.createEl("h2", { text: `Delete ${this.files.length} items` }); 241 | contentEl.createEl("p", { text: `Are you sure you want to delete these ${this.files.length} items?` }); 242 | 243 | // Only show the option to delete children if at least one selected file is a markdown file. 244 | if (this.hasMarkdownFiles) { 245 | new Setting(contentEl) 246 | .setName("Delete children as well?") 247 | .setDesc("If enabled, all notes and folders directly linked as children to these files will also be deleted.") 248 | .addToggle(toggle => toggle 249 | .setValue(this.deleteChildren) 250 | .onChange(value => this.deleteChildren = value)); 251 | } else { 252 | // If no markdown files are selected, this option doesn't make sense, so ensure deleteChildren is false. 253 | this.deleteChildren = false; 254 | } 255 | 256 | const list = contentEl.createEl("ul"); 257 | // Show up to 5 files, then "...and X more" 258 | const maxDisplay = 5; 259 | this.files.slice(0, maxDisplay).forEach(file => { 260 | list.createEl("li", { text: file.name }); 261 | }); 262 | if (this.files.length > maxDisplay) { 263 | list.createEl("li", { text: `...and ${this.files.length - maxDisplay} more` }); 264 | } 265 | 266 | const buttonContainer = contentEl.createDiv({ cls: "modal-button-container" }); 267 | 268 | const deleteButton = buttonContainer.createEl("button", { text: "Delete all", cls: "mod-warning" }); 269 | deleteButton.addEventListener("click", () => { 270 | this.onConfirm(this.deleteChildren); 271 | this.close(); 272 | }); 273 | 274 | const cancelButton = buttonContainer.createEl("button", { text: "Cancel" }); 275 | cancelButton.addEventListener("click", () => { 276 | this.close(); 277 | }); 278 | } 279 | 280 | onClose() { 281 | const { contentEl } = this; 282 | contentEl.empty(); 283 | } 284 | } 285 | 286 | export class FolderSelectionModal extends FuzzySuggestModal { 287 | private onChoose: (folder: TFolder) => void; 288 | 289 | constructor(app: App, onChoose: (folder: TFolder) => void) { 290 | super(app); 291 | this.onChoose = onChoose; 292 | this.setPlaceholder("Select a folder to convert..."); 293 | } 294 | 295 | getItems(): TFolder[] { 296 | const allFiles = this.app.vault.getAllLoadedFiles(); 297 | return allFiles.filter((f): f is TFolder => f instanceof TFolder); 298 | } 299 | 300 | getItemText(folder: TFolder): string { 301 | return folder.path; 302 | } 303 | 304 | onChooseItem(folder: TFolder, evt: MouseEvent | KeyboardEvent) { 305 | this.onChoose(folder); 306 | } 307 | } 308 | 309 | export class ConversionOptionsModal extends Modal { 310 | private folder: TFolder; 311 | private onConfirm: (options: ConversionOptions) => void; 312 | private options: ConversionOptions = { 313 | createParentNotes: true, 314 | existingRelationshipsStrategy: 'append', 315 | folderNoteStrategy: 'outside' 316 | }; 317 | 318 | constructor(app: App, folder: TFolder, onConfirm: (options: ConversionOptions) => void) { 319 | super(app); 320 | this.folder = folder; 321 | this.onConfirm = onConfirm; 322 | } 323 | 324 | onOpen() { 325 | const { contentEl } = this; 326 | contentEl.createEl("h2", { text: "Convert folder structure" }); 327 | contentEl.createEl("p", { text: `Convert folder "${this.folder.path}" to Abstract Folder format.` }); 328 | 329 | new Setting(contentEl) 330 | .setName("Create parent notes") 331 | .setDesc("Create a corresponding Markdown note for folders if one doesn't exist.") 332 | .addToggle(toggle => toggle 333 | .setValue(this.options.createParentNotes) 334 | .onChange(value => this.options.createParentNotes = value)); 335 | 336 | new Setting(contentEl) 337 | .setName("Existing relationships") 338 | .setDesc("How to handle files that already have parents defined.") 339 | .addDropdown(dropdown => dropdown 340 | .addOption('append', 'Append new parents') 341 | .addOption('replace', 'Replace existing parents') 342 | .setValue(this.options.existingRelationshipsStrategy) 343 | .onChange((value: 'append' | 'replace') => this.options.existingRelationshipsStrategy = value)); 344 | 345 | new Setting(contentEl) 346 | .setName("Folder note strategy") 347 | .setDesc("Where to look for the note representing the folder.") 348 | .addDropdown(dropdown => dropdown 349 | .addOption('outside', 'Outside (Sibling note, e.g. "Folder.md" next to "Folder/")') 350 | .addOption('inside', 'Inside (Index note, e.g. "Folder/Folder.md")') 351 | .setValue(this.options.folderNoteStrategy) 352 | .onChange((value: 'outside' | 'inside') => this.options.folderNoteStrategy = value)); 353 | 354 | new Setting(contentEl) 355 | .addButton(btn => btn 356 | .setButtonText("Convert") 357 | .setCta() 358 | .onClick(() => { 359 | this.onConfirm(this.options); 360 | this.close(); 361 | })); 362 | } 363 | 364 | onClose() { 365 | this.contentEl.empty(); 366 | } 367 | } 368 | 369 | export class ScopeSelectionModal extends Modal { 370 | private onConfirm: (scope: 'vault' | TFile) => void; 371 | 372 | constructor(app: App, onConfirm: (scope: 'vault' | TFile) => void) { 373 | super(app); 374 | this.onConfirm = onConfirm; 375 | } 376 | 377 | onOpen() { 378 | const { contentEl } = this; 379 | contentEl.createEl("h2", { text: "Select export scope" }); 380 | 381 | new Setting(contentEl) 382 | .setName("Entire vault") 383 | .setDesc("Export the entire abstract structure to folders.") 384 | .addButton(btn => btn 385 | .setButtonText("Export all") 386 | .onClick(() => { 387 | this.onConfirm('vault'); 388 | this.close(); 389 | })); 390 | 391 | new Setting(contentEl) 392 | .setName("Specific branch") 393 | .setDesc("Export starting from a specific parent note.") 394 | .addButton(btn => btn 395 | .setButtonText("Select note") 396 | .setCta() 397 | .onClick(() => { 398 | this.close(); 399 | new ParentPickerModal(this.app, (file) => { 400 | this.onConfirm(file); 401 | }).open(); 402 | })); 403 | } 404 | 405 | onClose() { 406 | this.contentEl.empty(); 407 | } 408 | } 409 | 410 | export class DestinationPickerModal extends FuzzySuggestModal { 411 | private onChoose: (folder: TFolder) => void; 412 | 413 | constructor(app: App, onChoose: (folder: TFolder) => void) { 414 | super(app); 415 | this.onChoose = onChoose; 416 | this.setPlaceholder("Select destination parent folder..."); 417 | } 418 | 419 | getItems(): TFolder[] { 420 | const allFiles = this.app.vault.getAllLoadedFiles(); 421 | return allFiles.filter((f): f is TFolder => f instanceof TFolder); 422 | } 423 | 424 | getItemText(folder: TFolder): string { 425 | return folder.path; 426 | } 427 | 428 | onChooseItem(folder: TFolder, evt: MouseEvent | KeyboardEvent) { 429 | this.onChoose(folder); 430 | } 431 | } 432 | 433 | export class NewFolderNameModal extends Modal { 434 | private parentFolder: TFolder; 435 | private onConfirm: (fullPath: string, placeIndexFileInside: boolean) => void; 436 | private folderName = "Abstract Export"; 437 | private placeIndexFileInside = true; 438 | 439 | constructor(app: App, parentFolder: TFolder, onConfirm: (fullPath: string, placeIndexFileInside: boolean) => void) { 440 | super(app); 441 | this.parentFolder = parentFolder; 442 | this.onConfirm = onConfirm; 443 | } 444 | 445 | onOpen() { 446 | const { contentEl } = this; 447 | contentEl.createEl("h2", { text: "Name export folder" }); 448 | contentEl.createEl("p", { text: `Creating new folder inside: ${this.parentFolder.path === '/' ? 'Root' : this.parentFolder.path}` }); 449 | 450 | new Setting(contentEl) 451 | .setName("Folder name") 452 | .setDesc("Enter a name for the folder that will contain the exported structure.") 453 | .addText(text => text 454 | .setValue(this.folderName) 455 | .onChange(value => this.folderName = value)); 456 | 457 | new Setting(contentEl) 458 | .setName("Create index files") 459 | .setDesc("ON: Create 'Folder/Folder.md' containing the note content. OFF: Create only the folder 'Folder/' (excludes note content if it has children).") 460 | .addToggle(toggle => toggle 461 | .setValue(this.placeIndexFileInside) 462 | .onChange(value => this.placeIndexFileInside = value)); 463 | 464 | new Setting(contentEl) 465 | .addButton(btn => btn 466 | .setButtonText("Confirm") 467 | .setCta() 468 | .onClick(() => { 469 | if (!this.folderName) { 470 | new Notice("Please enter a folder name."); 471 | return; 472 | } 473 | // Construct full path and normalize it 474 | const parentPath = this.parentFolder.path === '/' ? '' : this.parentFolder.path + '/'; 475 | const fullPath = normalizePath(parentPath + this.folderName); 476 | this.onConfirm(fullPath, this.placeIndexFileInside); 477 | this.close(); 478 | })); 479 | } 480 | 481 | onClose() { 482 | this.contentEl.empty(); 483 | } 484 | } 485 | 486 | export class SimulationModal extends Modal { 487 | private conflicts: FileConflict[]; 488 | private onConfirm: (conflicts: FileConflict[]) => void; 489 | 490 | constructor(app: App, conflicts: FileConflict[], onConfirm: (conflicts: FileConflict[]) => void) { 491 | super(app); 492 | this.conflicts = conflicts; 493 | this.onConfirm = onConfirm; 494 | } 495 | 496 | onOpen() { 497 | const { contentEl } = this; 498 | contentEl.createEl("h2", { text: "Review folder generation" }); 499 | 500 | if (this.conflicts.length === 0) { 501 | contentEl.createEl("p", { text: "No conflicts detected. Ready to generate." }); 502 | } else { 503 | contentEl.createEl("p", { text: `${this.conflicts.length} files have multiple parents. Please resolve conflicts.` }); 504 | 505 | const conflictContainer = contentEl.createDiv({ cls: "abstract-folder-conflict-container" }); 506 | 507 | this.conflicts.forEach(conflict => { 508 | const div = conflictContainer.createDiv({ cls: "abstract-folder-conflict-item" }); 509 | 510 | div.createEl("strong", { text: conflict.file.path }); 511 | 512 | new Setting(div) 513 | .setName("Resolution") 514 | .addDropdown(dropdown => { 515 | dropdown.addOption('duplicate', 'Duplicate in all locations'); 516 | conflict.targetPaths.forEach(path => { 517 | dropdown.addOption(path, `Move to: ${path}`); 518 | }); 519 | dropdown.setValue('duplicate'); 520 | dropdown.onChange(value => { 521 | conflict.resolution = value; 522 | }); 523 | }); 524 | }); 525 | } 526 | 527 | const buttonContainer = contentEl.createDiv({ cls: "modal-button-container" }); 528 | buttonContainer.createEl("button", { text: "Generate folders", cls: "mod-cta" }) 529 | .addEventListener("click", () => { 530 | this.onConfirm(this.conflicts); 531 | this.close(); 532 | }); 533 | } 534 | 535 | onClose() { 536 | this.contentEl.empty(); 537 | } 538 | } 539 | --------------------------------------------------------------------------------