├── .npmrc ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── docs-issue.md │ ├── config.yml │ └── bug_report.md └── workflows │ ├── deploy-mkdocs.yml │ ├── label-beta.yml │ ├── release.yaml │ ├── build-and-push.yml │ ├── beta-release.yaml │ └── codeql.yml ├── src ├── modals │ ├── CommandSettings.ts │ ├── AskForExtension.ts │ ├── FolderName.ts │ ├── AddSupportedFileType.ts │ ├── NewFolderName.ts │ ├── ExistingNote.ts │ └── DeleteConfirmation.ts ├── settings │ ├── modals │ │ ├── ChangeFnName.ts │ │ ├── RenameFns.ts │ │ ├── BackupWarning.ts │ │ └── CreateFnForEveryFolder.ts │ ├── FolderOverviewSettings.ts │ ├── ExcludedFoldersSettings.ts │ ├── PathSettings.ts │ └── FileExplorerSettings.ts ├── events │ ├── EventEmitter.ts │ ├── handleDelete.ts │ ├── TabManager.ts │ ├── handleClick.ts │ ├── handleCreate.ts │ ├── FrontMatterTitle.ts │ └── MutationObserver.ts ├── ExcludeFolders │ ├── WhitelistFolder.ts │ ├── WhitelistPattern.ts │ ├── ExcludeFolder.ts │ ├── ExcludePattern.ts │ ├── modals │ │ ├── WhitelistedFoldersSettings.ts │ │ ├── WhitelistPatternSettings.ts │ │ ├── PatternSettings.ts │ │ ├── WhitelistFolderSettings.ts │ │ └── ExcludeFolderSettings.ts │ └── functions │ │ ├── patternFunctions.ts │ │ ├── whitelistPatternFunctions.ts │ │ └── whitelistFolderFunctions.ts ├── functions │ ├── excalidraw.ts │ ├── utils.ts │ ├── ListComponent.ts │ └── styleFunctions.ts ├── suggesters │ ├── FileSuggester.ts │ ├── FolderSuggester.ts │ ├── TemplateSuggester.ts │ └── Suggest.ts ├── globals.d.ts └── template.ts ├── versions.json ├── .gitattributes ├── docs ├── docs │ ├── assets │ │ ├── 2wzCXFTpD2.gif │ │ ├── Untitled.png │ │ ├── 2KHZfsNVuJL8dIz6cTF5.png │ │ ├── FKhiQZLm4Juu4VdFTxPC.png │ │ ├── Ny4aCJtZStlFmlsN9zp2.png │ │ ├── TOtiFIYzUI8rwxjCLhyN.png │ │ ├── VyBTGhA5eJAVVFusZXIz.png │ │ ├── XjlSBfx6ouPrRpb2JmcL.png │ │ ├── mbZW1SXv8o3fKU4g8j3V.png │ │ ├── n5AGi3VCxF5JcNx2Wm5O.mp4 │ │ ├── u6ccTTzVbwzBivySFacZ.png │ │ ├── xUo6aJuIiABXaPl7eosX.png │ │ ├── xdS3VV7wjIRFNHADE0lx.mp4 │ │ ├── 2023_Obsidian_logo.svg.png │ │ ├── screenshots │ │ │ ├── P7yvNZmF5e.png │ │ │ ├── b4QOtkzJs0.png │ │ │ ├── Obsidian_3VEQ3kOT4S.png │ │ │ ├── Obsidian_7B8zdp5xDh.png │ │ │ ├── Obsidian_K3ph5zw0mq.png │ │ │ ├── Obsidian_LaoyRX8jr2.png │ │ │ └── Obsidian_nAqAIrlZFW.png │ │ └── videos │ │ │ ├── Obsidian_Nc9OYAHHT1.mp4 │ │ │ └── Obsidian_bUlTDSG9a9.mp4 │ ├── Troubleshooting.md │ ├── Features │ │ ├── File context menu commands.md │ │ ├── Folder context menu commands.md │ │ ├── Settings │ │ │ ├── Path.md │ │ │ ├── File explorer.md │ │ │ └── General.md │ │ ├── Command palette commands.md │ │ └── Exclude folders.md │ ├── Getting started.md │ ├── index.md │ └── Folder overview.md └── mkdocs.yml ├── .gitmodules ├── .editorconfig ├── .gitignore ├── manifest.json ├── manifest-beta.json ├── tsconfig.json ├── version-bump.mjs ├── esbuild.config.mjs ├── package.json ├── README.md ├── eslint.config.mts └── styles.css /.npmrc: -------------------------------------------------------------------------------- 1 | tag-version-prefix="" -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | ko_fi: paul305844 2 | -------------------------------------------------------------------------------- /src/modals/CommandSettings.ts: -------------------------------------------------------------------------------- 1 | export {}; 2 | -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "1.0.1": "0.15.0" 3 | } 4 | -------------------------------------------------------------------------------- /src/settings/modals/ChangeFnName.ts: -------------------------------------------------------------------------------- 1 | export {}; 2 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /docs/docs/assets/2wzCXFTpD2.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LostPaul/obsidian-folder-notes/HEAD/docs/docs/assets/2wzCXFTpD2.gif -------------------------------------------------------------------------------- /docs/docs/assets/Untitled.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LostPaul/obsidian-folder-notes/HEAD/docs/docs/assets/Untitled.png -------------------------------------------------------------------------------- /docs/docs/assets/2KHZfsNVuJL8dIz6cTF5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LostPaul/obsidian-folder-notes/HEAD/docs/docs/assets/2KHZfsNVuJL8dIz6cTF5.png -------------------------------------------------------------------------------- /docs/docs/assets/FKhiQZLm4Juu4VdFTxPC.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LostPaul/obsidian-folder-notes/HEAD/docs/docs/assets/FKhiQZLm4Juu4VdFTxPC.png -------------------------------------------------------------------------------- /docs/docs/assets/Ny4aCJtZStlFmlsN9zp2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LostPaul/obsidian-folder-notes/HEAD/docs/docs/assets/Ny4aCJtZStlFmlsN9zp2.png -------------------------------------------------------------------------------- /docs/docs/assets/TOtiFIYzUI8rwxjCLhyN.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LostPaul/obsidian-folder-notes/HEAD/docs/docs/assets/TOtiFIYzUI8rwxjCLhyN.png -------------------------------------------------------------------------------- /docs/docs/assets/VyBTGhA5eJAVVFusZXIz.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LostPaul/obsidian-folder-notes/HEAD/docs/docs/assets/VyBTGhA5eJAVVFusZXIz.png -------------------------------------------------------------------------------- /docs/docs/assets/XjlSBfx6ouPrRpb2JmcL.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LostPaul/obsidian-folder-notes/HEAD/docs/docs/assets/XjlSBfx6ouPrRpb2JmcL.png -------------------------------------------------------------------------------- /docs/docs/assets/mbZW1SXv8o3fKU4g8j3V.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LostPaul/obsidian-folder-notes/HEAD/docs/docs/assets/mbZW1SXv8o3fKU4g8j3V.png -------------------------------------------------------------------------------- /docs/docs/assets/n5AGi3VCxF5JcNx2Wm5O.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LostPaul/obsidian-folder-notes/HEAD/docs/docs/assets/n5AGi3VCxF5JcNx2Wm5O.mp4 -------------------------------------------------------------------------------- /docs/docs/assets/u6ccTTzVbwzBivySFacZ.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LostPaul/obsidian-folder-notes/HEAD/docs/docs/assets/u6ccTTzVbwzBivySFacZ.png -------------------------------------------------------------------------------- /docs/docs/assets/xUo6aJuIiABXaPl7eosX.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LostPaul/obsidian-folder-notes/HEAD/docs/docs/assets/xUo6aJuIiABXaPl7eosX.png -------------------------------------------------------------------------------- /docs/docs/assets/xdS3VV7wjIRFNHADE0lx.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LostPaul/obsidian-folder-notes/HEAD/docs/docs/assets/xdS3VV7wjIRFNHADE0lx.mp4 -------------------------------------------------------------------------------- /docs/docs/assets/2023_Obsidian_logo.svg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LostPaul/obsidian-folder-notes/HEAD/docs/docs/assets/2023_Obsidian_logo.svg.png -------------------------------------------------------------------------------- /docs/docs/assets/screenshots/P7yvNZmF5e.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LostPaul/obsidian-folder-notes/HEAD/docs/docs/assets/screenshots/P7yvNZmF5e.png -------------------------------------------------------------------------------- /docs/docs/assets/screenshots/b4QOtkzJs0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LostPaul/obsidian-folder-notes/HEAD/docs/docs/assets/screenshots/b4QOtkzJs0.png -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "src/obsidian-folder-overview"] 2 | path = src/obsidian-folder-overview 3 | url = https://github.com/LostPaul/obsidian-folder-overview 4 | -------------------------------------------------------------------------------- /docs/docs/assets/videos/Obsidian_Nc9OYAHHT1.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LostPaul/obsidian-folder-notes/HEAD/docs/docs/assets/videos/Obsidian_Nc9OYAHHT1.mp4 -------------------------------------------------------------------------------- /docs/docs/assets/videos/Obsidian_bUlTDSG9a9.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LostPaul/obsidian-folder-notes/HEAD/docs/docs/assets/videos/Obsidian_bUlTDSG9a9.mp4 -------------------------------------------------------------------------------- /docs/docs/assets/screenshots/Obsidian_3VEQ3kOT4S.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LostPaul/obsidian-folder-notes/HEAD/docs/docs/assets/screenshots/Obsidian_3VEQ3kOT4S.png -------------------------------------------------------------------------------- /docs/docs/assets/screenshots/Obsidian_7B8zdp5xDh.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LostPaul/obsidian-folder-notes/HEAD/docs/docs/assets/screenshots/Obsidian_7B8zdp5xDh.png -------------------------------------------------------------------------------- /docs/docs/assets/screenshots/Obsidian_K3ph5zw0mq.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LostPaul/obsidian-folder-notes/HEAD/docs/docs/assets/screenshots/Obsidian_K3ph5zw0mq.png -------------------------------------------------------------------------------- /docs/docs/assets/screenshots/Obsidian_LaoyRX8jr2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LostPaul/obsidian-folder-notes/HEAD/docs/docs/assets/screenshots/Obsidian_LaoyRX8jr2.png -------------------------------------------------------------------------------- /docs/docs/assets/screenshots/Obsidian_nAqAIrlZFW.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LostPaul/obsidian-folder-notes/HEAD/docs/docs/assets/screenshots/Obsidian_nAqAIrlZFW.png -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/docs-issue.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Docs issue 3 | about: Report issues related to the docs of the plugin 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | .obsidian 21 | 22 | # Exclude macOS Finder (System Explorer) View States 23 | .DS_Store 24 | 25 | venv -------------------------------------------------------------------------------- /docs/docs/Troubleshooting.md: -------------------------------------------------------------------------------- 1 | # Troubleshooting 2 | ## Folder with folder note still collapses 3 | Issue: You've "Disable folder collapsing" enabled, click on a folder name that has folder note linked to it and it still collapses. 4 | 5 | Possible cause & solution: You've "auto-reveal current file" enabled and need to disable it in order to make this feature work. 6 | 7 | ![Auto-reveal current file](../assets/screenshots/Obsidian_K3ph5zw0mq.png) 8 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "folder-notes", 3 | "name": "Folder notes", 4 | "version": "1.8.17", 5 | "minAppVersion": "0.15.0", 6 | "description": "Create notes within folders that can be accessed without collapsing the folder, similar to the functionality offered in Notion.", 7 | "author": "Lost Paul", 8 | "authorUrl": "https://github.com/LostPaul", 9 | "fundingUrl": "https://ko-fi.com/paul305844", 10 | "helpUrl": "https://lostpaul.github.io/obsidian-folder-notes/", 11 | "isDesktopOnly": false 12 | } 13 | -------------------------------------------------------------------------------- /manifest-beta.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "folder-notes", 3 | "name": "Folder notes beta", 4 | "version": "1.8.17-5-beta", 5 | "minAppVersion": "0.15.0", 6 | "description": "Create notes within folders that can be accessed without collapsing the folder, similar to the functionality offered in Notion.", 7 | "author": "Lost Paul", 8 | "authorUrl": "https://github.com/LostPaul", 9 | "fundingUrl": "https://ko-fi.com/paul305844", 10 | "helpUrl": "https://lostpaul.github.io/obsidian-folder-notes/", 11 | "isDesktopOnly": false 12 | } 13 | -------------------------------------------------------------------------------- /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 | "ES2021" 20 | ] 21 | }, 22 | "include": [ 23 | "**/*.ts" 24 | ], 25 | "types": [ 26 | "obsidian-typings" 27 | ] 28 | } -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | contact_links: 2 | - name: Request a new feature 3 | url: https://github.com/LostPaul/obsidian-folder-notes/discussions/ 4 | about: Instead of creating a new issue for feature requests, use the discussions feature instead 5 | - name: Anything regarding the folder overview feature 6 | url: https://github.com/LostPaul/obsidian-folder-overview 7 | about: The folder overview feature is a separate plugin, but it is bundled with this plugin. If you have any issues or feature requests regarding the folder overview feature, please create an issue in the folder overview repository. -------------------------------------------------------------------------------- /docs/docs/Features/File context menu commands.md: -------------------------------------------------------------------------------- 1 | # File context menu commands 2 | 3 | ## Create folder note 4 | This command creates a new folder and uses the file you clicked on as the folder note. The new folder will be in the same folder as the folder the file was in. 5 | 6 | ![Example type:video](../assets/videos/Obsidian_Nc9OYAHHT1.mp4) 7 | ## Turn into folder note for "{folderName}" 8 | This command will attach the file to the folder it's in. For example file "test" that is in the folder "test2" will be the new folder note for the folder "test2". 9 | 10 | ![Example type:video](../assets/videos/Obsidian_bUlTDSG9a9.mp4) -------------------------------------------------------------------------------- /docs/mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: Obsidian folder notes 2 | theme: 3 | name: material 4 | logo: assets/2023_Obsidian_logo.svg.png 5 | features: 6 | - navigation.expand 7 | palette: 8 | - media: "(prefers-color-scheme: light)" 9 | scheme: default 10 | toggle: 11 | icon: material/brightness-7 12 | name: Switch to dark mode 13 | - media: "(prefers-color-scheme: dark)" 14 | scheme: slate 15 | toggle: 16 | icon: material/brightness-4 17 | name: Switch to light mode 18 | 19 | markdown_extensions: 20 | - attr_list 21 | - md_in_html 22 | - def_list 23 | 24 | plugins: 25 | - mkdocs-video 26 | -------------------------------------------------------------------------------- /src/events/EventEmitter.ts: -------------------------------------------------------------------------------- 1 | export class CustomEventEmitter { 2 | private events: { [key: string]: Array<(data?: unknown) => void> } = {}; 3 | 4 | on(event: string, listener: (data?: unknown) => void): void { 5 | if (!this.events[event]) { 6 | this.events[event] = []; 7 | } 8 | this.events[event].push(listener); 9 | } 10 | 11 | off(event: string, listener: (data?: unknown) => void): void { 12 | if (!this.events[event]) return; 13 | 14 | this.events[event] = this.events[event].filter((l) => l !== listener); 15 | } 16 | 17 | emit(event: string, data?: unknown): void { 18 | if (!this.events[event]) return; 19 | 20 | this.events[event].forEach((listener) => listener(data)); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /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 | let 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 | let versions = JSON.parse(readFileSync("versions.json", "utf8")); 13 | versions[targetVersion] = minAppVersion; 14 | writeFileSync("versions.json", JSON.stringify(versions, null, "\t")); 15 | -------------------------------------------------------------------------------- /.github/workflows/deploy-mkdocs.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | on: 3 | push: 4 | branches: 5 | - master 6 | - main 7 | permissions: 8 | contents: write 9 | jobs: 10 | deploy: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | - uses: actions/setup-python@v4 15 | with: 16 | python-version: '3.x' 17 | 18 | - uses: actions/cache@v4 19 | with: 20 | path: .cache 21 | key: mkdocs-${{ runner.os }}-${{ github.sha }} 22 | restore-keys: | 23 | mkdocs-${{ runner.os }}- 24 | 25 | - run: pip install mkdocs-material mkdocs-video pillow cairosvg 26 | 27 | - working-directory: ./docs 28 | run: mkdocs gh-deploy --force 29 | 30 | -------------------------------------------------------------------------------- /src/ExcludeFolders/WhitelistFolder.ts: -------------------------------------------------------------------------------- 1 | import type FolderNotesPlugin from '../main'; 2 | export class WhitelistedFolder { 3 | type: string; 4 | id: string; 5 | path: string; 6 | string: string; 7 | subFolders: boolean; 8 | enableSync: boolean; 9 | enableAutoCreate: boolean; 10 | enableFolderNote: boolean; 11 | disableCollapsing: boolean; 12 | showInFolderOverview: boolean; 13 | hideInFileExplorer: boolean; 14 | position: number; 15 | hideInSettings: boolean; 16 | constructor(path: string, position: number, id: string | undefined, plugin: FolderNotesPlugin) { 17 | this.type = 'folder'; 18 | this.id = id || crypto.randomUUID(); 19 | this.path = path; 20 | this.subFolders = plugin.settings.excludeFolderDefaultSettings.subFolders; 21 | this.position = position; 22 | this.string = ''; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/ExcludeFolders/WhitelistPattern.ts: -------------------------------------------------------------------------------- 1 | import type FolderNotesPlugin from '../main'; 2 | export class WhitelistedPattern { 3 | type: string; 4 | id: string; 5 | string: string; 6 | path: string; 7 | position: number; 8 | subFolders: boolean; 9 | enableSync: boolean; 10 | enableAutoCreate: boolean; 11 | enableFolderNote: boolean; 12 | disableCollapsing: boolean; 13 | showInFolderOverview: boolean; 14 | hideInFileExplorer: boolean; 15 | hideInSettings: boolean; 16 | constructor( 17 | pattern: string, 18 | position: number, 19 | id: string | undefined, 20 | plugin: FolderNotesPlugin, 21 | ) { 22 | this.type = 'pattern'; 23 | this.id = id || crypto.randomUUID(); 24 | this.subFolders = plugin.settings.excludePatternDefaultSettings.subFolders; 25 | this.position = position; 26 | this.string = pattern; 27 | this.path = ''; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /docs/docs/Features/Folder context menu commands.md: -------------------------------------------------------------------------------- 1 | # Folder context menu commands 2 | 3 | 4 | ## Exclude folder from folder notes 5 | This will add the folder to the excluded folder list. [Click here to find more about excluded folders out.](Exclude%20folders.md) 6 | ## Create {type} folder note 7 | This command will create a folder note of the file type you selected and depending on your settings the folder note will be in the folder itself or in the parent folder of the folder. 8 | ## Delete folder note 9 | The folder note from the folder you clicked on gets deleted. (Only the file and note the folder itself) 10 | ## Open folder note 11 | Opens folder notes in the currently active tab or if you have the setting "open folder note in a new tab by default" enabled the note opens in a new tab. 12 | 13 | ## Copy Obsidian URL 14 | Copies the Obsidian URL of the file which is linked to the folder and it can be used in other programs to directly open the file. -------------------------------------------------------------------------------- /.github/workflows/label-beta.yml: -------------------------------------------------------------------------------- 1 | name: Label Beta Issues 2 | 3 | on: 4 | issues: 5 | types: [opened, edited] 6 | 7 | jobs: 8 | label-beta: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - name: Check for Beta Version checkbox 13 | uses: actions/github-script@v7 14 | with: 15 | script: | 16 | const betaChecked = context.payload.issue.body.includes("- [x] Beta version"); 17 | 18 | if (betaChecked) { 19 | const issue_number = context.payload.issue.number; 20 | const labels = context.payload.issue.labels.map(label => label.name); 21 | 22 | if (!labels.includes('beta')) { 23 | await github.rest.issues.addLabels({ 24 | owner: context.repo.owner, 25 | repo: context.repo.repo, 26 | issue_number: issue_number, 27 | labels: ['beta'] 28 | }); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Report issues with plugin 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **Steps to Reproduce** 14 | (It would be great if you could do it in the sandbox vault (https://help.obsidian.md/sandbox) or at least disable all other plugins except folder notes) 15 | Steps to reproduce the behavior: 16 | 1. Go to '...' 17 | 2. Click on '....' 18 | 3. Scroll down to '....' 19 | 4. See error 20 | 21 | **Expected behavior** 22 | A clear and concise description of what you expected to happen. 23 | 24 | **Screenshots** 25 | If applicable, add screenshots to help explain your problem. 26 | 27 | **Obsidian version** 28 | - Installer version: [e.g. v1.8.7] 29 | - Current version: [e.g. v1.8.10] 30 | 31 | **Plugin information** 32 | - Version: [e.g. v1.7.30] 33 | - [ ] Beta version 34 | 35 | **Additional context** 36 | Add any other context about the problem here. 37 | -------------------------------------------------------------------------------- /src/functions/excalidraw.ts: -------------------------------------------------------------------------------- 1 | import type { WorkspaceLeaf, App } from 'obsidian'; 2 | 3 | interface ExcalidrawPlugin { 4 | setExcalidrawView(leaf: WorkspaceLeaf): void; 5 | } 6 | 7 | export async function openExcalidrawView( 8 | app: App, 9 | leaf: WorkspaceLeaf, 10 | ): Promise { 11 | const { excalidraw, excalidrawEnabled } = await getExcalidrawPlugin(app); 12 | if (excalidrawEnabled && excalidraw) { 13 | excalidraw.setExcalidrawView(leaf); 14 | } 15 | } 16 | 17 | export async function getExcalidrawPlugin( 18 | app: App, 19 | ): Promise<{ excalidraw: ExcalidrawPlugin | null; excalidrawEnabled: boolean }> { 20 | const { plugins: pluginManager } = app as App & { 21 | plugins: { 22 | plugins: Record; 23 | enabledPlugins: Set; 24 | }; 25 | }; 26 | const excalidraw = ( 27 | pluginManager.plugins[ 28 | 'obsidian-excalidraw-plugin' 29 | ] as unknown as ExcalidrawPlugin | undefined 30 | ); 31 | const excalidrawEnabled = pluginManager.enabledPlugins.has('obsidian-excalidraw-plugin'); 32 | return { 33 | excalidraw: excalidraw ?? null, 34 | excalidrawEnabled, 35 | }; 36 | } 37 | -------------------------------------------------------------------------------- /docs/docs/Features/Settings/Path.md: -------------------------------------------------------------------------------- 1 | # Path settings 2 | The "path" is the file path above a note in the editor. 3 | 4 | ![Screenshot](../../assets/screenshots/Obsidian_LaoyRX8jr2.png) 5 | ### Open folder note through path 6 | Open a folder note by click on a folder name in the path when it has a folder note linked to it. 7 | 8 | ### Open sidebar when opening a folder note through path 9 | Disable/enable if the left sidebar should also open when you open a folder note through the path. 10 | 11 | ### Auto update folder name in the path ([front matter title plugin only](https://github.com/snezhig/obsidian-front-matter-title)) 12 | Automatically update the folder name in the path when the front matter title plugin is enabled and the title for a folder note is changed in the front matter. This will not change the file name, only the displayed name in the path. 13 | 14 | ## Style settings 15 | 16 | ### Underline folder in the path 17 | Underline every folder with a folder note in the path. 18 | 19 | ### Bold folders in the path 20 | Bold every folder with a folder note in the path. 21 | ### Cursive folders in the path 22 | Make every folder with a folder note in the path cursive. -------------------------------------------------------------------------------- /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 | esbuild.build({ 15 | banner: { 16 | js: banner, 17 | }, 18 | entryPoints: ['./src/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 | "f:/Obsidian/test/.obsidian/plugins/obsidian-folder-notes/node_modules/obsidian", 35 | ...builtins], 36 | format: 'cjs', 37 | watch: !prod, 38 | target: 'es2018', 39 | logLevel: "info", 40 | sourcemap: prod ? false : 'inline', 41 | treeShaking: true, 42 | outfile: 'main.js', 43 | conditions: ['types'], 44 | }).catch(() => process.exit(1)); 45 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "folder-notes", 3 | "version": "1.0.1", 4 | "description": "Adds Folder Notes to the default file tree.", 5 | "main": "main.js", 6 | "scripts": { 7 | "fn-dev": "node esbuild.config.mjs", 8 | "dev": "npm run fn-dev", 9 | "fn-build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", 10 | "fv-build": "tsc -noEmit -skipLibCheck && node ./src/obsidian-folder-overview/esbuild.config.mjs production", 11 | "version": "node version-bump.mjs && git add manifest.json versions.json", 12 | "folder-overview": "node ./src/obsidian-folder-overview/esbuild.config.mjs", 13 | "fv-dev": "npm run folder-overview" 14 | }, 15 | "keywords": [], 16 | "author": "Lost Paul", 17 | "license": "GPL-3.0-or-later", 18 | "devDependencies": { 19 | "@eslint/js": "^8.57.1", 20 | "@types/node": "^16.11.6", 21 | "@typescript-eslint/eslint-plugin": "^8.38.0", 22 | "@typescript-eslint/parser": "^8.38.0", 23 | "builtin-modules": "3.3.0", 24 | "esbuild": "0.14.47", 25 | "eslint": "^9.32.0", 26 | "front-matter-plugin-api-provider": "^0.1.4-alpha", 27 | "globals": "^16.3.0", 28 | "jiti": "^2.5.1", 29 | "obsidian": "latest", 30 | "obsidian-typings": "^2.2.0", 31 | "tslib": "2.4.0", 32 | "typescript": "^4.8.4", 33 | "typescript-eslint": "^8.38.0" 34 | }, 35 | "dependencies": { 36 | "@popperjs/core": "^2.11.6" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /docs/docs/Features/Command palette commands.md: -------------------------------------------------------------------------------- 1 | # Command palette commands 2 | 3 | ## Use this file as the folder note for its parent folder 4 | The note that's currently in focus will be the new folder note for the parent folder of the file. 5 | ## Make a folder with this file as its folder note 6 | This command creates a new folder and uses the current file that is in focus as the folder note. The new folder will be in the same folder as the folder the file was in. 7 | ## Create {type} folder note for this folder 8 | Create a new folder note for the folder of the current file that is in focus. The plugin will not use the file in focus but instead a completely new file. 9 | ## Delete this folder's linked note 10 | Delete the folder note (only the file itself) of the parent folder of the currently focused file. 11 | ## Open this folder's linked note 12 | Open the folder note of the parent folder of the currently focused file. 13 | ## Insert folder overview 14 | Insert a folder overview in a note. [Click here to know more about a folder overview](./Folder%20overview.md). 15 | ## Edit folder overview 16 | Edit the default settings or an already created folder overview in the right view. [Click here to know more about a folder overview](./Folder%20overview.md). 17 | ## Create folder note from selection 18 | Create a new file, a new folder and the file as the folder note. The text that has been selected will be the name of the folder note and the folder. -------------------------------------------------------------------------------- /src/suggesters/FileSuggester.ts: -------------------------------------------------------------------------------- 1 | import { type TAbstractFile, TFile } from 'obsidian'; 2 | import { TextInputSuggest } from './Suggest'; 3 | import type FolderNotesPlugin from '../main'; 4 | export enum FileSuggestMode { 5 | TemplateFiles, 6 | ScriptFiles, 7 | } 8 | 9 | export class FileSuggest extends TextInputSuggest { 10 | constructor( 11 | public inputEl: HTMLInputElement, 12 | plugin: FolderNotesPlugin, 13 | ) { 14 | super(inputEl, plugin); 15 | } 16 | 17 | get_error_msg(mode: FileSuggestMode): string { 18 | switch (mode) { 19 | case FileSuggestMode.TemplateFiles: 20 | return 'Templates folder doesn\'t exist'; 21 | case FileSuggestMode.ScriptFiles: 22 | return 'User Scripts folder doesn\'t exist'; 23 | } 24 | } 25 | 26 | getSuggestions(input_str: string): TFile[] { 27 | const files: TFile[] = []; 28 | const lower_input_str = input_str.toLowerCase(); 29 | 30 | this.plugin.app.vault.getFiles().forEach((file: TAbstractFile) => { 31 | if ( 32 | file instanceof TFile && 33 | file.path.toLowerCase().contains(lower_input_str) 34 | ) { 35 | files.push(file); 36 | } 37 | }); 38 | 39 | return files; 40 | } 41 | 42 | renderSuggestion(file: TFile, el: HTMLElement): void { 43 | el.setText(file.path); 44 | } 45 | 46 | selectSuggestion(file: TFile): void { 47 | this.inputEl.value = file.path; 48 | this.inputEl.trigger('input'); 49 | this.close(); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/ExcludeFolders/ExcludeFolder.ts: -------------------------------------------------------------------------------- 1 | import type FolderNotesPlugin from '../main'; 2 | export class ExcludedFolder { 3 | type: string; 4 | id: string; 5 | path: string; 6 | string: string; 7 | subFolders: boolean; 8 | disableSync: boolean; 9 | disableAutoCreate: boolean; 10 | disableFolderNote: boolean; 11 | enableCollapsing: boolean; 12 | position: number; 13 | excludeFromFolderOverview: boolean; 14 | hideInSettings: boolean; 15 | detached: boolean; 16 | detachedFilePath?: string; 17 | showFolderNote: boolean; 18 | constructor(path: string, position: number, id: string | undefined, plugin: FolderNotesPlugin) { 19 | this.type = 'folder'; 20 | this.id = id || crypto.randomUUID(); 21 | this.path = path; 22 | this.subFolders = plugin.settings.excludeFolderDefaultSettings.subFolders; 23 | this.disableSync = plugin.settings.excludeFolderDefaultSettings.disableSync; 24 | this.disableAutoCreate = plugin.settings.excludeFolderDefaultSettings.disableAutoCreate; 25 | this.disableFolderNote = plugin.settings.excludeFolderDefaultSettings.disableFolderNote; 26 | this.enableCollapsing = plugin.settings.excludeFolderDefaultSettings.enableCollapsing; 27 | this.position = position; 28 | // eslint-disable-next-line max-len 29 | this.excludeFromFolderOverview = plugin.settings.excludeFolderDefaultSettings.excludeFromFolderOverview; 30 | this.string = ''; 31 | this.hideInSettings = false; 32 | this.showFolderNote = plugin.settings.excludeFolderDefaultSettings.showFolderNote; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/modals/AskForExtension.ts: -------------------------------------------------------------------------------- 1 | import { FuzzySuggestModal, type TFile } from 'obsidian'; 2 | import type FolderNotesPlugin from 'src/main'; 3 | import { createFolderNote } from 'src/functions/folderNoteFunctions'; 4 | export class AskForExtensionModal extends FuzzySuggestModal { 5 | plugin: FolderNotesPlugin; 6 | extension: string; 7 | folderPath: string; 8 | openFile: boolean; 9 | useModal: boolean | undefined; 10 | existingNote: TFile | undefined; 11 | constructor( 12 | plugin: FolderNotesPlugin, 13 | folderPath: string, 14 | openFile: boolean, 15 | extension: string, 16 | useModal?: boolean, 17 | existingNote?: TFile, 18 | ) { 19 | super(plugin.app); 20 | this.plugin = plugin; 21 | this.folderPath = folderPath; 22 | this.extension = extension; 23 | this.openFile = openFile; 24 | this.useModal = useModal; 25 | this.existingNote = existingNote; 26 | plugin.askModalCurrentlyOpen = true; 27 | } 28 | 29 | getItems(): string[] { 30 | return this.plugin.settings.supportedFileTypes.filter( 31 | (item) => item.toLowerCase() !== '.ask', 32 | ); 33 | } 34 | 35 | getItemText(item: string): string { 36 | return item; 37 | } 38 | 39 | onChooseItem(item: string, _evt: MouseEvent | KeyboardEvent): void { 40 | this.plugin.askModalCurrentlyOpen = false; 41 | this.extension = '.' + item; 42 | createFolderNote( 43 | this.plugin, 44 | this.folderPath, 45 | this.openFile, 46 | this.extension, 47 | this.useModal, 48 | this.existingNote, 49 | ); 50 | this.close(); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/ExcludeFolders/ExcludePattern.ts: -------------------------------------------------------------------------------- 1 | import type FolderNotesPlugin from '../main'; 2 | export class ExcludePattern { 3 | type: string; 4 | id: string; 5 | string: string; 6 | path: string; 7 | position: number; 8 | subFolders: boolean; 9 | disableSync: boolean; 10 | disableAutoCreate: boolean; 11 | disableFolderNote: boolean; 12 | enableCollapsing: boolean; 13 | excludeFromFolderOverview: boolean; 14 | hideInSettings: boolean; 15 | detached: boolean; 16 | detachedFilePath?: string; 17 | showFolderNote: boolean; 18 | constructor( 19 | pattern: string, 20 | position: number, 21 | id: string | undefined, 22 | plugin: FolderNotesPlugin, 23 | ) { 24 | this.type = 'pattern'; 25 | this.id = id || crypto.randomUUID(); 26 | this.string = pattern; 27 | this.position = position; 28 | this.subFolders = plugin.settings.excludePatternDefaultSettings.subFolders; 29 | this.disableSync = plugin.settings.excludePatternDefaultSettings.disableSync; 30 | this.disableAutoCreate = plugin.settings.excludePatternDefaultSettings.disableAutoCreate; 31 | this.disableFolderNote = plugin.settings.excludePatternDefaultSettings.disableFolderNote; 32 | this.enableCollapsing = plugin.settings.excludePatternDefaultSettings.enableCollapsing; 33 | // eslint-disable-next-line max-len 34 | this.excludeFromFolderOverview = plugin.settings.excludePatternDefaultSettings.excludeFromFolderOverview; 35 | this.path = ''; 36 | this.hideInSettings = false; 37 | this.showFolderNote = plugin.settings.excludePatternDefaultSettings.showFolderNote; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/modals/FolderName.ts: -------------------------------------------------------------------------------- 1 | import { Modal, Setting, type App, type TFolder } from 'obsidian'; 2 | import type FolderNotesPlugin from '../main'; 3 | export default class FolderNameModal extends Modal { 4 | plugin: FolderNotesPlugin; 5 | app: App; 6 | folder: TFolder; 7 | constructor(app: App, plugin: FolderNotesPlugin, folder: TFolder) { 8 | super(app); 9 | this.plugin = plugin; 10 | this.app = app; 11 | this.folder = folder; 12 | } 13 | 14 | onOpen(): void { 15 | const { contentEl } = this; 16 | // close when user presses enter 17 | contentEl.addEventListener('keydown', (e) => { 18 | if (e.key === 'Enter') { 19 | this.close(); 20 | } 21 | }); 22 | contentEl.createEl('h2', { text: 'Folder name' }); 23 | new Setting(contentEl) 24 | .setName('Enter the name of the folder') 25 | .addText((text) => 26 | text 27 | .setValue(this.folder.name.replace(this.plugin.settings.folderNoteType, '')) 28 | .onChange(async (value) => { 29 | if (value.trim() !== '') { 30 | const parentPath = this.folder.path.slice( 31 | 0, 32 | this.folder.path.lastIndexOf('/') + 1, 33 | ); 34 | const newFolderPath = parentPath + value.trim(); 35 | if ( 36 | !this.app.vault.getAbstractFileByPath(newFolderPath) 37 | ) { 38 | this.plugin.app.fileManager.renameFile( 39 | this.folder, 40 | newFolderPath, 41 | ); 42 | } 43 | } 44 | }), 45 | ); 46 | } 47 | 48 | onClose(): void { 49 | const { contentEl } = this; 50 | contentEl.empty(); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/settings/modals/RenameFns.ts: -------------------------------------------------------------------------------- 1 | import BackupWarningModal from './BackupWarning'; 2 | import type FolderNotesPlugin from 'src/main'; 3 | import { Setting } from 'obsidian'; 4 | 5 | export default class RenameFolderNotesModal extends BackupWarningModal { 6 | constructor( 7 | plugin: FolderNotesPlugin, 8 | title: string, 9 | description: string, 10 | callback: (...args: unknown[]) => void, 11 | args: unknown[] = [], 12 | ) { 13 | super(plugin, title, description, callback, args); 14 | } 15 | 16 | insertCustomHtml(): void { 17 | const { contentEl } = this; 18 | new Setting(contentEl) 19 | .setName('Old Folder Note Name') 20 | // eslint-disable-next-line max-len 21 | .setDesc('Every folder note that matches this name will be renamed to the new folder note name.') 22 | .addText((text) => text 23 | .setPlaceholder('Enter the old folder note name') 24 | .setValue(this.plugin.settings.oldFolderNoteName || '') 25 | .onChange(async (value) => { 26 | this.plugin.settings.oldFolderNoteName = value; 27 | }), 28 | ); 29 | 30 | new Setting(contentEl) 31 | .setName('New Folder Note Name') 32 | // eslint-disable-next-line max-len 33 | .setDesc('Every folder note that matches the old folder note name will be renamed to this name.') 34 | .addText((text) => text 35 | .setPlaceholder('Enter the new folder note name') 36 | .setValue(this.plugin.settings.folderNoteName || '') 37 | .onChange(async (value) => { 38 | this.plugin.settings.folderNoteName = value; 39 | this.plugin.settingsTab.display(); 40 | }), 41 | ); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Obsidian folder notes 2 | 3 | Folder notes is a plugin for the note taking app [Obsidian](https://obsidian.md/) that lets you attach notes to folders so that you can click on the name of a folder to open the note like in the app [Notion](https://www.notion.so/). 4 | This plugin has some unique features that separate it from similar "Folder note" plugins like opening folder notes through the path, creating folder notes for every existing folder, templater/template support and more. 5 | 6 | Support the development of the plugin 7 | 8 | Buy Me a Coffee at ko-fi.com 9 | ## Documentation & download link 10 | The plugin can be downloaded by clicking on https://obsidian.md/plugins?id=folder-notes and then on install. If you need help with the plugin or want to know what the features are that the plugin has then you can find the documentation at https://lostpaul.github.io/obsidian-folder-notes/. 11 | 12 | ## How to install the beta version 13 | 14 | The easiest option is to install the [BRAT plugin](https://obsidian.md/plugins?id=obsidian42-brat) and then to follow the following guide: https://tfthacker.com/brat-quick-guide & use this link https://github.com/LostPaul/obsidian-folder-notes to install the beta version. 15 | 16 | Join the Discord server to chat about the beta and to also get the beta user role. 17 | 18 | ## Discord server 19 | [For regular updates on Folder Notes and my other plugins, join the Discord server to get notified and participate in discussions.](https://discord.gg/4UQEDfQmuH) 20 | -------------------------------------------------------------------------------- /src/settings/modals/BackupWarning.ts: -------------------------------------------------------------------------------- 1 | import { Modal, ButtonComponent } from 'obsidian'; 2 | import type FolderNotesPlugin from 'src/main'; 3 | 4 | export default class BackupWarningModal extends Modal { 5 | plugin: FolderNotesPlugin; 6 | title: string; 7 | desc: string; 8 | callback: (...args: unknown[]) => void; 9 | args: unknown[]; 10 | 11 | constructor( 12 | plugin: FolderNotesPlugin, 13 | title: string, 14 | description: string, 15 | callback: (...args: unknown[]) => void, 16 | args: unknown[] = [], 17 | ) { 18 | super(plugin.app); 19 | this.plugin = plugin; 20 | this.title = title; 21 | this.callback = callback; 22 | this.args = args; 23 | this.desc = description; 24 | } 25 | 26 | onOpen(): void { 27 | this.modalEl.addClass('fn-backup-warning-modal'); 28 | const { contentEl } = this; 29 | 30 | contentEl.createEl('h2', { text: this.title }); 31 | 32 | contentEl.createEl('p', { text: this.desc }); 33 | 34 | // eslint-disable-next-line max-len 35 | contentEl.createEl('p', { text: 'Make sure to backup your vault before using this feature.' }).style.color = '#fb464c'; 36 | 37 | const buttonContainer = contentEl.createDiv({ cls: 'fn-modal-button-container' }); 38 | const confirmButton = new ButtonComponent(buttonContainer); 39 | confirmButton.setButtonText('Confirm') 40 | .setCta() 41 | .onClick(() => { 42 | this.callback(...this.args); 43 | this.close(); 44 | }); 45 | 46 | const cancelButton = new ButtonComponent(buttonContainer); 47 | cancelButton.setButtonText('Cancel') 48 | .onClick(() => { 49 | this.close(); 50 | }); 51 | } 52 | 53 | onClose(): void { 54 | const { contentEl } = this; 55 | contentEl.empty(); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/events/handleDelete.ts: -------------------------------------------------------------------------------- 1 | import { TFolder, TFile, type TAbstractFile } from 'obsidian'; 2 | import type FolderNotesPlugin from 'src/main'; 3 | import { getFolderNote, getFolder, deleteFolderNote } from 'src/functions/folderNoteFunctions'; 4 | import { 5 | removeCSSClassFromFileExplorerEL, 6 | addCSSClassToFileExplorerEl, 7 | hideFolderNoteInFileExplorer, 8 | } from 'src/functions/styleFunctions'; 9 | import { getFolderPathFromString } from 'src/functions/utils'; 10 | 11 | export function handleDelete(file: TAbstractFile, plugin: FolderNotesPlugin): void { 12 | const folder = plugin.app.vault.getAbstractFileByPath(getFolderPathFromString(file.path)); 13 | if (folder instanceof TFolder) { 14 | if (plugin.isEmptyFolderNoteFolder(folder) && getFolderNote(plugin, folder.path)) { 15 | addCSSClassToFileExplorerEl(folder.path, 'only-has-folder-note', true, plugin); 16 | } else { 17 | removeCSSClassFromFileExplorerEL(folder.path, 'only-has-folder-note', true, plugin); 18 | } 19 | } 20 | 21 | if (file instanceof TFile) { 22 | const folderNoteFolder = getFolder(plugin, file); 23 | if (!folderNoteFolder) { return; } 24 | const folderNote = getFolderNote(plugin, folderNoteFolder.path); 25 | if (folderNote) { return; } 26 | removeCSSClassFromFileExplorerEL(folderNoteFolder.path, 'has-folder-note', false, plugin); 27 | removeCSSClassFromFileExplorerEL( 28 | folderNoteFolder.path, 'only-has-folder-note', true, plugin, 29 | ); 30 | hideFolderNoteInFileExplorer(folderNoteFolder.path, plugin); 31 | } 32 | 33 | if (!(file instanceof TFolder)) { return; } 34 | const folderNote = getFolderNote(plugin, file.path); 35 | if (!folderNote) { return; } 36 | removeCSSClassFromFileExplorerEL(folderNote.path, 'is-folder-note', false, plugin); 37 | if (!plugin.settings.syncDelete) { return; } 38 | deleteFolderNote(plugin, folderNote, false); 39 | } 40 | -------------------------------------------------------------------------------- /src/modals/AddSupportedFileType.ts: -------------------------------------------------------------------------------- 1 | import { Modal, Setting, Notice, type App, type SettingTab } from 'obsidian'; 2 | import type FolderNotesPlugin from '../main'; 3 | import type { ListComponent } from 'src/functions/ListComponent'; 4 | 5 | export default class AddSupportedFileModal extends Modal { 6 | plugin: FolderNotesPlugin; 7 | app: App; 8 | name: string; 9 | list: ListComponent; 10 | settingsTab: SettingTab; 11 | constructor(app: App, plugin: FolderNotesPlugin, settingsTab: SettingTab, list: ListComponent) { 12 | super(app); 13 | this.plugin = plugin; 14 | this.app = app; 15 | this.name = ''; 16 | this.list = list; 17 | this.settingsTab = settingsTab; 18 | } 19 | 20 | onOpen(): void { 21 | const { contentEl } = this; 22 | // close when user presses enter 23 | contentEl.addEventListener('keydown', (e) => { 24 | if (e.key === 'Enter') { 25 | this.close(); 26 | } 27 | }); 28 | contentEl.createEl('h2', { text: 'Extension name' }); 29 | new Setting(contentEl) 30 | .setName('Enter the name of the extension (only the short form, e.g. "md")') 31 | .addText((text) => 32 | text 33 | .setValue('') 34 | .onChange(async (value) => { 35 | if (value.trim() !== '') { 36 | this.name = value.trim(); 37 | } 38 | }), 39 | ); 40 | } 41 | async onClose(): Promise { 42 | if (this.name.toLocaleLowerCase() === 'markdown') { 43 | this.name = 'md'; 44 | } 45 | const { contentEl } = this; 46 | if (this.name === '') { 47 | contentEl.empty(); 48 | this.settingsTab.display(); 49 | } else if (this.plugin.settings.supportedFileTypes.includes(this.name.toLowerCase())) { 50 | new Notice('This extension is already supported'); 51 | return; 52 | } else { 53 | await this.list.addValue(this.name.toLowerCase()); 54 | this.settingsTab.display(); 55 | this.plugin.saveSettings(); 56 | contentEl.empty(); 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /docs/docs/Features/Settings/File explorer.md: -------------------------------------------------------------------------------- 1 | # File explorer settings 2 | ### Hide folder note 3 | Hide files in the file explorer which are linked to folders 4 | ### Disable click-to-open folder note on mobile 5 | Disable the option to open folder notes by tapping on the folder name of a folder with a folder note in the file explorer. With that option enabled it's only possible to open folder notes with the command palette oder the context menu. 6 | 7 | ### Open folder notes by only clicking directly on the folder name 8 | With this option enabled you can only open a folder note by clicking when doing so exactly on the folder name and not on the whitespace around it. When you also have "Disable folder collapsing" enabled folders also won't collapse when you click on a folder name. 9 | 10 | ### Disable folder collapsing 11 | Stop folders with folder notes from collapsing when click directly on the folder name or on the whitespace around it. Except when you click on the collapse icon you can still collapse folders. 12 | 13 | ### Use submenus 14 | When this option is enabled and you use the context menu for folder/files at first you can only see "Folder Note Commands" in the menu and you have to hover over it to see all options. 15 | 16 | ## Style settings 17 | 18 | ### Highlight folder in the file explorer 19 | Normally only the file gets highlighted in the explorer but when you enable this option the folder of a folder note gets also highlighted. 20 | 21 | ### Hide collapse icon 22 | Hide the collapse icon next to a folder name for a folder with a folder note or also for every folder with no files. 23 | 24 | ### Underline the name of folder note 25 | Underline the names of folders with folder notes in the file explorer. 26 | 27 | ### Bold the name of folder notes 28 | Bold folders in the file explorer that have a folder note. 29 | 30 | ### Cursive the name of folder notes 31 | Make folder with folder notes in the file explorer cursive. 32 | 33 | -------------------------------------------------------------------------------- /src/events/TabManager.ts: -------------------------------------------------------------------------------- 1 | import type FolderNotesPlugin from 'src/main'; 2 | import { EditableFileView, TFolder, type App } from 'obsidian'; 3 | import { getFolder, getFolderNote } from 'src/functions/folderNoteFunctions'; 4 | export class TabManager { 5 | plugin: FolderNotesPlugin; 6 | app: App; 7 | constructor(plugin: FolderNotesPlugin) { 8 | this.plugin = plugin; 9 | this.app = plugin.app; 10 | } 11 | 12 | resetTabs(): void { 13 | if (!this.isEnabled()) return; 14 | 15 | this.app.workspace.iterateAllLeaves((leaf) => { 16 | if (!(leaf.view instanceof EditableFileView)) return; 17 | const file = leaf.view?.file; 18 | if (!file) return; 19 | leaf.tabHeaderInnerTitleEl.setText(file.basename); 20 | }); 21 | } 22 | 23 | updateTabs(): void { 24 | if (!this.isEnabled()) return; 25 | this.app.workspace.iterateAllLeaves((leaf) => { 26 | if (!(leaf.view instanceof EditableFileView)) return; 27 | const file = leaf.view?.file; 28 | if (!file) return; 29 | const folder = getFolder(this.plugin, file); 30 | if (!folder) return; 31 | leaf.tabHeaderInnerTitleEl.setText(folder.name); 32 | }); 33 | } 34 | 35 | updateTab(folderPath: string): void { 36 | if (!this.isEnabled()) return; 37 | 38 | const folder = this.app.vault.getAbstractFileByPath(folderPath); 39 | if (!(folder instanceof TFolder)) return; 40 | 41 | const folderNote = getFolderNote(this.plugin, folder.path); 42 | if (!folderNote) return; 43 | 44 | this.app.workspace.iterateAllLeaves((leaf) => { 45 | if (!(leaf.view instanceof EditableFileView)) return; 46 | const file = leaf.view?.file; 47 | if (!file) return; 48 | if (file.path === folderNote.path) { 49 | leaf.tabHeaderInnerTitleEl.setText(folder.name); 50 | } 51 | }); 52 | } 53 | 54 | isEnabled(): boolean { 55 | if (this.plugin.settings.folderNoteName === '{{folder_name}}') return false; 56 | return this.plugin.settings.tabManagerEnabled; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/settings/FolderOverviewSettings.ts: -------------------------------------------------------------------------------- 1 | import { Setting } from 'obsidian'; 2 | import type { SettingsTab } from './SettingsTab'; 3 | import { createOverviewSettings } from 'src/obsidian-folder-overview/src/settings'; 4 | 5 | export async function renderFolderOverview(settingsTab: SettingsTab): Promise { 6 | const { plugin } = settingsTab; 7 | const defaultOverviewSettings = plugin.settings.defaultOverview; 8 | const containerEl = settingsTab.settingsPage; 9 | 10 | containerEl.createEl('h3', { text: 'Global settings' }); 11 | new Setting(containerEl) 12 | .setName('Auto-update links without opening the overview') 13 | // eslint-disable-next-line max-len 14 | .setDesc('If enabled, the links that appear in the graph view will be updated even when you don\'t have the overview open somewhere.') 15 | .addToggle((toggle) => 16 | toggle 17 | .setValue(plugin.settings.fvGlobalSettings.autoUpdateLinks) 18 | .onChange(async (value) => { 19 | plugin.settings.fvGlobalSettings.autoUpdateLinks = value; 20 | await plugin.saveSettings(); 21 | if (value) { 22 | plugin.fvIndexDB.init(true); 23 | } else { 24 | plugin.fvIndexDB.active = false; 25 | } 26 | }), 27 | ); 28 | 29 | containerEl.createEl('h3', { text: 'Overviews default settings' }); 30 | const pEl = containerEl.createEl('p', { 31 | text: 'Edit the default settings for new folder overviews, ', 32 | cls: 'setting-item-description', 33 | }); 34 | const span = createSpan({ text: "this won't apply to already existing overviews.", cls: '' }); 35 | const accentColor = (settingsTab.app.vault.getConfig('accentColor') as string) || '#7d5bed'; 36 | span.setAttr('style', `color: ${accentColor};`); 37 | pEl.appendChild(span); 38 | 39 | createOverviewSettings( 40 | containerEl, 41 | defaultOverviewSettings, 42 | plugin, 43 | plugin.settings.defaultOverview, 44 | settingsTab.display, 45 | undefined, 46 | undefined, 47 | undefined, 48 | settingsTab, 49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /src/suggesters/FolderSuggester.ts: -------------------------------------------------------------------------------- 1 | // Credits go to Liam's Periodic Notes Plugin: https://github.com/liamcain/obsidian-periodic-notes and https://github.com/SilentVoid13/Templater 2 | 3 | import { TFolder, type TAbstractFile } from 'obsidian'; 4 | import { TextInputSuggest } from './Suggest'; 5 | import type FolderNotesPlugin from '../main'; 6 | export enum FileSuggestMode { 7 | TemplateFiles, 8 | ScriptFiles, 9 | } 10 | 11 | export class FolderSuggest extends TextInputSuggest { 12 | constructor( 13 | public inputEl: HTMLInputElement, 14 | plugin: FolderNotesPlugin, 15 | private whitelistSuggester: boolean, 16 | public folder?: TFolder, 17 | ) { 18 | super(inputEl, plugin); 19 | } 20 | 21 | 22 | get_error_msg(mode: FileSuggestMode): string { 23 | switch (mode) { 24 | case FileSuggestMode.TemplateFiles: 25 | return 'Templates folder doesn\'t exist'; 26 | case FileSuggestMode.ScriptFiles: 27 | return 'User Scripts folder doesn\'t exist'; 28 | } 29 | } 30 | 31 | getSuggestions(input_str: string): TFolder[] { 32 | const folders: TFolder[] = []; 33 | const lower_input_str = input_str.toLowerCase(); 34 | let files: TAbstractFile[] = []; 35 | if (this.folder) { 36 | files = this.folder.children; 37 | } else { 38 | const MAX_FILE_SUGGESTIONS = 100; 39 | files = this.plugin.app.vault.getAllLoadedFiles().slice(0, MAX_FILE_SUGGESTIONS); 40 | } 41 | files.forEach((folder: TAbstractFile) => { 42 | if ( 43 | folder instanceof TFolder && 44 | folder.path.toLowerCase().contains(lower_input_str) && 45 | ( 46 | !this.plugin.settings.excludeFolders.find( 47 | (f) => f.path === folder.path, 48 | ) || this.whitelistSuggester 49 | ) 50 | ) { 51 | folders.push(folder); 52 | } 53 | }); 54 | 55 | return folders; 56 | } 57 | 58 | renderSuggestion(folder: TFolder, el: HTMLElement): void { 59 | el.setText(folder.path); 60 | } 61 | 62 | selectSuggestion(folder: TFolder): void { 63 | this.inputEl.value = folder.path; 64 | this.inputEl.trigger('input'); 65 | this.close(); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/globals.d.ts: -------------------------------------------------------------------------------- 1 | import type { TAbstractFile, TFile, TFolder, View, WorkspaceLeaf } from 'obsidian'; 2 | 3 | declare module 'obsidian' { 4 | interface Setting { 5 | createList: (list: ListComponent | ((list: ListComponent) => void)) => ListComponent; 6 | } 7 | interface TFolder { 8 | newName: string | null; 9 | collapsed: boolean; 10 | } 11 | interface MenuItem { 12 | dom: HTMLElement; 13 | } 14 | interface FileManager { 15 | promptForFolderDeletion: (folder: TFolder) => void; 16 | } 17 | 18 | class ListComponent { 19 | containerEl: HTMLElement; 20 | emptyStateEl: HTMLElement; 21 | listEl: HTMLElement; 22 | values: string[]; 23 | constructor(containerEl: HTMLElement); 24 | } 25 | } 26 | 27 | interface FileExplorerWorkspaceLeaf extends WorkspaceLeaf { 28 | containerEl: HTMLElement; 29 | view: FileExplorerView; 30 | } 31 | 32 | interface FileExplorerViewFileItem extends TAbstractFile { 33 | titleEl: HTMLElement 34 | selfEl: HTMLElement 35 | } 36 | 37 | type FileOrFolderItem = FolderItem | FileItem; 38 | 39 | interface FileItem { 40 | el: HTMLDivElement; 41 | file: TFile; 42 | fileExplorer: FileExplorerView; 43 | selfEl: HTMLDivElement; 44 | innerEl: HTMLDivElement; 45 | } 46 | 47 | interface FolderItem { 48 | el: HTMLDivElement; 49 | fileExplorer: FileExplorerView; 50 | selfEl: HTMLDivElement; 51 | innerEl: HTMLDivElement; 52 | file: TFolder; 53 | children: FileOrFolderItem[]; 54 | childrenEl: HTMLDivElement; 55 | collapseIndicatorEl: HTMLDivElement; 56 | collapsed: boolean; 57 | setCollapsed: (collapsed: boolean) => void; 58 | pusherEl: HTMLDivElement; 59 | } 60 | 61 | interface TreeItem { 62 | focusedItem: FileOrFolderItem; 63 | setFocusedItem: (item: FileOrFolderItem, moveViewport: boolean) => void; 64 | selectedDoms: Set; 65 | } 66 | interface FileExplorerView extends View { 67 | fileItems: { [path: string]: FileExplorerViewFileItem }; 68 | activeDom: FileOrFolderItem; 69 | tree: TreeItem; 70 | } 71 | 72 | declare global { 73 | interface Window { 74 | i18next: { 75 | t: (key: string, options?: { [key: string]: string }) => string; 76 | }; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/ExcludeFolders/modals/WhitelistedFoldersSettings.ts: -------------------------------------------------------------------------------- 1 | import { Modal, Setting, type App } from 'obsidian'; 2 | import type { SettingsTab } from 'src/settings/SettingsTab'; 3 | import type FolderNotesPlugin from '../../main'; 4 | import { WhitelistedFolder } from '../WhitelistFolder'; 5 | import { 6 | addWhitelistFolderListItem, 7 | addWhitelistedFolder, 8 | } from '../functions/whitelistFolderFunctions'; 9 | import { addWhitelistedPatternListItem } from '../functions/whitelistPatternFunctions'; 10 | 11 | export default class WhitelistedFoldersSettings extends Modal { 12 | plugin: FolderNotesPlugin; 13 | app: App; 14 | settingsTab: SettingsTab; 15 | constructor(settingsTab: SettingsTab) { 16 | super(settingsTab.app); 17 | this.plugin = settingsTab.plugin; 18 | this.settingsTab = settingsTab; 19 | this.app = settingsTab.app; 20 | } 21 | 22 | onOpen(): void { 23 | 24 | const { contentEl } = this; 25 | contentEl.createEl('h2', { text: 'Manage whitelisted folders' }); 26 | 27 | new Setting(contentEl) 28 | .setName('Add whitelisted folder') 29 | .setClass('add-exclude-folder-item') 30 | .addButton((cb) => { 31 | cb.setIcon('plus'); 32 | cb.setClass('add-exclude-folder'); 33 | cb.setTooltip('Add whitelisted folder'); 34 | cb.onClick(() => { 35 | const whitelistedFolder = new WhitelistedFolder( 36 | '', this.plugin.settings.whitelistFolders.length, 37 | undefined, this.plugin, 38 | ); 39 | addWhitelistFolderListItem( 40 | this.plugin.settingsTab, contentEl, whitelistedFolder, 41 | ); 42 | addWhitelistedFolder(this.plugin, whitelistedFolder); 43 | this.settingsTab.display(); 44 | }); 45 | }); 46 | 47 | this.plugin.settings.whitelistFolders 48 | .sort((a, b) => a.position - b.position) 49 | .forEach((whitelistedFolder) => { 50 | if (whitelistedFolder.string?.trim() !== '' && 51 | whitelistedFolder.path?.trim() === '') { 52 | addWhitelistedPatternListItem(this.settingsTab, contentEl, whitelistedFolder); 53 | } else { 54 | addWhitelistFolderListItem(this.settingsTab, contentEl, whitelistedFolder); 55 | } 56 | }); 57 | } 58 | 59 | onClose(): void { 60 | const { contentEl } = this; 61 | contentEl.empty(); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | # https://github.com/SilentVoid13/Templater/blob/master/.github/workflows/release.yml 2 | name: Plugin release 3 | 4 | on: 5 | push: 6 | tags: 7 | - "[0-9]+.[0-9]+.[0-9]+" 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - name: Checkout code 15 | uses: actions/checkout@v3 16 | with: 17 | submodules: recursive 18 | fetch-depth: 0 19 | 20 | - name: Fetch all branches 21 | run: git fetch --all 22 | 23 | - name: Extract branch name 24 | run: | 25 | BRANCH_NAME=$(git name-rev --name-only $GITHUB_SHA | sed 's|^remotes/origin/||' | sed 's|^origin/||') 26 | echo "BRANCH_NAME=$BRANCH_NAME" >> $GITHUB_ENV 27 | 28 | # Checkout the main branch to update manifest.json 29 | - name: Checkout main branch to update manifest 30 | run: | 31 | git checkout main 32 | git pull origin main 33 | 34 | - name: Update manifest.json in main branch 35 | run: | 36 | VERSION=${GITHUB_REF#refs/tags/} 37 | jq --arg version "$VERSION" '.version = $version' manifest.json > manifest-temp.json 38 | mv manifest-temp.json manifest.json 39 | 40 | - name: Commit and push manifest.json changes in main 41 | run: | 42 | git config --local user.email "github-actions[bot]@users.noreply.github.com" 43 | git config --local user.name "github-actions[bot]" 44 | git add manifest.json 45 | git commit -m "Update manifest.json to version $VERSION" || echo "No changes to commit" 46 | git push origin main 47 | 48 | 49 | - name: Ensure HEAD is at updated main commit 50 | run: | 51 | git fetch origin 52 | git checkout main 53 | git reset --hard origin/main 54 | 55 | - name: npm build 56 | run: | 57 | npm install 58 | npm run fn-build --if-present 59 | 60 | - name: Create Plugin release 61 | uses: ncipollo/release-action@v1.12.0 62 | with: 63 | artifacts: "main.js,manifest.json,styles.css" 64 | token: ${{ secrets.GITHUB_TOKEN }} 65 | tag: ${{ github.ref_name }} 66 | -------------------------------------------------------------------------------- /.github/workflows/build-and-push.yml: -------------------------------------------------------------------------------- 1 | name: Build and Push to Folder Overview 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | tag: 7 | description: "Tag to build" 8 | required: true 9 | 10 | jobs: 11 | build-and-push: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout folder-notes 15 | uses: actions/checkout@v3 16 | with: 17 | submodules: false 18 | fetch-depth: 0 19 | 20 | - name: Update submodules manually 21 | run: | 22 | git submodule sync 23 | git submodule update --init --recursive --remote 24 | 25 | - name: Install dependencies 26 | run: npm install 27 | 28 | - name: Build folder-overview 29 | run: npm run fv-build 30 | 31 | - name: Verify build output 32 | run: | 33 | ls -la src/obsidian-folder-overview/ 34 | 35 | - name: Checkout folder-overview 36 | uses: actions/checkout@v3 37 | with: 38 | repository: LostPaul/obsidian-folder-overview 39 | token: ${{ secrets.PAT }} 40 | ref: main 41 | path: folder-overview 42 | 43 | - name: Copy built files to folder-overview 44 | run: | 45 | cp src/obsidian-folder-overview/main.js \ 46 | src/obsidian-folder-overview/styles.css folder-overview/ 47 | 48 | - name: Commit and push built files 49 | run: | 50 | cd folder-overview 51 | git config --local user.email "github-actions[bot]@users.noreply.github.com" 52 | git config --local user.name "github-actions[bot]" 53 | git add -f main.js styles.css 54 | if git diff --cached --quiet; then 55 | echo "No changes to commit." 56 | else 57 | git commit -m "Add built files for release" 58 | git push origin main 59 | fi 60 | 61 | - name: Trigger release on folder-overview 62 | run: | 63 | curl -X POST -H "Authorization: token ${{ secrets.PAT }}" \ 64 | -H "Accept: application/vnd.github.v3+json" \ 65 | https://api.github.com/repos/LostPaul/obsidian-folder-overview/actions/workflows/release.yml/dispatches \ 66 | -d '{"ref": "main", "inputs": {"tag": "${{ github.event.inputs.tag }}"}}' 67 | -------------------------------------------------------------------------------- /src/modals/NewFolderName.ts: -------------------------------------------------------------------------------- 1 | import { Modal, type App, type TFolder } from 'obsidian'; 2 | import type FolderNotesPlugin from '../main'; 3 | export default class NewFolderNameModal extends Modal { 4 | plugin: FolderNotesPlugin; 5 | app: App; 6 | folder: TFolder; 7 | constructor(app: App, plugin: FolderNotesPlugin, folder: TFolder) { 8 | super(app); 9 | this.plugin = plugin; 10 | this.app = app; 11 | this.folder = folder; 12 | } 13 | 14 | onOpen(): void { 15 | const { contentEl } = this; 16 | 17 | contentEl.addEventListener('keydown', (e) => { 18 | if (e.key === 'Enter') { 19 | this.saveFolderName(); 20 | this.close(); 21 | } 22 | }); 23 | 24 | this.modalEl.classList.add('mod-file-rename'); 25 | const modalTitle = this.modalEl.querySelector('div.modal-title'); 26 | if (modalTitle) { 27 | modalTitle.textContent = 'Folder title'; 28 | } 29 | 30 | const textarea = contentEl.createEl('textarea', { 31 | text: this.folder.name.replace(this.plugin.settings.folderNoteType, ''), 32 | attr: { 33 | placeholder: 'Enter the name of the folder', 34 | rows: '1', 35 | spellcheck: 'false', 36 | class: 'rename-textarea', 37 | }, 38 | }); 39 | 40 | textarea.addEventListener('focus', function () { 41 | this.select(); 42 | }); 43 | 44 | textarea.focus(); 45 | 46 | const buttonContainer = this.modalEl.createDiv({ cls: 'modal-button-container' }); 47 | const saveButton = buttonContainer.createEl('button', { text: 'Save', cls: 'mod-cta' }); 48 | saveButton.addEventListener('click', async () => { 49 | this.saveFolderName(); 50 | this.close(); 51 | }); 52 | 53 | const cancelButton = buttonContainer.createEl('button', { 54 | text: 'Cancel', 55 | cls: 'mod-cancel', 56 | }); 57 | cancelButton.addEventListener('click', () => { 58 | this.close(); 59 | }); 60 | } 61 | 62 | onClose(): void { 63 | const { contentEl } = this; 64 | contentEl.empty(); 65 | } 66 | 67 | saveFolderName(): void { 68 | const textarea = this.contentEl.querySelector('textarea'); 69 | if (textarea) { 70 | const newName = textarea.value.trim(); 71 | if (newName.trim() !== '') { 72 | const folderBasePath = this.folder.path.slice( 73 | 0, 74 | this.folder.path.lastIndexOf('/') + 1, 75 | ); 76 | const newFolderPath = folderBasePath + newName.trim(); 77 | if (!this.app.vault.getAbstractFileByPath(newFolderPath)) { 78 | this.plugin.app.fileManager.renameFile(this.folder, newFolderPath); 79 | } 80 | } 81 | } 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /docs/docs/Getting started.md: -------------------------------------------------------------------------------- 1 | # Getting started 2 | 3 | ## How to install the plugin 4 | The plugin can be downloaded by clicking on [https://obsidian.md/plugins?id=folder-notes](https://obsidian.md/plugins?id=folder-notes) and then on install. 5 | ## Basic features 6 | 7 | ### Create folder note 8 | 9 | There are three options: the context menu, ctrl/strg + click and commands 10 | 11 | Right click or on mobile hold longer on a folder name to get to the context menu and then click on create folder note. 12 | 13 | ![Context menu](./assets/TOtiFIYzUI8rwxjCLhyN.png) 14 | 15 | On PC either use ctrl or strg (depending on your settings) and then click on a folder name to create a folder note. 16 | 17 | Open the [command palette](https://help.obsidian.md/Plugins/Command+palette) and then type "folder notes" and select the command you need. 18 | 19 | ![Command palette](./assets/screenshots/Obsidian_3VEQ3kOT4S.png) 20 | 21 | ### Open folder note 22 | 23 | If you haven't changed any settings the simplest option is just to click on the name of a folder to open its folder note. 24 | 25 | Use the context menu and instead of create click of open folder note. It only works with folders that also have a folder note and [if you don't know how to open the context menu click here.](#Create folder note) 26 | 27 | ![Context menu](./assets/XjlSBfx6ouPrRpb2JmcL.png) 28 | 29 | The second option is to open one file of the files from the folder from which you want to open the folder note and then use the command "Open folder note of current folder of active note" from the [command palette](https://help.obsidian.md/Plugins/Command+palette) 30 | 31 | The last option and the option that is only available to desktop users is to use alt or ctrl and click on the folder name. When you don't hold ctrl/alt and click on a folder name the folder gets collapsed as normal. To use this option you first have to enable it in the plugin settings under the tab general. 32 | 33 | ![Settings page](./assets/screenshots/Obsidian_7B8zdp5xDh.png) 34 | 35 | ### Create folder overview 36 | Find more out about the folder overview feature on the [folder overview page](./Folder%20overview.md) 37 | 38 | To create a folder overview in a note either use the command "Insert folder overview" or on pc right click and select "create folder overview" 39 | 40 | ![Context menu](./assets/VyBTGhA5eJAVVFusZXIz.png){: style="max-width: 40%;max-height: 40%"} 41 | 42 | The folder overview with the default settings looks like this with one note 43 | 44 | ![Folder overview](./assets/u6ccTTzVbwzBivySFacZ.png) 45 | To find out how to edit the folder overview have a look at [folder overview page](./Folder%20overview.md) 46 | 47 | -------------------------------------------------------------------------------- /docs/docs/index.md: -------------------------------------------------------------------------------- 1 | # Overview 2 | Folder Notes is a plugin for the note-taking app [Obsidian](https://obsidian.md/) that allows you to attach notes directly to folders. This lets you open a note by clicking on a folder name—similar to the functionality found in [Notion](https://www.notion.so/). 3 | 4 | Unlike other “folder note” plugins, Folder Notes offers several unique features, including support for custom file types, templating, and advanced folder handling options. 5 | 6 | ## Get started 7 | 8 | Read the [Getting started page](Getting%20started.md) for further details and the pages under the "Features" category explain every feature in detail. 9 | ## Features 10 | - Open folder notes by clicking folder names in the file explorer or path 11 | - Optional modifier key (Alt or Ctrl + click) to open folder notes 12 | - Full support for any file type as folder notes (e.g., Excalidraw, Canvas, etc.) 13 | - Automatically sync folder note names with their folder names 14 | - Auto-create a folder note when a new folder is created 15 | - Convert all existing folders into folder notes 16 | - Use a folder-overview code block with configurable default settings 17 | - Option to hide folder notes from the file explorer 18 | - Style folder names (underline, bold, italic) in the file explorer or path 19 | - Prevent automatic collapsing of folders that have folder notes 20 | - Choose to only open the folder note when clicking the folder name; other clicks collapse the folder 21 | - Front matter title plugin support 22 | - Exclude specific folders using regex, path, or wildcard patterns (`*`) 23 | 24 | ## Credits 25 | Thank you to everyone who has created a plugin that inspired me and I took code from. 26 | - Template suggester, created by [SilentVoid13](https://github.com/SilentVoid13) and [Liamcain](https://github.com/liamcain) (their plugins: [Templater](https://github.com/SilentVoid13/Templater/), [Periodic notes](https://github.com/liamcain/obsidian-periodic-notes)) 27 | - Apply template to note, first seen in [https://github.com/mgmeyers/obsidian-kanban/](https://github.com/mgmeyers/obsidian-kanban/) from [Mgmeyers](https://github.com/mgmeyers) 28 | - Folder underline, inspired from [https://github.com/aidenlx/alx-folder-note](https://github.com/aidenlx/alx-folder-note) 29 | - Stop folders from collapsing, the basic idea from [https://github.com/alangrainger/obsidian-folder-notes](https://github.com/alangrainger/obsidian-folder-notes) 30 | - The basic idea is from [https://github.com/xpgo/obsidian-folder-note-plugin](https://github.com/xpgo/obsidian-folder-note-plugin) 31 | 32 | ## Support the development of the plugin 33 | 34 | Buy Me a Coffee at ko-fi.com -------------------------------------------------------------------------------- /docs/docs/Features/Settings/General.md: -------------------------------------------------------------------------------- 1 | # General settings 2 | ### Folder note name template 3 | The entered text is going to be the name of all folder notes and when file name matches this text it becomes a folder note. {{folder_name}} is a placeholder for the name of the folder linked to the file. If you change the text every existing folder note which matched the old text won't be a folder note anymore and you have to use the button "Rename existing folder notes" to rename all folder notes. 4 | 5 | ### Supported file types 6 | Only the file types you select will be selected will be recognized as folder notes. This means you that a file that matches the name of a folder and normally would be a folder note but has an extension that isn't included in the supported file types list won't be a folder note and you can't open it by click on the folder name. 7 | 8 | ### Template path 9 | When you don't have templater installed & enabled you can choose any file in your vault as a template. But if you have templater enabled you first have to choose the folder where your templates are located, in the settings of the templater plugin. You can also use the core templates plugin for applying templates to new folder notes. 10 | 11 | ### Storage location 12 | Choose if the folder notes should be stored in the folder they're linked to or in the parent folder of the folder they're linked to. If you switch the storage method all existing folder won't be recognized as folder notes anymore until you click on the switch button and the location of old folder note files gets changed. 13 | 14 | ## Folder note behavior 15 | ### Confirm folder note deletion 16 | When enabled a modal pops up when you try to delete folder note using the context menu. 17 | ### Open folder note in a new tab by default 18 | When enabled every folder note opens in a new tab unless it is already open in the currently focused file in the editor and if the subsetting "focus existing tab instead of creating a new one" is also enabled every folder note only has one tab open. If you have different tab open than of a folder note you want to open and the folder note already has a tab open it'll switch to it. 19 | 20 | ## Integration & Compatibility 21 | ### Enable [front matter title plugin](https://github.com/snezhig/obsidian-front-matter-title) integration 22 | Applies the changes made to the files names in the frontmatter also to the folder names in the file explorer & the path for folders with folder notes. This is also requires to enable the auto update setting in the file explorer & path settings of the folder notes plugin. 23 | 24 | ## Session & Persistence 25 | 26 | ### Persist tab after restart 27 | Open the same tab you had open in the plugin settings before restarting Obsidian. 28 | ### Persist tab during session only 29 | Open the same tab after closing the plugin settings and opening it again but if "Persist tab after restart" is disabled it won't open the same tab again after restarting Obsidian. -------------------------------------------------------------------------------- /.github/workflows/beta-release.yaml: -------------------------------------------------------------------------------- 1 | # https://github.com/SilentVoid13/Templater/blob/master/.github/workflows/release.yml 2 | 3 | name: Plugin-beta release 4 | on: 5 | push: 6 | tags: 7 | - "[0-9]+.[0-9]+.[0-9]+-[0-9]+-beta" 8 | workflow_dispatch: 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout current branch 15 | uses: actions/checkout@v3 16 | with: 17 | # This checks out the branch that triggered the workflow 18 | submodules: recursive 19 | fetch-depth: 0 20 | ref: ${{ github.ref }} 21 | 22 | - name: Initialize and update submodules 23 | run: | 24 | git submodule init 25 | git submodule update --remote 26 | 27 | - name: Fetch all branches 28 | run: git fetch --all 29 | 30 | - name: Extract version from tag 31 | id: extract_version 32 | run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV 33 | 34 | - name: Strip -beta from version 35 | run: | 36 | VERSION=${{ env.VERSION }} 37 | RELEASE_VERSION=${VERSION%-beta} 38 | echo "RELEASE_VERSION=$RELEASE_VERSION" >> $GITHUB_ENV 39 | 40 | # Update manifest-beta.json in main branch 41 | - name: Checkout main branch to update manifest-beta.json 42 | run: | 43 | git checkout main 44 | git pull origin main 45 | 46 | - name: Update manifest-beta.json in main branch 47 | run: | 48 | VERSION=${{ env.VERSION }} 49 | jq --arg version "$VERSION" '.version = $version' manifest-beta.json > manifest-beta-temp.json 50 | mv manifest-beta-temp.json manifest-beta.json 51 | 52 | - name: Commit and push manifest-beta.json changes in main 53 | run: | 54 | git config --local user.email "github-actions[bot]@users.noreply.github.com" 55 | git config --local user.name "github-actions[bot]" 56 | git add manifest-beta.json 57 | git commit -m "Update manifest-beta.json to version $VERSION in main branch" 58 | git push origin main 59 | 60 | # Switch back to the original branch that triggered the workflow 61 | - name: Switch back to branch that triggered the workflow 62 | run: | 63 | git checkout ${{ github.ref_name }} 64 | git pull origin ${{ github.ref_name }} 65 | 66 | - name: npm build 67 | run: | 68 | npm install 69 | npm run fn-build --if-present 70 | 71 | - name: Set Release Name 72 | run: echo "RELEASE_NAME=${{ env.RELEASE_VERSION }}" >> $GITHUB_ENV 73 | 74 | - name: Create Plugin beta release 75 | uses: ncipollo/release-action@v1.12.0 76 | with: 77 | name: Beta release ${{ env.RELEASE_NAME }} 78 | prerelease: true 79 | artifacts: "main.js,manifest.json,styles.css" 80 | token: ${{ secrets.GITHUB_TOKEN }} 81 | tag: ${{ env.VERSION }} 82 | -------------------------------------------------------------------------------- /src/suggesters/TemplateSuggester.ts: -------------------------------------------------------------------------------- 1 | import { TFile, TFolder, Vault, AbstractInputSuggest, type TAbstractFile } from 'obsidian'; 2 | import type FolderNotesPlugin from '../main'; 3 | import { getTemplatePlugins } from 'src/template'; 4 | export enum FileSuggestMode { 5 | TemplateFiles, 6 | ScriptFiles, 7 | } 8 | 9 | export class TemplateSuggest extends AbstractInputSuggest { 10 | constructor( 11 | public inputEl: HTMLInputElement, 12 | public plugin: FolderNotesPlugin, 13 | ) { 14 | super(plugin.app, inputEl); 15 | } 16 | 17 | 18 | get_error_msg(mode: FileSuggestMode): string { 19 | switch (mode) { 20 | case FileSuggestMode.TemplateFiles: 21 | return 'Templates folder doesn\'t exist'; 22 | case FileSuggestMode.ScriptFiles: 23 | return 'User Scripts folder doesn\'t exist'; 24 | } 25 | } 26 | 27 | getSuggestions(input_str: string): TFile[] { 28 | const { templateFolder, templaterPlugin } = getTemplatePlugins(this.app); 29 | 30 | let files: TFile[] = []; 31 | const lower_input_str = input_str.toLowerCase(); 32 | 33 | if ((!templateFolder || templateFolder.trim() === '') && !templaterPlugin) { 34 | files = this.plugin.app.vault.getFiles().filter((file) => 35 | file.path.toLowerCase().includes(lower_input_str), 36 | ); 37 | } else { 38 | let folder: TFolder | TAbstractFile | null = null; 39 | if (templaterPlugin) { 40 | folder = this.plugin.app.vault.getAbstractFileByPath( 41 | (templaterPlugin as unknown as { 42 | plugin?: { settings?: { templates_folder?: string } } 43 | }).plugin?.settings?.templates_folder as string, 44 | ); 45 | if (!(folder instanceof TFolder)) { 46 | return [ 47 | { 48 | path: '', 49 | name: 50 | // eslint-disable-next-line max-len 51 | 'You need to set the Templates folder in the Templater settings first.', 52 | } as TFile, 53 | ]; 54 | } 55 | } else if (templateFolder) { 56 | folder = this.plugin.app.vault.getAbstractFileByPath(templateFolder) as TFolder; 57 | } 58 | 59 | if (!(folder instanceof TFolder)) { 60 | return []; 61 | } 62 | 63 | Vault.recurseChildren(folder, (file: TAbstractFile) => { 64 | if (file instanceof TFile && file.path.toLowerCase().includes(lower_input_str)) { 65 | files.push(file); 66 | } 67 | }); 68 | } 69 | 70 | return files; 71 | } 72 | 73 | 74 | renderSuggestion(file: TFile, el: HTMLElement): void { 75 | const { templateFolder, templaterPlugin } = getTemplatePlugins(this.app); 76 | 77 | if ((!templateFolder || templateFolder.trim() === '') && !templaterPlugin) { 78 | el.setText(`${file.parent?.path !== '/' ? file.parent?.path + '/' : ''}${file.name}`); 79 | } else { 80 | el.setText(file.name); 81 | } 82 | } 83 | 84 | 85 | selectSuggestion(file: TFile): void { 86 | this.inputEl.value = file.name.replace('.md', ''); 87 | this.inputEl.trigger('input'); 88 | this.plugin.settings.templatePath = file.path; 89 | this.plugin.saveSettings(); 90 | this.close(); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/ExcludeFolders/modals/WhitelistPatternSettings.ts: -------------------------------------------------------------------------------- 1 | import { Modal, Setting, type App } from 'obsidian'; 2 | import type FolderNotesPlugin from '../../main'; 3 | import type { WhitelistedPattern } from '../WhitelistPattern'; 4 | 5 | export default class WhitelistPatternSettings extends Modal { 6 | plugin: FolderNotesPlugin; 7 | app: App; 8 | pattern: WhitelistedPattern; 9 | constructor(app: App, plugin: FolderNotesPlugin, pattern: WhitelistedPattern) { 10 | super(app); 11 | this.plugin = plugin; 12 | this.app = app; 13 | this.pattern = pattern; 14 | } 15 | 16 | onOpen(): void { 17 | this.display(); 18 | } 19 | 20 | display(): void { 21 | const { contentEl } = this; 22 | contentEl.empty(); 23 | contentEl.createEl('h2', { text: 'Whitelisted pattern settings' }); 24 | new Setting(contentEl) 25 | .setName('Enable folder name sync') 26 | // eslint-disable-next-line max-len 27 | .setDesc('Choose if the name of a folder note should be renamed when the folder name is changed') 28 | .addToggle((toggle) => 29 | toggle 30 | .setValue(this.pattern.enableSync) 31 | .onChange(async (value) => { 32 | this.pattern.enableSync = value; 33 | await this.plugin.saveSettings(); 34 | }), 35 | ); 36 | 37 | new Setting(contentEl) 38 | .setName('Allow auto creation of folder notes in this folder') 39 | .addToggle((toggle) => 40 | toggle 41 | .setValue(this.pattern.enableAutoCreate) 42 | .onChange(async (value) => { 43 | this.pattern.enableAutoCreate = value; 44 | await this.plugin.saveSettings(); 45 | }), 46 | ); 47 | 48 | new Setting(contentEl) 49 | .setName('Show folder in folder overview') 50 | .setDesc('Choose if the folder should be shown in the folder overview') 51 | .addToggle((toggle) => 52 | toggle 53 | .setValue(this.pattern.showInFolderOverview) 54 | .onChange(async (value) => { 55 | this.pattern.showInFolderOverview = value; 56 | await this.plugin.saveSettings(); 57 | }), 58 | ); 59 | 60 | 61 | new Setting(contentEl) 62 | .setName('Open folder note when clicking on the folder') 63 | .setDesc('Choose if the folder note should be opened when you click on the folder') 64 | .addToggle((toggle) => 65 | toggle 66 | .setValue(this.pattern.enableFolderNote) 67 | .onChange(async (value) => { 68 | this.pattern.enableFolderNote = value; 69 | await this.plugin.saveSettings(true); 70 | this.display(); 71 | }), 72 | ); 73 | 74 | if (this.pattern.enableFolderNote) { 75 | new Setting(contentEl) 76 | .setName('Don\'t collapse folder when opening folder note') 77 | .setDesc('Choose if the folder should be collapsed when the folder note is opened') 78 | .addToggle((toggle) => 79 | toggle 80 | .setValue(this.pattern.disableCollapsing) 81 | .onChange(async (value) => { 82 | this.pattern.disableCollapsing = value; 83 | await this.plugin.saveSettings(); 84 | }), 85 | ); 86 | } 87 | } 88 | 89 | onClose(): void { 90 | const { contentEl } = this; 91 | contentEl.empty(); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /docs/docs/Features/Exclude folders.md: -------------------------------------------------------------------------------- 1 | # Exclude folders 2 | The exclude folder feature can be found in the plugin settings under the tab "Exclude folders". Depending on what you exclude a folder from you for example can't open a folder note, the file/folder names don't sync, it doesn't show up in the folder overview and more. 3 | ## Exclude only one folder 4 | To only exclude the folder itself and not the sub folders also you have to click on the edit icon and disable include subfolders in the settings. 5 | 6 | ## How to exclude a folder? 7 | ### Directly in the settings 8 | Click on the plus icon, then click on the text input, search a folder name and then select the folder you want to exclude from the list. 9 | ![Video type:video](../assets/xdS3VV7wjIRFNHADE0lx.mp4) 10 | ### Through the file explorer 11 | Click on a folder and then use the context menu and click on "exclude folder from folder notes". The plugin will then apply the [default exclude folder settings](#Default settings) and add it to the excluded folders list. 12 | ![Screenshot](../assets/Ny4aCJtZStlFmlsN9zp2.png) 13 | ## Exclude a folder and their subfolders 14 | To exclude a folder and its subfolders you have to do the same steps as in how to exclude only one folder but enable include subfolders in the settings of an excluded folder. 15 | ## Exclude multiple folders (with a pattern) 16 | The pattern only looks at the names of the folders and nothing else. You have two options to exclude folders with a pattern and the first option is to use * before and after the string. The second option is to use a [regex](https://en.wikipedia.org/wiki/Regular_expression). 17 | 18 | To use a regex you have to add "{regex}" at the beginning of the string and everything after it is going to be a regex that matches the folder names. 19 | 20 | For example this regex matches every folder in the file explorer. 21 | ![Screenshot](../assets/xUo6aJuIiABXaPl7eosX.png) 22 | 23 | When we use * to match a folder it only works when it's at the start or at the end of a string or both. 24 | For example when we use the folder name "Test" and the following patterns we get this: 25 | 26 | \*est => matches the name 27 | Test* => matches the name 28 | \*es\* => matches the name 29 | \*Test\* => matches the name 30 | \*Test 2\* => doesn't match the name 31 | ## How to change the settings 32 | Open the exclude folders settings tab and click on a edit button if you've already excluded folders or added patterns to exclude folders. 33 | ![Screenshot](../assets/mbZW1SXv8o3fKU4g8j3V.png) 34 | ## Default settings 35 | To edit the default settings for new excluded folders/patterns you just have to click on one of the "Manage" buttons in the settings that are next to "Exclude folder/pattern default settings". 36 | 37 | ## Whitelisted folders 38 | The whitelisted folders overwrite the already excluded folders with options that are the opposite from the excluded folders. For example when you add a pattern to exclude all folders, select to disable opening of folders and then add one specific folder to the whitelist and select "open folder ..." you can only open the specific folder and not all the other folder notes. -------------------------------------------------------------------------------- /src/modals/ExistingNote.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Modal, 3 | Setting, 4 | Platform, 5 | type App, 6 | type TFile, 7 | type TFolder, 8 | type TAbstractFile, 9 | } from 'obsidian'; 10 | import type FolderNotesPlugin from '../main'; 11 | import { turnIntoFolderNote } from 'src/functions/folderNoteFunctions'; 12 | export default class ExistingFolderNoteModal extends Modal { 13 | plugin: FolderNotesPlugin; 14 | app: App; 15 | file: TFile; 16 | folder: TFolder; 17 | folderNote: TAbstractFile; 18 | constructor( 19 | app: App, 20 | plugin: FolderNotesPlugin, 21 | file: TFile, 22 | folder: TFolder, 23 | folderNote: TAbstractFile, 24 | ) { 25 | super(app); 26 | this.plugin = plugin; 27 | this.app = app; 28 | this.file = file; 29 | this.folder = folder; 30 | this.folderNote = folderNote; 31 | } 32 | onOpen(): void { 33 | const { contentEl } = this; 34 | contentEl.createEl('h2', { text: 'A folder note for this folder already exists' }); 35 | const setting = new Setting(contentEl); 36 | // eslint-disable-next-line max-len 37 | setting.infoEl.createEl('p', { text: 'Are you sure you want to turn the note into a folder note and rename the existing folder note?' }); 38 | 39 | setting.infoEl.parentElement?.classList.add('fn-delete-confirmation-modal'); 40 | 41 | // Create a container for the buttons and the checkbox 42 | // eslint-disable-next-line max-len 43 | const buttonContainer = setting.infoEl.createEl('div', { cls: 'fn-delete-confirmation-modal-buttons' }); 44 | if (Platform.isMobileApp) { 45 | const confirmButton = buttonContainer.createEl('button', { 46 | text: 'Rename and don\'t ask again', 47 | }); 48 | confirmButton.classList.add('mod-warning', 'fn-confirmation-modal-button'); 49 | confirmButton.addEventListener('click', async () => { 50 | this.plugin.settings.showRenameConfirmation = false; 51 | this.plugin.saveSettings(); 52 | this.close(); 53 | turnIntoFolderNote(this.plugin, this.file, this.folder, this.folderNote, true); 54 | }); 55 | } else { 56 | const checkbox = buttonContainer.createEl('input', { type: 'checkbox' }); 57 | checkbox.addEventListener('change', (e) => { 58 | const target = e.target as HTMLInputElement; 59 | if (target.checked) { 60 | this.plugin.settings.showRenameConfirmation = false; 61 | } else { 62 | this.plugin.settings.showRenameConfirmation = true; 63 | } 64 | }); 65 | const checkBoxText = buttonContainer.createEl('span', { text: 'Don\'t ask again' }); 66 | checkBoxText.addEventListener('click', () => { 67 | checkbox.click(); 68 | }); 69 | } 70 | const button = buttonContainer.createEl('button', { text: 'Rename' }); 71 | button.classList.add('mod-warning', 'fn-confirmation-modal-button'); 72 | button.addEventListener('click', async () => { 73 | this.plugin.saveSettings(); 74 | this.close(); 75 | turnIntoFolderNote(this.plugin, this.file, this.folder, this.folderNote, true); 76 | }); 77 | button.focus(); 78 | const cancelButton = buttonContainer.createEl('button', { text: 'Cancel' }); 79 | cancelButton.addEventListener('click', async () => { 80 | this.close(); 81 | }); 82 | 83 | } 84 | onClose(): void { 85 | const { contentEl } = this; 86 | contentEl.empty(); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/modals/DeleteConfirmation.ts: -------------------------------------------------------------------------------- 1 | import { Modal, Platform, type App, type TFile } from 'obsidian'; 2 | import type FolderNotesPlugin from '../main'; 3 | import { deleteFolderNote } from 'src/functions/folderNoteFunctions'; 4 | export default class DeleteConfirmationModal extends Modal { 5 | plugin: FolderNotesPlugin; 6 | app: App; 7 | file: TFile; 8 | constructor(app: App, plugin: FolderNotesPlugin, file: TFile) { 9 | super(app); 10 | this.plugin = plugin; 11 | this.app = app; 12 | this.file = file; 13 | } 14 | onOpen(): void { 15 | const { contentEl, plugin } = this; 16 | const modalTitle = contentEl.createDiv({ cls: 'fn-modal-title' }); 17 | const modalContent = contentEl.createDiv({ cls: 'fn-modal-content' }); 18 | modalTitle.createEl('h2', { text: 'Delete folder note' }); 19 | // eslint-disable-next-line max-len 20 | modalContent.createEl('p', { text: `Are you sure you want to delete the folder note '${this.file.name}' ?` }); 21 | switch (plugin.settings.deleteFilesAction) { 22 | case 'trash': 23 | modalContent.createEl('p', { text: 'It will be moved to your system trash.' }); 24 | break; 25 | case 'obsidianTrash': 26 | // eslint-disable-next-line max-len 27 | modalContent.createEl('p', { text: 'It will be moved to your Obsidian trash, which is located in the ".trash" hidden folder in your vault.' }); 28 | break; 29 | case 'delete': 30 | modalContent 31 | .createEl('p', { text: 'It will be permanently deleted.' }) 32 | .setCssStyles({ color: 'red' }); 33 | break; 34 | } 35 | 36 | const buttonContainer = contentEl.createEl('div', { cls: 'modal-button-container' }); 37 | 38 | if (!Platform.isMobile) { 39 | const checkbox = buttonContainer.createEl('label', { cls: 'mod-checkbox' }); 40 | checkbox.tabIndex = -1; 41 | const input = checkbox.createEl('input', { type: 'checkbox' }); 42 | checkbox.appendText('Don\'t ask again'); 43 | input.addEventListener('change', (e) => { 44 | const target = e.target as HTMLInputElement; 45 | if (target.checked) { 46 | plugin.settings.showDeleteConfirmation = false; 47 | } else { 48 | plugin.settings.showDeleteConfirmation = true; 49 | } 50 | plugin.saveSettings(); 51 | }); 52 | } else { 53 | const confirmButton = buttonContainer.createEl('button', { 54 | text: 'Delete and don\'t ask again', 55 | cls: 'mod-destructive', 56 | }); 57 | confirmButton.addEventListener('click', async () => { 58 | plugin.settings.showDeleteConfirmation = false; 59 | plugin.saveSettings(); 60 | this.close(); 61 | deleteFolderNote(plugin, this.file, false); 62 | }); 63 | } 64 | 65 | const deleteButton = buttonContainer.createEl('button', { 66 | text: 'Delete', 67 | cls: 'mod-warning', 68 | }); 69 | deleteButton.addEventListener('click', async () => { 70 | this.close(); 71 | deleteFolderNote(plugin, this.file, false); 72 | }); 73 | deleteButton.focus(); 74 | 75 | const cancelButton = buttonContainer.createEl('button', { 76 | text: 'Cancel', 77 | cls: 'mod-cancel', 78 | }); 79 | cancelButton.addEventListener('click', async () => { 80 | this.close(); 81 | }); 82 | } 83 | onClose(): void { 84 | const { contentEl } = this; 85 | contentEl.empty(); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ "master", pull-requests-rules ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ "master" ] 20 | schedule: 21 | - cron: '29 9 * * 3' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'javascript' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Use only 'java' to analyze code written in Java, Kotlin or both 38 | # Use only 'javascript' to analyze code written in JavaScript, TypeScript or both 39 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 40 | 41 | steps: 42 | - name: Checkout repository 43 | uses: actions/checkout@v3 44 | 45 | # Initializes the CodeQL tools for scanning. 46 | - name: Initialize CodeQL 47 | uses: github/codeql-action/init@v2 48 | with: 49 | languages: ${{ matrix.language }} 50 | # If you wish to specify custom queries, you can do so here or in a config file. 51 | # By default, queries listed here will override any specified in a config file. 52 | # Prefix the list here with "+" to use these queries and those in the config file. 53 | 54 | # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 55 | # queries: security-extended,security-and-quality 56 | 57 | 58 | # Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java). 59 | # If this step fails, then you should remove it and run the build manually (see below) 60 | - name: Autobuild 61 | uses: github/codeql-action/autobuild@v2 62 | 63 | # ℹ️ Command-line programs to run using the OS shell. 64 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 65 | 66 | # If the Autobuild fails above, remove it and uncomment the following three lines. 67 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 68 | 69 | # - run: | 70 | # echo "Run, Build Application using script" 71 | # ./location_of_script_within_repo/buildscript.sh 72 | 73 | - name: Perform CodeQL Analysis 74 | uses: github/codeql-action/analyze@v2 75 | with: 76 | category: "/language:${{matrix.language}}" 77 | -------------------------------------------------------------------------------- /src/events/handleClick.ts: -------------------------------------------------------------------------------- 1 | import { Keymap, Platform, type TFile } from 'obsidian'; 2 | import type FolderNotesPlugin from 'src/main'; 3 | import { openFolderNote, createFolderNote, getFolderNote } from 'src/functions/folderNoteFunctions'; 4 | import { getExcludedFolder } from 'src/ExcludeFolders/functions/folderFunctions'; 5 | import { 6 | addCSSClassToFileExplorerEl, 7 | removeCSSClassFromFileExplorerEL, 8 | } from 'src/functions/styleFunctions'; 9 | 10 | 11 | 12 | export async function handleViewHeaderClick( 13 | event: MouseEvent, 14 | plugin: FolderNotesPlugin, 15 | ): Promise { 16 | if (!plugin.settings.openFolderNoteOnClickInPath) return; 17 | event.stopImmediatePropagation(); 18 | event.preventDefault(); 19 | event.stopPropagation(); 20 | if (!(event.target instanceof HTMLElement)) return; 21 | 22 | const folderPath = event.target.getAttribute('data-path'); 23 | if (!folderPath) { return; } 24 | 25 | if (await isExcludedFolder(event, plugin, folderPath)) return; 26 | 27 | const folderNote = getFolderNote(plugin, folderPath); 28 | if (folderNote) { 29 | await openFolderNote(plugin, folderNote, event).then(() => 30 | handleFolderNoteReveal(plugin, folderNote), 31 | ); 32 | return; 33 | } else if (event.altKey || Keymap.isModEvent(event) === 'tab') { 34 | if (await handleFolderNoteCreation(event, plugin, folderPath)) return; 35 | } 36 | (event.target as HTMLElement).onclick = null; 37 | (event.target as HTMLElement).click(); 38 | } 39 | 40 | async function isExcludedFolder( 41 | event: MouseEvent, 42 | plugin: FolderNotesPlugin, 43 | folderPath: string, 44 | ): Promise { 45 | const excludedFolder = getExcludedFolder(plugin, folderPath, true); 46 | if (excludedFolder?.disableFolderNote) { 47 | (event.target as HTMLElement).onclick = null; 48 | (event.target as HTMLElement).click(); 49 | return true; 50 | } else if (excludedFolder?.enableCollapsing || plugin.settings.enableCollapsing) { 51 | (event.target as HTMLElement).onclick = null; 52 | (event.target as HTMLElement).click(); 53 | } 54 | return false; 55 | } 56 | 57 | async function handleFolderNoteReveal(plugin: FolderNotesPlugin, folderNote: TFile): Promise { 58 | const fileExplorerPlugin = plugin.app.internalPlugins.getEnabledPluginById('file-explorer'); 59 | if (fileExplorerPlugin && Platform.isMobile && plugin.settings.openSidebar.mobile) { 60 | const OPEN_SIDEBAR_DELAY = 200; 61 | setTimeout(() => { fileExplorerPlugin.revealInFolder(folderNote); }, OPEN_SIDEBAR_DELAY); 62 | } else if (fileExplorerPlugin && Platform.isDesktop && plugin.settings.openSidebar.desktop) { 63 | fileExplorerPlugin.revealInFolder(folderNote); 64 | } 65 | } 66 | 67 | async function handleFolderNoteCreation( 68 | event: MouseEvent, 69 | plugin: FolderNotesPlugin, 70 | folderPath: string, 71 | ): Promise { 72 | const usedCtrl = Platform.isMacOS ? event.metaKey : event.ctrlKey; 73 | if ((plugin.settings.altKey && event.altKey) || 74 | (usedCtrl && Keymap.isModEvent(event) === 'tab')) { 75 | await createFolderNote(plugin, folderPath, true, undefined, true); 76 | addCSSClassToFileExplorerEl(folderPath, 'has-folder-note', false, plugin); 77 | removeCSSClassFromFileExplorerEL(folderPath, 'has-not-folder-note', false, plugin); 78 | return true; 79 | } 80 | return false; 81 | } 82 | -------------------------------------------------------------------------------- /src/functions/utils.ts: -------------------------------------------------------------------------------- 1 | import { TFolder, TFile, View } from 'obsidian'; 2 | import type { FileExplorerWorkspaceLeaf, FileExplorerView } from 'src/globals'; 3 | import { getFolderNote } from './folderNoteFunctions'; 4 | import type FolderNotesPlugin from 'src/main'; 5 | import type { FileExplorerLeaf, FileTreeItem, TreeNode } from 'obsidian-typings'; 6 | import type FolderOverviewPlugin from 'src/obsidian-folder-overview/src/main'; 7 | 8 | export function getFileNameFromPathString(path: string): string { 9 | return path.substring(path.lastIndexOf('/') >= 0 ? path.lastIndexOf('/') + 1 : 0); 10 | } 11 | 12 | export function getFolderNameFromPathString(path: string): string { 13 | const PARENT_FOLDER_INDEX = -2; 14 | const LAST_FOLDER_INDEX = -1; 15 | if (path.endsWith('.md') || path.endsWith('.canvas')) { 16 | return path.split('/').slice(PARENT_FOLDER_INDEX)[0]; 17 | } 18 | return path.split('/').slice(LAST_FOLDER_INDEX)[0]; 19 | } 20 | 21 | export function removeExtension(name: string): string { 22 | return name.replace(/\.[^/.]+$/, ''); 23 | } 24 | 25 | export function getExtensionFromPathString(path: string): string { 26 | return path.slice(path.lastIndexOf('.') >= 0 ? path.lastIndexOf('.') : 0); 27 | } 28 | 29 | export function getFolderPathFromString(path: string): string { 30 | const subString = path.lastIndexOf('/') >= 0 ? path.lastIndexOf('/') : 0; 31 | const folderPath = path.substring(0, subString); 32 | if (folderPath === '') { 33 | return '/'; 34 | } 35 | return folderPath; 36 | 37 | } 38 | 39 | export function getParentFolderPath(path: string): string { 40 | return this.getFolderPathFromString(this.getFolderPathFromString(path)); 41 | } 42 | 43 | export function getFileExplorer( 44 | plugin: FolderNotesPlugin | FolderOverviewPlugin, 45 | ): FileExplorerWorkspaceLeaf { 46 | // eslint-disable-next-line max-len 47 | const leaf = plugin.app.workspace.getLeavesOfType('file-explorer')[0] as unknown as FileExplorerWorkspaceLeaf; 48 | return leaf; 49 | } 50 | 51 | export function getFileExplorerView(plugin: FolderNotesPlugin): FileExplorerView { 52 | return getFileExplorer(plugin).view; 53 | } 54 | 55 | export function getFocusedItem(plugin: FolderNotesPlugin): TreeNode | null { 56 | const fileExplorer = getFileExplorer(plugin) as unknown as FileExplorerLeaf; 57 | const { focusedItem } = fileExplorer.view.tree; 58 | return focusedItem; 59 | } 60 | 61 | export function getFileExplorerActiveFolder(): TFolder | null { 62 | // Check if the active view is a file explorer. 63 | const view = this.app.workspace.getActiveViewOfType(View); 64 | if (view?.getViewType() !== 'file-explorer') return null; 65 | // Check if there is a focused or active item in the file explorer. 66 | const fe = view as FileExplorerView; 67 | const activeFileOrFolder = 68 | fe.tree.focusedItem?.file ?? fe.activeDom?.file; 69 | if (!(activeFileOrFolder instanceof TFolder)) return null; 70 | return activeFileOrFolder as TFolder; 71 | } 72 | 73 | export function getFileExplorerActiveFolderNote(): TFile | null { 74 | const folder = getFileExplorerActiveFolder(); 75 | if (!folder) return null; 76 | // Is there any folder note for the active folder? 77 | const folderNote = getFolderNote(this.plugin, folder.path); 78 | if (!(folderNote instanceof TFile)) return null; 79 | return folderNote; 80 | } 81 | -------------------------------------------------------------------------------- /src/events/handleCreate.ts: -------------------------------------------------------------------------------- 1 | import { TFolder, TFile, type TAbstractFile } from 'obsidian'; 2 | import type FolderNotesPlugin from 'src/main'; 3 | import { 4 | createFolderNote, 5 | getFolder, 6 | getFolderNote, 7 | turnIntoFolderNote, 8 | } from 'src/functions/folderNoteFunctions'; 9 | import { getExcludedFolder } from 'src/ExcludeFolders/functions/folderFunctions'; 10 | import { 11 | removeCSSClassFromFileExplorerEL, 12 | addCSSClassToFileExplorerEl, 13 | } from 'src/functions/styleFunctions'; 14 | 15 | export async function handleCreate(file: TAbstractFile, plugin: FolderNotesPlugin): Promise { 16 | if (!plugin.app.workspace.layoutReady) return; 17 | 18 | const folder = file.parent; 19 | if (folder instanceof TFolder) { 20 | if (plugin.isEmptyFolderNoteFolder(folder) && getFolderNote(plugin, folder.path)) { 21 | addCSSClassToFileExplorerEl(folder.path, 'only-has-folder-note', true, plugin); 22 | } else { 23 | removeCSSClassFromFileExplorerEL(folder.path, 'only-has-folder-note', true, plugin); 24 | } 25 | } 26 | 27 | if (file instanceof TFile) { 28 | handleFileCreation(file, plugin); 29 | } else if (file instanceof TFolder && plugin.settings.autoCreate) { 30 | handleFolderCreation(file, plugin); 31 | } 32 | } 33 | 34 | async function handleFileCreation(file: TFile, plugin: FolderNotesPlugin): Promise { 35 | const folder = getFolder(plugin, file); 36 | 37 | if (!(folder instanceof TFolder) && plugin.settings.autoCreateForFiles) { 38 | if (!file.parent) { return; } 39 | const newFolder = await plugin.app.fileManager.createNewFolder(file.parent); 40 | turnIntoFolderNote(plugin, file, newFolder); 41 | } else if (folder instanceof TFolder) { 42 | if (folder.children.length >= 1) { 43 | removeCSSClassFromFileExplorerEL(folder.path, 'fn-empty-folder', false, plugin); 44 | } 45 | 46 | const detachedFolder = getExcludedFolder(plugin, folder.path, true); 47 | if (detachedFolder) { return; } 48 | const folderNote = getFolderNote(plugin, folder.path); 49 | 50 | if (folderNote && folderNote.path === file.path) { 51 | addCSSClassToFileExplorerEl(folder.path, 'has-folder-note', false, plugin); 52 | addCSSClassToFileExplorerEl(file.path, 'is-folder-note', false, plugin); 53 | } else if (plugin.settings.autoCreateForFiles) { 54 | if (!file.parent) { return; } 55 | const newFolder = await plugin.app.fileManager.createNewFolder(file.parent); 56 | turnIntoFolderNote(plugin, file, newFolder); 57 | } 58 | } 59 | } 60 | 61 | async function handleFolderCreation(folder: TFolder, plugin: FolderNotesPlugin): Promise { 62 | let openFile = plugin.settings.autoCreateFocusFiles; 63 | 64 | const attachmentFolderPath = plugin.app.vault.getConfig('attachmentFolderPath') as string; 65 | const cleanAttachmentFolderPath = attachmentFolderPath?.replace('./', '') || ''; 66 | const attachmentsAreInRootFolder = attachmentFolderPath === './' || attachmentFolderPath === ''; 67 | addCSSClassToFileExplorerEl(folder.path, 'fn-empty-folder', false, plugin); 68 | 69 | if (!plugin.settings.autoCreateForAttachmentFolder) { 70 | if (!attachmentsAreInRootFolder && cleanAttachmentFolderPath === folder.name) return; 71 | } else if (!attachmentsAreInRootFolder && cleanAttachmentFolderPath === folder.name) { 72 | openFile = false; 73 | } 74 | 75 | const excludedFolder = getExcludedFolder(plugin, folder.path, true); 76 | if (excludedFolder?.disableAutoCreate) return; 77 | 78 | const folderNote = getFolderNote(plugin, folder.path); 79 | if (folderNote) return; 80 | 81 | createFolderNote(plugin, folder.path, openFile, undefined, true); 82 | addCSSClassToFileExplorerEl(folder.path, 'has-folder-note', false, plugin); 83 | } 84 | -------------------------------------------------------------------------------- /src/functions/ListComponent.ts: -------------------------------------------------------------------------------- 1 | import { CustomEventEmitter } from 'src/events/EventEmitter'; 2 | 3 | export class ListComponent { 4 | emitter: CustomEventEmitter; 5 | containerEl: HTMLElement; 6 | controlEl: HTMLElement; 7 | emptyStateEl: HTMLElement; 8 | listEl: HTMLElement; 9 | values: string[]; 10 | defaultValues: string[]; 11 | constructor(containerEl: HTMLElement, values: string[] = [], defaultValues: string[] = []) { 12 | this.emitter = new CustomEventEmitter(); 13 | this.containerEl = containerEl; 14 | this.controlEl = containerEl.querySelector('.setting-item-control') || containerEl; 15 | this.listEl = this.controlEl.createDiv('setting-command-hotkeys'); 16 | this.addResetButton(); 17 | this.setValues(values); 18 | this.defaultValues = defaultValues; 19 | } 20 | 21 | on(event: string, listener: (data?: unknown) => void): void { 22 | this.emitter.on(event, listener); 23 | } 24 | 25 | off(event: string, listener: (data?: unknown) => void): void { 26 | this.emitter.off(event, listener); 27 | } 28 | 29 | private emit(event: string, data?: unknown): void { 30 | this.emitter.emit(event, data); 31 | } 32 | 33 | setValues(values: string[]): void { 34 | this.removeElements(); 35 | this.values = values; 36 | if (values.length !== 0) { 37 | values.forEach((value) => { 38 | this.addElement(value); 39 | }); 40 | } 41 | this.emit('update', this.values); 42 | } 43 | 44 | removeElements(): void { 45 | this.listEl.empty(); 46 | } 47 | 48 | addElement(value: string): void { 49 | this.listEl.createSpan('setting-hotkey', (span) => { 50 | if (value.toLocaleLowerCase() === 'md') { 51 | span.innerText = 'markdown'; 52 | } else { 53 | span.innerText = value; 54 | } 55 | span.setAttribute('extension', value); 56 | // eslint-disable-next-line max-len 57 | const removeSpan = span.createEl('span', { cls: 'ofn-list-item-remove setting-hotkey-icon' }); 58 | // eslint-disable-next-line max-len 59 | const svg = ''; 60 | const svgElement = removeSpan.createEl('span', { cls: 'ofn-list-item-remove-icon' }); 61 | svgElement.innerHTML = svg; 62 | removeSpan.onClickEvent(() => { 63 | this.removeValue(value); 64 | span.remove(); 65 | }); 66 | }); 67 | } 68 | 69 | async addValue(value: string): Promise { 70 | this.values.push(value); 71 | this.addElement(value); 72 | this.emit('add', value); 73 | this.emit('update', this.values); 74 | } 75 | 76 | addResetButton(): this { 77 | // eslint-disable-next-line max-len 78 | const resetButton = this.controlEl.createEl('span', { cls: 'clickable-icon setting-restore-hotkey-button' }); 79 | // eslint-disable-next-line max-len 80 | const svg = ''; 81 | resetButton.innerHTML = svg; 82 | resetButton.onClickEvent(() => { 83 | this.setValues(this.defaultValues); 84 | }); 85 | return this; 86 | } 87 | 88 | removeValue(value: string): void { 89 | this.values = this.values.filter((v) => v !== value); 90 | this.listEl.find(`[extension='${value}']`).remove(); 91 | this.emit('remove', value); 92 | this.emit('update', this.values); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/template.ts: -------------------------------------------------------------------------------- 1 | import { TFile, WorkspaceLeaf, type App } from 'obsidian'; 2 | import type FolderNotesPlugin from './main'; 3 | 4 | interface TemplatesPlugin { 5 | enabled: boolean; 6 | instance: { 7 | options: { 8 | folder: string; 9 | }; 10 | insertTemplate: (templateFile: TFile) => Promise; 11 | }; 12 | } 13 | 14 | interface TemplaterPlugin { 15 | settings?: { 16 | empty_file_template?: string; 17 | template_folder?: string; 18 | }; 19 | templater?: { 20 | write_template_to_file: (templateFile: TFile, targetFile: TFile) => Promise; 21 | }; 22 | } 23 | 24 | interface TemplatePluginReturn { 25 | templatesPlugin: TemplatesPlugin | null; 26 | templatesEnabled: boolean; 27 | templaterPlugin: TemplaterPlugin['templater'] | null; 28 | templaterEnabled: boolean; 29 | templaterEmptyFileTemplate?: string; 30 | templateFolder?: string; 31 | } 32 | 33 | export async function applyTemplate( 34 | plugin: FolderNotesPlugin, 35 | file: TFile, 36 | leaf?: WorkspaceLeaf | null, 37 | templatePath?: string, 38 | ): Promise { 39 | const fileContent = await plugin.app.vault.read(file).catch((err) => { 40 | console.error(`Error reading file ${file.path}:`, err); 41 | }); 42 | if (fileContent !== '') return; 43 | 44 | const templateFile = templatePath 45 | ? plugin.app.vault.getAbstractFileByPath(templatePath) 46 | : null; 47 | 48 | if (templateFile && templateFile instanceof TFile) { 49 | try { 50 | const { 51 | templatesEnabled, 52 | templaterEnabled, 53 | templatesPlugin, 54 | templaterPlugin, 55 | } = getTemplatePlugins(plugin.app); 56 | const templateContent = await plugin.app.vault.read(templateFile); 57 | // eslint-disable-next-line max-len 58 | if (templateContent.includes('==⚠ Switch to EXCALIDRAW VIEW in the MORE OPTIONS menu of this document. ⚠==')) { 59 | return; 60 | } 61 | 62 | // Prioritize Templater if both plugins are enabled 63 | if (templaterEnabled && templaterPlugin) { 64 | return await templaterPlugin.write_template_to_file(templateFile, file); 65 | } else if (templatesEnabled && templatesPlugin) { 66 | if (leaf instanceof WorkspaceLeaf) { 67 | await leaf.openFile(file); 68 | } 69 | return await templatesPlugin.instance.insertTemplate(templateFile); 70 | } 71 | await plugin.app.vault.modify(file, templateContent); 72 | 73 | 74 | } catch (e) { 75 | console.error(e); 76 | } 77 | } 78 | } 79 | 80 | export function getTemplatePlugins(app: App): TemplatePluginReturn { 81 | const appAsUnknown = app as unknown as { 82 | internalPlugins: { 83 | plugins: { 84 | templates: TemplatesPlugin; 85 | }; 86 | }; 87 | plugins: { 88 | plugins: { 89 | 'templater-obsidian': TemplaterPlugin; 90 | }; 91 | enabledPlugins: Set; 92 | }; 93 | }; 94 | 95 | const templatesPlugin = appAsUnknown.internalPlugins.plugins.templates; 96 | const templatesEnabled = templatesPlugin?.enabled ?? false; 97 | const templaterPlugin = appAsUnknown.plugins.plugins['templater-obsidian']; 98 | const templaterEnabled = appAsUnknown.plugins.enabledPlugins.has('templater-obsidian'); 99 | 100 | const templaterEmptyFileTemplate = 101 | templaterPlugin && templaterPlugin.settings?.empty_file_template; 102 | 103 | const templateFolder = templatesEnabled 104 | ? templatesPlugin.instance.options.folder 105 | : templaterPlugin?.settings?.template_folder; 106 | 107 | return { 108 | templatesPlugin: templatesPlugin || null, 109 | templatesEnabled, 110 | templaterPlugin: templaterPlugin?.templater || null, 111 | templaterEnabled, 112 | templaterEmptyFileTemplate, 113 | templateFolder, 114 | }; 115 | } 116 | -------------------------------------------------------------------------------- /eslint.config.mts: -------------------------------------------------------------------------------- 1 | import tseslint from "typescript-eslint"; 2 | 3 | export default [ 4 | { 5 | files: ["**/*.ts"], 6 | languageOptions: { 7 | parser: tseslint.parser, 8 | parserOptions: { 9 | sourceType: "module" 10 | } 11 | }, 12 | ignores: [ 13 | "**/node_modules/**", 14 | "**/dist/**", 15 | ], 16 | plugins: { 17 | "@typescript-eslint": tseslint.plugin 18 | }, 19 | rules: { 20 | "no-unused-vars": "off", 21 | "quotes": [ 22 | "error", 23 | "single", 24 | { 25 | "avoidEscape": true 26 | } 27 | ], 28 | "no-mixed-spaces-and-tabs": "error", 29 | "indent": [ 30 | "error", 31 | "tab", 32 | { 33 | "SwitchCase": 1 34 | } 35 | ], 36 | "arrow-parens": [ 37 | "error", 38 | "always" 39 | ], 40 | "eol-last": [ 41 | "error", 42 | "always" 43 | ], 44 | "func-call-spacing": [ 45 | "error", 46 | "never" 47 | ], 48 | "comma-dangle": [ 49 | "error", 50 | "always-multiline" 51 | ], 52 | "no-multi-spaces": "error", 53 | "no-trailing-spaces": "error", 54 | "no-whitespace-before-property": "off", 55 | "semi": [ 56 | "error", 57 | "always" 58 | ], 59 | "semi-style": [ 60 | "error", 61 | "last" 62 | ], 63 | "space-in-parens": [ 64 | "error", 65 | "never" 66 | ], 67 | "block-spacing": [ 68 | "error", 69 | "always" 70 | ], 71 | "object-curly-spacing": [ 72 | "error", 73 | "always" 74 | ], 75 | "eqeqeq": [ 76 | "error", 77 | "always", 78 | { 79 | "null": "ignore" 80 | } 81 | ], 82 | "spaced-comment": [ 83 | "error", 84 | "always", 85 | { 86 | "markers": [ 87 | "!" 88 | ] 89 | } 90 | ], 91 | "yoda": "error", 92 | "prefer-destructuring": [ 93 | "error", 94 | { 95 | "object": true, 96 | "array": false 97 | } 98 | ], 99 | "operator-assignment": [ 100 | "error", 101 | "always" 102 | ], 103 | "no-useless-computed-key": "error", 104 | "no-unneeded-ternary": [ 105 | "error", 106 | { 107 | "defaultAssignment": false 108 | } 109 | ], 110 | "no-invalid-regexp": "error", 111 | "no-constant-condition": [ 112 | "error", 113 | { 114 | "checkLoops": false 115 | } 116 | ], 117 | "no-duplicate-imports": "error", 118 | "no-extra-semi": "error", 119 | "dot-notation": "error", 120 | "no-useless-escape": "error", 121 | '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }], 122 | '@typescript-eslint/no-explicit-any': 'warn', 123 | '@typescript-eslint/consistent-type-imports': 'error', 124 | '@typescript-eslint/consistent-type-definitions': [ 125 | 'error', 126 | 'interface'], 127 | '@typescript-eslint/explicit-function-return-type': 'warn', 128 | '@typescript-eslint/ban-ts-comment': 'warn', 129 | 'array-bracket-spacing': [ 130 | 'error', 'never'], 131 | 'linebreak-style': [ 132 | 'error', 133 | 'unix' 134 | ], 135 | 'no-nested-ternary': 'error', 136 | 'no-shadow': 'error', 137 | 'no-return-await': 'error', 138 | 'no-else-return': 'error', 139 | 'no-empty-function': 'warn', 140 | 'complexity': [ 141 | 'warn', 142 | 15 143 | ], 144 | 'max-len': [ 145 | 'warn', { 146 | code: 100, 147 | ignoreComments: true, 148 | } 149 | ], 150 | 'no-inline-comments': 'warn', 151 | 'no-magic-numbers': [ 152 | 'warn', { 153 | ignore: [0, 1], 154 | enforceConst: true, 155 | ignoreTypeIndexes: true, 156 | ignoreEnums: true, 157 | ignoreNumericLiteralTypes: true, 158 | ignoreClassFieldInitialValues: true, 159 | } 160 | ], 161 | } 162 | } 163 | ]; 164 | -------------------------------------------------------------------------------- /src/ExcludeFolders/modals/PatternSettings.ts: -------------------------------------------------------------------------------- 1 | import { Modal, Setting, type App } from 'obsidian'; 2 | import type FolderNotesPlugin from '../../main'; 3 | import type { ExcludePattern } from 'src/ExcludeFolders/ExcludePattern'; 4 | import { refreshAllFolderStyles } from 'src/functions/styleFunctions'; 5 | 6 | export default class PatternSettings extends Modal { 7 | plugin: FolderNotesPlugin; 8 | app: App; 9 | pattern: ExcludePattern; 10 | constructor(app: App, plugin: FolderNotesPlugin, pattern: ExcludePattern) { 11 | super(app); 12 | this.plugin = plugin; 13 | this.app = app; 14 | this.pattern = pattern; 15 | } 16 | 17 | onOpen(): void { 18 | this.display(); 19 | } 20 | 21 | display(): void { 22 | const { contentEl } = this; 23 | contentEl.empty(); 24 | contentEl.createEl('h2', { text: 'Pattern settings' }); 25 | 26 | new Setting(contentEl) 27 | .setName('Disable folder name sync') 28 | // eslint-disable-next-line max-len 29 | .setDesc('Choose if the folder name should be renamed when the file name has been changed') 30 | .addToggle((toggle) => 31 | toggle 32 | .setValue(this.pattern.disableSync) 33 | .onChange(async (value) => { 34 | this.pattern.disableSync = value; 35 | await this.plugin.saveSettings(); 36 | }), 37 | ); 38 | 39 | new Setting(contentEl) 40 | .setName('Disable auto creation of folder notes in this folder') 41 | // eslint-disable-next-line max-len 42 | .setDesc('Choose if a folder note should be created when a new folder is created that matches this pattern') 43 | .addToggle((toggle) => 44 | toggle 45 | .setValue(this.pattern.disableAutoCreate) 46 | .onChange(async (value) => { 47 | this.pattern.disableAutoCreate = value; 48 | await this.plugin.saveSettings(); 49 | }), 50 | ); 51 | 52 | new Setting(contentEl) 53 | .setName('Don\'t show folder in folder overview') 54 | .setDesc('Choose if the folder should be shown in the folder overview') 55 | .addToggle((toggle) => 56 | toggle 57 | .setValue(this.pattern.excludeFromFolderOverview) 58 | .onChange(async (value) => { 59 | this.pattern.excludeFromFolderOverview = value; 60 | await this.plugin.saveSettings(); 61 | }), 62 | ); 63 | 64 | new Setting(contentEl) 65 | .setName('Show folder note in the file explorer') 66 | .setDesc('Choose if the folder note should be shown in the file explorer') 67 | .addToggle((toggle) => 68 | toggle 69 | .setValue(this.pattern.showFolderNote) 70 | .onChange(async (value) => { 71 | this.pattern.showFolderNote = value; 72 | await this.plugin.saveSettings(); 73 | refreshAllFolderStyles(true, this.plugin); 74 | this.display(); 75 | }), 76 | ); 77 | 78 | new Setting(contentEl) 79 | .setName('Disable open folder note') 80 | .setDesc('Choose if the folder note should be opened when the folder is opened') 81 | .addToggle((toggle) => 82 | toggle 83 | .setValue(this.pattern.disableFolderNote) 84 | .onChange(async (value) => { 85 | this.pattern.disableFolderNote = value; 86 | await this.plugin.saveSettings(true); 87 | this.display(); 88 | }), 89 | ); 90 | 91 | if (!this.pattern.disableFolderNote) { 92 | new Setting(contentEl) 93 | .setName('Collapse folder when opening folder note') 94 | .setDesc('Choose if the folder should be collapsed when the folder note is opened') 95 | .addToggle((toggle) => 96 | toggle 97 | .setValue(this.pattern.enableCollapsing) 98 | .onChange(async (value) => { 99 | this.pattern.enableCollapsing = value; 100 | await this.plugin.saveSettings(); 101 | }), 102 | ); 103 | } 104 | } 105 | 106 | onClose(): void { 107 | const { contentEl } = this; 108 | contentEl.empty(); 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/ExcludeFolders/modals/WhitelistFolderSettings.ts: -------------------------------------------------------------------------------- 1 | import { Modal, Setting, type App } from 'obsidian'; 2 | import type FolderNotesPlugin from '../../main'; 3 | import type { WhitelistedFolder } from '../WhitelistFolder'; 4 | export default class WhitelistFolderSettings extends Modal { 5 | plugin: FolderNotesPlugin; 6 | app: App; 7 | whitelistedFolder: WhitelistedFolder; 8 | constructor(app: App, plugin: FolderNotesPlugin, whitelistedFolder: WhitelistedFolder) { 9 | super(app); 10 | this.plugin = plugin; 11 | this.app = app; 12 | this.whitelistedFolder = whitelistedFolder; 13 | } 14 | 15 | onOpen(): void { 16 | this.display(); 17 | } 18 | 19 | display(): void { 20 | const { contentEl } = this; 21 | contentEl.empty(); 22 | contentEl.createEl('h2', { text: 'Whitelisted folder settings' }); 23 | new Setting(contentEl) 24 | .setName('Include subfolders') 25 | .setDesc('Choose if the subfolders of the folder should also be whitelisted') 26 | .addToggle((toggle) => 27 | toggle 28 | .setValue(this.whitelistedFolder.subFolders) 29 | .onChange(async (value) => { 30 | this.whitelistedFolder.subFolders = value; 31 | await this.plugin.saveSettings(true); 32 | }), 33 | ); 34 | 35 | new Setting(contentEl) 36 | .setName('Enable folder name sync') 37 | // eslint-disable-next-line max-len 38 | .setDesc('Choose if the name of a folder note should be renamed when the folder name is changed') 39 | .addToggle((toggle) => 40 | toggle 41 | .setValue(this.whitelistedFolder.enableSync) 42 | .onChange(async (value) => { 43 | this.whitelistedFolder.enableSync = value; 44 | await this.plugin.saveSettings(); 45 | }), 46 | ); 47 | 48 | new Setting(contentEl) 49 | .setName('Show folder in folder overview') 50 | .setDesc('Choose if the folder should be shown in the folder overview') 51 | .addToggle((toggle) => 52 | toggle 53 | .setValue(this.whitelistedFolder.showInFolderOverview) 54 | .onChange(async (value) => { 55 | this.whitelistedFolder.showInFolderOverview = value; 56 | await this.plugin.saveSettings(); 57 | }), 58 | ); 59 | 60 | new Setting(contentEl) 61 | .setName('Hide folder note in file explorer') 62 | .setDesc('Choose if the folder note should be hidden in the file explorer') 63 | .addToggle((toggle) => 64 | toggle 65 | .setValue(this.whitelistedFolder.hideInFileExplorer) 66 | .onChange(async (value) => { 67 | this.whitelistedFolder.hideInFileExplorer = value; 68 | await this.plugin.saveSettings(); 69 | }), 70 | ); 71 | 72 | new Setting(contentEl) 73 | .setName('Allow auto creation of folder notes in this folder') 74 | .addToggle((toggle) => 75 | toggle 76 | .setValue(this.whitelistedFolder.enableAutoCreate) 77 | .onChange(async (value) => { 78 | this.whitelistedFolder.enableAutoCreate = value; 79 | await this.plugin.saveSettings(); 80 | }), 81 | ); 82 | 83 | 84 | new Setting(contentEl) 85 | .setName('Open folder note when clicking on the folder') 86 | .setDesc('Choose if the folder note should be opened when the folder is opened') 87 | .addToggle((toggle) => 88 | toggle 89 | .setValue(this.whitelistedFolder.enableFolderNote) 90 | .onChange(async (value) => { 91 | this.whitelistedFolder.enableFolderNote = value; 92 | await this.plugin.saveSettings(true); 93 | this.display(); 94 | }), 95 | ); 96 | 97 | if (this.whitelistedFolder.enableFolderNote) { 98 | new Setting(contentEl) 99 | .setName('Don\'t collapse folder when opening folder note') 100 | .setDesc('Choose if the folder should be collapsed when the folder note is opened') 101 | .addToggle((toggle) => 102 | toggle 103 | .setValue(this.whitelistedFolder.disableCollapsing) 104 | .onChange(async (value) => { 105 | this.whitelistedFolder.disableCollapsing = value; 106 | await this.plugin.saveSettings(); 107 | }), 108 | ); 109 | } 110 | 111 | } 112 | onClose(): void { 113 | const { contentEl } = this; 114 | contentEl.empty(); 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/settings/modals/CreateFnForEveryFolder.ts: -------------------------------------------------------------------------------- 1 | import { Modal, Setting, TFolder, Notice, type App, type ButtonComponent } from 'obsidian'; 2 | import type FolderNotesPlugin from '../../main'; 3 | import { createFolderNote, getFolderNote } from 'src/functions/folderNoteFunctions'; 4 | import { getTemplatePlugins } from 'src/template'; 5 | import { getExcludedFolder } from 'src/ExcludeFolders/functions/folderFunctions'; 6 | export default class ConfirmationModal extends Modal { 7 | plugin: FolderNotesPlugin; 8 | app: App; 9 | folder: TFolder; 10 | extension: string; 11 | constructor(app: App, plugin: FolderNotesPlugin) { 12 | super(app); 13 | this.plugin = plugin; 14 | this.app = app; 15 | this.extension = plugin.settings.folderNoteType; 16 | } 17 | 18 | onOpen(): void { 19 | this.modalEl.addClass('fn-confirmation-modal'); 20 | let templateFolderPath: string; 21 | const { templateFolder, templaterPlugin } = getTemplatePlugins(this.plugin.app); 22 | if ((!templateFolder || templateFolder?.trim() === '') && !templaterPlugin) { 23 | templateFolderPath = ''; 24 | } 25 | if (templaterPlugin) { 26 | templateFolderPath = ( 27 | templaterPlugin as unknown as { 28 | plugin?: { settings?: { templates_folder?: string } } 29 | } 30 | ).plugin?.settings?.templates_folder as string; 31 | } else if (templateFolder) { 32 | templateFolderPath = templateFolder; 33 | } 34 | 35 | const { contentEl } = this; 36 | contentEl.createEl('h2', { text: 'Create folder note for every folder' }); 37 | const setting = new Setting(contentEl); 38 | // eslint-disable-next-line max-len 39 | setting.infoEl.createEl('p', { text: 'Make sure to backup your vault before using this feature.' }).style.color = '#fb464c'; 40 | // eslint-disable-next-line max-len 41 | setting.infoEl.createEl('p', { text: 'This feature will create a folder note for every folder in your vault.' }); 42 | // eslint-disable-next-line max-len 43 | setting.infoEl.createEl('p', { text: 'Every folder that already has a folder note will be ignored.' }); 44 | setting.infoEl.createEl('p', { text: 'Every excluded folder will be ignored.' }); 45 | if ( 46 | !this.plugin.settings.templatePath || 47 | this.plugin.settings.templatePath?.trim() === '' 48 | ) { 49 | new Setting(contentEl) 50 | .setName('Folder note file extension') 51 | .setDesc('Choose the file extension for the folder notes.') 52 | .addDropdown((cb) => { 53 | this.plugin.settings.supportedFileTypes.forEach((extension) => { 54 | cb.addOption('.' + extension, extension); 55 | }); 56 | cb.setValue(this.extension); 57 | cb.onChange(async (value) => { 58 | this.extension = value; 59 | }); 60 | }, 61 | ); 62 | } 63 | new Setting(contentEl) 64 | .addButton((cb: ButtonComponent) => { 65 | cb.setButtonText('Create'); 66 | cb.setCta(); 67 | cb.buttonEl.focus(); 68 | cb.onClick(async () => { 69 | if ( 70 | this.plugin.settings.templatePath && 71 | this.plugin.settings.templatePath.trim() !== '' 72 | ) { 73 | this.extension = '.' + this.plugin.settings.templatePath.split('.').pop(); 74 | } 75 | if (this.extension === '.ask') { 76 | return new Notice('Please choose a file extension'); 77 | } 78 | this.close(); 79 | const folders = this.app.vault 80 | .getAllLoadedFiles() 81 | .filter((file) => file.parent instanceof TFolder); 82 | for (const folder of folders) { 83 | if (folder instanceof TFolder) { 84 | const excludedFolder = getExcludedFolder( 85 | this.plugin, folder.path, true, 86 | ); 87 | if (excludedFolder) continue; 88 | if (folder.path === templateFolderPath) continue; 89 | const folderNote = getFolderNote(this.plugin, folder.path); 90 | if (folderNote) continue; 91 | await createFolderNote(this.plugin, folder.path, false, this.extension); 92 | } 93 | } 94 | }); 95 | }) 96 | .addButton((cb: ButtonComponent) => { 97 | cb.setButtonText('Cancel'); 98 | cb.onClick(async () => { 99 | this.close(); 100 | }); 101 | }); 102 | } 103 | 104 | onClose(): void { 105 | const { contentEl } = this; 106 | contentEl.empty(); 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/ExcludeFolders/modals/ExcludeFolderSettings.ts: -------------------------------------------------------------------------------- 1 | import { Modal, Setting, type App } from 'obsidian'; 2 | import type FolderNotesPlugin from '../../main'; 3 | import type { ExcludedFolder } from 'src/ExcludeFolders/ExcludeFolder'; 4 | import { updateCSSClassesForFolder } from 'src/functions/styleFunctions'; 5 | export default class ExcludedFolderSettings extends Modal { 6 | plugin: FolderNotesPlugin; 7 | app: App; 8 | excludedFolder: ExcludedFolder; 9 | constructor(app: App, plugin: FolderNotesPlugin, excludedFolder: ExcludedFolder) { 10 | super(app); 11 | this.plugin = plugin; 12 | this.app = app; 13 | this.excludedFolder = excludedFolder; 14 | } 15 | onOpen(): void { 16 | this.display(); 17 | } 18 | display(): void { 19 | const { contentEl } = this; 20 | contentEl.empty(); 21 | contentEl.createEl('h2', { text: 'Excluded folder settings' }); 22 | new Setting(contentEl) 23 | .setName('Include subfolders') 24 | .setDesc('Choose if the subfolders of the folder should also be excluded') 25 | .addToggle((toggle) => 26 | toggle 27 | .setValue(this.excludedFolder.subFolders) 28 | .onChange(async (value) => { 29 | this.excludedFolder.subFolders = value; 30 | await this.plugin.saveSettings(true); 31 | }), 32 | ); 33 | 34 | new Setting(contentEl) 35 | .setName('Disable folder name sync') 36 | .setDesc('Choose if the folder note should be renamed when the folder name is changed') 37 | .addToggle((toggle) => 38 | toggle 39 | .setValue(this.excludedFolder.disableSync) 40 | .onChange(async (value) => { 41 | this.excludedFolder.disableSync = value; 42 | await this.plugin.saveSettings(); 43 | }), 44 | ); 45 | 46 | new Setting(contentEl) 47 | .setName('Don\'t show folder in folder overview') 48 | .setDesc('Choose if the folder should be shown in the folder overview') 49 | .addToggle((toggle) => 50 | toggle 51 | .setValue(this.excludedFolder.excludeFromFolderOverview) 52 | .onChange(async (value) => { 53 | this.excludedFolder.excludeFromFolderOverview = value; 54 | await this.plugin.saveSettings(); 55 | }), 56 | ); 57 | 58 | new Setting(contentEl) 59 | .setName('Show folder note in the file explorer') 60 | .setDesc('Choose if the folder note should be shown in the file explorer') 61 | .addToggle((toggle) => 62 | toggle 63 | .setValue(this.excludedFolder.showFolderNote) 64 | .onChange(async (value) => { 65 | this.excludedFolder.showFolderNote = value; 66 | updateCSSClassesForFolder(this.excludedFolder.path, this.plugin); 67 | await this.plugin.saveSettings(); 68 | this.display(); 69 | }), 70 | ); 71 | 72 | new Setting(contentEl) 73 | .setName('Disable auto creation of folder notes in this folder') 74 | .setDesc('Choose if a folder note should be created when a new folder is created') 75 | .addToggle((toggle) => 76 | toggle 77 | .setValue(this.excludedFolder.disableAutoCreate) 78 | .onChange(async (value) => { 79 | this.excludedFolder.disableAutoCreate = value; 80 | await this.plugin.saveSettings(); 81 | }), 82 | ); 83 | 84 | new Setting(contentEl) 85 | .setName('Disable open folder note') 86 | .setDesc('Choose if the folder note should be opened when the folder is opened') 87 | .addToggle((toggle) => 88 | toggle 89 | .setValue(this.excludedFolder.disableFolderNote) 90 | .onChange(async (value) => { 91 | this.excludedFolder.disableFolderNote = value; 92 | await this.plugin.saveSettings(true); 93 | this.display(); 94 | }), 95 | ); 96 | 97 | if (!this.excludedFolder.disableFolderNote) { 98 | new Setting(contentEl) 99 | .setName('Collapse folder when opening folder note') 100 | .setDesc('Choose if the folder should be collapsed when the folder note is opened') 101 | .addToggle((toggle) => 102 | toggle 103 | .setValue(this.excludedFolder.enableCollapsing) 104 | .onChange(async (value) => { 105 | this.excludedFolder.enableCollapsing = value; 106 | await this.plugin.saveSettings(); 107 | }), 108 | ); 109 | } 110 | } 111 | 112 | onClose(): void { 113 | const { contentEl } = this; 114 | contentEl.empty(); 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/settings/ExcludedFoldersSettings.ts: -------------------------------------------------------------------------------- 1 | import { 2 | addExcludeFolderListItem, 3 | addExcludedFolder, 4 | } from 'src/ExcludeFolders/functions/folderFunctions'; 5 | import { ExcludedFolder } from 'src/ExcludeFolders/ExcludeFolder'; 6 | import { addExcludePatternListItem } from 'src/ExcludeFolders/functions/patternFunctions'; 7 | import { Setting } from 'obsidian'; 8 | import type { SettingsTab } from './SettingsTab'; 9 | import ExcludedFolderSettings from 'src/ExcludeFolders/modals/ExcludeFolderSettings'; 10 | import PatternSettings from 'src/ExcludeFolders/modals/PatternSettings'; 11 | import WhitelistedFoldersSettings from 'src/ExcludeFolders/modals/WhitelistedFoldersSettings'; 12 | // import ExcludedFoldersWhitelist from 'src/ExcludeFolders/modals/WhitelistModal'; 13 | 14 | export async function renderExcludeFolders(settingsTab: SettingsTab): Promise { 15 | const containerEl = settingsTab.settingsPage; 16 | const manageExcluded = new Setting(containerEl) 17 | .setHeading() 18 | .setClass('fn-excluded-folder-heading') 19 | .setName('Manage excluded folders'); 20 | const desc3 = document.createDocumentFragment(); 21 | desc3.append( 22 | 'Add {regex} at the beginning of the folder name to use a regex pattern.', 23 | desc3.createEl('br'), 24 | 'Use * before and after to exclude folders that include the name between the *s.', 25 | desc3.createEl('br'), 26 | 'Use * before the folder name to exclude folders that end with the folder name.', 27 | desc3.createEl('br'), 28 | 'Use * after the folder name to exclude folders that start with the folder name.', 29 | ); 30 | manageExcluded.setDesc(desc3); 31 | // eslint-disable-next-line max-len 32 | manageExcluded.infoEl.appendText('The regexes and wildcards are only for the folder name, not the path.'); 33 | manageExcluded.infoEl.createEl('br'); 34 | // eslint-disable-next-line max-len 35 | manageExcluded.infoEl.appendText('If you want to switch to a folder path delete the pattern first.'); 36 | // eslint-disable-next-line max-len 37 | manageExcluded.infoEl.style.color = settingsTab.app.vault.getConfig('accentColor') as string || '#7d5bed'; 38 | 39 | 40 | new Setting(containerEl) 41 | .setName('Whitelisted folders') 42 | .setDesc('Folders that override the excluded folders/patterns') 43 | .addButton((cb) => { 44 | cb.setButtonText('Manage'); 45 | cb.setCta(); 46 | cb.onClick(async () => { 47 | new WhitelistedFoldersSettings(settingsTab).open(); 48 | }); 49 | }); 50 | 51 | new Setting(containerEl) 52 | .setName('Exclude folder default settings') 53 | .addButton((cb) => { 54 | cb.setButtonText('Manage'); 55 | cb.setCta(); 56 | cb.onClick(async () => { 57 | new ExcludedFolderSettings( 58 | settingsTab.app, 59 | settingsTab.plugin, 60 | settingsTab.plugin.settings.excludeFolderDefaultSettings, 61 | ).open(); 62 | }); 63 | }); 64 | 65 | new Setting(containerEl) 66 | .setName('Exclude pattern default settings') 67 | .addButton((cb) => { 68 | cb.setButtonText('Manage'); 69 | cb.setCta(); 70 | cb.onClick(async () => { 71 | new PatternSettings( 72 | settingsTab.app, 73 | settingsTab.plugin, 74 | settingsTab.plugin.settings.excludePatternDefaultSettings, 75 | ).open(); 76 | }); 77 | }); 78 | 79 | 80 | new Setting(containerEl) 81 | .setName('Add excluded folder') 82 | .setClass('add-exclude-folder-item') 83 | .addButton((cb) => { 84 | cb.setIcon('plus'); 85 | cb.setClass('add-exclude-folder'); 86 | cb.setTooltip('Add excluded folder'); 87 | cb.onClick(() => { 88 | const excludedFolder = new ExcludedFolder( 89 | '', 90 | settingsTab.plugin.settings.excludeFolders.length, 91 | undefined, 92 | settingsTab.plugin, 93 | ); 94 | addExcludeFolderListItem(settingsTab, containerEl, excludedFolder); 95 | addExcludedFolder(settingsTab.plugin, excludedFolder); 96 | settingsTab.display(); 97 | }); 98 | }); 99 | 100 | settingsTab.plugin.settings.excludeFolders 101 | .filter((folder) => !folder.hideInSettings) 102 | .sort((a, b) => a.position - b.position) 103 | .forEach((excludedFolder) => { 104 | if ( 105 | excludedFolder.string?.trim() !== '' && 106 | excludedFolder.path?.trim() === '' 107 | ) { 108 | addExcludePatternListItem(settingsTab, containerEl, excludedFolder); 109 | } else { 110 | addExcludeFolderListItem(settingsTab, containerEl, excludedFolder); 111 | } 112 | }); 113 | } 114 | -------------------------------------------------------------------------------- /src/settings/PathSettings.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable max-len */ 2 | import { Setting } from 'obsidian'; 3 | import type { SettingsTab } from './SettingsTab'; 4 | export async function renderPath(settingsTab: SettingsTab): Promise { 5 | const containerEl = settingsTab.settingsPage; 6 | new Setting(containerEl) 7 | .setName('Open folder note through path') 8 | .setDesc('Open a folder note when clicking on a folder name in the path if it is a folder note') 9 | .addToggle((toggle) => 10 | toggle 11 | .setValue(settingsTab.plugin.settings.openFolderNoteOnClickInPath) 12 | .onChange(async (value) => { 13 | settingsTab.plugin.settings.openFolderNoteOnClickInPath = value; 14 | await settingsTab.plugin.saveSettings(); 15 | settingsTab.display(); 16 | }), 17 | ); 18 | 19 | if (settingsTab.plugin.settings.openFolderNoteOnClickInPath) { 20 | new Setting(containerEl) 21 | .setName('Open sidebar when opening a folder note through path (Mobile only)') 22 | .setDesc('Open the sidebar when opening a folder note through the path on mobile') 23 | .addToggle((toggle) => 24 | toggle 25 | .setValue(settingsTab.plugin.settings.openSidebar.mobile) 26 | .onChange(async (value) => { 27 | settingsTab.plugin.settings.openSidebar.mobile = value; 28 | await settingsTab.plugin.saveSettings(); 29 | }), 30 | ); 31 | 32 | new Setting(containerEl) 33 | .setName('Open sidebar when opening a folder note through path (Desktop only)') 34 | .setDesc('Open the sidebar when opening a folder note through the path on desktop') 35 | .addToggle((toggle) => 36 | toggle 37 | .setValue(settingsTab.plugin.settings.openSidebar.desktop) 38 | .onChange(async (value) => { 39 | settingsTab.plugin.settings.openSidebar.desktop = value; 40 | await settingsTab.plugin.saveSettings(); 41 | }), 42 | ); 43 | } 44 | 45 | if (settingsTab.plugin.settings.frontMatterTitle.enabled) { 46 | new Setting(containerEl) 47 | .setName('Auto update folder name in the path (front matter title plugin only)') 48 | .setDesc('Automatically update the folder name in the path when the front matter title plugin is enabled and the title for a folder note is changed in the front matter. This will not change the file name, only the displayed name in the path.') 49 | .addToggle((toggle) => 50 | toggle 51 | .setValue(settingsTab.plugin.settings.frontMatterTitle.path) 52 | .onChange(async (value) => { 53 | settingsTab.plugin.settings.frontMatterTitle.path = value; 54 | await settingsTab.plugin.saveSettings(); 55 | if (value) { 56 | settingsTab.plugin.updateAllBreadcrumbs(); 57 | } else { 58 | settingsTab.plugin.updateAllBreadcrumbs(true); 59 | } 60 | }), 61 | ); 62 | } 63 | 64 | settingsTab.settingsPage.createEl('h3', { text: 'Style settings' }); 65 | 66 | new Setting(containerEl) 67 | .setName('Underline folders in the path') 68 | .setDesc('Add an underline to folders that have a folder note in the path above a note') 69 | .addToggle((toggle) => 70 | toggle 71 | .setValue(settingsTab.plugin.settings.underlineFolderInPath) 72 | .onChange(async (value) => { 73 | settingsTab.plugin.settings.underlineFolderInPath = value; 74 | if (value) { 75 | document.body.classList.add('folder-note-underline-path'); 76 | } else { 77 | document.body.classList.remove('folder-note-underline-path'); 78 | } 79 | await settingsTab.plugin.saveSettings(); 80 | }), 81 | ); 82 | 83 | new Setting(containerEl) 84 | .setName('Bold folders in the path') 85 | .setDesc('Make the folder name bold in the path above a note when it has a folder note') 86 | .addToggle((toggle) => 87 | toggle 88 | .setValue(settingsTab.plugin.settings.boldNameInPath) 89 | .onChange(async (value) => { 90 | settingsTab.plugin.settings.boldNameInPath = value; 91 | if (value) { 92 | document.body.classList.add('folder-note-bold-path'); 93 | } else { 94 | document.body.classList.remove('folder-note-bold-path'); 95 | } 96 | await settingsTab.plugin.saveSettings(); 97 | }), 98 | ); 99 | 100 | new Setting(containerEl) 101 | .setName('Cursive the name of folder notes in the path') 102 | .setDesc('Make the folder name cursive in the path above a note when it has a folder note') 103 | .addToggle((toggle) => 104 | toggle 105 | .setValue(settingsTab.plugin.settings.cursiveNameInPath) 106 | .onChange(async (value) => { 107 | settingsTab.plugin.settings.cursiveNameInPath = value; 108 | if (value) { 109 | document.body.classList.add('folder-note-cursive-path'); 110 | } else { 111 | document.body.classList.remove('folder-note-cursive-path'); 112 | } 113 | await settingsTab.plugin.saveSettings(); 114 | }), 115 | ); 116 | } 117 | -------------------------------------------------------------------------------- /src/events/FrontMatterTitle.ts: -------------------------------------------------------------------------------- 1 | import type FolderNotesPlugin from 'src/main'; 2 | import { 3 | type Listener, 4 | type Events, 5 | type ApiInterface, 6 | type DeferInterface, 7 | type ListenerRef, 8 | type EventDispatcherInterface, 9 | getDefer, 10 | } from 'front-matter-plugin-api-provider'; 11 | import { type App, TFile, TFolder } from 'obsidian'; 12 | import { getFolder, getFolderNote } from 'src/functions/folderNoteFunctions'; 13 | 14 | interface UpdateData { 15 | id: string; 16 | result: boolean; 17 | path: string; 18 | pathOnly: boolean; 19 | breadcrumb?: HTMLElement; 20 | } 21 | 22 | interface WrappedUpdateData { 23 | data: UpdateData; 24 | } 25 | 26 | export class FrontMatterTitlePluginHandler { 27 | plugin: FolderNotesPlugin; 28 | app: App; 29 | api: ApiInterface | null = null; 30 | deffer: DeferInterface | null = null; 31 | modifiedFolders: Map = new Map(); 32 | eventRef: ListenerRef<'manager:update'>; 33 | dispatcher: EventDispatcherInterface; 34 | constructor(plugin: FolderNotesPlugin) { 35 | this.plugin = plugin; 36 | this.app = plugin.app; 37 | 38 | (async (): Promise => { 39 | this.deffer = getDefer(this.app); 40 | if (this.deffer.isPluginReady()) { 41 | this.api = this.deffer.getApi(); 42 | } else { 43 | await this.deffer.awaitPlugin(); 44 | this.api = this.deffer.getApi(); 45 | // if you want to wait features you can use the following chain 46 | if (!this.deffer.isFeaturesReady()) { 47 | await this.deffer.awaitFeatures(); 48 | } 49 | } 50 | if (plugin.settings.frontMatterTitle.enabled) { 51 | const dispatcher = this.api?.getEventDispatcher(); 52 | if (dispatcher) { 53 | this.dispatcher = dispatcher; 54 | } 55 | const event: Listener = { 56 | name: 'manager:update', 57 | cb: (data): void => { 58 | this.fmptUpdateFileName(data as unknown as UpdateData, true); 59 | }, 60 | }; 61 | // Keep ref to remove listener 62 | const ref = dispatcher?.addListener(event); 63 | if (ref) { 64 | this.eventRef = ref; 65 | } 66 | // this.plugin.app.vault.getFiles().forEach((file) => { 67 | // this.handleRename({ id: '', result: false, path: file.path }, false); 68 | // }); 69 | this.plugin.updateAllBreadcrumbs(); 70 | } 71 | })(); 72 | } 73 | 74 | deleteEvent(): void { 75 | if (this.eventRef) { 76 | this.dispatcher.removeListener(this.eventRef); 77 | } 78 | } 79 | async fmptUpdateFileName(data: UpdateData, isEvent: boolean): Promise { 80 | const hasNestedData = 'data' in (data as unknown as Record); 81 | const actualData: UpdateData = hasNestedData 82 | ? (data as unknown as WrappedUpdateData).data 83 | : data; 84 | const file = this.app.vault.getAbstractFileByPath(actualData.path); 85 | if (!(file instanceof TFile)) { return; } 86 | 87 | const resolver = this.api?.getResolverFactory()?.createResolver('#feature-id#'); 88 | const newName = resolver?.resolve(file?.path ?? ''); 89 | const folder = getFolder(this.plugin, file); 90 | if (!(folder instanceof TFolder)) { return; } 91 | 92 | const folderNote = getFolderNote(this.plugin, folder.path); 93 | if (!folderNote) { return; } 94 | if (folderNote !== file) { return; } 95 | if (!actualData.pathOnly) { 96 | this.plugin.changeFolderNameInExplorer(folder, newName); 97 | } 98 | 99 | const { breadcrumb } = actualData; 100 | if (breadcrumb) { 101 | this.plugin.changeFolderNameInPath(folder, newName, breadcrumb); 102 | } 103 | 104 | if (isEvent) { 105 | this.plugin.updateAllBreadcrumbs(); 106 | } 107 | 108 | if (newName) { 109 | folder.newName = newName; 110 | this.modifiedFolders.set(folder.path, folder); 111 | } else { 112 | folder.newName = null; 113 | this.modifiedFolders.delete(folder.path); 114 | } 115 | 116 | } 117 | 118 | async fmptUpdateFolderName(data: UpdateData, _replacePath: boolean): Promise { 119 | const hasNestedData = 'data' in (data as unknown as Record); 120 | const actualData: UpdateData = hasNestedData 121 | ? (data as unknown as WrappedUpdateData).data 122 | : data; 123 | const folder = this.app.vault.getAbstractFileByPath(actualData.path); 124 | if (!(folder instanceof TFolder)) { return; } 125 | const folderNote = getFolderNote(this.plugin, folder.path); 126 | if (!folderNote) { return; } 127 | 128 | const resolver = this.api?.getResolverFactory()?.createResolver('#feature-id#'); 129 | const newName = resolver?.resolve(folderNote?.path ?? ''); 130 | if (!newName) return; 131 | 132 | if (!actualData.pathOnly) { 133 | this.plugin.changeFolderNameInExplorer(folder, newName); 134 | } 135 | 136 | const { breadcrumb } = actualData; 137 | if (breadcrumb) { 138 | this.plugin.changeFolderNameInPath(folder, newName, breadcrumb); 139 | } 140 | 141 | folder.newName = newName; 142 | this.modifiedFolders.set(folder.path, folder); 143 | } 144 | 145 | async getNewFolderName(folder: TFolder): Promise { 146 | if (this.modifiedFolders.has(folder.path)) { 147 | const modifiedFolder = this.modifiedFolders.get(folder.path); 148 | if (modifiedFolder) { 149 | return modifiedFolder.newName; 150 | } 151 | } 152 | const folderNote = getFolderNote(this.plugin, folder.path); 153 | if (!folderNote) return null; 154 | const resolver = this.api?.getResolverFactory()?.createResolver('#feature-id#'); 155 | return resolver?.resolve(folderNote?.path ?? '') ?? null; 156 | } 157 | 158 | async getNewFileName(file: TFile): Promise { 159 | const resolver = this.api?.getResolverFactory()?.createResolver('#feature-id#'); 160 | const changedName = resolver?.resolve(file?.path ?? ''); 161 | return changedName ?? null; 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /src/ExcludeFolders/functions/patternFunctions.ts: -------------------------------------------------------------------------------- 1 | import type FolderNotesPlugin from '../../main'; 2 | import type { ExcludePattern } from '../ExcludePattern'; 3 | import { Setting, Platform } from 'obsidian'; 4 | import type { SettingsTab } from '../../settings/SettingsTab'; 5 | import { addExcludedFolder, resyncArray, updateExcludedFolder } from './folderFunctions'; 6 | import PatternSettings from '../modals/PatternSettings'; 7 | 8 | const REGEX_PREFIX = '{regex}'; 9 | const STAR = '*'; 10 | const INDEX_START = 0; 11 | const SLICE_START_ONE = 1; 12 | const SLICE_EXCLUDE_LAST = -1; 13 | 14 | function matchesPatternSpec(raw: string | undefined, folderName: string): boolean { 15 | if (!raw) return false; 16 | const string = raw.trim(); 17 | const isRegex = string.startsWith(REGEX_PREFIX); 18 | const hasStartStar = string.startsWith(STAR); 19 | const hasEndStar = string.endsWith(STAR); 20 | if (!isRegex && !(hasStartStar || hasEndStar)) return false; 21 | 22 | if (isRegex) { 23 | const body = string.replace(REGEX_PREFIX, '').trim(); 24 | if (body === '') return false; 25 | try { 26 | return new RegExp(body).test(folderName); 27 | } catch { 28 | return false; 29 | } 30 | } 31 | 32 | if (hasStartStar && hasEndStar) { 33 | const inner = string.slice(SLICE_START_ONE, SLICE_EXCLUDE_LAST); 34 | return folderName.includes(inner); 35 | } 36 | if (hasStartStar) { 37 | const suffix = string.slice(SLICE_START_ONE); 38 | return folderName.endsWith(suffix); 39 | } 40 | if (hasEndStar) { 41 | const prefix = string.slice(INDEX_START, SLICE_EXCLUDE_LAST); 42 | return folderName.startsWith(prefix); 43 | } 44 | return false; 45 | } 46 | 47 | export function updatePattern( 48 | plugin: FolderNotesPlugin, 49 | pattern: ExcludePattern, 50 | newPattern: ExcludePattern, 51 | ): void { 52 | plugin.settings.excludeFolders = plugin.settings.excludeFolders.filter( 53 | (folder) => folder.id !== pattern.id, 54 | ); 55 | addExcludedFolder(plugin, newPattern); 56 | } 57 | 58 | export async function deletePattern( 59 | plugin: FolderNotesPlugin, 60 | pattern: ExcludePattern, 61 | ): Promise { 62 | plugin.settings.excludeFolders = plugin.settings.excludeFolders.filter( 63 | (folder) => folder.id !== pattern.id || folder.type === 'folder', 64 | ); 65 | await plugin.saveSettings(true); 66 | resyncArray(plugin); 67 | } 68 | 69 | export function getExcludedFoldersByPattern( 70 | plugin: FolderNotesPlugin, 71 | folderName: string, 72 | ): ExcludePattern[] { 73 | return plugin.settings.excludeFolders 74 | .filter((s) => s.type === 'pattern') 75 | .filter((pattern) => matchesPatternSpec(pattern.string, folderName)) as ExcludePattern[]; 76 | } 77 | 78 | export function getExcludedFolderByPattern( 79 | plugin: FolderNotesPlugin, 80 | folderName: string, 81 | ): ExcludePattern | undefined { 82 | return ( 83 | plugin.settings.excludeFolders 84 | .filter((s) => s.type === 'pattern') 85 | .find((pattern) => matchesPatternSpec(pattern.string, folderName)) 86 | ) as ExcludePattern | undefined; 87 | } 88 | 89 | export function addExcludePatternListItem( 90 | settings: SettingsTab, 91 | containerEl: HTMLElement, 92 | pattern: ExcludePattern, 93 | ): void { 94 | const { plugin } = settings; 95 | const setting = new Setting(containerEl); 96 | setting.setClass('fn-exclude-folder-list'); 97 | setting.addSearch((cb) => { 98 | // @ts-expect-error Obsidian's public types don't include containerEl on this control 99 | cb.containerEl.addClass('fn-exclude-folder-path'); 100 | cb.setPlaceholder('Pattern'); 101 | cb.setValue(pattern.string); 102 | cb.onChange((value) => { 103 | pattern.string = value; 104 | updatePattern(plugin, pattern, pattern); 105 | }); 106 | }); 107 | setting.addButton((cb) => { 108 | cb.setIcon('edit'); 109 | cb.setTooltip('Edit pattern'); 110 | cb.onClick(() => { 111 | new PatternSettings(plugin.app, plugin, pattern).open(); 112 | }); 113 | }); 114 | 115 | if (Platform.isDesktop || Platform.isTablet) { 116 | setting.addButton((cb) => { 117 | cb.setIcon('up-chevron-glyph'); 118 | cb.setTooltip('Move up'); 119 | cb.onClick(() => { 120 | if (pattern.position === 0) { return; } 121 | pattern.position -= 1; 122 | updatePattern(plugin, pattern, pattern); 123 | const oldPattern = plugin.settings.excludeFolders.find( 124 | (folder) => folder.position === pattern.position, 125 | ); 126 | if (oldPattern) { 127 | oldPattern.position += 1; 128 | if (oldPattern.type === 'pattern') { 129 | const pat = oldPattern as ExcludePattern; 130 | updatePattern( 131 | plugin, 132 | pat, 133 | pat, 134 | ); 135 | } else { 136 | updateExcludedFolder(plugin, oldPattern, oldPattern); 137 | } 138 | } 139 | settings.display(); 140 | }); 141 | }); 142 | 143 | setting.addButton((cb) => { 144 | cb.setIcon('down-chevron-glyph'); 145 | cb.setTooltip('Move down'); 146 | cb.onClick(() => { 147 | if (pattern.position === plugin.settings.excludeFolders.length - 1) { 148 | return; 149 | } 150 | pattern.position += 1; 151 | 152 | updatePattern(plugin, pattern, pattern); 153 | const oldPattern = plugin.settings.excludeFolders.find( 154 | (folder) => folder.position === pattern.position, 155 | ); 156 | if (oldPattern) { 157 | oldPattern.position -= 1; 158 | if (oldPattern.type === 'pattern') { 159 | const pat = oldPattern as ExcludePattern; 160 | updatePattern( 161 | plugin, 162 | pat, 163 | pat, 164 | ); 165 | } else { 166 | updateExcludedFolder(plugin, oldPattern, oldPattern); 167 | } 168 | } 169 | settings.display(); 170 | }); 171 | }); 172 | } 173 | 174 | setting.addButton((cb) => { 175 | cb.setIcon('trash-2'); 176 | cb.setTooltip('Delete pattern'); 177 | cb.onClick(() => { 178 | void deletePattern(plugin, pattern); 179 | setting.clear(); 180 | setting.settingEl.remove(); 181 | }); 182 | }); 183 | } 184 | -------------------------------------------------------------------------------- /src/ExcludeFolders/functions/whitelistPatternFunctions.ts: -------------------------------------------------------------------------------- 1 | import type FolderNotesPlugin from '../../main'; 2 | import { Setting } from 'obsidian'; 3 | import type { SettingsTab } from '../../settings/SettingsTab'; 4 | import { resyncArray } from './folderFunctions'; 5 | import WhitelistPatternSettings from '../modals/WhitelistPatternSettings'; 6 | import type { WhitelistedPattern } from '../WhitelistPattern'; 7 | import { addWhitelistedFolder, updateWhitelistedFolder } from './whitelistFolderFunctions'; 8 | 9 | const REGEX_PREFIX = '{regex}'; 10 | const STAR = '*'; 11 | const SLICE_START_ONE = 1; 12 | const SLICE_EXCLUDE_LAST = -1; 13 | 14 | function matchesPatternSpec(raw: string | undefined, folderName: string): boolean { 15 | if (!raw) return false; 16 | const string = raw.trim(); 17 | const isRegex = string.startsWith(REGEX_PREFIX); 18 | const hasStartStar = string.startsWith(STAR); 19 | const hasEndStar = string.endsWith(STAR); 20 | if (!isRegex && !(hasStartStar || hasEndStar)) return false; 21 | 22 | if (isRegex) { 23 | const body = string.replace(REGEX_PREFIX, '').trim(); 24 | if (body === '') return false; 25 | try { 26 | return new RegExp(body).test(folderName); 27 | } catch { 28 | return false; 29 | } 30 | } 31 | 32 | if (hasStartStar && hasEndStar) { 33 | const inner = string.slice(SLICE_START_ONE, SLICE_EXCLUDE_LAST); 34 | return folderName.includes(inner); 35 | } 36 | if (hasStartStar) { 37 | const suffix = string.slice(SLICE_START_ONE); 38 | return folderName.endsWith(suffix); 39 | } 40 | if (hasEndStar) { 41 | const prefix = string.slice(0, SLICE_EXCLUDE_LAST); 42 | return folderName.startsWith(prefix); 43 | } 44 | return false; 45 | } 46 | 47 | export function updateWhitelistedPattern( 48 | plugin: FolderNotesPlugin, 49 | pattern: WhitelistedPattern, 50 | newPattern: WhitelistedPattern, 51 | ): void { 52 | plugin.settings.whitelistFolders = plugin.settings.whitelistFolders.filter( 53 | (folder) => folder.id !== pattern.id, 54 | ); 55 | addWhitelistedFolder(plugin, newPattern); 56 | } 57 | 58 | export async function deletePattern( 59 | plugin: FolderNotesPlugin, 60 | pattern: WhitelistedPattern, 61 | ): Promise { 62 | plugin.settings.whitelistFolders = plugin.settings.whitelistFolders.filter( 63 | (folder) => folder.id !== pattern.id || folder.type === 'folder', 64 | ); 65 | await plugin.saveSettings(true); 66 | resyncArray(plugin); 67 | } 68 | 69 | export function getWhitelistedFolderByPattern( 70 | plugin: FolderNotesPlugin, 71 | folderName: string, 72 | ): WhitelistedPattern | undefined { 73 | return ( 74 | plugin.settings.whitelistFolders 75 | .filter((s) => s.type === 'pattern') 76 | .find((pattern) => matchesPatternSpec(pattern.string, folderName)) 77 | ) as WhitelistedPattern | undefined; 78 | } 79 | 80 | export function getWhitelistedFoldersByPattern( 81 | plugin: FolderNotesPlugin, 82 | folderName: string, 83 | ): WhitelistedPattern[] { 84 | return ( 85 | plugin.settings.whitelistFolders 86 | .filter((s) => s.type === 'pattern') 87 | .filter((pattern) => matchesPatternSpec(pattern.string, folderName)) 88 | ) as WhitelistedPattern[]; 89 | } 90 | 91 | export function addWhitelistedPatternListItem( 92 | settings: SettingsTab, 93 | containerEl: HTMLElement, 94 | pattern: WhitelistedPattern, 95 | ): void { 96 | const { plugin } = settings; 97 | const setting = new Setting(containerEl); 98 | setting.setClass('fn-exclude-folder-list'); 99 | setting.addSearch((cb) => { 100 | // @ts-expect-error Obsidian's public types don't expose containerEl on this control 101 | cb.containerEl.addClass('fn-exclude-folder-path'); 102 | cb.setPlaceholder('Pattern'); 103 | cb.setValue(pattern.string); 104 | cb.onChange((value) => { 105 | const exists = plugin.settings.whitelistFolders.some( 106 | (folder) => folder.string === value, 107 | ); 108 | if (exists) { return; } 109 | pattern.string = value; 110 | updateWhitelistedPattern(plugin, pattern, pattern); 111 | }); 112 | }); 113 | setting.addButton((cb) => { 114 | cb.setIcon('edit'); 115 | cb.setTooltip('Edit pattern'); 116 | cb.onClick(() => { 117 | new WhitelistPatternSettings(plugin.app, plugin, pattern).open(); 118 | }); 119 | }); 120 | 121 | setting.addButton((cb) => { 122 | cb.setIcon('up-chevron-glyph'); 123 | cb.setTooltip('Move up'); 124 | cb.onClick(() => { 125 | if (pattern.position === 0) { return; } 126 | pattern.position -= 1; 127 | updateWhitelistedPattern(plugin, pattern, pattern); 128 | const oldPattern = plugin.settings.whitelistFolders.find( 129 | (folder) => folder.position === pattern.position, 130 | ); 131 | if (oldPattern) { 132 | oldPattern.position += 1; 133 | if (oldPattern.type === 'pattern') { 134 | updateWhitelistedPattern( 135 | plugin, 136 | oldPattern as WhitelistedPattern, 137 | oldPattern as WhitelistedPattern, 138 | ); 139 | } else { 140 | updateWhitelistedFolder(plugin, oldPattern, oldPattern); 141 | } 142 | } 143 | settings.display(); 144 | }); 145 | }); 146 | 147 | setting.addButton((cb) => { 148 | cb.setIcon('down-chevron-glyph'); 149 | cb.setTooltip('Move down'); 150 | cb.onClick(() => { 151 | if (pattern.position === plugin.settings.whitelistFolders.length - 1) { 152 | return; 153 | } 154 | pattern.position += 1; 155 | 156 | updateWhitelistedPattern(plugin, pattern, pattern); 157 | const oldPattern = plugin.settings.whitelistFolders.find( 158 | (folder) => folder.position === pattern.position, 159 | ); 160 | if (oldPattern) { 161 | oldPattern.position -= 1; 162 | if (oldPattern.type === 'pattern') { 163 | updateWhitelistedPattern( 164 | plugin, 165 | oldPattern as WhitelistedPattern, 166 | oldPattern as WhitelistedPattern, 167 | ); 168 | } else { 169 | updateWhitelistedFolder(plugin, oldPattern, oldPattern); 170 | } 171 | } 172 | settings.display(); 173 | }); 174 | }); 175 | 176 | setting.addButton((cb) => { 177 | cb.setIcon('trash-2'); 178 | cb.setTooltip('Delete pattern'); 179 | cb.onClick(() => { 180 | void deletePattern(plugin, pattern); 181 | setting.clear(); 182 | setting.settingEl.remove(); 183 | }); 184 | }); 185 | } 186 | -------------------------------------------------------------------------------- /src/suggesters/Suggest.ts: -------------------------------------------------------------------------------- 1 | // Credits go to Liam's Periodic Notes Plugin: https://github.com/liamcain/obsidian-periodic-notes and https://github.com/SilentVoid13/Templater 2 | 3 | import { Scope, type ISuggestOwner } from 'obsidian'; 4 | import { createPopper, type Instance as PopperInstance } from '@popperjs/core'; 5 | import type FolderNotesPlugin from 'src/main'; 6 | 7 | const wrapAround = (value: number, size: number): number => { 8 | return ((value % size) + size) % size; 9 | }; 10 | 11 | class Suggest { 12 | private owner: ISuggestOwner; 13 | private values: T[]; 14 | private suggestions: HTMLDivElement[]; 15 | private selectedItem: number; 16 | private containerEl: HTMLElement; 17 | plugin: FolderNotesPlugin; 18 | 19 | constructor( 20 | owner: ISuggestOwner, 21 | containerEl: HTMLElement, 22 | scope: Scope, 23 | ) { 24 | this.owner = owner; 25 | this.containerEl = containerEl; 26 | 27 | containerEl.on( 28 | 'click', 29 | '.suggestion-item', 30 | this.onSuggestionClick.bind(this), 31 | ); 32 | containerEl.on( 33 | 'mousemove', 34 | '.suggestion-item', 35 | this.onSuggestionMouseover.bind(this), 36 | ); 37 | 38 | scope.register([], 'ArrowUp', (event) => { 39 | if (!event.isComposing) { 40 | this.setSelectedItem(this.selectedItem - 1, true); 41 | return false; 42 | } 43 | }); 44 | 45 | scope.register([], 'ArrowDown', (event) => { 46 | if (!event.isComposing) { 47 | this.setSelectedItem(this.selectedItem + 1, true); 48 | return false; 49 | } 50 | }); 51 | 52 | scope.register([], 'Enter', (event) => { 53 | if (!event.isComposing) { 54 | this.useSelectedItem(event); 55 | return false; 56 | } 57 | }); 58 | } 59 | 60 | onSuggestionClick(event: MouseEvent, el: HTMLDivElement): void { 61 | event.preventDefault(); 62 | 63 | const item = this.suggestions.indexOf(el); 64 | this.setSelectedItem(item, false); 65 | this.useSelectedItem(event); 66 | } 67 | 68 | onSuggestionMouseover(_event: MouseEvent, el: HTMLDivElement): void { 69 | const item = this.suggestions.indexOf(el); 70 | this.setSelectedItem(item, false); 71 | } 72 | 73 | setSuggestions(values: T[]): void { 74 | this.containerEl.empty(); 75 | const suggestionEls: HTMLDivElement[] = []; 76 | 77 | values.forEach((value) => { 78 | const suggestionEl = this.containerEl.createDiv('suggestion-item'); 79 | this.owner.renderSuggestion(value, suggestionEl); 80 | suggestionEls.push(suggestionEl); 81 | }); 82 | 83 | this.values = values; 84 | this.suggestions = suggestionEls; 85 | this.setSelectedItem(0, false); 86 | } 87 | 88 | useSelectedItem(event: MouseEvent | KeyboardEvent): void { 89 | const currentValue = this.values[this.selectedItem]; 90 | if (currentValue) { 91 | this.owner.selectSuggestion(currentValue, event); 92 | } 93 | } 94 | 95 | setSelectedItem(selectedIndex: number, scrollIntoView: boolean): void { 96 | const normalizedIndex = wrapAround( 97 | selectedIndex, 98 | this.suggestions.length, 99 | ); 100 | const prevSelectedSuggestion = this.suggestions[this.selectedItem]; 101 | const selectedSuggestion = this.suggestions[normalizedIndex]; 102 | 103 | prevSelectedSuggestion?.removeClass('is-selected'); 104 | selectedSuggestion?.addClass('is-selected'); 105 | 106 | this.selectedItem = normalizedIndex; 107 | 108 | if (scrollIntoView) { 109 | selectedSuggestion.scrollIntoView(false); 110 | } 111 | } 112 | } 113 | 114 | export abstract class TextInputSuggest implements ISuggestOwner { 115 | protected inputEl: HTMLInputElement | HTMLTextAreaElement; 116 | 117 | private popper: PopperInstance; 118 | private scope: Scope; 119 | private suggestEl: HTMLElement; 120 | private suggest: Suggest; 121 | plugin: FolderNotesPlugin; 122 | 123 | constructor(inputEl: HTMLInputElement | HTMLTextAreaElement, plugin: FolderNotesPlugin) { 124 | this.inputEl = inputEl; 125 | this.plugin = plugin; 126 | this.scope = new Scope(); 127 | 128 | this.suggestEl = createDiv('suggestion-container'); 129 | const suggestion = this.suggestEl.createDiv('suggestion'); 130 | this.suggest = new Suggest(this, suggestion, this.scope); 131 | 132 | this.scope.register([], 'Escape', this.close.bind(this)); 133 | 134 | this.inputEl.addEventListener('input', this.onInputChanged.bind(this)); 135 | this.inputEl.addEventListener('focus', this.onInputChanged.bind(this)); 136 | this.inputEl.addEventListener('blur', this.close.bind(this)); 137 | this.suggestEl.on( 138 | 'mousedown', 139 | '.suggestion-container', 140 | (event: MouseEvent) => { 141 | event.preventDefault(); 142 | }, 143 | ); 144 | } 145 | 146 | onInputChanged(): void { 147 | const inputStr = this.inputEl.value; 148 | const suggestions = this.getSuggestions(inputStr); 149 | 150 | if (!suggestions) { 151 | this.close(); 152 | return; 153 | } 154 | 155 | if (suggestions.length > 0) { 156 | this.suggest.setSuggestions(suggestions); 157 | // @ts-expect-error App may not exist 158 | this.open(app.dom.appContainerEl, this.inputEl); 159 | } else { 160 | this.close(); 161 | } 162 | } 163 | 164 | open(container: HTMLElement, inputEl: HTMLElement): void { 165 | 166 | this.plugin.app.keymap.pushScope(this.scope); 167 | 168 | container.appendChild(this.suggestEl); 169 | this.popper = createPopper(inputEl, this.suggestEl, { 170 | placement: 'bottom-start', 171 | modifiers: [ 172 | { 173 | name: 'sameWidth', 174 | enabled: true, 175 | fn: ({ state, instance }): void => { 176 | // Note: positioning needs to be calculated twice - 177 | // first pass - positioning it according to the width of the popper 178 | // second pass - position it with the width bound to the reference element 179 | // we need to early exit to avoid an infinite loop 180 | const targetWidth = `${state.rects.reference.width}px`; 181 | if (state.styles.popper.width === targetWidth) { 182 | return; 183 | } 184 | state.styles.popper.width = targetWidth; 185 | instance.update(); 186 | }, 187 | phase: 'beforeWrite', 188 | requires: ['computeStyles'], 189 | }, 190 | ], 191 | }); 192 | } 193 | 194 | close(): void { 195 | this.plugin.app.keymap.popScope(this.scope); 196 | 197 | this.suggest.setSuggestions([]); 198 | if (this.popper) this.popper.destroy(); 199 | this.suggestEl.detach(); 200 | } 201 | 202 | abstract getSuggestions(inputStr: string): T[]; 203 | abstract renderSuggestion(item: T, el: HTMLElement): void; 204 | abstract selectSuggestion(item: T): void; 205 | } 206 | -------------------------------------------------------------------------------- /docs/docs/Folder overview.md: -------------------------------------------------------------------------------- 1 | # Folder overview 2 | [The folder overview feature can be installed as a standalone plugin and works without the folder notes plugin by clicking on this.](https://obsidian.md/plugins?id=folder-overview) 3 | **Don't install "folder notes" & the "folder overview" plugin instead only install the folder notes plugin otherwise you can't enable both and there can be other bugs.** 4 | 5 | This feature/plugin creates a [code block](https://help.obsidian.md/Editing+and+formatting/Basic+formatting+syntax#Code+blocks) that provides an overview of your entire vault or specific folders. Instructions for creating or editing a folder overview, along with additional guidance, can be found further down the page. 6 | Currently there are two styles available: **file explorer** & **list style**, with more coming soon. The **overview updates** itself **automatically** when you create, delete or modify a file. You can also integrate it into **templates**, as explained further down below this page. 7 | 8 | Additionally, you can **customize** the overview by selecting **specific file types**, adjusting **file depth**, and changing the **file order** (e.g., alphabetical: A-Z). 9 | ## Create overview 10 | There are two options to create a folder overview in a note 11 | 12 | - Use the command "Insert folder overview" from the [command palette](https://help.obsidian.md/Plugins/Command+palette) 13 | - On desktop right click and click on the option "Insert folder overview" 14 | ## Edit overview 15 | If the overview shows no files or folders yet you can just click on the button that says "Edit overview". But if there are already files/folders you can use the command "Edit folder overview" from the [command palette](https://help.obsidian.md/Plugins/Command+palette) or click on the button as showed in the video below. 16 | 17 | ![type:video](../assets/n5AGi3VCxF5JcNx2Wm5O.mp4) 18 | 19 | ## Default settings 20 | To edit the default values for new folder overviews you can either use the command "Edit folder overview" or open the plugins settings tab and then the folder overview tab to the edit the default settings. This won't change the settings of already existing folder overviews. 21 | 22 | 23 | ![Screenshot](../assets/screenshots/b4QOtkzJs0.png) 24 | (On the left is the plugin settings tab and the right the view you see when you use the "Edit folder overview" command) 25 | 26 | ## FAQ 27 | ### How can I use this in a template? 28 | 29 | Just create the folder overview as normal in a template and then change the settings to be what you want to use as a default for the files you apply the templates to. It also doesn't matter that the id of the overview in the template will be the same as the id of the overview that gets created when the template has been used. 30 | 31 | ### What's the id in the code block for? 32 | They're there to differentiate the overviews from each other so that you can use "Edit folder overview" command to edit the overviews from one file (there can be multiple overviews in one file). It's especially useful on mobile because the button to edit the code block on mobile is quite small. 33 | 34 | ### How can I make the links appear in the graph view? 35 | [Edit the folder overview](#edit-overview) and then enable "Use actual links" to let the links appear in the graph view. The file will be then edited under the code block and there will be another list added which is hidden by default. You can choose to either hide the code block or the list under it in the folder overview settings. The list of links under the code block will also be visible in other apps that support markdown. 36 | 37 | ![Screenshot](../assets/screenshots/Obsidian_nAqAIrlZFW.png) 38 | ![Screenshot](../assets/screenshots/P7yvNZmF5e.png) 39 | 40 | ## Settings 41 | 42 | ### Auto sync 43 | When this is enabled and you rename/create/delete a file/folder which is a children of the selected folder the overview will automatically update. 44 | ### Title 45 | The title is above the overview when you enable "Show the title". You can use variables like this: {{variableName}} which will be replaced with something and the list of variables you can use is below this text. 46 | 47 | | Variable | Explanation | Example | 48 | | ------------------ | ------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | 49 | | folderName | The name of the folder depending on what folder you choose to display | Folder1 | 50 | | folderPath | The path of the choosen folder | Folder1/Folder2 | 51 | | fileName | The name of the file where the overview is located in | File1 | 52 | | filePath | The path of the file where the overview is located in | Folder1/File1 | 53 | | fmtpFileName | The changed file name using the [front matter title plugin](https://github.com/snezhig/obsidian-front-matter-title) | Real file name: 1234, changed name: File1 | 54 | | properties. | Choose any property from a file | properties.name => File1
properties.date => e.g. 01.01.2001 | 55 | 56 | ### Folder path 57 | The overview will show the children of the selected folder. 58 | 59 | When you leave the path empty it will use the parent folder of where the overview is located in. The same is true for "File's parent folder path". If you're using the folder notes plugin and have the storage location set to parent folder you'll need to use "Path of folder linked to the file" to show the children of the folder which is linked to the file. Otherwise if you would for example have the overview located in "File1" which is linked to "Folder1" it would choose all the files/folders of the vault. 60 | 61 | ### Use actual links 62 | This allows the links of the overview to show up in the grap view, see [How can I make the links appear in the graph view?](#how-can-i-make-the-links-appear-in-the-graph-view?). 63 | 64 | #### Hide link list/folder overview 65 | Either hide the code block or the list under it. When you hide the link list every list item will have a span item added to it. It looks like on the image at [How can I make the links appear in the graph view?](#how-can-i-make-the-links-appear-in-the-graph-view?). 66 | 67 | ### Include types 68 | Only the file types which are selected here will show up in the overview. 69 | 70 | ### Show folder notes 71 | Show the file itself which is linked to one folder. 72 | 73 | ### File depth 74 | Limit the depth of files/folders which are shown in the overview. When it is at depth one only the first level files/folders will be shown. When you can't see the childrens of folder the name won't be shown except when you enable ["Show folder names of folders that appear empty in the folder overview"](#show-folder-names-of-folders-that-appear-empty-in-the-folder-overview) or the folder has a file linked to it. 75 | 76 | ### Sort files by 77 | Choose the order of files and folders. 78 | 79 | ### Show folder names of folders that appear empty in the folder overview 80 | When folders don't have any childrens they don't show up until you enable this setting but they also don't show up until you set the "File depth" setting until the level of the children of a folder. For example you can see "Folder1" at level 2 and their children at level 3 and the folder name gets shown at level 3 or when you enable this setting. -------------------------------------------------------------------------------- /src/events/MutationObserver.ts: -------------------------------------------------------------------------------- 1 | import { Keymap, Platform } from 'obsidian'; 2 | import type FolderNotesPlugin from 'src/main'; 3 | import { getFolderNote } from 'src/functions/folderNoteFunctions'; 4 | import { handleViewHeaderClick } from './handleClick'; 5 | import { getExcludedFolder } from 'src/ExcludeFolders/functions/folderFunctions'; 6 | import { updateCSSClassesForFolder } from 'src/functions/styleFunctions'; 7 | 8 | let fileExplorerMutationObserver: MutationObserver | null = null; 9 | 10 | export function registerFileExplorerObserver(plugin: FolderNotesPlugin): void { 11 | // Run once on initial layout 12 | plugin.app.workspace.onLayoutReady(() => { 13 | initializeFolderNoteFeatures(plugin); 14 | initializeBreadcrumbs(plugin); 15 | }); 16 | 17 | // Re-run when layout changes (e.g. File Explorer is reopened) 18 | plugin.registerEvent( 19 | plugin.app.workspace.on('layout-change', () => { 20 | initializeFolderNoteFeatures(plugin); 21 | 22 | const activeLeaf = plugin.app.workspace.getActiveFileView()?.containerEl; 23 | if (!activeLeaf) return; 24 | 25 | const titleContainer = activeLeaf.querySelector('.view-header-title-container'); 26 | if (!(titleContainer instanceof HTMLElement)) return; 27 | 28 | updateFolderNamesInPath(plugin, titleContainer); 29 | }), 30 | ); 31 | } 32 | 33 | export function unregisterFileExplorerObserver(): void { 34 | if (fileExplorerMutationObserver) { 35 | fileExplorerMutationObserver.disconnect(); 36 | fileExplorerMutationObserver = null; 37 | } 38 | } 39 | 40 | function initializeFolderNoteFeatures(plugin: FolderNotesPlugin): void { 41 | initializeAllFolderTitles(plugin); 42 | observeFolderTitleMutations(plugin); 43 | } 44 | 45 | function initializeBreadcrumbs(plugin: FolderNotesPlugin): void { 46 | const titleContainers = document.querySelectorAll('.view-header-title-container'); 47 | if (!titleContainers.length) return; 48 | titleContainers.forEach((container) => { 49 | if (!(container instanceof HTMLElement)) return; 50 | scheduleIdle(() => updateFolderNamesInPath(plugin, container), { timeout: 1000 }); 51 | }); 52 | } 53 | 54 | /** 55 | * Observes the File Explorer for newly added folder elements and applies plugin logic (e.g., styles, event listeners) 56 | * automatically when folders are created, expanded, or when the File Explorer view is reopened. 57 | */ 58 | function observeFolderTitleMutations(plugin: FolderNotesPlugin): void { 59 | if (fileExplorerMutationObserver) { 60 | fileExplorerMutationObserver.disconnect(); 61 | } 62 | fileExplorerMutationObserver = new MutationObserver((mutations) => { 63 | for (const mutation of mutations) { 64 | for (const node of Array.from(mutation.addedNodes)) { 65 | if (!(node instanceof HTMLElement)) continue; 66 | processAddedFolders(node, plugin); 67 | } 68 | } 69 | }); 70 | 71 | fileExplorerMutationObserver.observe(document, { childList: true, subtree: true }); 72 | } 73 | 74 | function initializeAllFolderTitles(plugin: FolderNotesPlugin): void { 75 | const allTitles = document.querySelectorAll('.nav-folder-title-content'); 76 | for (const title of Array.from(allTitles)) { 77 | const folderTitle = title as HTMLElement; 78 | const folderEl = folderTitle.closest('.nav-folder-title'); 79 | if (!folderEl) continue; 80 | 81 | const folderPath = folderEl.getAttribute('data-path') || ''; 82 | setupFolderTitle(folderTitle, plugin, folderPath); 83 | } 84 | } 85 | 86 | function processAddedFolders(node: HTMLElement, plugin: FolderNotesPlugin): void { 87 | const titles: HTMLElement[] = []; 88 | if (node.matches('.nav-folder-title-content')) { 89 | titles.push(node); 90 | } 91 | node.querySelectorAll('.nav-folder-title-content').forEach((el) => { 92 | titles.push(el as HTMLElement); 93 | }); 94 | 95 | titles.forEach((folderTitle) => { 96 | const folderEl = folderTitle.closest('.nav-folder-title'); 97 | const folderPath = folderEl?.getAttribute('data-path') || ''; 98 | const RETRY_TIMEOUT = 50; 99 | if (!folderEl || !folderPath) { 100 | setTimeout(() => { 101 | const retryFolderEl = folderTitle.closest('.nav-folder-title'); 102 | const retryFolderPath = retryFolderEl?.getAttribute('data-path') || ''; 103 | if (retryFolderEl && retryFolderPath) { 104 | setupFolderTitle(folderTitle, plugin, retryFolderPath); 105 | } 106 | }, RETRY_TIMEOUT); 107 | return; 108 | } 109 | setupFolderTitle(folderTitle, plugin, folderPath); 110 | }); 111 | } 112 | 113 | async function setupFolderTitle( 114 | folderTitle: HTMLElement, 115 | plugin: FolderNotesPlugin, 116 | folderPath: string, 117 | ): Promise { 118 | if (folderTitle.dataset.initialized === 'true') return; 119 | if (!folderPath) return; 120 | 121 | folderTitle.dataset.initialized = 'true'; 122 | await updateCSSClassesForFolder(folderPath, plugin); 123 | 124 | if (plugin.settings.frontMatterTitle.enabled) { 125 | plugin.fmtpHandler?.fmptUpdateFolderName( 126 | { id: '', result: false, path: folderPath, pathOnly: false }, 127 | false, 128 | ); 129 | } 130 | 131 | if (Platform.isMobile && plugin.settings.disableOpenFolderNoteOnClick) return; 132 | 133 | plugin.registerDomEvent(folderTitle, 'pointerover', (event: MouseEvent) => { 134 | plugin.hoveredElement = folderTitle; 135 | plugin.mouseEvent = event; 136 | 137 | if (!Keymap.isModEvent(event)) return; 138 | if (!(event.target instanceof HTMLElement)) return; 139 | 140 | const folderNote = getFolderNote(plugin, folderPath); 141 | if (!folderNote) return; 142 | 143 | plugin.app.workspace.trigger('hover-link', { 144 | event, 145 | source: 'preview', 146 | hoverParent: { file: folderNote }, 147 | targetEl: event.target, 148 | linktext: folderNote.basename, 149 | sourcePath: folderNote.path, 150 | }); 151 | plugin.hoverLinkTriggered = true; 152 | }); 153 | 154 | plugin.registerDomEvent(folderTitle, 'pointerout', () => { 155 | plugin.hoveredElement = null; 156 | plugin.mouseEvent = null; 157 | plugin.hoverLinkTriggered = false; 158 | }); 159 | } 160 | 161 | async function updateFolderNamesInPath( 162 | plugin: FolderNotesPlugin, 163 | titleContainer: HTMLElement, 164 | ): Promise { 165 | const headers = titleContainer.querySelectorAll('span.view-header-breadcrumb'); 166 | let path = ''; 167 | const TRAILING_SLASH_LENGTH = 1; 168 | headers.forEach(async (breadcrumb: HTMLElement) => { 169 | path += breadcrumb.getAttribute('old-name') ?? (breadcrumb as HTMLElement).innerText.trim(); 170 | path += '/'; 171 | const folderPath = path.slice(0, -TRAILING_SLASH_LENGTH); 172 | 173 | const excludedFolder = getExcludedFolder(plugin, folderPath, true); 174 | if (excludedFolder?.disableFolderNote) return; 175 | const folderNote = getFolderNote(plugin, folderPath); 176 | if (!folderNote) return; 177 | if (folderNote) breadcrumb.classList.add('has-folder-note'); 178 | breadcrumb?.setAttribute('data-path', path.slice(0, -TRAILING_SLASH_LENGTH)); 179 | if (!breadcrumb.onclick) { 180 | breadcrumb.addEventListener('click', (e) => { 181 | handleViewHeaderClick(e as MouseEvent, plugin); 182 | }, { capture: true }); 183 | } 184 | 185 | if (plugin.settings.frontMatterTitle.enabled) { 186 | plugin.fmtpHandler?.fmptUpdateFolderName( 187 | { id: '', result: false, path: folderPath, pathOnly: true, breadcrumb: breadcrumb }, 188 | true, 189 | ); 190 | } 191 | }); 192 | } 193 | 194 | // Schedules a callback to run when the browser is idle, or after a timeout as a fallback. 195 | // - callback: The function to execute when idle or after the timeout. 196 | // - options: Optional object with a 'timeout' property (in milliseconds). 197 | function scheduleIdle(callback: () => void, options?: { timeout: number }): void { 198 | const DEFAULT_IDLE_TIMEOUT = 200; 199 | if ('requestIdleCallback' in window) { 200 | const windowWithIdle = window as Window & { 201 | requestIdleCallback: (callback: () => void, options?: { timeout: number }) => void 202 | }; 203 | windowWithIdle.requestIdleCallback(callback, options); 204 | } else { 205 | setTimeout(callback, options?.timeout || DEFAULT_IDLE_TIMEOUT); 206 | } 207 | } 208 | -------------------------------------------------------------------------------- /src/ExcludeFolders/functions/whitelistFolderFunctions.ts: -------------------------------------------------------------------------------- 1 | import type FolderNotesPlugin from '../../main'; 2 | import { getFolderNameFromPathString, getFolderPathFromString } from '../../functions/utils'; 3 | import type { WhitelistedFolder } from '../WhitelistFolder'; 4 | import { WhitelistedPattern } from '../WhitelistPattern'; 5 | import { Setting, ButtonComponent } from 'obsidian'; 6 | import { FolderSuggest } from '../../suggesters/FolderSuggester'; 7 | import type { SettingsTab } from '../../settings/SettingsTab'; 8 | import WhitelistFolderSettings from '../modals/WhitelistFolderSettings'; 9 | import { 10 | updateWhitelistedPattern, 11 | getWhitelistedFoldersByPattern, 12 | addWhitelistedPatternListItem, 13 | } from './whitelistPatternFunctions'; 14 | 15 | export function getWhitelistedFolder( 16 | plugin: FolderNotesPlugin, 17 | path: string, 18 | ): WhitelistedFolder | WhitelistedPattern | undefined { 19 | let whitelistedFolder: Partial | undefined = {}; 20 | const folderName = getFolderNameFromPathString(path); 21 | const matchedPatterns = getWhitelistedFoldersByPattern(plugin, folderName); 22 | const whitelistedFolders = getWhitelistedFoldersByPath(plugin, path); 23 | const combinedWhitelistedFolders = [...matchedPatterns, ...whitelistedFolders]; 24 | const propertiesToCopy: (keyof WhitelistedFolder)[] = [ 25 | 'enableAutoCreate', 26 | 'enableFolderNote', 27 | 'enableSync', 28 | 'showInFolderOverview', 29 | ]; 30 | 31 | if (combinedWhitelistedFolders.length > 0) { 32 | for (const matchedFolder of combinedWhitelistedFolders) { 33 | propertiesToCopy.forEach((property) => { 34 | const value = (matchedFolder as Partial)[property]; 35 | if (value === true) { 36 | (whitelistedFolder as Partial)[property] = true as never; 37 | } else if (!value) { 38 | (whitelistedFolder as Partial)[property] = false as never; 39 | } 40 | }); 41 | } 42 | } 43 | 44 | if ( 45 | whitelistedFolder 46 | && Object.keys(whitelistedFolder).length === 0 47 | ) { 48 | whitelistedFolder = undefined; 49 | } 50 | 51 | return whitelistedFolder as WhitelistedFolder | WhitelistedPattern | undefined; 52 | } 53 | 54 | export function getWhitelistedFolderByPath( 55 | plugin: FolderNotesPlugin, 56 | path: string, 57 | ): WhitelistedFolder | WhitelistedPattern | undefined { 58 | return plugin.settings.whitelistFolders.find((whitelistedFolder) => { 59 | if (whitelistedFolder.path === path) { return true; } 60 | if (!whitelistedFolder.subFolders) { return false; } 61 | return getFolderPathFromString(path).startsWith(whitelistedFolder.path); 62 | }); 63 | } 64 | 65 | export function getWhitelistedFoldersByPath( 66 | plugin: FolderNotesPlugin, 67 | path: string, 68 | ): Array { 69 | return plugin.settings.whitelistFolders.filter((whitelistedFolder) => { 70 | if (whitelistedFolder.path === path) { return true; } 71 | if (!whitelistedFolder.subFolders) { return false; } 72 | return getFolderPathFromString(path).startsWith(whitelistedFolder.path); 73 | }); 74 | } 75 | 76 | export function addWhitelistedFolder( 77 | plugin: FolderNotesPlugin, 78 | whitelistedFolder: WhitelistedFolder | WhitelistedPattern, 79 | ): void { 80 | plugin.settings.whitelistFolders.push(whitelistedFolder); 81 | void plugin.saveSettings(true); 82 | } 83 | 84 | export async function deleteWhitelistedFolder( 85 | plugin: FolderNotesPlugin, 86 | whitelistedFolder: WhitelistedFolder | WhitelistedPattern, 87 | ): Promise { 88 | plugin.settings.whitelistFolders = plugin.settings.whitelistFolders.filter( 89 | (folder) => folder.id !== whitelistedFolder.id || folder.type === 'pattern', 90 | ); 91 | await plugin.saveSettings(true); 92 | resyncArray(plugin); 93 | } 94 | 95 | export function updateWhitelistedFolder( 96 | plugin: FolderNotesPlugin, 97 | whitelistedFolder: WhitelistedFolder, 98 | newWhitelistFolder: WhitelistedFolder, 99 | ): void { 100 | plugin.settings.whitelistFolders = plugin.settings.whitelistFolders.filter( 101 | (folder) => folder.id !== whitelistedFolder.id, 102 | ); 103 | addWhitelistedFolder(plugin, newWhitelistFolder); 104 | } 105 | 106 | export function resyncArray(plugin: FolderNotesPlugin): void { 107 | plugin.settings.whitelistFolders = plugin.settings.whitelistFolders.sort( 108 | (a, b) => a.position - b.position, 109 | ); 110 | plugin.settings.whitelistFolders.forEach((folder, index) => { 111 | folder.position = index; 112 | }); 113 | void plugin.saveSettings(); 114 | } 115 | 116 | export function addWhitelistFolderListItem( 117 | settings: SettingsTab, 118 | containerEl: HTMLElement, 119 | whitelistedFolder: WhitelistedFolder, 120 | ): void { 121 | const { plugin } = settings; 122 | const setting = new Setting(containerEl); 123 | setting.setClass('fn-exclude-folder-list'); 124 | 125 | const inputContainer = setting.settingEl.createDiv({ 126 | cls: 'fn-whitelist-folder-input-container', 127 | }); 128 | const SearchComponent = new Setting(inputContainer); 129 | SearchComponent.addSearch((cb) => { 130 | new FolderSuggest( 131 | cb.inputEl, 132 | plugin, 133 | true, 134 | ); 135 | // @ts-expect-error Obsidian's public types don't include this property 136 | cb.containerEl.addClass('fn-exclude-folder-path'); 137 | cb.setPlaceholder('Folder path'); 138 | cb.setValue(whitelistedFolder.path); 139 | cb.onChange((value) => { 140 | if (value.startsWith('{regex}') || value.includes('*')) { 141 | void deleteWhitelistedFolder(plugin, whitelistedFolder); 142 | const pattern = new WhitelistedPattern( 143 | value, 144 | plugin.settings.whitelistFolders.length, 145 | undefined, 146 | plugin, 147 | ); 148 | addWhitelistedFolder(plugin, pattern); 149 | addWhitelistedPatternListItem(settings, containerEl, pattern); 150 | setting.clear(); 151 | setting.settingEl.remove(); 152 | } 153 | if (!plugin.app.vault.getAbstractFileByPath(value)) return; 154 | whitelistedFolder.path = value; 155 | updateWhitelistedFolder(plugin, whitelistedFolder, whitelistedFolder); 156 | }); 157 | }); 158 | const buttonContainer = setting.settingEl.createDiv({ cls: 'fn-whitelist-folder-buttons' }); 159 | 160 | new ButtonComponent(buttonContainer) 161 | .setIcon('edit') 162 | .setTooltip('Edit folder note') 163 | .onClick(() => { 164 | new WhitelistFolderSettings(plugin.app, plugin, whitelistedFolder).open(); 165 | }); 166 | 167 | new ButtonComponent(buttonContainer) 168 | .setIcon('up-chevron-glyph') 169 | .setTooltip('Move up') 170 | .onClick(() => { 171 | if (whitelistedFolder.position === 0) { return; } 172 | whitelistedFolder.position -= 1; 173 | updateWhitelistedFolder(plugin, whitelistedFolder, whitelistedFolder); 174 | const oldWhitelistedFolder = plugin.settings.whitelistFolders.find( 175 | (folder) => folder.position === whitelistedFolder.position, 176 | ); 177 | if (oldWhitelistedFolder) { 178 | oldWhitelistedFolder.position += 1; 179 | if (oldWhitelistedFolder.type === 'pattern') { 180 | updateWhitelistedPattern(plugin, oldWhitelistedFolder, oldWhitelistedFolder); 181 | } else { 182 | updateWhitelistedFolder(plugin, oldWhitelistedFolder, oldWhitelistedFolder); 183 | } 184 | } 185 | settings.display(); 186 | }); 187 | 188 | new ButtonComponent(buttonContainer) 189 | .setIcon('down-chevron-glyph') 190 | .setTooltip('Move down') 191 | .onClick(() => { 192 | if (whitelistedFolder.position === plugin.settings.whitelistFolders.length - 1) { 193 | return; 194 | } 195 | whitelistedFolder.position += 1; 196 | 197 | updateWhitelistedFolder(plugin, whitelistedFolder, whitelistedFolder); 198 | const oldWhitelistedFolder = plugin.settings.whitelistFolders.find( 199 | (folder) => folder.position === whitelistedFolder.position, 200 | ); 201 | if (oldWhitelistedFolder) { 202 | oldWhitelistedFolder.position -= 1; 203 | if (oldWhitelistedFolder.type === 'pattern') { 204 | updateWhitelistedPattern(plugin, oldWhitelistedFolder, oldWhitelistedFolder); 205 | } else { 206 | updateWhitelistedFolder(plugin, oldWhitelistedFolder, oldWhitelistedFolder); 207 | } 208 | } 209 | 210 | settings.display(); 211 | }); 212 | 213 | new ButtonComponent(buttonContainer) 214 | .setIcon('trash-2') 215 | .setTooltip('Delete excluded folder') 216 | .onClick(() => { 217 | void deleteWhitelistedFolder(plugin, whitelistedFolder); 218 | setting.clear(); 219 | setting.settingEl.remove(); 220 | }); 221 | } 222 | -------------------------------------------------------------------------------- /src/settings/FileExplorerSettings.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable max-len */ 2 | import { Setting } from 'obsidian'; 3 | import type { SettingsTab } from './SettingsTab'; 4 | export async function renderFileExplorer(settingsTab: SettingsTab): Promise { 5 | const containerEl = settingsTab.settingsPage; 6 | 7 | new Setting(containerEl) 8 | .setName('Hide folder note') 9 | .setDesc('Hide the folder note file from appearing in the file explorer') 10 | .addToggle((toggle) => 11 | toggle 12 | .setValue(settingsTab.plugin.settings.hideFolderNote) 13 | .onChange(async (value) => { 14 | settingsTab.plugin.settings.hideFolderNote = value; 15 | await settingsTab.plugin.saveSettings(); 16 | if (value) { 17 | document.body.classList.add('hide-folder-note'); 18 | } else { 19 | document.body.classList.remove('hide-folder-note'); 20 | } 21 | settingsTab.display(); 22 | }), 23 | ); 24 | 25 | const setting2 = new Setting(containerEl) 26 | .setName('Disable click-to-open folder note on mobile') 27 | .setDesc('Prevents folder notes from opening when tapping the folder name or surrounding area on mobile devices. They can now only be opened via the context menu or a command.') 28 | .addToggle((toggle) => 29 | toggle 30 | .setValue(settingsTab.plugin.settings.disableOpenFolderNoteOnClick) 31 | .onChange(async (value) => { 32 | settingsTab.plugin.settings.disableOpenFolderNoteOnClick = value; 33 | await settingsTab.plugin.saveSettings(); 34 | }), 35 | ); 36 | 37 | setting2.infoEl.appendText('Requires a restart to take effect'); 38 | const setting2AccentColor = settingsTab.app.vault.getConfig('accentColor') as string || '#7d5bed'; 39 | setting2.infoEl.style.color = setting2AccentColor; 40 | 41 | new Setting(containerEl) 42 | .setName('Open folder notes by only clicking directly on the folder name') 43 | .setDesc('Only allow folder notes to open when clicking directly on the folder name in the file explorer') 44 | .addToggle((toggle) => 45 | toggle 46 | .setValue(!settingsTab.plugin.settings.stopWhitespaceCollapsing) 47 | .onChange(async (value) => { 48 | if (!value) { 49 | document.body.classList.add('fn-whitespace-stop-collapsing'); 50 | } else { 51 | document.body.classList.remove('fn-whitespace-stop-collapsing'); 52 | } 53 | settingsTab.plugin.settings.stopWhitespaceCollapsing = !value; 54 | await settingsTab.plugin.saveSettings(); 55 | }), 56 | ); 57 | 58 | const disableSetting = new Setting(containerEl); 59 | disableSetting.setName('Disable folder collapsing'); 60 | disableSetting.setDesc('When enabled, folders in the file explorer will only collapse when clicking the collapse icon next to the folder name, not when clicking near a folder name when it has a folder note.'); 61 | disableSetting.addToggle((toggle) => 62 | toggle 63 | .setValue(!settingsTab.plugin.settings.enableCollapsing) 64 | .onChange(async (value) => { 65 | settingsTab.plugin.settings.enableCollapsing = !value; 66 | await settingsTab.plugin.saveSettings(); 67 | }), 68 | ); 69 | disableSetting.infoEl.appendText('Requires a restart to take effect'); 70 | const accentColor = settingsTab.app.vault.getConfig('accentColor') as string || '#7d5bed'; 71 | disableSetting.infoEl.style.color = accentColor; 72 | 73 | new Setting(containerEl) 74 | .setName('Use submenus') 75 | .setDesc('Use submenus for file/folder commands') 76 | .addToggle((toggle) => 77 | toggle 78 | .setValue(settingsTab.plugin.settings.useSubmenus) 79 | .onChange(async (value) => { 80 | settingsTab.plugin.settings.useSubmenus = value; 81 | await settingsTab.plugin.saveSettings(); 82 | settingsTab.display(); 83 | }), 84 | ); 85 | 86 | if (settingsTab.plugin.settings.frontMatterTitle.enabled) { 87 | new Setting(containerEl) 88 | .setName('Auto update folder name in the file explorer (front matter title plugin only)') 89 | .setDesc('Automatically update the folder name in the file explorer when the front matter title plugin is enabled and the title for a folder note is changed in the front matter. This will not change the file name, only the displayed name in the file explorer.') 90 | .addToggle((toggle) => 91 | toggle 92 | .setValue(settingsTab.plugin.settings.frontMatterTitle.explorer) 93 | .onChange(async (value) => { 94 | settingsTab.plugin.settings.frontMatterTitle.explorer = value; 95 | await settingsTab.plugin.saveSettings(); 96 | settingsTab.plugin.app.vault.getFiles().forEach((file) => { 97 | settingsTab.plugin.fmtpHandler?.fmptUpdateFileName( 98 | { 99 | id: '', 100 | result: false, 101 | path: file.path, 102 | pathOnly: false, 103 | }, 104 | false, 105 | ); 106 | }); 107 | }), 108 | ); 109 | } 110 | 111 | settingsTab.settingsPage.createEl('h3', { text: 'Style settings' }); 112 | 113 | new Setting(containerEl) 114 | .setName('Highlight folder in the file explorer') 115 | .setDesc('Highlight the folder in the file explorer when it has a folder note and the folder note is open in the editor') 116 | .addToggle((toggle) => 117 | toggle 118 | .setValue(settingsTab.plugin.settings.highlightFolder) 119 | .onChange(async (value) => { 120 | settingsTab.plugin.settings.highlightFolder = value; 121 | if (!value) { 122 | document.body.classList.add('disable-folder-highlight'); 123 | } else { 124 | document.body.classList.remove('disable-folder-highlight'); 125 | } 126 | await settingsTab.plugin.saveSettings(); 127 | }), 128 | ); 129 | 130 | new Setting(containerEl) 131 | .setName('Hide collapse icon') 132 | .setDesc('Hide the collapse icon in the file explorer next to the name of a folder when a folder only contains a folder note') 133 | .addToggle((toggle) => 134 | toggle 135 | .setValue(settingsTab.plugin.settings.hideCollapsingIcon) 136 | .onChange(async (value) => { 137 | settingsTab.plugin.settings.hideCollapsingIcon = value; 138 | if (value) { 139 | document.body.classList.add('fn-hide-collapse-icon'); 140 | } else { 141 | document.body.classList.remove('fn-hide-collapse-icon'); 142 | } 143 | await settingsTab.plugin.saveSettings(); 144 | settingsTab.display(); 145 | }), 146 | ); 147 | 148 | new Setting(containerEl) 149 | .setName('Hide collapse icon for every empty folder') 150 | .setDesc('Hide the collapse icon in the file explorer next to the name of a folder when a folder is empty') 151 | .addToggle((toggle) => 152 | toggle 153 | .setValue(settingsTab.plugin.settings.hideCollapsingIconForEmptyFolders) 154 | .onChange(async (value) => { 155 | settingsTab.plugin.settings.hideCollapsingIconForEmptyFolders = value; 156 | await settingsTab.plugin.saveSettings(); 157 | if (value) { 158 | document.body.classList.add('fn-hide-empty-collapse-icon'); 159 | } else { 160 | document.body.classList.remove('fn-hide-empty-collapse-icon'); 161 | } 162 | settingsTab.display(); 163 | }, 164 | )); 165 | 166 | if (settingsTab.plugin.settings.hideCollapsingIcon) { 167 | new Setting(containerEl) 168 | .setName('Hide collapse icon also when only the attachment folder is in the same folder') 169 | .addToggle((toggle) => 170 | toggle 171 | .setValue(settingsTab.plugin.settings.ignoreAttachmentFolder) 172 | .onChange(async (value) => { 173 | if (value) { 174 | document.body.classList.add('fn-ignore-attachment-folder'); 175 | } else { 176 | document.body.classList.remove('fn-ignore-attachment-folder'); 177 | } 178 | settingsTab.plugin.settings.ignoreAttachmentFolder = value; 179 | await settingsTab.plugin.saveSettings(); 180 | }), 181 | ); 182 | } 183 | 184 | new Setting(containerEl) 185 | .setName('Underline the name of folder notes') 186 | .setDesc('Add an underline to folders that have a folder note in the file explorer') 187 | .addToggle((toggle) => 188 | toggle 189 | .setValue(settingsTab.plugin.settings.underlineFolder) 190 | .onChange(async (value) => { 191 | settingsTab.plugin.settings.underlineFolder = value; 192 | if (value) { 193 | document.body.classList.add('folder-note-underline'); 194 | } else { 195 | document.body.classList.remove('folder-note-underline'); 196 | } 197 | await settingsTab.plugin.saveSettings(); 198 | }), 199 | ); 200 | 201 | new Setting(containerEl) 202 | .setName('Bold the name of folder notes') 203 | .setDesc('Make the folder name bold in the file explorer when it has a folder note') 204 | .addToggle((toggle) => 205 | toggle 206 | .setValue(settingsTab.plugin.settings.boldName) 207 | .onChange(async (value) => { 208 | settingsTab.plugin.settings.boldName = value; 209 | if (value) { 210 | document.body.classList.add('folder-note-bold'); 211 | } else { 212 | document.body.classList.remove('folder-note-bold'); 213 | } 214 | await settingsTab.plugin.saveSettings(); 215 | }), 216 | ); 217 | 218 | new Setting(containerEl) 219 | .setName('Cursive the name of folder notes') 220 | .setDesc('Make the folder name cursive in the file explorer when it has a folder note') 221 | .addToggle((toggle) => 222 | toggle 223 | .setValue(settingsTab.plugin.settings.cursiveName) 224 | .onChange(async (value) => { 225 | settingsTab.plugin.settings.cursiveName = value; 226 | if (value) { 227 | document.body.classList.add('folder-note-cursive'); 228 | } else { 229 | document.body.classList.remove('folder-note-cursive'); 230 | } 231 | await settingsTab.plugin.saveSettings(); 232 | }), 233 | ); 234 | 235 | } 236 | -------------------------------------------------------------------------------- /src/functions/styleFunctions.ts: -------------------------------------------------------------------------------- 1 | import { TFile, TFolder } from 'obsidian'; 2 | import type FolderNotesPlugin from '../main'; 3 | import { 4 | getDetachedFolder, 5 | getExcludedFolder, 6 | addExcludedFolder, 7 | } from 'src/ExcludeFolders/functions/folderFunctions'; 8 | import { getFolder, getFolderNote } from 'src/functions/folderNoteFunctions'; 9 | import { getFileExplorer } from './utils'; 10 | import { ExcludedFolder } from 'src/ExcludeFolders/ExcludeFolder'; 11 | import type FolderOverviewPlugin from 'src/obsidian-folder-overview/src/main'; 12 | 13 | /** 14 | * @description Refreshes the CSS classes for all folder notes in the file explorer. 15 | */ 16 | export function refreshAllFolderStyles(forceReload = false, plugin: FolderNotesPlugin): void { 17 | if (plugin.activeFileExplorer === getFileExplorer(plugin) && !forceReload) { return; } 18 | plugin.activeFileExplorer = getFileExplorer(plugin); 19 | plugin.app.vault.getAllLoadedFiles().forEach(async (file) => { 20 | if (file instanceof TFolder) { 21 | await updateCSSClassesForFolder(file.path, plugin); 22 | } 23 | }); 24 | } 25 | 26 | /** 27 | * @description Updates the CSS classes for a specific folder in the file explorer. 28 | */ 29 | export async function updateCSSClassesForFolder( 30 | folderPath: string, 31 | plugin: FolderNotesPlugin, 32 | ): Promise { 33 | const folder = plugin.app.vault.getAbstractFileByPath(folderPath); 34 | if (!folder || !(folder instanceof TFolder)) { return; } 35 | 36 | const folderNote = getFolderNote(plugin, folder.path); 37 | const detachedFolderNote = getDetachedFolder(plugin, folder.path); 38 | 39 | if (folder.children.length === 0) { 40 | addCSSClassToFileExplorerEl(folder.path, 'fn-empty-folder', false, plugin); 41 | } 42 | 43 | if (!folderNote || detachedFolderNote) { 44 | removeCSSClassFromFileExplorerEL(folder?.path, 'has-folder-note', false, plugin); 45 | removeCSSClassFromFileExplorerEL(folder?.path, 'only-has-folder-note', true, plugin); 46 | return; 47 | } 48 | 49 | const excludedFolder = getExcludedFolder(plugin, folder.path, true); 50 | if (excludedFolder?.disableFolderNote) { 51 | removeCSSClassFromFileExplorerEL(folderNote.path, 'is-folder-note', false, plugin); 52 | removeCSSClassFromFileExplorerEL(folder.path, 'has-folder-note', false, plugin); 53 | removeCSSClassFromFileExplorerEL(folder?.path, 'only-has-folder-note', true, plugin); 54 | } else { 55 | markFolderWithFolderNoteClasses(folder, plugin); 56 | if (excludedFolder?.showFolderNote) { 57 | addCSSClassToFileExplorerEl(folder.path, 'show-folder-note-in-explorer', true, plugin); 58 | unmarkFileAsFolderNote(folderNote, plugin); 59 | return; 60 | } 61 | if (plugin.isEmptyFolderNoteFolder(folder) && getFolderNote(plugin, folder.path)) { 62 | addCSSClassToFileExplorerEl(folder.path, 'only-has-folder-note', true, plugin); 63 | } else { 64 | removeCSSClassFromFileExplorerEL(folder.path, 'only-has-folder-note', true, plugin); 65 | } 66 | } 67 | 68 | markFolderAndNoteWithClasses(folderNote, folder, plugin); 69 | } 70 | 71 | /** 72 | * @description Updates the CSS classes for a folder note file in the file explorer and then also updates the folder it belongs to. 73 | */ 74 | export async function updateCSSClassesForFolderNote( 75 | filePath: string, 76 | plugin: FolderNotesPlugin, 77 | ): Promise { 78 | const file = plugin.app.vault.getAbstractFileByPath(filePath); 79 | if (!file || !(file instanceof TFile)) { return; } 80 | 81 | const folder = getFolder(plugin, file); 82 | if (!folder || !(folder instanceof TFolder)) { return; } 83 | 84 | updateCSSClassesForFolder(folder.path, plugin); 85 | } 86 | 87 | export function markFolderAndNoteWithClasses( 88 | file: TFile, 89 | folder: TFolder, 90 | plugin: FolderNotesPlugin, 91 | ): void { 92 | markFileAsFolderNote(file, plugin); 93 | markFolderWithFolderNoteClasses(folder, plugin); 94 | } 95 | 96 | export function clearFolderAndNoteClasses( 97 | folder: TFolder, 98 | file: TFile, 99 | plugin: FolderNotesPlugin, 100 | ): void { 101 | unmarkFileAsFolderNote(file, plugin); 102 | clearFolderNoteClassesFromFolder(folder, plugin); 103 | } 104 | 105 | export function markFolderWithFolderNoteClasses(folder: TFolder, plugin: FolderNotesPlugin): void { 106 | addCSSClassToFileExplorerEl(folder.path, 'has-folder-note', false, plugin); 107 | if (plugin.isEmptyFolderNoteFolder(folder) && getFolderNote(plugin, folder.path)) { 108 | addCSSClassToFileExplorerEl(folder.path, 'only-has-folder-note', true, plugin); 109 | } else { 110 | removeCSSClassFromFileExplorerEL(folder.path, 'only-has-folder-note', true, plugin); 111 | } 112 | } 113 | 114 | export function markFileAsFolderNote(file: TFile, plugin: FolderNotesPlugin): void { 115 | addCSSClassToFileExplorerEl(file.path, 'is-folder-note', false, plugin); 116 | } 117 | 118 | export function unmarkFileAsFolderNote(file: TFile, plugin: FolderNotesPlugin): void { 119 | removeCSSClassFromFileExplorerEL(file.path, 'is-folder-note', false, plugin); 120 | } 121 | 122 | export function unmarkFolderAsFolderNote(folder: TFolder, plugin: FolderNotesPlugin): void { 123 | removeCSSClassFromFileExplorerEL(folder.path, 'has-folder-note', false, plugin); 124 | removeCSSClassFromFileExplorerEL(folder.path, 'only-has-folder-note', true, plugin); 125 | } 126 | 127 | export function clearFolderNoteClassesFromFolder(folder: TFolder, plugin: FolderNotesPlugin): void { 128 | removeCSSClassFromFileExplorerEL(folder.path, 'has-folder-note', false, plugin); 129 | removeCSSClassFromFileExplorerEL(folder.path, 'only-has-folder-note', true, plugin); 130 | } 131 | 132 | /** 133 | * @param path Can be a folder or file path 134 | * @returns nothing 135 | */ 136 | export async function addCSSClassToFileExplorerEl( 137 | path: string, 138 | cssClass: string, 139 | parent = false, 140 | plugin: FolderNotesPlugin, 141 | waitForCreate = false, 142 | count = 0, 143 | ): Promise { 144 | const fileExplorerItem = getFileExplorerElement(path, plugin); 145 | const MAX_RETRIES = 5; 146 | const RETRY_DELAY = 500; 147 | 148 | if (!fileExplorerItem) { 149 | if (waitForCreate && count < MAX_RETRIES) { 150 | await new Promise((r) => setTimeout(r, RETRY_DELAY)); 151 | addCSSClassToFileExplorerEl(path, cssClass, parent, plugin, waitForCreate, count + 1); 152 | return; 153 | } 154 | return; 155 | } 156 | if (parent) { 157 | const parentElement = fileExplorerItem?.parentElement; 158 | if (parentElement) { 159 | parentElement.addClass(cssClass); 160 | } 161 | } else { 162 | fileExplorerItem.addClass(cssClass); 163 | document.querySelectorAll(`[data-path='${CSS.escape(path)}']`).forEach((item) => { 164 | item.addClass(cssClass); 165 | }); 166 | } 167 | } 168 | 169 | /** 170 | * @param path Can be a folder or file path 171 | * @param cssClass The CSS class to remove from the file explorer element 172 | * @returns nothing 173 | */ 174 | export function removeCSSClassFromFileExplorerEL( 175 | path: string | undefined, 176 | cssClass: string, 177 | parent: boolean, 178 | plugin: FolderNotesPlugin, 179 | ): void { 180 | if (!path) return; 181 | const fileExplorerItem = getFileExplorerElement(path, plugin); 182 | document.querySelectorAll(`[data-path='${CSS.escape(path)}']`).forEach((item) => { 183 | item.removeClass(cssClass); 184 | }); 185 | if (!fileExplorerItem) { return; } 186 | if (parent) { 187 | const parentElement = fileExplorerItem?.parentElement; 188 | if (parentElement) { 189 | parentElement.removeClass(cssClass); 190 | } 191 | return; 192 | } 193 | fileExplorerItem.removeClass(cssClass); 194 | 195 | } 196 | 197 | export function getFileExplorerElement( 198 | path: string, 199 | plugin: FolderNotesPlugin | FolderOverviewPlugin, 200 | ): HTMLElement | null { 201 | const fileExplorer = getFileExplorer(plugin); 202 | if (!fileExplorer?.view?.fileItems) { return null; } 203 | const fileExplorerItem = fileExplorer.view.fileItems?.[path]; 204 | return fileExplorerItem?.selfEl ?? fileExplorerItem?.titleEl ?? null; 205 | } 206 | 207 | export function showFolderNoteInFileExplorer(path: string, plugin: FolderNotesPlugin): void { 208 | const excludedFolder = new ExcludedFolder( 209 | path, 210 | plugin.settings.excludeFolders.length, 211 | undefined, 212 | plugin, 213 | ); 214 | excludedFolder.subFolders = false; 215 | excludedFolder.disableSync = false; 216 | excludedFolder.disableAutoCreate = false; 217 | excludedFolder.disableFolderNote = false; 218 | excludedFolder.enableCollapsing = false; 219 | excludedFolder.excludeFromFolderOverview = false; 220 | excludedFolder.hideInSettings = true; 221 | excludedFolder.showFolderNote = true; 222 | addExcludedFolder(plugin, excludedFolder, false); 223 | addCSSClassToFileExplorerEl(path, 'show-folder-note-in-explorer', true, plugin); 224 | updateCSSClassesForFolder(path, plugin); 225 | } 226 | 227 | export function hideFolderNoteInFileExplorer(folderPath: string, plugin: FolderNotesPlugin): void { 228 | plugin.settings.excludeFolders = plugin.settings.excludeFolders.filter( 229 | (folder) => (folder.path !== folderPath) && folder.showFolderNote); 230 | plugin.saveSettings(false); 231 | removeCSSClassFromFileExplorerEL(folderPath, 'show-folder-note-in-explorer', true, plugin); 232 | updateCSSClassesForFolder(folderPath, plugin); 233 | } 234 | 235 | export function setActiveFolder(folderPath: string, plugin: FolderNotesPlugin): void { 236 | const fileExplorerItem = getFileExplorerElement(folderPath, plugin); 237 | if (fileExplorerItem) { 238 | fileExplorerItem.addClass('fn-is-active'); 239 | plugin.activeFolderDom = fileExplorerItem; 240 | } 241 | } 242 | 243 | export function removeActiveFolder(plugin: FolderNotesPlugin): void { 244 | if (plugin.activeFolderDom) { 245 | plugin.activeFolderDom.removeClass('fn-is-active'); 246 | plugin.activeFolderDom?.removeClass('has-focus'); 247 | plugin.activeFolderDom = null; 248 | } 249 | } 250 | -------------------------------------------------------------------------------- /styles.css: -------------------------------------------------------------------------------- 1 | /* ========================================================================== 2 | General States & Utilities 3 | ========================================================================== */ 4 | 5 | .hide, 6 | .hide-folder .folder-name, 7 | .hide-folder-note .is-folder-note { 8 | display: none; 9 | } 10 | 11 | .pointer-cursor, 12 | .has-folder-note .nav-folder-title-content:hover, 13 | .has-folder-note.view-header-breadcrumb:hover, 14 | .nav-folder-collapse-indicator:hover, 15 | .fn-delete-confirmation-modal-buttons span:hover, 16 | .fn-delete-confirmation-modal-buttons input:hover { 17 | cursor: pointer !important; 18 | } 19 | 20 | 21 | /* ========================================================================== 22 | Tree Items 23 | ========================================================================== */ 24 | 25 | body:not(.is-grabbing) .tree-item-self.fn-is-active:hover, 26 | body:not(.disable-folder-highlight) .tree-item-self.fn-is-active { 27 | color: var(--nav-item-color-active); 28 | background-color: var(--nav-item-background-active); 29 | font-weight: var(--nav-item-weight-active); 30 | } 31 | 32 | 33 | /* ========================================================================== 34 | Exclude Folder Settings 35 | ========================================================================== */ 36 | 37 | .fn-excluded-folder-heading { 38 | margin-top: 0 !important; 39 | border-top: 1px solid var(--background-modifier-border); 40 | } 41 | 42 | .add-exclude-folder-item, 43 | .fn-exclude-folder-list { 44 | padding-bottom: 0 !important; 45 | } 46 | 47 | .fn-exclude-folder-list.setting-item { 48 | border-top: 0 !important; 49 | border-bottom: 0 !important; 50 | } 51 | 52 | .fn-exclude-folder-list .setting-item-control { 53 | display: flex; 54 | justify-content: flex-start !important; 55 | } 56 | 57 | .fn-exclude-folder-list .setting-item-info { 58 | display: none !important; 59 | } 60 | 61 | .fn-exclude-folder-list .search-input-container { 62 | width: 100%; 63 | } 64 | 65 | 66 | /* ========================================================================== 67 | Modal Styles 68 | ========================================================================== */ 69 | 70 | .fn-backup-warning-modal .fn-modal-button-container { 71 | display: flex; 72 | gap: 0.5rem; 73 | justify-content: flex-end; 74 | } 75 | 76 | .fn-confirmation-modal { 77 | padding-bottom: 0; 78 | } 79 | 80 | .fn-confirmation-modal .setting-item { 81 | border-top: 0 !important; 82 | padding-top: 0 !important; 83 | } 84 | 85 | :not(.is-phone) .fn-confirmation-modal-button { 86 | margin-right: 0.7rem; 87 | } 88 | 89 | :not(.is-phone) .fn-delete-confirmation-modal-buttons { 90 | display: flex; 91 | align-items: center; 92 | margin-top: 10px; 93 | } 94 | 95 | :not(.is-phone) .fn-delete-confirmation-modal-buttons .fn-confirmation-modal-button { 96 | margin-left: auto; 97 | } 98 | 99 | :not(.is-phone) .fn-delete-confirmation-modal-buttons input[type="checkbox"] { 100 | margin-right: 5px; 101 | } 102 | 103 | .is-phone .fn-delete-confirmation-modal-buttons { 104 | display: flex; 105 | flex-direction: column; 106 | align-items: center; 107 | } 108 | 109 | .is-phone .fn-delete-confirmation-modal-buttons .fn-confirmation-modal-button { 110 | margin-top: 10px; 111 | } 112 | 113 | 114 | /* ========================================================================== 115 | Folder Overview 116 | ========================================================================== */ 117 | 118 | .folder-overview-container.fv-remove-edit-button .folder-overview-edit-button { 119 | display: none; 120 | } 121 | 122 | .cm-line:has(.fv-link-list-item), 123 | li:has(.fv-link-list-item), 124 | .el-ul:has(.fv-link-list-item), 125 | .cm-line:has(.fv-link-list-start), 126 | .cm-line:has(.fv-link-list-end), 127 | .fv-hide-overview { 128 | display: none !important; 129 | } 130 | 131 | 132 | .folder-overview-list { 133 | margin-top: 0 !important; 134 | margin-bottom: 0 !important; 135 | padding-bottom: 1.200 !important; 136 | padding-top: 1.200 !important; 137 | } 138 | 139 | .folder-overview-list-item { 140 | display: flex; 141 | } 142 | 143 | .folder-overview-list::marker { 144 | color: var(--text-faint); 145 | } 146 | 147 | .folder-list::marker { 148 | color: var(--text-normal) !important; 149 | } 150 | 151 | .folder-overview-grid { 152 | display: grid; 153 | grid-gap: 20px; 154 | grid-template-columns: repeat(3, 1fr); 155 | } 156 | 157 | .folder-overview-grid-item { 158 | flex: 1 1 auto; 159 | margin: 0 1.2rem 1.2rem 0; 160 | } 161 | 162 | .folder-overview-grid-item-article article { 163 | display: flex; 164 | flex-direction: column; 165 | justify-content: space-between; 166 | padding: 15px; 167 | flex: 1; 168 | } 169 | 170 | .folder-overview-grid-item-article a { 171 | text-decoration: none !important; 172 | } 173 | 174 | .folder-overview-grid-item-article h1 { 175 | font-size: 1.2rem; 176 | } 177 | 178 | .overview-setting-item-fv { 179 | border-top: 1px solid var(--background-modifier-border); 180 | padding: 0.75em 0; 181 | align-items: center; 182 | } 183 | 184 | .overview-setting-item-fv .setting-item { 185 | padding: 0; 186 | } 187 | 188 | 189 | /* ========================================================================== 190 | File Explorer & Path Styling 191 | ========================================================================== */ 192 | 193 | .folder-note-underline .has-folder-note .nav-folder-title-content { 194 | text-decoration-line: underline; 195 | text-decoration-color: var(--text-faint); 196 | text-decoration-thickness: 2px; 197 | text-underline-offset: 1px; 198 | } 199 | 200 | .folder-note-underline-path .has-folder-note.view-header-breadcrumb { 201 | text-decoration-line: underline; 202 | text-decoration-color: var(--text-faint); 203 | text-decoration-thickness: 1px; 204 | text-underline-offset: 2px; 205 | } 206 | 207 | .folder-note-bold .has-folder-note .nav-folder-title-content, 208 | .folder-note-bold-path .has-folder-note.view-header-breadcrumb { 209 | font-weight: bold; 210 | } 211 | 212 | .folder-note-cursive .has-folder-note .nav-folder-title-content, 213 | .folder-note-cursive-path .has-folder-note.view-header-breadcrumb { 214 | font-style: italic; 215 | } 216 | 217 | 218 | /* Collapse Icon Handling */ 219 | 220 | .fn-folder-overview-collapse-icon { 221 | display: block !important; 222 | } 223 | 224 | .fn-has-no-files .collapse-icon, 225 | .fn-hide-collapse-icon .has-folder-note.only-has-folder-note .tree-item-icon, 226 | body.fn-ignore-attachment-folder.fn-hide-collapse-icon .only-has-folder-note .fn-empty-folder.fn-has-attachment-folder .tree-item-icon, 227 | body.fn-hide-collapse-icon .only-has-folder-note .fn-empty-folder:not(.fn-has-attachment-folder) .tree-item-icon, 228 | body.fn-hide-empty-collapse-icon :not(.only-has-folder-note) > .fn-empty-folder:not(.fn-has-attachment-folder) .tree-item-icon, 229 | body.fn-hide-collapse-icon.only-has-folder-note:not(.is-collapsed):not(.show-folder-note-in-explorer)>.nav-folder-children { 230 | display: none; 231 | } 232 | 233 | 234 | /* ========================================================================== 235 | Settings Tabs 236 | ========================================================================== */ 237 | 238 | .fn-settings-tab-bar { 239 | display: flex; 240 | flex-direction: row; 241 | padding-bottom: 1rem; 242 | } 243 | 244 | .fn-settings-tab { 245 | display: flex; 246 | flex-direction: row; 247 | align-items: center; 248 | gap: var(--size-4-2); 249 | padding: 10px; 250 | border: 1px solid var(--background-modifier-border); 251 | } 252 | 253 | .fn-settings-tab-active { 254 | background-color: var(--color-accent); 255 | color: var(--text-on-accent); 256 | } 257 | 258 | .fn-settings-tab-name { 259 | font-weight: bold; 260 | } 261 | 262 | .fn-settings-tab-icon { 263 | display: flex; 264 | } 265 | 266 | 267 | /* ========================================================================== 268 | Suggestion Container 269 | ========================================================================== */ 270 | 271 | .fn-suggestion-container { 272 | position: absolute; 273 | overflow: hidden; 274 | display: flex; 275 | flex-direction: column; 276 | background-color: var(--background-primary); 277 | max-width: 500px; 278 | max-height: 300px; 279 | border-radius: var(--radius-m); 280 | border: 1px solid var(--background-modifier-border); 281 | box-shadow: var(--shadow-s); 282 | z-index: var(--layer-notice); 283 | } 284 | 285 | 286 | /* ========================================================================== 287 | Whitelist Folder Input (Desktop & Mobile) 288 | ========================================================================== */ 289 | 290 | /* Default Desktop Layout */ 291 | .fn-whitelist-folder-input-container { 292 | display: flex; 293 | justify-content: space-between; 294 | align-items: center; 295 | width: 100%; 296 | margin: 0; 297 | } 298 | 299 | .fn-whitelist-folder-input-container input { 300 | flex-grow: 1; 301 | width: auto; 302 | margin-right: 8px; 303 | height: 40px; 304 | box-sizing: border-box; 305 | } 306 | 307 | .fn-whitelist-folder-buttons { 308 | display: flex; 309 | gap: 8px; 310 | justify-content: flex-end; 311 | align-items: center; 312 | flex-grow: 0; 313 | flex-shrink: 0; 314 | } 315 | 316 | /* Mobile Overrides */ 317 | @media (max-width: 768px) { 318 | .fn-whitelist-folder-input-container { 319 | display: block; 320 | width: 100%; 321 | text-align: center; 322 | } 323 | 324 | .fn-whitelist-folder-input-container input { 325 | width: 100%; 326 | margin-right: 0; 327 | } 328 | 329 | .fn-whitelist-folder-buttons { 330 | display: flex; 331 | flex-direction: row; 332 | justify-content: flex-start; 333 | align-items: center; 334 | width: 100%; 335 | } 336 | 337 | .is-phone .fn-overview-folder-path .setting-item-control { 338 | display: block; 339 | } 340 | } --------------------------------------------------------------------------------