├── .editorconfig ├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── .prettierrc ├── LICENSE ├── README.md ├── esbuild.config.mjs ├── manifest.json ├── package.json ├── src ├── Extension.ts ├── FileList.ts ├── Formatter.ts ├── Generator.ts ├── Setting.ts ├── Uncover.ts ├── Util.ts ├── main.ts └── suggesters │ ├── FileSuggester.ts │ ├── FolderSuggester.ts │ └── suggest.ts ├── styles.css ├── tsconfig.json └── versions.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | insert_final_newline = true 6 | indent_style = tab 7 | indent_size = 4 8 | tab_width = 4 9 | end_of_line = lf 10 | trim_trailing_whitespace = true 11 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | .eslintrc.js 2 | dst 3 | main.js 4 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es2021: true, 5 | }, 6 | extends: [ 7 | "eslint:recommended", 8 | "plugin:react/recommended", 9 | "plugin:@typescript-eslint/recommended", 10 | "prettier", 11 | ], 12 | parser: "@typescript-eslint/parser", 13 | parserOptions: { 14 | ecmaFeatures: { 15 | jsx: true, 16 | }, 17 | ecmaVersion: 13, 18 | sourceType: "module", 19 | }, 20 | plugins: ["react", "@typescript-eslint"], 21 | rules: { 22 | "no-console": process.env.NODE_ENV === "production" ? 2 : 0, 23 | "@typescript-eslint/explicit-module-boundary-types": 0, 24 | "no-unused-vars": ["error", { argsIgnorePattern: "^_" }], 25 | }, 26 | settings: { 27 | react: { 28 | version: "detect", 29 | }, 30 | }, 31 | }; 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # vscode 2 | .vscode 3 | .devcontainer 4 | 5 | # Dockerfile 6 | Dockerfile 7 | 8 | # npm 9 | node_modules 10 | package-lock.json 11 | 12 | # Don't include the compiled main.js file in the repo. 13 | # They should be uploaded to GitHub releases instead. 14 | main.js 15 | 16 | # Exclude sourcemaps 17 | *.map 18 | 19 | # obsidian 20 | data.json 21 | 22 | # file generated by this plugin 23 | .binary-file-manager_binary-file-list.txt 24 | 25 | # other 26 | .DS_Store 27 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "semi": true, 4 | "singleQuote": true 5 | } 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 qawatake 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Binary File Manager Plugin 2 | 3 | This plugin detects new binary files in the vault and create markdown files with metadata. 4 | 5 | By using metadata files, you can take advantage of the rich functionality provied by Obsidian such as 6 | - full text search, 7 | - tags and aliases, 8 | - internal links, and so on. 9 | 10 | For example, if you add tags to the metadata of an image file, then you can indirectly access the image file by tag-searching (and following an internal link in the metadata). 11 | 12 | [![Image from Gyazo](https://i.gyazo.com/6c46d863e4c31d0815bcf027fdb48f92.gif)](https://gyazo.com/6c46d863e4c31d0815bcf027fdb48f92) 13 | 14 | ### Quick start 15 | 1. Install and enable this plugin. 16 | 2. Go to the setting tab of Binary File Manager and enable auto detection. 17 | 3. Add a static file like `sample.pdf` to your vault. 18 | 19 | Then you will find a meta data file `INFO_sample_PDF.md` in the root directory. 20 | You can customize the new file location and the templates for names and contents of metadata files. 21 | 22 | ### Format syntax 23 | You can use the following syntax to format the names and contents of metadata files. 24 | #### Date 25 | | Syntax | Description | 26 | | -- | -- | 27 | | `{{CDATE:}}` | Creation time of the static file. | 28 | | `{{NOW:}}` | Current time. | 29 | 30 | - Replace `` by a [Moment.js format](https://momentjs.com/docs/#/displaying/format/). 31 | 32 | #### Link 33 | | Syntax | Description | 34 | | -- | -- | 35 | | `{{LINK}}` | Internal link like `[[image.png]]` | 36 | | `{{EMBED}}` | Embedded link like `![[image.png]]` | 37 | 38 | #### Path 39 | | Syntax | Description | 40 | | -- | -- | 41 | | `{{PATH}}` | Path of a static file. | 42 | | `{{FULLNAME}}` | Name of a static file. | 43 | | `{{NAME}}` | Name of a static file with extension removed. | 44 | | `{{EXTENSION}}` | Extension of a static file. | 45 | 46 | - You can choose between uppercase and lowercase letters by adding suffixes `:UP` and `:LOW`, respectively. For example, `{{NAME:UP}}`. 47 | 48 | ### Templater plugin support 49 | You can also use [Templater plugin](https://github.com/SilentVoid13/Templater) to format your meta data files. 50 | Just install Templater plugin and set `Use Templater` in the setting tab of Binary File Manager. 51 | -------------------------------------------------------------------------------- /esbuild.config.mjs: -------------------------------------------------------------------------------- 1 | import esbuild from 'esbuild'; 2 | import process from 'process'; 3 | import builtins from 'builtin-modules'; 4 | 5 | const banner = `/* 6 | THIS IS A GENERATED/BUNDLED FILE BY ESBUILD 7 | if you want to view the source, please visit the github repository of this plugin 8 | */ 9 | `; 10 | 11 | const prod = process.argv[2] === 'production'; 12 | 13 | esbuild 14 | .build({ 15 | banner: { 16 | js: banner, 17 | }, 18 | entryPoints: ['src/main.ts'], 19 | bundle: true, 20 | external: ['obsidian', 'electron', ...builtins], 21 | format: 'cjs', 22 | watch: !prod, 23 | target: 'es2016', 24 | logLevel: 'info', 25 | sourcemap: prod ? false : 'inline', 26 | treeShaking: true, 27 | outfile: 'main.js', 28 | }) 29 | .catch(() => process.exit(1)); 30 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "obsidian-binary-file-manager-plugin", 3 | "name": "Binary File Manager", 4 | "version": "0.3.0", 5 | "minAppVersion": "0.12.0", 6 | "description": "Detects new binary files in the vault and create markdown files with metadata.", 7 | "author": "qawatake", 8 | "authorUrl": "https://github.com/qawatake/obsidian-binary-file-manager-plugin", 9 | "isDesktopOnly": false 10 | } 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "obsidian-binary-file-manager-plugin", 3 | "version": "0.12.0", 4 | "description": "This plugin detects new binary files in the vault and create markdown files with metadata.", 5 | "main": "main.js", 6 | "scripts": { 7 | "fix:prettier": "prettier --write src", 8 | "lint:prettier": "prettier --check src", 9 | "fix": "run-s fix:prettier fix:eslint", 10 | "fix:eslint": "eslint src --ext .ts --fix", 11 | "lint": "run-p lint:prettier lint:eslint", 12 | "lint:eslint": "eslint src --ext .ts", 13 | "dev": "node esbuild.config.mjs", 14 | "build": "node esbuild.config.mjs production" 15 | }, 16 | "keywords": [], 17 | "author": "qawatake", 18 | "license": "MIT", 19 | "devDependencies": { 20 | "@types/node": "^17.0.5", 21 | "@typescript-eslint/eslint-plugin": "^5.8.1", 22 | "@typescript-eslint/parser": "^5.8.1", 23 | "builtin-modules": "^3.2.0", 24 | "esbuild": "~0.13.12", 25 | "eslint": "^8.5.0", 26 | "eslint-config-prettier": "^8.3.0", 27 | "eslint-plugin-react": "^7.28.0", 28 | "npm-run-all": "^4.1.5", 29 | "obsidian": "^0.13.11", 30 | "prettier": "^2.5.1", 31 | "tslib": "^2.3.1", 32 | "typescript": "^4.5.4" 33 | }, 34 | "dependencies": { 35 | "@popperjs/core": "^2.11.0" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Extension.ts: -------------------------------------------------------------------------------- 1 | import BinaryFileManagerPlugin from 'main'; 2 | 3 | export class FileExtensionManager { 4 | private plugin: BinaryFileManagerPlugin; 5 | private extensions: Set; 6 | 7 | constructor(plugin: BinaryFileManagerPlugin) { 8 | this.plugin = plugin; 9 | this.extensions = new Set(this.plugin.settings.extensions); 10 | } 11 | 12 | public getExtensionMatchedBest(filename: string): string | undefined { 13 | // investigate extensions from longer to shorter 14 | for (let id = 0; id < filename.length; id++) { 15 | if (filename[id] !== '.') { 16 | continue; 17 | } 18 | const ext = filename.slice(id).replace(/^\./, ''); 19 | if (ext === '') { 20 | return undefined; 21 | } 22 | if (this.extensions.has(ext)) { 23 | return ext; 24 | } 25 | } 26 | return undefined; 27 | } 28 | 29 | public add(ext: string): void { 30 | this.extensions.add(ext); 31 | } 32 | 33 | public delete(ext: string): void { 34 | this.extensions.delete(ext); 35 | } 36 | 37 | public has(ext: string): boolean { 38 | return this.extensions.has(ext); 39 | } 40 | 41 | public verify(filepath: string): boolean { 42 | // i want to use return so avoid to use forEach 43 | for (const ext of this.extensions) { 44 | if (filepath.endsWith('.' + ext)) { 45 | return true; 46 | } 47 | } 48 | return false; 49 | } 50 | 51 | public toArray(): string[] { 52 | return Array.from(this.extensions); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/FileList.ts: -------------------------------------------------------------------------------- 1 | import BinaryFileManagerPlugin from 'main'; 2 | import { App, normalizePath } from 'obsidian'; 3 | 4 | const PLUGIN_NAME = 'obsidian-binary-file-manager-plugin'; 5 | const REGISTERED_BINARY_FILE_STORAGE_FILE_NAME = 6 | '.binary-file-manager_binary-file-list.txt'; 7 | 8 | export class FileListAdapter { 9 | private app: App; 10 | private plugin: BinaryFileManagerPlugin; 11 | private registeredBinaryFilePaths: Set; 12 | 13 | constructor(app: App, plugin: BinaryFileManagerPlugin) { 14 | this.app = app; 15 | this.plugin = plugin; 16 | this.registeredBinaryFilePaths = new Set(); 17 | this.app.workspace.onLayoutReady(async () => { 18 | this.deleteNonExistingBinaryFiles(); 19 | }); 20 | } 21 | 22 | async load(): Promise { 23 | await this.loadBinaryFiles(); 24 | return this; 25 | } 26 | 27 | async save() { 28 | await this.saveBinaryFiles(); 29 | } 30 | 31 | add(filepath: string): void { 32 | this.registeredBinaryFilePaths.add(filepath); 33 | } 34 | 35 | delete(filepath: string): void { 36 | this.registeredBinaryFilePaths.delete(filepath); 37 | } 38 | 39 | has(filepath: string): boolean { 40 | return this.registeredBinaryFilePaths.has(filepath); 41 | } 42 | 43 | deleteAll(): void { 44 | this.registeredBinaryFilePaths = new Set(); 45 | } 46 | 47 | private async loadBinaryFiles() { 48 | const configDir = this.app.vault.configDir; 49 | const storageFilePath = normalizePath( 50 | `${configDir}/plugins/${PLUGIN_NAME}/${REGISTERED_BINARY_FILE_STORAGE_FILE_NAME}` 51 | ); 52 | 53 | if (!(await this.app.vault.adapter.exists(storageFilePath))) { 54 | this.registeredBinaryFilePaths = new Set(); 55 | return; 56 | } 57 | 58 | const binaryFiles = (await this.app.vault.adapter.read(storageFilePath)) 59 | .trim() 60 | .split(/\r?\n/); 61 | this.registeredBinaryFilePaths = new Set(binaryFiles); 62 | } 63 | 64 | private async saveBinaryFiles() { 65 | const configDir = this.app.vault.configDir; 66 | const storageFilePath = normalizePath( 67 | `${configDir}/plugins/${PLUGIN_NAME}/${REGISTERED_BINARY_FILE_STORAGE_FILE_NAME}` 68 | ); 69 | 70 | await this.app.vault.adapter.write( 71 | storageFilePath, 72 | Array.from(this.registeredBinaryFilePaths).join('\n') 73 | ); 74 | } 75 | 76 | private async deleteNonExistingBinaryFiles() { 77 | const difference = new Set(this.registeredBinaryFilePaths); 78 | for (const file of this.app.vault.getFiles()) { 79 | difference.delete(file.path); 80 | } 81 | for (const fileToBeUnregistered of difference) { 82 | this.registeredBinaryFilePaths.delete(fileToBeUnregistered); 83 | } 84 | await this.saveBinaryFiles(); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/Formatter.ts: -------------------------------------------------------------------------------- 1 | import BinaryFileManagerPlugin from 'main'; 2 | import { App, moment } from 'obsidian'; 3 | const DATE_REGEXP = /{{CDATE:([^}\n\r]*)}}/g; 4 | const NAME_REGEX = /{{NAME(((:UP)|(:LOW))?)}}/g; 5 | const FULLNAME_REGEX = /{{FULLNAME(((:UP)|(:LOW))?)}}/g; 6 | const EXTENSION_REGEX = /{{EXTENSION(((:UP)|(:LOW))?)}}/g; 7 | const PATH_REGEX = /{{PATH(((:UP)|(:LOW))?)}}/g; 8 | const NOW_REGEXP = /{{NOW:([^}\n\r]*)}}/g; 9 | const LINK_SYNTAX = '{{LINK}}'; 10 | const EMBED_SYNTAX = '{{EMBED}}'; 11 | 12 | export class Formatter { 13 | app: App; 14 | plugin: BinaryFileManagerPlugin; 15 | 16 | constructor(app: App, plugin: BinaryFileManagerPlugin) { 17 | this.app = app; 18 | this.plugin = plugin; 19 | } 20 | 21 | format(input: string, filepath: string, createdAt: number): string { 22 | let output = input; 23 | output = this.replaceDate(output, createdAt); 24 | output = this.replaceNow(output); 25 | const fullname = basename(filepath); 26 | const extension = 27 | this.plugin.fileExtensionManager.getExtensionMatchedBest( 28 | fullname 29 | ) ?? ''; 30 | const nameWithoutExtension = basename(fullname, extension); // add "." to get like ".png" 31 | output = this.replacePath(output, filepath); 32 | output = this.replaceFullName(output, fullname); 33 | output = this.replaceName(output, nameWithoutExtension); 34 | output = this.replaceExtension(output, extension); 35 | output = this.replaceLink(output, filepath); 36 | output = this.replaceEmbed(output, filepath); 37 | return output; 38 | } 39 | 40 | private replaceDate(input: string, unixMilliSecond: number): string { 41 | const output = input.replace( 42 | DATE_REGEXP, 43 | (_matched: string, fmt: string): string => { 44 | return moment(unixMilliSecond).format(fmt); 45 | } 46 | ); 47 | return output; 48 | } 49 | 50 | private replaceFullName(input: string, fullname: string): string { 51 | return input.replace( 52 | FULLNAME_REGEX, 53 | (_matched: string, caseMode: string): string => { 54 | if (!caseMode) { 55 | return fullname; 56 | } else if (caseMode == ':UP') { 57 | return fullname.toUpperCase(); 58 | } else { 59 | return fullname.toLowerCase(); 60 | } 61 | } 62 | ); 63 | } 64 | 65 | private replaceName(input: string, filename: string): string { 66 | return input.replace( 67 | NAME_REGEX, 68 | (_matched: string, caseMode: string): string => { 69 | if (!caseMode) { 70 | return filename; 71 | } else if (caseMode == ':UP') { 72 | return filename.toUpperCase(); 73 | } else { 74 | return filename.toLowerCase(); 75 | } 76 | } 77 | ); 78 | } 79 | 80 | private replaceExtension(input: string, extension: string): string { 81 | return input.replace( 82 | EXTENSION_REGEX, 83 | (_matched: string, caseMode: string): string => { 84 | if (!caseMode) { 85 | return extension; 86 | } else if (caseMode == ':UP') { 87 | return extension.toUpperCase(); 88 | } else { 89 | return extension.toLowerCase(); 90 | } 91 | } 92 | ); 93 | } 94 | 95 | private replaceNow(input: string): string { 96 | const currentDate = moment(); 97 | return input.replace( 98 | NOW_REGEXP, 99 | (_matched: string, fmt: string): string => { 100 | return currentDate.format(fmt); 101 | } 102 | ); 103 | } 104 | 105 | private replacePath(input: string, filepath: string): string { 106 | return input.replace( 107 | PATH_REGEX, 108 | (_matched: string, caseMode: string): string => { 109 | if (!caseMode) { 110 | return filepath; 111 | } else if (caseMode == ':UP') { 112 | return filepath.toUpperCase(); 113 | } else { 114 | return filepath.toLowerCase(); 115 | } 116 | } 117 | ); 118 | } 119 | 120 | private replaceLink(input: string, filepath: string): string { 121 | return input.replace(LINK_SYNTAX, `[[${filepath}]]`); 122 | } 123 | 124 | private replaceEmbed(input: string, filepath: string): string { 125 | return input.replace(EMBED_SYNTAX, `![[${filepath}]]`); 126 | } 127 | } 128 | 129 | function cleanPath(filepath: string): string { 130 | // Always use forward slash 131 | let cleanedPath = filepath; 132 | cleanedPath = cleanedPath.replace(/\\/g, '/'); 133 | 134 | // Use '/' for root 135 | if (cleanedPath === '') { 136 | cleanedPath = '/'; 137 | } 138 | 139 | // Trim start slash 140 | for (let i = 0; i < cleanedPath.length; i++) { 141 | if (cleanedPath[i] !== '/') { 142 | cleanedPath = cleanedPath.substring(i); 143 | break; 144 | } else if (i === cleanedPath.length - 1) { 145 | cleanedPath = '/'; 146 | break; 147 | } 148 | } 149 | 150 | // Trim end slash 151 | for (let i = cleanedPath.length - 1; i >= 0; i--) { 152 | if (cleanedPath[i] !== '/') { 153 | break; 154 | } else if (i === 0) { 155 | cleanedPath = '/'; 156 | break; 157 | } 158 | } 159 | return cleanedPath; 160 | } 161 | 162 | // extension is like 'png' not '.png' 163 | function basename(filepath: string, extension?: string): string { 164 | const segments = cleanPath(filepath).split('/'); 165 | const filename = segments.last() ?? ''; 166 | const ext = extension ?? ''; 167 | return filename.replace(new RegExp(`\\.${ext}$`), ''); 168 | } 169 | -------------------------------------------------------------------------------- /src/Generator.ts: -------------------------------------------------------------------------------- 1 | import BinaryFileManagerPlugin from 'main'; 2 | import { 3 | App, 4 | normalizePath, 5 | TAbstractFile, 6 | TFile, 7 | moment, 8 | Notice, 9 | Plugin, 10 | } from 'obsidian'; 11 | import { UncoveredApp } from 'Uncover'; 12 | import { retry } from 'Util'; 13 | 14 | const TEMPLATER_PLUGIN_NAME = 'templater-obsidian'; 15 | const DEFAULT_TEMPLATE_CONTENT = `![[{{PATH}}]] 16 | LINK: [[{{PATH}}]] 17 | CREATED At: {{CDATE:YYYY-MM-DD}} 18 | FILE TYPE: {{EXTENSION:UP}} 19 | `; 20 | 21 | const RETRY_NUMBER = 1000; 22 | const TIMEOUT_MILLISECOND = 1000; 23 | 24 | export class MetaDataGenerator { 25 | private app: App; 26 | private plugin: BinaryFileManagerPlugin; 27 | 28 | constructor(app: App, plugin: BinaryFileManagerPlugin) { 29 | this.app = app; 30 | this.plugin = plugin; 31 | } 32 | 33 | async shouldCreateMetaDataFile(file: TAbstractFile): Promise { 34 | if (!(file instanceof TFile)) { 35 | return false; 36 | } 37 | 38 | const matchedExtension = 39 | this.plugin.fileExtensionManager.getExtensionMatchedBest(file.name); 40 | if (!matchedExtension) { 41 | return false; 42 | } 43 | 44 | if (this.plugin.fileListAdapter.has(file.path)) { 45 | return false; 46 | } 47 | 48 | return true; 49 | } 50 | 51 | async create(file: TFile) { 52 | const metaDataFileName = this.uniquefyMetaDataFileName( 53 | this.generateMetaDataFileName(file) 54 | ); 55 | const metaDataFilePath = `${this.plugin.settings.folder}/${metaDataFileName}`; 56 | 57 | await this.createMetaDataFile(metaDataFilePath, file as TFile); 58 | } 59 | 60 | private generateMetaDataFileName(file: TFile): string { 61 | const metaDataFileName = `${this.plugin.formatter.format( 62 | this.plugin.settings.filenameFormat, 63 | file.path, 64 | file.stat.ctime 65 | )}.md`; 66 | return metaDataFileName; 67 | } 68 | 69 | private uniquefyMetaDataFileName(metaDataFileName: string): string { 70 | const metaDataFilePath = normalizePath( 71 | `${this.plugin.settings.folder}/${metaDataFileName}` 72 | ); 73 | if (this.app.vault.getAbstractFileByPath(metaDataFilePath)) { 74 | return `CONFLICT-${moment().format( 75 | 'YYYY-MM-DD-hh-mm-ss' 76 | )}-${metaDataFileName}`; 77 | } else { 78 | return metaDataFileName; 79 | } 80 | } 81 | 82 | private async createMetaDataFile( 83 | metaDataFilePath: string, 84 | binaryFile: TFile 85 | ): Promise { 86 | const templateContent = await this.fetchTemplateContent(); 87 | 88 | // process by Templater 89 | const templaterPlugin = await this.getTemplaterPlugin(); 90 | if (!(this.plugin.settings.useTemplater && templaterPlugin)) { 91 | this.app.vault.create( 92 | metaDataFilePath, 93 | this.plugin.formatter.format( 94 | templateContent, 95 | binaryFile.path, 96 | binaryFile.stat.ctime 97 | ) 98 | ); 99 | } else { 100 | const targetFile = await this.app.vault.create( 101 | metaDataFilePath, 102 | '' 103 | ); 104 | 105 | try { 106 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 107 | // @ts-ignore 108 | const content = await templaterPlugin.templater.parse_template( 109 | { target_file: targetFile, run_mode: 4 }, 110 | this.plugin.formatter.format( 111 | templateContent, 112 | binaryFile.path, 113 | binaryFile.stat.ctime 114 | ) 115 | ); 116 | this.app.vault.modify(targetFile, content); 117 | } catch (err) { 118 | new Notice( 119 | 'ERROR in Binary File Manager Plugin: failed to connect to Templater. Your Templater version may not be supported' 120 | ); 121 | console.log(err); 122 | } 123 | } 124 | } 125 | 126 | private async fetchTemplateContent(): Promise { 127 | if (this.plugin.settings.templatePath === '') { 128 | return DEFAULT_TEMPLATE_CONTENT; 129 | } 130 | 131 | const templateFile = await retry( 132 | () => { 133 | return this.app.vault.getAbstractFileByPath( 134 | this.plugin.settings.templatePath 135 | ); 136 | }, 137 | TIMEOUT_MILLISECOND, 138 | RETRY_NUMBER, 139 | (abstractFile) => abstractFile !== null 140 | ); 141 | 142 | if (!(templateFile instanceof TFile)) { 143 | const msg = `Template file ${this.plugin.settings.templatePath} is invalid`; 144 | console.log(msg); 145 | new Notice(msg); 146 | return DEFAULT_TEMPLATE_CONTENT; 147 | } 148 | return await this.app.vault.read(templateFile); 149 | } 150 | 151 | private async getTemplaterPlugin(): Promise { 152 | const app = this.app as UncoveredApp; 153 | return await retry( 154 | () => { 155 | return app.plugins.plugins[TEMPLATER_PLUGIN_NAME]; 156 | }, 157 | TIMEOUT_MILLISECOND, 158 | RETRY_NUMBER 159 | ); 160 | } 161 | 162 | findUnlinkedBinaries(): TFile[] { 163 | const unlinkedBinaries: TFile[] = []; 164 | const linkedPaths = new Set(); 165 | 166 | // collect all link destinations 167 | Object.values(this.app.metadataCache.resolvedLinks).forEach((links) => { 168 | Object.keys(links).forEach((dest) => { 169 | linkedPaths.add(dest); 170 | }); 171 | }); 172 | 173 | // collect only unlinked binaries 174 | this.app.vault.getFiles().forEach((file) => { 175 | const isUnlinkedBinary = 176 | !linkedPaths.has(file.path) && 177 | this.plugin.fileExtensionManager.verify(file.path); 178 | if (isUnlinkedBinary) { 179 | unlinkedBinaries.push(file); 180 | } 181 | }); 182 | 183 | return unlinkedBinaries; 184 | } 185 | } 186 | -------------------------------------------------------------------------------- /src/Setting.ts: -------------------------------------------------------------------------------- 1 | import BinaryFileManagerPlugin from 'main'; 2 | import { 3 | PluginSettingTab, 4 | Setting, 5 | App, 6 | Notice, 7 | moment, 8 | Modal, 9 | ButtonComponent, 10 | } from 'obsidian'; 11 | import { FolderSuggest } from 'suggesters/FolderSuggester'; 12 | import { FileSuggest } from 'suggesters/FileSuggester'; 13 | import { validFileName } from 'Util'; 14 | 15 | export class BinaryFileManagerSettingTab extends PluginSettingTab { 16 | plugin: BinaryFileManagerPlugin; 17 | 18 | constructor(app: App, plugin: BinaryFileManagerPlugin) { 19 | super(app, plugin); 20 | this.plugin = plugin; 21 | } 22 | 23 | display(): void { 24 | const { containerEl } = this; 25 | 26 | containerEl.empty(); 27 | 28 | new Setting(containerEl) 29 | .setName('Enable auto detection') 30 | .setDesc( 31 | 'Detects new binary files and create metadata automatically.' 32 | ) 33 | .addToggle((component) => { 34 | component 35 | .setValue(this.plugin.settings.autoDetection) 36 | .onChange(async (value: boolean) => { 37 | this.plugin.settings.autoDetection = value; 38 | await this.plugin.saveSettings(); 39 | }); 40 | }); 41 | 42 | new Setting(containerEl) 43 | .setName('New file location') 44 | .setDesc('New metadata file will be placed here') 45 | .addSearch((component) => { 46 | new FolderSuggest(this.app, component.inputEl); 47 | component 48 | .setPlaceholder('Example: folder1/folder2') 49 | .setValue(this.plugin.settings.folder) 50 | .onChange((newFolder) => { 51 | this.plugin.settings.folder = newFolder; 52 | this.plugin.saveSettings(); 53 | }); 54 | }); 55 | 56 | new Setting(containerEl).setName('File name format').then((setting) => { 57 | setting.addText((component) => { 58 | component 59 | .setValue(this.plugin.settings.filenameFormat) 60 | .onChange((input) => { 61 | const newFormat = input.trim().replace(/\.md$/, ''); 62 | if (newFormat === '') { 63 | new Notice('File name format must not be blanck'); 64 | return; 65 | } 66 | 67 | const sampleFileName = this.plugin.formatter.format( 68 | newFormat, 69 | 'folder/sample.png', 70 | moment.now() 71 | ); 72 | 73 | this.displaySampleFileNameDesc( 74 | setting.descEl, 75 | sampleFileName 76 | ); 77 | 78 | // check if file name contains valid letters like "/" or ":" 79 | const { valid } = validFileName(sampleFileName); 80 | if (!valid) { 81 | return; 82 | } 83 | 84 | this.plugin.settings.filenameFormat = newFormat; 85 | this.plugin.saveSettings(); 86 | }); 87 | 88 | const sampleFileName = this.plugin.formatter.format( 89 | this.plugin.settings.filenameFormat, 90 | 'folder/sample.png', 91 | moment.now() 92 | ); 93 | this.displaySampleFileNameDesc(setting.descEl, sampleFileName); 94 | }); 95 | }); 96 | 97 | new Setting(containerEl) 98 | .setName('Template file location') 99 | .addSearch((component) => { 100 | new FileSuggest(this.app, component.inputEl); 101 | component 102 | .setPlaceholder('Example: folder1/note') 103 | .setValue(this.plugin.settings.templatePath) 104 | .onChange((newTemplateFile) => { 105 | this.plugin.settings.templatePath = newTemplateFile; 106 | this.plugin.saveSettings(); 107 | }); 108 | }); 109 | 110 | new Setting(containerEl) 111 | .setName('Use Templater') 112 | .addToggle(async (component) => { 113 | component 114 | .setValue(this.plugin.settings.useTemplater) 115 | .onChange((value) => { 116 | this.plugin.settings.useTemplater = value; 117 | this.plugin.saveSettings(); 118 | }); 119 | }); 120 | 121 | let extensionToBeAdded: string; 122 | new Setting(containerEl) 123 | .setName('Extension to be watched') 124 | .addText((text) => 125 | text.setPlaceholder('Example: pdf').onChange((value) => { 126 | extensionToBeAdded = value.trim().replace(/^\./, ''); 127 | }) 128 | ) 129 | .addButton((cb) => { 130 | cb.setButtonText('Add').onClick(async () => { 131 | if (extensionToBeAdded === 'md') { 132 | new Notice('extension "md" is prohibited'); 133 | return; 134 | } 135 | if ( 136 | this.plugin.fileExtensionManager.has(extensionToBeAdded) 137 | ) { 138 | new Notice( 139 | `${extensionToBeAdded} is already registered` 140 | ); 141 | return; 142 | } 143 | this.plugin.fileExtensionManager.add(extensionToBeAdded); 144 | this.plugin.settings.extensions.push(extensionToBeAdded); 145 | await this.plugin.saveSettings(); 146 | this.display(); 147 | }); 148 | }); 149 | 150 | this.plugin.settings.extensions.forEach((ext) => { 151 | new Setting(containerEl).setName(ext).addExtraButton((cb) => { 152 | cb.setIcon('cross').onClick(async () => { 153 | this.plugin.fileExtensionManager.delete(ext); 154 | this.plugin.settings.extensions = 155 | this.plugin.fileExtensionManager.toArray(); 156 | await this.plugin.saveSettings(); 157 | this.display(); 158 | }); 159 | }); 160 | }); 161 | 162 | new Setting(containerEl) 163 | .setName('Forget all binary files') 164 | .setDesc( 165 | 'Binary File Manager remembers binary files for which it has created metadata. If it forgets, then it recognizes all binary files as newly created files and tries to create their metadata again.' 166 | ) 167 | .addButton((component) => { 168 | component 169 | .setButtonText('Forget') 170 | .setWarning() 171 | .onClick(() => { 172 | new ForgetAllModal(this.app, this.plugin).open(); 173 | }); 174 | }); 175 | } 176 | 177 | displaySampleFileNameDesc( 178 | descEl: HTMLElement, 179 | sampleFileName: string 180 | ): void { 181 | descEl.empty(); 182 | descEl.appendChild( 183 | createFragment((fragment) => { 184 | fragment.appendText('For more syntax, refer to '); 185 | fragment.createEl('a', { 186 | href: 'https://github.com/qawatake/obsidian-binary-file-manager-plugin#format-syntax', 187 | text: 'format reference', 188 | }); 189 | fragment.createEl('br'); 190 | fragment.appendText('Your current syntax looks like this: '); 191 | fragment.createEl('b', { 192 | text: sampleFileName, 193 | }); 194 | 195 | const { valid, included } = validFileName(sampleFileName); 196 | if (!valid && included !== undefined) { 197 | fragment.createEl('br'); 198 | const msgEl = fragment.createEl('span'); 199 | msgEl.appendText(`${included} must not be included`); 200 | msgEl.addClass('binary-file-manager-text-error'); 201 | } 202 | }) 203 | ); 204 | } 205 | } 206 | 207 | class ForgetAllModal extends Modal { 208 | plugin: BinaryFileManagerPlugin; 209 | 210 | constructor(app: App, plugin: BinaryFileManagerPlugin) { 211 | super(app); 212 | this.plugin = plugin; 213 | } 214 | 215 | override onOpen() { 216 | const { contentEl, titleEl } = this; 217 | titleEl.setText('Forget all'); 218 | contentEl 219 | .createEl('p', { 220 | text: 'Are you sure? You cannot undo this action.', 221 | }) 222 | .addClass('mod-warning'); 223 | 224 | const buttonContainerEl = contentEl.createEl('div'); 225 | buttonContainerEl.addClass('modal-button-container'); 226 | 227 | new ButtonComponent(buttonContainerEl) 228 | .setButtonText('Forget') 229 | .setWarning() 230 | .onClick(async () => { 231 | this.plugin.fileListAdapter.deleteAll(); 232 | await this.plugin.fileListAdapter.save(); 233 | new Notice('Binary File Manager forgets all!'); 234 | this.close(); 235 | }); 236 | 237 | new ButtonComponent(buttonContainerEl) 238 | .setButtonText('Cancel') 239 | .onClick(() => { 240 | this.close(); 241 | }); 242 | } 243 | 244 | override onClose() { 245 | const { contentEl } = this; 246 | contentEl.empty(); 247 | } 248 | } 249 | -------------------------------------------------------------------------------- /src/Uncover.ts: -------------------------------------------------------------------------------- 1 | import { App, Plugin } from 'obsidian'; 2 | 3 | export class UncoveredApp extends App { 4 | plugins: { plugins: PluginMap }; 5 | } 6 | 7 | interface PluginMap { 8 | [K: string]: Plugin; 9 | } 10 | -------------------------------------------------------------------------------- /src/Util.ts: -------------------------------------------------------------------------------- 1 | export async function retry( 2 | callback: () => T | undefined, 3 | timeoutMilliSecond: number, 4 | trials: number, 5 | check: (_value: T) => boolean = (_: T) => true 6 | ): Promise { 7 | if (!Number.isInteger(trials)) { 8 | throw `arg trials: ${trials} is not an integer 9 | `; 10 | } 11 | const result = await Promise.race([ 12 | delay(timeoutMilliSecond), 13 | (async (): Promise => { 14 | for (let i = 0; i < trials; i++) { 15 | const t = callback(); 16 | if (t !== undefined && check(t)) { 17 | return t; 18 | } 19 | await delay(1); 20 | } 21 | return undefined; 22 | })(), 23 | ]); 24 | 25 | if (result === undefined) { 26 | return undefined; 27 | } 28 | return result as T; 29 | } 30 | 31 | async function delay(milliSecond: number): Promise { 32 | await new Promise((resolve) => setTimeout(resolve, milliSecond)); 33 | return undefined; 34 | } 35 | 36 | const INVALID_CHARS_IN_FILE_NAME = new Set([ 37 | '\\', 38 | '/', 39 | ':', 40 | '*', 41 | '?', 42 | '"', 43 | '<', 44 | '>', 45 | '|', 46 | ]); 47 | 48 | export function validFileName(fileName: string): { 49 | valid: boolean; 50 | included?: string; 51 | } { 52 | for (const char of fileName) { 53 | if (INVALID_CHARS_IN_FILE_NAME.has(char)) { 54 | return { valid: false, included: char }; 55 | } 56 | } 57 | 58 | return { valid: true }; 59 | } 60 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { Notice, Plugin, TAbstractFile, TFile } from 'obsidian'; 2 | import { Formatter } from 'Formatter'; 3 | import { BinaryFileManagerSettingTab } from 'Setting'; 4 | import { FileExtensionManager } from 'Extension'; 5 | import { FileListAdapter } from 'FileList'; 6 | import { MetaDataGenerator } from 'Generator'; 7 | 8 | interface BinaryFileManagerSettings { 9 | autoDetection: boolean; 10 | extensions: string[]; 11 | folder: string; 12 | filenameFormat: string; 13 | templatePath: string; 14 | useTemplater: boolean; 15 | } 16 | 17 | const DEFAULT_SETTINGS: BinaryFileManagerSettings = { 18 | autoDetection: false, 19 | extensions: [ 20 | 'png', 21 | 'jpg', 22 | 'jpeg', 23 | 'gif', 24 | 'bmp', 25 | 'svg', 26 | 'mp3', 27 | 'webm', 28 | 'wav', 29 | 'm4a', 30 | 'ogg', 31 | '3gp', 32 | 'flac', 33 | 'mp4', 34 | 'webm', 35 | 'ogv', 36 | 'pdf', 37 | ], 38 | folder: '/', 39 | filenameFormat: 'INFO_{{NAME}}_{{EXTENSION:UP}}', 40 | templatePath: '', 41 | useTemplater: false, 42 | }; 43 | 44 | export default class BinaryFileManagerPlugin extends Plugin { 45 | settings: BinaryFileManagerSettings; 46 | formatter: Formatter; 47 | metaDataGenerator: MetaDataGenerator; 48 | fileExtensionManager: FileExtensionManager; 49 | fileListAdapter: FileListAdapter; 50 | 51 | override async onload() { 52 | await this.loadSettings(); 53 | 54 | this.formatter = new Formatter(this.app, this); 55 | this.fileExtensionManager = new FileExtensionManager(this); 56 | this.fileListAdapter = await new FileListAdapter(this.app, this).load(); 57 | this.metaDataGenerator = new MetaDataGenerator(this.app, this); 58 | 59 | this.registerEvent( 60 | this.app.vault.on('create', async (file: TAbstractFile) => { 61 | if (!this.settings.autoDetection) { 62 | return; 63 | } 64 | if ( 65 | !(await this.metaDataGenerator.shouldCreateMetaDataFile( 66 | file 67 | )) 68 | ) { 69 | return; 70 | } 71 | 72 | await this.metaDataGenerator.create(file as TFile); 73 | new Notice(`Metadata file of ${file.name} is created.`); 74 | this.fileListAdapter.add(file.path); 75 | await this.fileListAdapter.save(); 76 | }) 77 | ); 78 | 79 | this.registerEvent( 80 | this.app.vault.on('delete', async (file: TAbstractFile) => { 81 | if (!this.fileListAdapter.has(file.path)) { 82 | return; 83 | } 84 | this.fileListAdapter.delete(file.path); 85 | await this.fileListAdapter.save(); 86 | }) 87 | ); 88 | 89 | // Commands 90 | this.addCommand({ 91 | id: 'binary-file-manager-manual-detection', 92 | name: 'Create metadata for binary files', 93 | callback: async () => { 94 | const promises: Promise[] = []; 95 | const allFiles = this.app.vault.getFiles(); 96 | for (const file of allFiles) { 97 | if ( 98 | !(await this.metaDataGenerator.shouldCreateMetaDataFile( 99 | file 100 | )) 101 | ) { 102 | continue; 103 | } 104 | 105 | promises.push( 106 | this.metaDataGenerator 107 | .create(file as TFile) 108 | .then(() => { 109 | new Notice( 110 | `Metadata file of ${file.name} is created.` 111 | ); 112 | this.fileListAdapter.add(file.path); 113 | }) 114 | ); 115 | } 116 | await Promise.all(promises); 117 | this.fileListAdapter.save(); 118 | }, 119 | }); 120 | 121 | this.addCommand({ 122 | id: 'binary-file-manager-detect-unlinked-binary-files', 123 | name: 'Create metadata for unlinked binary files', 124 | callback: async () => { 125 | const promises: Promise[] = []; 126 | const unlinkedFiles = 127 | this.metaDataGenerator.findUnlinkedBinaries(); 128 | unlinkedFiles.forEach((file) => { 129 | promises.push( 130 | this.metaDataGenerator 131 | .create(file as TFile) 132 | .then(() => { 133 | new Notice( 134 | `Metadata file of ${file.name} is created.` 135 | ); 136 | this.fileListAdapter.add(file.path); 137 | }) 138 | ); 139 | }); 140 | await Promise.all(promises); 141 | this.fileListAdapter.save(); 142 | }, 143 | }); 144 | 145 | // This adds a settings tab so the user can configure various aspects of the plugin 146 | this.addSettingTab(new BinaryFileManagerSettingTab(this.app, this)); 147 | } 148 | 149 | // onunload() {} 150 | 151 | async loadSettings() { 152 | this.settings = Object.assign( 153 | {}, 154 | DEFAULT_SETTINGS, 155 | await this.loadData() 156 | ); 157 | } 158 | 159 | async saveSettings() { 160 | await this.saveData(this.settings); 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /src/suggesters/FileSuggester.ts: -------------------------------------------------------------------------------- 1 | // Credits go to Liam's Periodic Notes Plugin: https://github.com/liamcain/obsidian-periodic-notes/blob/c8b1040f9d84ec8f4b8eae4782b23c2c6bf14e0e/src/ui/file-suggest.ts 2 | import { TAbstractFile, TFile, TFolder } from 'obsidian'; 3 | 4 | import { TextInputSuggest } from 'suggesters/suggest'; 5 | 6 | export class FileSuggest extends TextInputSuggest { 7 | getSuggestions(inputStr: string): TFile[] { 8 | const abstractFiles = this.app.vault.getAllLoadedFiles(); 9 | const files: TFile[] = []; 10 | const lowerCaseInputStr = inputStr.toLowerCase(); 11 | 12 | abstractFiles.forEach((file: TAbstractFile) => { 13 | if ( 14 | file instanceof TFile && 15 | file.extension === 'md' && 16 | file.path.toLowerCase().contains(lowerCaseInputStr) 17 | ) { 18 | files.push(file); 19 | } 20 | }); 21 | 22 | return files; 23 | } 24 | 25 | renderSuggestion(file: TFile, el: HTMLElement): void { 26 | el.setText(file.path); 27 | } 28 | 29 | selectSuggestion(file: TFile): void { 30 | this.inputEl.value = file.path; 31 | this.inputEl.trigger('input'); 32 | this.close(); 33 | } 34 | } 35 | 36 | export class FolderSuggest extends TextInputSuggest { 37 | getSuggestions(inputStr: string): TFolder[] { 38 | const abstractFiles = this.app.vault.getAllLoadedFiles(); 39 | const folders: TFolder[] = []; 40 | const lowerCaseInputStr = inputStr.toLowerCase(); 41 | 42 | abstractFiles.forEach((folder: TAbstractFile) => { 43 | if ( 44 | folder instanceof TFolder && 45 | folder.path.toLowerCase().contains(lowerCaseInputStr) 46 | ) { 47 | folders.push(folder); 48 | } 49 | }); 50 | 51 | return folders; 52 | } 53 | 54 | renderSuggestion(file: TFolder, el: HTMLElement): void { 55 | el.setText(file.path); 56 | } 57 | 58 | selectSuggestion(file: TFolder): void { 59 | this.inputEl.value = file.path; 60 | this.inputEl.trigger('input'); 61 | this.close(); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/suggesters/FolderSuggester.ts: -------------------------------------------------------------------------------- 1 | // Credits go to Liam's Periodic Notes Plugin: https://github.com/liamcain/obsidian-periodic-notes/blob/c8b1040f9d84ec8f4b8eae4782b23c2c6bf14e0e/src/ui/file-suggest.ts 2 | import { TAbstractFile, TFolder } from 'obsidian'; 3 | 4 | import { TextInputSuggest } from 'suggesters/suggest'; 5 | 6 | export class FolderSuggest extends TextInputSuggest { 7 | getSuggestions(inputStr: string): TFolder[] { 8 | const abstractFiles = this.app.vault.getAllLoadedFiles(); 9 | const folders: TFolder[] = []; 10 | const lowerCaseInputStr = inputStr.toLowerCase(); 11 | 12 | abstractFiles.forEach((folder: TAbstractFile) => { 13 | if ( 14 | folder instanceof TFolder && 15 | folder.path.toLowerCase().contains(lowerCaseInputStr) 16 | ) { 17 | folders.push(folder); 18 | } 19 | }); 20 | 21 | return folders; 22 | } 23 | 24 | renderSuggestion(file: TFolder, el: HTMLElement): void { 25 | el.setText(file.path); 26 | } 27 | 28 | selectSuggestion(file: TFolder): void { 29 | this.inputEl.value = file.path; 30 | this.inputEl.trigger('input'); 31 | this.close(); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/suggesters/suggest.ts: -------------------------------------------------------------------------------- 1 | // Credits go to Liam's Periodic Notes Plugin: https://github.com/liamcain/obsidian-periodic-notes/blob/c8b1040f9d84ec8f4b8eae4782b23c2c6bf14e0e/src/ui/suggest.ts 2 | 3 | import { App, ISuggestOwner, Scope } from 'obsidian'; 4 | import { createPopper, Instance as PopperInstance } from '@popperjs/core'; 5 | 6 | const wrapAround = (value: number, size: number): number => { 7 | return ((value % size) + size) % size; 8 | }; 9 | 10 | class Suggest { 11 | private owner: ISuggestOwner; 12 | private values: T[]; 13 | private suggestions: HTMLDivElement[]; 14 | private selectedItem: number; 15 | private containerEl: HTMLElement; 16 | 17 | constructor( 18 | owner: ISuggestOwner, 19 | containerEl: HTMLElement, 20 | scope: Scope 21 | ) { 22 | this.owner = owner; 23 | this.containerEl = containerEl; 24 | 25 | containerEl.on( 26 | 'click', 27 | '.suggestion-item', 28 | this.onSuggestionClick.bind(this) 29 | ); 30 | containerEl.on( 31 | 'mousemove', 32 | '.suggestion-item', 33 | this.onSuggestionMouseover.bind(this) 34 | ); 35 | 36 | scope.register([], 'ArrowUp', (event) => { 37 | if (!event.isComposing) { 38 | this.setSelectedItem(this.selectedItem - 1, true); 39 | return false; 40 | } 41 | }); 42 | 43 | scope.register([], 'ArrowDown', (event) => { 44 | if (!event.isComposing) { 45 | this.setSelectedItem(this.selectedItem + 1, true); 46 | return false; 47 | } 48 | }); 49 | 50 | scope.register([], 'Enter', (event) => { 51 | if (!event.isComposing) { 52 | this.useSelectedItem(event); 53 | return false; 54 | } 55 | }); 56 | } 57 | 58 | onSuggestionClick(event: MouseEvent, el: HTMLDivElement): void { 59 | event.preventDefault(); 60 | 61 | const item = this.suggestions.indexOf(el); 62 | this.setSelectedItem(item, false); 63 | this.useSelectedItem(event); 64 | } 65 | 66 | onSuggestionMouseover(_event: MouseEvent, el: HTMLDivElement): void { 67 | const item = this.suggestions.indexOf(el); 68 | this.setSelectedItem(item, false); 69 | } 70 | 71 | setSuggestions(values: T[]) { 72 | this.containerEl.empty(); 73 | const suggestionEls: HTMLDivElement[] = []; 74 | 75 | values.forEach((value) => { 76 | const suggestionEl = this.containerEl.createDiv('suggestion-item'); 77 | this.owner.renderSuggestion(value, suggestionEl); 78 | suggestionEls.push(suggestionEl); 79 | }); 80 | 81 | this.values = values; 82 | this.suggestions = suggestionEls; 83 | this.setSelectedItem(0, false); 84 | } 85 | 86 | useSelectedItem(event: MouseEvent | KeyboardEvent) { 87 | const currentValue = this.values[this.selectedItem]; 88 | if (currentValue) { 89 | this.owner.selectSuggestion(currentValue, event); 90 | } 91 | } 92 | 93 | setSelectedItem(selectedIndex: number, scrollIntoView: boolean) { 94 | const normalizedIndex = wrapAround( 95 | selectedIndex, 96 | this.suggestions.length 97 | ); 98 | const prevSelectedSuggestion = this.suggestions[this.selectedItem]; 99 | const selectedSuggestion = this.suggestions[normalizedIndex]; 100 | 101 | prevSelectedSuggestion?.removeClass('is-selected'); 102 | selectedSuggestion?.addClass('is-selected'); 103 | 104 | this.selectedItem = normalizedIndex; 105 | 106 | if (scrollIntoView) { 107 | selectedSuggestion.scrollIntoView(false); 108 | } 109 | } 110 | } 111 | 112 | export abstract class TextInputSuggest implements ISuggestOwner { 113 | protected app: App; 114 | protected inputEl: HTMLInputElement | HTMLTextAreaElement; 115 | 116 | private popper: PopperInstance; 117 | private scope: Scope; 118 | private suggestEl: HTMLElement; 119 | private suggest: Suggest; 120 | 121 | constructor(app: App, inputEl: HTMLInputElement | HTMLTextAreaElement) { 122 | this.app = app; 123 | this.inputEl = inputEl; 124 | this.scope = new Scope(); 125 | 126 | this.suggestEl = createDiv('suggestion-container'); 127 | const suggestion = this.suggestEl.createDiv('suggestion'); 128 | this.suggest = new Suggest(this, suggestion, this.scope); 129 | 130 | this.scope.register([], 'Escape', this.close.bind(this)); 131 | 132 | this.inputEl.addEventListener('input', this.onInputChanged.bind(this)); 133 | this.inputEl.addEventListener('focus', this.onInputChanged.bind(this)); 134 | this.inputEl.addEventListener('blur', this.close.bind(this)); 135 | this.suggestEl.on( 136 | 'mousedown', 137 | '.suggestion-container', 138 | (event: MouseEvent) => { 139 | event.preventDefault(); 140 | } 141 | ); 142 | } 143 | 144 | onInputChanged(): void { 145 | const inputStr = this.inputEl.value; 146 | const suggestions = this.getSuggestions(inputStr); 147 | 148 | if (!suggestions) { 149 | this.close(); 150 | return; 151 | } 152 | 153 | if (suggestions.length > 0) { 154 | this.suggest.setSuggestions(suggestions); 155 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 156 | this.open((this.app).dom.appContainerEl, this.inputEl); 157 | } else { 158 | this.close(); 159 | } 160 | } 161 | 162 | open(container: HTMLElement, inputEl: HTMLElement): void { 163 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 164 | (this.app).keymap.pushScope(this.scope); 165 | 166 | container.appendChild(this.suggestEl); 167 | this.popper = createPopper(inputEl, this.suggestEl, { 168 | placement: 'bottom-start', 169 | modifiers: [ 170 | { 171 | name: 'sameWidth', 172 | enabled: true, 173 | fn: ({ state, instance }) => { 174 | // Note: positioning needs to be calculated twice - 175 | // first pass - positioning it according to the width of the popper 176 | // second pass - position it with the width bound to the reference element 177 | // we need to early exit to avoid an infinite loop 178 | const targetWidth = `${state.rects.reference.width}px`; 179 | if (state.styles.popper.width === targetWidth) { 180 | return; 181 | } 182 | state.styles.popper.width = targetWidth; 183 | instance.update(); 184 | }, 185 | phase: 'beforeWrite', 186 | requires: ['computeStyles'], 187 | }, 188 | ], 189 | }); 190 | } 191 | 192 | close(): void { 193 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 194 | (this.app).keymap.popScope(this.scope); 195 | 196 | this.suggest.setSuggestions([]); 197 | if (this.popper) this.popper.destroy(); 198 | this.suggestEl.detach(); 199 | } 200 | 201 | abstract getSuggestions(_inputStr: string): T[]; 202 | abstract renderSuggestion(_item: T, _el: HTMLElement): void; 203 | abstract selectSuggestion(_item: T): void; 204 | } 205 | -------------------------------------------------------------------------------- /styles.css: -------------------------------------------------------------------------------- 1 | .binary-file-manager-text-error { 2 | color: var(--text-error); 3 | } 4 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "src", 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 | "lib": ["DOM", "ES5", "ES6", "ES7"], 13 | "experimentalDecorators": true, 14 | "emitDecoratorMetadata": true, 15 | "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */, 16 | 17 | "outDir": ".", 18 | /* Type Checking */ 19 | "strict": true /* Enable all strict type-checking options. */, 20 | "strictNullChecks": true /* When type checking, take into account `null` and `undefined`. */, 21 | "strictFunctionTypes": true /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */, 22 | "strictBindCallApply": true /* Check that the arguments for `bind`, `call`, and `apply` methods match the original function. */, 23 | // "strictPropertyInitialization": true /* Check for class properties that are declared but not set in the constructor. */, 24 | "noImplicitThis": true /* Enable error reporting when `this` is given the type `any`. */, 25 | "useUnknownInCatchVariables": true /* Type catch clause variables as 'unknown' instead of 'any'. */, 26 | "alwaysStrict": true /* Ensure 'use strict' is always emitted. */, 27 | "noUnusedLocals": true /* Enable error reporting when a local variables aren't read. */, 28 | "noUnusedParameters": true /* Raise an error when a function parameter isn't read */, 29 | "exactOptionalPropertyTypes": true /* Interpret optional property types as written, rather than adding 'undefined'. */, 30 | "noImplicitReturns": true /* Enable error reporting for codepaths that do not explicitly return in a function. */, 31 | "noFallthroughCasesInSwitch": true /* Enable error reporting for fallthrough cases in switch statements. */, 32 | "noUncheckedIndexedAccess": true /* Include 'undefined' in index signature results */, 33 | "noImplicitOverride": true /* Ensure overriding members in derived classes are marked with an override modifier. */, 34 | "noPropertyAccessFromIndexSignature": true /* Enforces using indexed accessors for keys declared using an indexed type */, 35 | 36 | /* Completeness */ 37 | "skipLibCheck": true /* Skip type checking all .d.ts files. */ 38 | }, 39 | "include": ["src/*.ts"], 40 | "exclude": ["node_modules"] 41 | } 42 | -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "0.0.0": "0.12.0" 3 | } 4 | --------------------------------------------------------------------------------