├── .cursor └── rules │ └── obsidian-plugin-developer.mdc ├── .editorconfig ├── .eslintignore ├── .eslintrc ├── .github └── workflows │ └── release.yml ├── .gitignore ├── .npmrc ├── CHANGELOG.md ├── LICENSE ├── Makefile ├── README.md ├── docs └── assets │ ├── commands.png │ ├── external-link-example.png │ ├── inline-link-example.png │ ├── pdf-example.png │ └── settings.png ├── esbuild.config.mjs ├── generate.py ├── jest.config.js ├── manifest.json ├── package-lock.json ├── package.json ├── src ├── DirectorySelectionModal.ts ├── HttpServer.ts ├── InlineAssetHandler.ts ├── LinkProcessor.ts ├── VirtualDirectoryManager.ts ├── __mocks__ │ ├── VirtualDirectoryManager.ts │ ├── obsidian.ts │ ├── setup.ts │ └── utils.ts ├── assets │ ├── pdfjs-5.2.133-dist │ │ ├── build │ │ │ ├── pdf.mjs │ │ │ └── pdf.worker.mjs │ │ └── web │ │ │ ├── images │ │ │ ├── annotation-noicon.svg │ │ │ ├── findbarButton-next.svg │ │ │ ├── findbarButton-previous.svg │ │ │ ├── loading-icon.gif │ │ │ ├── loading.svg │ │ │ ├── toolbarButton-currentOutlineItem.svg │ │ │ ├── toolbarButton-menuArrow.svg │ │ │ ├── toolbarButton-openFile.svg │ │ │ ├── toolbarButton-pageDown.svg │ │ │ ├── toolbarButton-pageUp.svg │ │ │ ├── toolbarButton-search.svg │ │ │ ├── toolbarButton-sidebarToggle.svg │ │ │ ├── toolbarButton-viewAttachments.svg │ │ │ ├── toolbarButton-viewLayers.svg │ │ │ ├── toolbarButton-viewOutline.svg │ │ │ ├── toolbarButton-viewThumbnail.svg │ │ │ ├── toolbarButton-zoomIn.svg │ │ │ ├── toolbarButton-zoomOut.svg │ │ │ ├── treeitem-collapsed.svg │ │ │ └── treeitem-expanded.svg │ │ │ ├── viewer.css │ │ │ ├── viewer.html │ │ │ ├── viewer.mjs │ │ │ └── wasm │ │ │ ├── openjpeg.wasm │ │ │ ├── openjpeg_nowasm_fallback.js │ │ │ └── qcms_bg.wasm │ └── pdfjs-viewer-element-2.7.1.js ├── embedProcessor.test.ts ├── embedProcessor.ts ├── local-settings.ts ├── main.ts ├── server.ts ├── settings.ts ├── templates │ ├── InlineAssetHandler.ts.jinja2 │ ├── pdf.html │ ├── pdf.html.d.ts │ ├── pdf1.html │ └── pdf1.html.d.ts ├── types │ └── inline.d.ts └── utils.ts ├── styles.css ├── tsconfig.json ├── types └── inline-import.d.ts ├── version-bump.mjs └── versions.json /.cursor/rules/obsidian-plugin-developer.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: 3 | globs: 4 | alwaysApply: true 5 | --- 6 | --- 7 | description: 8 | globs: 9 | alwaysApply: true 10 | --- 11 | You are an expert in TypeScript, Node.js, Obsidian plugin development. 12 | 13 | Do NOT use Chinese in code and comments. 14 | 15 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | assets/ 3 | main.js 4 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "env": { "node": true }, 5 | "plugins": [ 6 | "@typescript-eslint" 7 | ], 8 | "extends": [ 9 | "eslint:recommended", 10 | "plugin:@typescript-eslint/eslint-recommended", 11 | "plugin:@typescript-eslint/recommended" 12 | ], 13 | "parserOptions": { 14 | "sourceType": "module" 15 | }, 16 | "rules": { 17 | "no-unused-vars": "off", 18 | "@typescript-eslint/no-unused-vars": ["error", { "args": "none" }], 19 | "@typescript-eslint/ban-ts-comment": "off", 20 | "no-prototype-builtins": "off", 21 | "@typescript-eslint/no-empty-function": "off" 22 | } 23 | } -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release Obsidian plugin 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*" 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | permissions: 12 | contents: write 13 | steps: 14 | - uses: actions/checkout@v3 15 | 16 | - name: Use Node.js 17 | uses: actions/setup-node@v3 18 | with: 19 | node-version: "18.x" 20 | 21 | - name: Build plugin 22 | run: | 23 | npm install 24 | npm run test 25 | npm run build 26 | 27 | - name: Create release 28 | env: 29 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 30 | run: | 31 | tag="${GITHUB_REF#refs/tags/}" 32 | 33 | gh release create "$tag" \ 34 | --title="$tag" \ 35 | --draft \ 36 | main.js manifest.json styles.css 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | 3 | # vscode 4 | .vscode 5 | 6 | # Intellij 7 | *.iml 8 | .idea 9 | 10 | # npm 11 | node_modules 12 | 13 | # Don't include the compiled main.js file in the repo. 14 | # They should be uploaded to GitHub releases instead. 15 | main.js 16 | 17 | # Exclude sourcemaps 18 | *.map 19 | 20 | # obsidian 21 | data.json 22 | 23 | # Exclude macOS Finder (System Explorer) View States 24 | .DS_Store 25 | 26 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | tag-version-prefix="" -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to the External File Embed and Link plugin will be documented in this file. 4 | 5 | ## [1.5.6] 6 | 7 | - Add include/exclude options for embed folder [#14](https://github.com/oylbin/obsidian-external-file-embed-and-link/issues/14) 8 | 9 | ## [1.5.5] 10 | 11 | - Fix issue with pdfjs viewer image rendering [#7](https://github.com/oylbin/obsidian-external-file-embed-and-link/issues/7) 12 | 13 | ## [1.5.4] 14 | 15 | - Fix issue with special characters in file paths [#13](https://github.com/oylbin/obsidian-external-file-embed-and-link/issues/13) 16 | 17 | ## [1.5.3] 18 | 19 | - Add better file type identity for folders [#11](https://github.com/oylbin/obsidian-external-file-embed-and-link/issues/11) 20 | 21 | ## [1.5.2] 22 | 23 | ### Added 24 | 25 | - Add a button or clickable text to open the embedded files 26 | 27 | ## [1.5.1] 28 | 29 | ### Added 30 | 31 | - Make text in pdf embed copyable and searchable 32 | 33 | ## [1.5.0] 34 | 35 | ### Added 36 | - **Virtual Directory System**: Introduced a flexible virtual directory system 37 | - No longer limited to using home or vault directories as starting paths 38 | - Map any local directory to a virtual directory ID 39 | - Configure different paths per device for the same virtual directory ID 40 | - Format: `VirtualDirectoryId://relative/path/to/file` 41 | - Home and vault directories are pre-defined as virtual directories 42 | 43 | - **New Commands**: Streamlined command palette options 44 | - `Add external embed`: Opens a dialog to select a virtual directory and file for embedding 45 | - `Add external inline link`: Opens a dialog to select a virtual directory and file for linking 46 | - Both commands generate the appropriate code automatically 47 | 48 | - **Improved Error Handling**: More descriptive error messages 49 | - Detailed context information when files cannot be found 50 | - Clearer guidance on how to resolve common issues 51 | 52 | ### Changed 53 | - **New Embed Syntax**: Introduced `EmbedRelativeTo` as the preferred syntax 54 | ``` 55 | ```EmbedRelativeTo 56 | home://SynologyDrive/README.md 57 | ``` 58 | ``` 59 | - Legacy `EmbedRelativeToHome` and `EmbedRelativeToVault` still supported but deprecated 60 | 61 | - **New Link Syntax**: Introduced `LinkRelativeTo` as the preferred syntax 62 | - New: `Makefile` 63 | - Legacy class attributes `LinkRelativeToHome` and `LinkRelativeToVault` still supported but deprecated 64 | 65 | ### Removed 66 | - **Drag & Drop Functionality**: Temporarily disabled 67 | - With multiple virtual directories, determining the correct target directory from drag & drop became ambiguous 68 | - May be reimplemented in the future with a directory selection dialog 69 | - Commands now provide a more controlled alternative for adding embeds and links 70 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 oylbin 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 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | dev: 2 | npm run dev 3 | 4 | build: 5 | npm run build 6 | 7 | clean: 8 | npm run clean 9 | 10 | install: 11 | mkdir -p ~/SynologyDrive/AppDataSync/obsidian/.obsidian/plugins/external-file-embed-and-link 12 | cp main.js ~/SynologyDrive/AppDataSync/obsidian/.obsidian/plugins/external-file-embed-and-link 13 | cp styles.css ~/SynologyDrive/AppDataSync/obsidian/.obsidian/plugins/external-file-embed-and-link 14 | cp manifest.json ~/SynologyDrive/AppDataSync/obsidian/.obsidian/plugins/external-file-embed-and-link 15 | 16 | download: 17 | curl -o manifest.json -L https://github.com/oylbin/obsidian-external-file-embed-and-link/releases/latest/download/manifest.json 18 | curl -o styles.css -L https://github.com/oylbin/obsidian-external-file-embed-and-link/releases/latest/download/styles.css 19 | curl -o main.js -L https://github.com/oylbin/obsidian-external-file-embed-and-link/releases/latest/download/main.js 20 | 21 | test: 22 | npm run test -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # External File Embed and Link 2 | 3 | Embed and link local files outside your obsidian vault with relative paths for cross-device and multi-platform compatibility. 4 | 5 | ## Features 6 | 7 | 1. Embed external files (Markdown, PDF, Images, Audio, Video) outside your obsidian vault. 8 | 2. Create links to files outside your obsidian vault that open with system default applications. 9 | 3. Reference files using virtual directories for cross-device and cross-platform compatibility. 10 | 4. Provide commands to add embeds or links via file picker. 11 | 12 | ## Virtual Directories 13 | 14 | The plugin uses a flexible virtual directory system that allows you to: 15 | 16 | - Map any local directory to a virtual directory ID 17 | - Configure different paths per device for the same virtual directory ID 18 | - Access files using the format: `VirtualDirectoryId://relative/path/to/file` 19 | 20 | Home and vault directories are pre-defined as virtual directories: 21 | - `home://` - Points to your user home directory 22 | - `vault://` - Points to your Obsidian vault directory 23 | 24 | You can define additional virtual directories in the plugin settings. This allows you to: 25 | 26 | 1. Use the same note across multiple devices, even when file paths differ 27 | 2. Reference files outside your vault in a consistent way 28 | 3. Maintain compatibility across different operating systems 29 | 30 | ## Detailed Usage 31 | 32 | ### Embedding External Files 33 | 34 | You can embed files using paths relative to a virtual directory. For example, if your Home path is `C:\Users\username`, you can embed a PDF file from `C:\Users\username\SynologyDrive\work\Document.pdf` like this: 35 | 36 | ~~~markdown 37 | ```EmbedRelativeTo 38 | home://SynologyDrive/work/Document.pdf 39 | ``` 40 | ~~~ 41 | 42 | This will be rendered in Live Preview and Reading Mode as: 43 | 44 | ![PDF Example](docs/assets/pdf-example.png) 45 | 46 | You can also use a custom virtual directory. For example, if you've defined a virtual directory called "project" that points to different paths on different computers: 47 | 48 | ~~~markdown 49 | ```EmbedRelativeTo 50 | project://documents/report.pdf 51 | ``` 52 | ~~~ 53 | 54 | Using virtual directories ensures compatibility across different computers and operating systems, especially useful when syncing files with services like SynologyDrive. 55 | 56 | ### Supported File Types for Embedding 57 | 58 | Almost the same as Obsidian's [Accepted file formats](https://help.obsidian.md/Files+and+folders/Accepted+file+formats) documentation except for JSON Canvas files. 59 | 60 | - **Markdown**: `.md`, `.markdown`, `.txt` 61 | - **Images**: `.avif`, `.bmp`, `.gif`, `.jpeg`, `.jpg`, `.png`, `.svg`, `.webp` 62 | - **Audio**: `.flac`, `.m4a`, `.mp3`, `.ogg`, `.wav`, `.webm`, `.3gp` 63 | - **Video**: `.mkv`, `.mov`, `.mp4`, `.ogv`, `.webm` 64 | - **PDF**: `.pdf` 65 | 66 | ### Embedding Options 67 | 68 | Following Obsidian's [Embed files](https://help.obsidian.md/Linking+notes+and+files/Embed+files) documentation, this plugin supports parameters for controlling display behavior: 69 | 70 | #### Markdown Files 71 | 72 | Add header name after `#` to embed only the header section: 73 | 74 | ~~~markdown 75 | ```EmbedRelativeTo 76 | home://SynologyDrive/work/Document.md#This is a header 77 | ``` 78 | ~~~ 79 | 80 | #### PDF Files 81 | Add parameters after `#` to control page number, width, and height: 82 | 83 | ~~~markdown 84 | ```EmbedRelativeTo 85 | home://SynologyDrive/work/Document.pdf#page=3&width=100%&height=80vh 86 | ``` 87 | ~~~ 88 | 89 | #### Images & Videos 90 | Add dimensions after `|` to control size: 91 | 92 | ~~~markdown 93 | ```EmbedRelativeTo 94 | home://Downloads/test.png|400 95 | ``` 96 | ~~~ 97 | 98 | ~~~markdown 99 | ```EmbedRelativeTo 100 | home://Videos/test.mp4|800x600 101 | ``` 102 | ~~~ 103 | 104 | #### Folders 105 | 106 | You can embed a folder by put `/#` after the folder name , and it will list all the files in the folder. 107 | You can also add parameters after `#` to filter the files to embed. 108 | 109 | ~~~markdown 110 | ```EmbedRelativeTo 111 | home://Downloads/#extensions=pdf,mp4 112 | ``` 113 | ~~~ 114 | 115 | ### External File Links 116 | 117 | If you don't need to render the file content in Reading Mode, you can create inline links within paragraphs to external files: 118 | 119 | ~~~markdown 120 | This is a sample.pdf outside of the vault. 121 | ~~~ 122 | 123 | Which renders as: 124 | 125 | ![Inline Link Example](docs/assets/inline-link-example.png) 126 | 127 | ### Adding Embeds or Links 128 | 129 | #### Using Commands 130 | Type "external" in the command palette to see available options: 131 | 132 | - `Add external embed`: Opens a dialog to select a virtual directory and file for embedding 133 | - `Add external inline link`: Opens a dialog to select a virtual directory and file for linking 134 | 135 | Both commands will automatically generate the appropriate code with the correct syntax. 136 | 137 | ![commands](docs/assets/commands.png) 138 | 139 | ## Acknowledgement and License Notice 140 | 141 | This project uses [PDF.js](https://github.com/mozilla/pdf.js) for PDF rendering, which is made available under the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). A copy of the PDF.js license can be found in the `LICENSE` file of the PDF.js repository. 142 | 143 | If you distribute this project or its binaries, please ensure that you include the appropriate copyright and license notices as required by the Apache License 2.0. 144 | 145 | For more information on PDF.js and its contributors, visit the official [GitHub repository](https://github.com/mozilla/pdf.js). 146 | -------------------------------------------------------------------------------- /docs/assets/commands.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oylbin/obsidian-external-file-embed-and-link/27af4b64f4b8cb8ea609a08621628ec7f4906ca8/docs/assets/commands.png -------------------------------------------------------------------------------- /docs/assets/external-link-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oylbin/obsidian-external-file-embed-and-link/27af4b64f4b8cb8ea609a08621628ec7f4906ca8/docs/assets/external-link-example.png -------------------------------------------------------------------------------- /docs/assets/inline-link-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oylbin/obsidian-external-file-embed-and-link/27af4b64f4b8cb8ea609a08621628ec7f4906ca8/docs/assets/inline-link-example.png -------------------------------------------------------------------------------- /docs/assets/pdf-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oylbin/obsidian-external-file-embed-and-link/27af4b64f4b8cb8ea609a08621628ec7f4906ca8/docs/assets/pdf-example.png -------------------------------------------------------------------------------- /docs/assets/settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oylbin/obsidian-external-file-embed-and-link/27af4b64f4b8cb8ea609a08621628ec7f4906ca8/docs/assets/settings.png -------------------------------------------------------------------------------- /esbuild.config.mjs: -------------------------------------------------------------------------------- 1 | import esbuild from "esbuild"; 2 | import inlineImportPlugin from "esbuild-plugin-inline-import"; 3 | import process from "process"; 4 | import builtins from "builtin-modules"; 5 | import fs from "fs"; 6 | import path from "path"; 7 | import { fileURLToPath } from "url"; 8 | import os from "os"; 9 | import dotenv from "dotenv"; 10 | 11 | // Load .env file 12 | dotenv.config(); 13 | 14 | const banner = 15 | `/* 16 | THIS IS A GENERATED/BUNDLED FILE BY ESBUILD 17 | if you want to view the source, please visit the github repository of this plugin 18 | */ 19 | `; 20 | 21 | const prod = (process.argv[2] === "production"); 22 | 23 | const pathSep = os.platform() === "win32" ? "\\" : "/"; 24 | // Get the test vault plugin directory path from .env file 25 | // If the environment variable doesn't exist, use the default path 26 | const testVaultPluginDir = process.env.TEST_VAULT_PLUGIN_DIR 27 | ? process.env.TEST_VAULT_PLUGIN_DIR.replace(/^~/, os.homedir()).replace(/\//g, pathSep) 28 | : ""; 29 | 30 | // Function to copy files to the test vault 31 | const copyToTestVault = (srcFile, destDir) => { 32 | if (!fs.existsSync(destDir)) { 33 | console.log(`Target directory does not exist: ${destDir}`); 34 | return; 35 | } 36 | 37 | const fileName = path.basename(srcFile); 38 | const destFile = path.join(destDir, fileName); 39 | 40 | try { 41 | fs.copyFileSync(srcFile, destFile); 42 | console.log(`Copied ${fileName} to ${destDir}`); 43 | } catch (err) { 44 | console.error(`Failed to copy file: ${err}`); 45 | } 46 | }; 47 | 48 | // Watch for CSS file changes 49 | const watchCssFile = () => { 50 | const cssFile = path.resolve("styles.css"); 51 | if (fs.existsSync(cssFile)) { 52 | fs.watchFile(cssFile, { interval: 100 }, () => { 53 | console.log("styles.css has been modified"); 54 | if(testVaultPluginDir.length > 0) { 55 | copyToTestVault(cssFile, testVaultPluginDir); 56 | } 57 | }); 58 | console.log("Watching for changes in styles.css"); 59 | } 60 | }; 61 | 62 | // Create a plugin to handle file copying after each build 63 | const copyFilesPlugin = { 64 | name: 'copy-files-plugin', 65 | setup(build) { 66 | build.onEnd(result => { 67 | if (result.errors.length === 0) { 68 | const mainJsFile = path.resolve("main.js"); 69 | if(testVaultPluginDir.length > 0) { 70 | copyToTestVault(mainJsFile, testVaultPluginDir); 71 | } 72 | } 73 | }); 74 | }, 75 | }; 76 | 77 | const context = await esbuild.context({ 78 | banner: { 79 | js: banner, 80 | }, 81 | entryPoints: ["src/main.ts"], 82 | bundle: true, 83 | external: [ 84 | "obsidian", 85 | "electron", 86 | "@codemirror/autocomplete", 87 | "@codemirror/collab", 88 | "@codemirror/commands", 89 | "@codemirror/language", 90 | "@codemirror/lint", 91 | "@codemirror/search", 92 | "@codemirror/state", 93 | "@codemirror/view", 94 | "@lezer/common", 95 | "@lezer/highlight", 96 | "@lezer/lr", 97 | ...builtins], 98 | format: "cjs", 99 | target: "es2018", 100 | logLevel: "info", 101 | sourcemap: prod ? false : "inline", 102 | treeShaking: true, 103 | outfile: "main.js", 104 | plugins: [ 105 | // Always include this plugin before others 106 | inlineImportPlugin({ 107 | baseDir: process.cwd(), 108 | filter: /^inline:/, 109 | transform: (content) => content 110 | }), 111 | // Add the custom plugin to copy files after build 112 | copyFilesPlugin 113 | ], 114 | minify: prod, 115 | }); 116 | 117 | if (prod) { 118 | await context.rebuild(); 119 | process.exit(0); 120 | } else { 121 | await context.watch(); 122 | 123 | // Watch for CSS file changes 124 | watchCssFile(); 125 | if(testVaultPluginDir.length > 0) { 126 | console.log(`Development mode: Files will be automatically copied to test vault directory [${testVaultPluginDir}]`); 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /generate.py: -------------------------------------------------------------------------------- 1 | import os 2 | import jinja2 3 | 4 | contentTypeDict = { 5 | "bcmap": "application/octet-stream", 6 | "css": "text/css", 7 | # FreeMarker Template Language files are typically processed server-side. The Content-Type will depend on the output they generate (e.g., text/html, application/json). text/plain is a fallback if serving the template file itself. 8 | "ftl": "text/plain", 9 | "gif": "image/gif", 10 | "html": "text/html", 11 | "icc": "application/vnd.iccprofile", # Also seen as .icm 12 | "js": "text/javascript", # application/javascript is also valid 13 | "json": "application/json", 14 | # Source maps are often .json. For generic binary map files, application/octet-stream might be used. 15 | "map": "application/json", 16 | "mjs": "text/javascript", # For JavaScript modules application/javascript is also valid 17 | "pdf": "application/pdf", 18 | # Printer Font Binary, specific for Adobe Type 1 fonts. application/octet-stream or application/x-font can also be encountered. 19 | "pfb": "application/x-font-pfb", 20 | "svg": "image/svg+xml", 21 | "ttf": "font/ttf", # application/font-sfnt or application/x-font-ttf are also seen. 22 | "wasm": "application/wasm" 23 | } 24 | 25 | 26 | def get_urls(): 27 | urls = [] 28 | # iterate all files recursively in src/assets/pdfjs-5.2.133-dist/ 29 | for root, dirs, files in os.walk('src/assets/pdfjs-5.2.133-dist/'): 30 | for file in files: 31 | url = os.path.join(root, file) 32 | # change sep to / 33 | url = url.replace('\\', '/') 34 | ext = url.split('.')[-1] 35 | if ext in ['map', 'pdf', 'bcmap', 'ftl']: 36 | continue 37 | filename = os.path.basename(url) 38 | if 'LICENSE' in filename: 39 | continue 40 | contentType = contentTypeDict.get(ext, "application/octet-stream") 41 | fileSize = os.path.getsize(url) 42 | print(f"{fileSize:,} bytes, {url}, {contentType}") 43 | # remove first 3 characters 44 | urls.append((url[3:], contentType)) 45 | return urls 46 | 47 | 48 | def get_urls2(): 49 | urls = [] 50 | filenames = """ 51 | /assets/pdfjs-5.2.133-dist/build/pdf.mjs 52 | /assets/pdfjs-5.2.133-dist/build/pdf.worker.mjs 53 | /assets/pdfjs-5.2.133-dist/web/viewer.css 54 | /assets/pdfjs-5.2.133-dist/web/viewer.mjs 55 | /assets/pdfjs-5.2.133-dist/web/images/annotation-noicon.svg 56 | /assets/pdfjs-5.2.133-dist/web/images/findbarButton-next.svg 57 | /assets/pdfjs-5.2.133-dist/web/images/findbarButton-previous.svg 58 | /assets/pdfjs-5.2.133-dist/web/images/loading.svg 59 | /assets/pdfjs-5.2.133-dist/web/images/loading-icon.gif 60 | /assets/pdfjs-5.2.133-dist/web/images/toolbarButton-currentOutlineItem.svg 61 | /assets/pdfjs-5.2.133-dist/web/images/toolbarButton-menuArrow.svg 62 | /assets/pdfjs-5.2.133-dist/web/images/toolbarButton-pageDown.svg 63 | /assets/pdfjs-5.2.133-dist/web/images/toolbarButton-pageUp.svg 64 | /assets/pdfjs-5.2.133-dist/web/images/toolbarButton-search.svg 65 | /assets/pdfjs-5.2.133-dist/web/images/toolbarButton-sidebarToggle.svg 66 | /assets/pdfjs-5.2.133-dist/web/images/toolbarButton-viewAttachments.svg 67 | /assets/pdfjs-5.2.133-dist/web/images/toolbarButton-viewLayers.svg 68 | /assets/pdfjs-5.2.133-dist/web/images/toolbarButton-viewOutline.svg 69 | /assets/pdfjs-5.2.133-dist/web/images/toolbarButton-viewThumbnail.svg 70 | /assets/pdfjs-5.2.133-dist/web/images/toolbarButton-zoomIn.svg 71 | /assets/pdfjs-5.2.133-dist/web/images/toolbarButton-zoomOut.svg 72 | /assets/pdfjs-5.2.133-dist/web/images/treeitem-collapsed.svg 73 | /assets/pdfjs-5.2.133-dist/web/images/treeitem-expanded.svg 74 | /assets/pdfjs-5.2.133-dist/web/images/toolbarButton-openFile.svg 75 | /assets/pdfjs-5.2.133-dist/web/wasm/openjpeg.wasm 76 | /assets/pdfjs-5.2.133-dist/web/wasm/openjpeg_nowasm_fallback.js 77 | /assets/pdfjs-5.2.133-dist/web/wasm/qcms_bg.wasm 78 | """ 79 | for filename in filenames.split('\n'): 80 | if filename.strip() == '': 81 | continue 82 | ext = filename.split('.')[-1] 83 | contentType = contentTypeDict.get(ext, "application/octet-stream") 84 | urls.append((filename, contentType)) 85 | return urls 86 | 87 | 88 | urls = get_urls2() 89 | 90 | # write urls to src/InlineAssetHandler.ts 91 | with open('src/InlineAssetHandler.ts', 'w') as file: 92 | file.write(jinja2.Template(open('src/templates/InlineAssetHandler.ts.jinja2').read()).render(urls=urls)) 93 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'node', 4 | moduleNameMapper: { 5 | '^obsidian$': '/src/__mocks__/obsidian.ts' 6 | }, 7 | setupFiles: ['/src/__mocks__/setup.ts'] 8 | }; 9 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "external-file-embed-and-link", 3 | "name": "External File Embed and Link", 4 | "version": "1.5.6", 5 | "minAppVersion": "0.15.0", 6 | "description": "Embed and link local files outside your vault with relative paths for cross-device and multi-platform compatibility.", 7 | "author": "oylbin", 8 | "authorUrl": "https://github.com/oylbin", 9 | "fundingUrl": "https://buymeacoffee.com/oylbin", 10 | "isDesktopOnly": true 11 | } 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "obsidian-sample-plugin", 3 | "version": "1.0.0", 4 | "description": "This is a sample plugin for Obsidian (https://obsidian.md)", 5 | "main": "main.js", 6 | "scripts": { 7 | "dev": "node esbuild.config.mjs", 8 | "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", 9 | "version": "node version-bump.mjs && git add manifest.json versions.json", 10 | "clean": "rm -f main.js", 11 | "test": "jest" 12 | }, 13 | "keywords": [], 14 | "author": "", 15 | "license": "MIT", 16 | "devDependencies": { 17 | "@types/jest": "^29.5.14", 18 | "@types/minimatch": "^5.1.2", 19 | "@types/node": "^16.11.6", 20 | "@typescript-eslint/eslint-plugin": "5.29.0", 21 | "@typescript-eslint/parser": "5.29.0", 22 | "builtin-modules": "3.3.0", 23 | "dotenv": "^16.5.0", 24 | "esbuild": "^0.25.4", 25 | "esbuild-plugin-inline-import": "^1.1.0", 26 | "jest": "^29.7.0", 27 | "obsidian": "latest", 28 | "ts-jest": "^29.3.4", 29 | "tslib": "2.4.0", 30 | "typescript": "4.7.4" 31 | }, 32 | "dependencies": { 33 | "marked": "^15.0.4", 34 | "minimatch": "^10.0.1", 35 | "uuid": "^11.1.0" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/DirectorySelectionModal.ts: -------------------------------------------------------------------------------- 1 | import { App, Modal } from 'obsidian'; 2 | 3 | export class DirectorySelectionModal extends Modal { 4 | private selectedDirectoryId: string | null = null; 5 | private resolvePromise: ((value: string | null) => void) | null = null; 6 | 7 | constructor( 8 | app: App, 9 | private directories: Record 10 | ) { 11 | super(app); 12 | } 13 | 14 | onOpen() { 15 | const { contentEl } = this; 16 | contentEl.empty(); 17 | 18 | // Add title 19 | contentEl.createEl('h2', { 20 | text: 'Which virtual directory to use for the link', 21 | cls: 'external-embed-modal-title' 22 | }); 23 | 24 | // Create container for directory buttons with vertical layout 25 | const buttonContainer = contentEl.createDiv('directory-buttons-container'); 26 | 27 | // Add a button for each directory 28 | Object.entries(this.directories).forEach(([id, path]) => { 29 | const button = buttonContainer.createEl('button', { 30 | text: `${id} (${path})`, 31 | cls: 'external-embed-directory-button' 32 | }); 33 | 34 | button.addEventListener('click', () => { 35 | this.selectedDirectoryId = id; 36 | this.close(); 37 | }); 38 | }); 39 | } 40 | 41 | onClose() { 42 | const { contentEl } = this; 43 | contentEl.empty(); 44 | if (this.resolvePromise) { 45 | this.resolvePromise(this.selectedDirectoryId); 46 | } 47 | } 48 | 49 | async waitForSelection(): Promise { 50 | return new Promise((resolve) => { 51 | this.resolvePromise = resolve; 52 | }); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/HttpServer.ts: -------------------------------------------------------------------------------- 1 | import * as http from 'http'; 2 | import { Notice } from 'obsidian'; 3 | import { httpRequestHandler, findAvailablePort, CrossComputerLinkContext } from './server'; 4 | 5 | export class HttpServer { 6 | private server: http.Server | null = null; 7 | 8 | constructor(private context: CrossComputerLinkContext) {} 9 | 10 | public async start() { 11 | try { 12 | const port = await findAvailablePort(this.context.port); 13 | if (port !== this.context.port) { 14 | this.context.port = port; 15 | } 16 | 17 | const server = http.createServer((req: http.IncomingMessage, res: http.ServerResponse) => { 18 | httpRequestHandler(req, res, this.context); 19 | }); 20 | 21 | server.listen(port, "127.0.0.1", () => { 22 | // Server started successfully 23 | }); 24 | 25 | server.on('error', (e: NodeJS.ErrnoException) => { 26 | if (e.code === 'EADDRINUSE') { 27 | new Notice(`Port ${port} is already in use. Please choose a different port in settings.`); 28 | } else { 29 | new Notice(`Failed to start HTTP server: ${e.message}`); 30 | } 31 | this.server = null; 32 | }); 33 | 34 | this.server = server; 35 | } catch (err) { 36 | new Notice(`Failed to start HTTP server: ${err.message}`); 37 | this.server = null; 38 | } 39 | } 40 | 41 | public stop() { 42 | if (this.server) { 43 | this.server.close(); 44 | this.server = null; 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/InlineAssetHandler.ts: -------------------------------------------------------------------------------- 1 | import * as http from 'http'; 2 | 3 | export async function InlineAssetHandler(url: string, req: http.IncomingMessage, res: http.ServerResponse) { 4 | // console.log("InlineAssetHandler", url); 5 | 6 | 7 | if(url === "/assets/pdfjs-5.2.133-dist/build/pdf.mjs") { 8 | res.setHeader('Content-Type', 'text/javascript'); 9 | const content = await import('inline:./assets/pdfjs-5.2.133-dist/build/pdf.mjs'); 10 | res.end(content.default); 11 | return; 12 | } 13 | 14 | if(url === "/assets/pdfjs-5.2.133-dist/build/pdf.worker.mjs") { 15 | res.setHeader('Content-Type', 'text/javascript'); 16 | const content = await import('inline:./assets/pdfjs-5.2.133-dist/build/pdf.worker.mjs'); 17 | res.end(content.default); 18 | return; 19 | } 20 | 21 | if(url === "/assets/pdfjs-5.2.133-dist/web/viewer.css") { 22 | res.setHeader('Content-Type', 'text/css'); 23 | const content = await import('inline:./assets/pdfjs-5.2.133-dist/web/viewer.css'); 24 | res.end(content.default); 25 | return; 26 | } 27 | 28 | if(url === "/assets/pdfjs-5.2.133-dist/web/viewer.mjs") { 29 | res.setHeader('Content-Type', 'text/javascript'); 30 | const content = await import('inline:./assets/pdfjs-5.2.133-dist/web/viewer.mjs'); 31 | res.end(content.default); 32 | return; 33 | } 34 | 35 | if(url === "/assets/pdfjs-5.2.133-dist/web/images/annotation-noicon.svg") { 36 | res.setHeader('Content-Type', 'image/svg+xml'); 37 | const content = await import('inline:./assets/pdfjs-5.2.133-dist/web/images/annotation-noicon.svg'); 38 | res.end(content.default); 39 | return; 40 | } 41 | 42 | if(url === "/assets/pdfjs-5.2.133-dist/web/images/findbarButton-next.svg") { 43 | res.setHeader('Content-Type', 'image/svg+xml'); 44 | const content = await import('inline:./assets/pdfjs-5.2.133-dist/web/images/findbarButton-next.svg'); 45 | res.end(content.default); 46 | return; 47 | } 48 | 49 | if(url === "/assets/pdfjs-5.2.133-dist/web/images/findbarButton-previous.svg") { 50 | res.setHeader('Content-Type', 'image/svg+xml'); 51 | const content = await import('inline:./assets/pdfjs-5.2.133-dist/web/images/findbarButton-previous.svg'); 52 | res.end(content.default); 53 | return; 54 | } 55 | 56 | if(url === "/assets/pdfjs-5.2.133-dist/web/images/loading.svg") { 57 | res.setHeader('Content-Type', 'image/svg+xml'); 58 | const content = await import('inline:./assets/pdfjs-5.2.133-dist/web/images/loading.svg'); 59 | res.end(content.default); 60 | return; 61 | } 62 | 63 | if(url === "/assets/pdfjs-5.2.133-dist/web/images/loading-icon.gif") { 64 | res.setHeader('Content-Type', 'image/gif'); 65 | const content = await import('inline:./assets/pdfjs-5.2.133-dist/web/images/loading-icon.gif'); 66 | res.end(content.default); 67 | return; 68 | } 69 | 70 | if(url === "/assets/pdfjs-5.2.133-dist/web/images/toolbarButton-currentOutlineItem.svg") { 71 | res.setHeader('Content-Type', 'image/svg+xml'); 72 | const content = await import('inline:./assets/pdfjs-5.2.133-dist/web/images/toolbarButton-currentOutlineItem.svg'); 73 | res.end(content.default); 74 | return; 75 | } 76 | 77 | if(url === "/assets/pdfjs-5.2.133-dist/web/images/toolbarButton-menuArrow.svg") { 78 | res.setHeader('Content-Type', 'image/svg+xml'); 79 | const content = await import('inline:./assets/pdfjs-5.2.133-dist/web/images/toolbarButton-menuArrow.svg'); 80 | res.end(content.default); 81 | return; 82 | } 83 | 84 | if(url === "/assets/pdfjs-5.2.133-dist/web/images/toolbarButton-pageDown.svg") { 85 | res.setHeader('Content-Type', 'image/svg+xml'); 86 | const content = await import('inline:./assets/pdfjs-5.2.133-dist/web/images/toolbarButton-pageDown.svg'); 87 | res.end(content.default); 88 | return; 89 | } 90 | 91 | if(url === "/assets/pdfjs-5.2.133-dist/web/images/toolbarButton-pageUp.svg") { 92 | res.setHeader('Content-Type', 'image/svg+xml'); 93 | const content = await import('inline:./assets/pdfjs-5.2.133-dist/web/images/toolbarButton-pageUp.svg'); 94 | res.end(content.default); 95 | return; 96 | } 97 | 98 | if(url === "/assets/pdfjs-5.2.133-dist/web/images/toolbarButton-search.svg") { 99 | res.setHeader('Content-Type', 'image/svg+xml'); 100 | const content = await import('inline:./assets/pdfjs-5.2.133-dist/web/images/toolbarButton-search.svg'); 101 | res.end(content.default); 102 | return; 103 | } 104 | 105 | if(url === "/assets/pdfjs-5.2.133-dist/web/images/toolbarButton-sidebarToggle.svg") { 106 | res.setHeader('Content-Type', 'image/svg+xml'); 107 | const content = await import('inline:./assets/pdfjs-5.2.133-dist/web/images/toolbarButton-sidebarToggle.svg'); 108 | res.end(content.default); 109 | return; 110 | } 111 | 112 | if(url === "/assets/pdfjs-5.2.133-dist/web/images/toolbarButton-viewAttachments.svg") { 113 | res.setHeader('Content-Type', 'image/svg+xml'); 114 | const content = await import('inline:./assets/pdfjs-5.2.133-dist/web/images/toolbarButton-viewAttachments.svg'); 115 | res.end(content.default); 116 | return; 117 | } 118 | 119 | if(url === "/assets/pdfjs-5.2.133-dist/web/images/toolbarButton-viewLayers.svg") { 120 | res.setHeader('Content-Type', 'image/svg+xml'); 121 | const content = await import('inline:./assets/pdfjs-5.2.133-dist/web/images/toolbarButton-viewLayers.svg'); 122 | res.end(content.default); 123 | return; 124 | } 125 | 126 | if(url === "/assets/pdfjs-5.2.133-dist/web/images/toolbarButton-viewOutline.svg") { 127 | res.setHeader('Content-Type', 'image/svg+xml'); 128 | const content = await import('inline:./assets/pdfjs-5.2.133-dist/web/images/toolbarButton-viewOutline.svg'); 129 | res.end(content.default); 130 | return; 131 | } 132 | 133 | if(url === "/assets/pdfjs-5.2.133-dist/web/images/toolbarButton-viewThumbnail.svg") { 134 | res.setHeader('Content-Type', 'image/svg+xml'); 135 | const content = await import('inline:./assets/pdfjs-5.2.133-dist/web/images/toolbarButton-viewThumbnail.svg'); 136 | res.end(content.default); 137 | return; 138 | } 139 | 140 | if(url === "/assets/pdfjs-5.2.133-dist/web/images/toolbarButton-zoomIn.svg") { 141 | res.setHeader('Content-Type', 'image/svg+xml'); 142 | const content = await import('inline:./assets/pdfjs-5.2.133-dist/web/images/toolbarButton-zoomIn.svg'); 143 | res.end(content.default); 144 | return; 145 | } 146 | 147 | if(url === "/assets/pdfjs-5.2.133-dist/web/images/toolbarButton-zoomOut.svg") { 148 | res.setHeader('Content-Type', 'image/svg+xml'); 149 | const content = await import('inline:./assets/pdfjs-5.2.133-dist/web/images/toolbarButton-zoomOut.svg'); 150 | res.end(content.default); 151 | return; 152 | } 153 | 154 | if(url === "/assets/pdfjs-5.2.133-dist/web/images/treeitem-collapsed.svg") { 155 | res.setHeader('Content-Type', 'image/svg+xml'); 156 | const content = await import('inline:./assets/pdfjs-5.2.133-dist/web/images/treeitem-collapsed.svg'); 157 | res.end(content.default); 158 | return; 159 | } 160 | 161 | if(url === "/assets/pdfjs-5.2.133-dist/web/images/treeitem-expanded.svg") { 162 | res.setHeader('Content-Type', 'image/svg+xml'); 163 | const content = await import('inline:./assets/pdfjs-5.2.133-dist/web/images/treeitem-expanded.svg'); 164 | res.end(content.default); 165 | return; 166 | } 167 | 168 | if(url === "/assets/pdfjs-5.2.133-dist/web/images/toolbarButton-openFile.svg") { 169 | res.setHeader('Content-Type', 'image/svg+xml'); 170 | const content = await import('inline:./assets/pdfjs-5.2.133-dist/web/images/toolbarButton-openFile.svg'); 171 | res.end(content.default); 172 | return; 173 | } 174 | 175 | if(url === "/assets/pdfjs-5.2.133-dist/web/wasm/openjpeg.wasm") { 176 | res.setHeader('Content-Type', 'application/wasm'); 177 | const content = await import('inline:./assets/pdfjs-5.2.133-dist/web/wasm/openjpeg.wasm'); 178 | res.end(content.default); 179 | return; 180 | } 181 | 182 | if(url === "/assets/pdfjs-5.2.133-dist/web/wasm/openjpeg_nowasm_fallback.js") { 183 | res.setHeader('Content-Type', 'text/javascript'); 184 | const content = await import('inline:./assets/pdfjs-5.2.133-dist/web/wasm/openjpeg_nowasm_fallback.js'); 185 | res.end(content.default); 186 | return; 187 | } 188 | 189 | if(url === "/assets/pdfjs-5.2.133-dist/web/wasm/qcms_bg.wasm") { 190 | res.setHeader('Content-Type', 'application/wasm'); 191 | const content = await import('inline:./assets/pdfjs-5.2.133-dist/web/wasm/qcms_bg.wasm'); 192 | res.end(content.default); 193 | return; 194 | } 195 | 196 | res.writeHead(404); 197 | res.end(`Invalid path ${url}`); 198 | console.log("Invalid path", url); 199 | } -------------------------------------------------------------------------------- /src/LinkProcessor.ts: -------------------------------------------------------------------------------- 1 | import { MarkdownPostProcessorContext } from 'obsidian'; 2 | import * as path from 'path'; 3 | import { openFileWithDefaultProgram } from './utils'; 4 | import { Notice } from 'obsidian'; 5 | import { existsSync } from 'fs'; 6 | 7 | export class LinkProcessor { 8 | constructor( 9 | private homeDirectory: string, 10 | private vaultDirectory: string, 11 | private directoryConfigManager: any 12 | ) {} 13 | 14 | public processInlineLink(element: HTMLElement, context: MarkdownPostProcessorContext) { 15 | element.querySelectorAll('.LinkRelativeToHome').forEach((el) => { 16 | const relativePath = el.textContent?.trim(); 17 | const fullPath = this.homeDirectory + "/" + relativePath; 18 | el.textContent = path.basename(fullPath); 19 | 20 | el.addEventListener("click", () => { 21 | openFileWithDefaultProgram(fullPath, (error) => { 22 | if (error) { 23 | new Notice("Failed to open file: " + error.message); 24 | } 25 | }); 26 | }); 27 | }); 28 | 29 | element.querySelectorAll('.LinkRelativeToVault').forEach((el) => { 30 | const relativePath = el.textContent?.trim(); 31 | const fullPath = this.vaultDirectory + "/" + relativePath; 32 | el.textContent = path.basename(fullPath); 33 | 34 | el.addEventListener("click", () => { 35 | openFileWithDefaultProgram(fullPath, (error) => { 36 | if (error) { 37 | new Notice("Failed to open file: " + error.message); 38 | } 39 | }); 40 | }); 41 | }); 42 | 43 | element.querySelectorAll('.LinkRelativeTo').forEach((el) => { 44 | try { 45 | const url0 = el.getAttribute('href'); 46 | if (!url0) { 47 | throw new Error(`href is not set for link "${el.textContent}"`); 48 | } 49 | const url = url0.substring(1); 50 | const directoryId = url.split(':')[0]; 51 | if (!directoryId) { 52 | throw new Error(`Failed to extract directoryId from href "${url0}" for link "${el.textContent}"`); 53 | } 54 | const relativePath = url.split(':')[1]; 55 | if (!relativePath) { 56 | throw new Error(`Failed to extract relativePath from href "${url0}" for link "${el.textContent}"`); 57 | } 58 | const directoryPath = this.directoryConfigManager.getLocalDirectory(directoryId); 59 | if (!directoryPath) { 60 | throw new Error(`Virtual directory "${directoryId}" not found for link "${el.textContent}"`); 61 | } 62 | const fullPath = path.join(directoryPath, relativePath); 63 | if(!existsSync(fullPath)) { 64 | throw new Error(`Virtual file url "${url0}" is resolved to non-existent file "${fullPath}" for link "${el.textContent}"`); 65 | } 66 | el.textContent = path.basename(fullPath); 67 | el.addEventListener("click", () => { 68 | openFileWithDefaultProgram(fullPath, (error) => { 69 | if (error) { 70 | new Notice("Failed to open file: " + error.message); 71 | } 72 | }); 73 | }); 74 | } catch(error) { 75 | el.addEventListener("click", () => { 76 | new Notice(`Inline link error: ${error.message}`); 77 | }); 78 | } 79 | }); 80 | } 81 | 82 | public processCodeBlockLink(relativeTo: "home" | "vault", source: string, element: HTMLElement, context: MarkdownPostProcessorContext) { 83 | let filePath = source.trim(); 84 | if (filePath.startsWith("/")) { 85 | filePath = filePath.substring(1); 86 | } 87 | const fullPath = `${relativeTo === "home" ? this.homeDirectory : this.vaultDirectory}/${filePath}`; 88 | const fileName = filePath.split("/").pop(); 89 | 90 | const link = document.createElement("a"); 91 | link.href = "#"; 92 | link.textContent = fileName ?? "Unknown file"; 93 | 94 | link.addEventListener("click", () => { 95 | openFileWithDefaultProgram(fullPath, (error) => { 96 | if (error) { 97 | new Notice("Failed to open file: " + error.message); 98 | } 99 | }); 100 | }); 101 | 102 | element.appendChild(link); 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/VirtualDirectoryManager.ts: -------------------------------------------------------------------------------- 1 | import { VirtualDirectoriesMap } from "settings"; 2 | import { DeviceInfo } from "settings"; 3 | import { existsSync } from "fs"; 4 | import * as os from "os"; 5 | import CrossComputerLinkPlugin from "main"; 6 | 7 | export interface VirtualDirectoryManager { 8 | getAllDevices(): DeviceInfo[]; 9 | setDeviceName(uuid: string, name: string): Promise; 10 | removeDevice(uuid: string): Promise; 11 | 12 | addDirectory(virtualDirectoryName: string): Promise; 13 | // TODO also update directory names in obsidian notes? 14 | // or just warn user to do it manually? 15 | renameDirectory(virtualDirectoryName: string, newName: string): Promise; 16 | deleteDirectory(virtualDirectoryName: string): Promise; 17 | 18 | registerCurrentDevice(): void; 19 | getLocalDirectory(virtualDirectoryName: string): string | null; 20 | setLocalDirectory(virtualDirectoryName: string, directory: string): Promise; 21 | setDirectory(virtualDirectoryName: string, uuid: string, directory: string): Promise; 22 | getAllDirectories(): VirtualDirectoriesMap; 23 | 24 | getAllLocalDirectories(): Record; 25 | } 26 | 27 | 28 | export class VirtualDirectoryManagerImpl implements VirtualDirectoryManager { 29 | private deviceUUID: string; 30 | constructor(private plugin: CrossComputerLinkPlugin, deviceUUID: string) { 31 | this.deviceUUID = deviceUUID; 32 | this.registerCurrentDevice(); 33 | } 34 | 35 | registerCurrentDevice() { 36 | if (this.plugin.settings.devices[this.deviceUUID]) { 37 | return; 38 | } 39 | let deviceName = os.hostname(); 40 | if (deviceName.length === 0) { 41 | deviceName = "Unknown"; 42 | } 43 | this.plugin.settings.devices[this.deviceUUID] = { 44 | uuid: this.deviceUUID, 45 | name: deviceName, 46 | os: os.platform() 47 | }; 48 | } 49 | 50 | getAllDevices(): DeviceInfo[] { 51 | return Object.values(this.plugin.settings.devices); 52 | } 53 | 54 | private checkDeviceName(name: string) { 55 | if (!/^[a-zA-Z0-9\s\-_.]+$/.test(name)) { 56 | throw new Error(`Invalid device name "${name}, only letters, numbers, spaces, dash, dot, and underscore are allowed`); 57 | } 58 | } 59 | 60 | private checkVirtualDirectoryName(name: string) { 61 | const lowerName = name.toLowerCase(); 62 | // home, vault, file are reserved 63 | if (lowerName === 'home' || lowerName === 'vault' || lowerName === 'file') { 64 | throw new Error(`Invalid virtual directory name "${name}", home, vault, and file are reserved`); 65 | } 66 | if (!/^[a-zA-Z0-9]+$/.test(name)) { 67 | throw new Error(`Invalid virtual directory name "${name}", only letters and numbers are allowed`); 68 | } 69 | } 70 | 71 | setDeviceName(uuid: string, name: string): Promise { 72 | // check if the device exists 73 | if (!this.plugin.settings.devices[uuid]) { 74 | throw new Error(`Device "${uuid}" not found`); 75 | } 76 | this.checkDeviceName(name); 77 | this.plugin.settings.devices[uuid].name = name; 78 | return this.plugin.saveSettings(); 79 | } 80 | 81 | removeDevice(uuid: string): Promise { 82 | delete this.plugin.settings.devices[uuid]; 83 | // remove all the directories for this device 84 | Object.keys(this.plugin.settings.virtualDirectories).forEach(virtualDirectoryName => { 85 | delete this.plugin.settings.virtualDirectories[virtualDirectoryName][uuid]; 86 | }); 87 | return this.plugin.saveSettings(); 88 | } 89 | 90 | addDirectory(virtualDirectoryName: string): Promise { 91 | if (this.plugin.settings.virtualDirectories[virtualDirectoryName]) { 92 | throw new Error(`Directory "${virtualDirectoryName}" already exists`); 93 | } 94 | this.checkVirtualDirectoryName(virtualDirectoryName); 95 | this.plugin.settings.virtualDirectories[virtualDirectoryName] = {}; 96 | return this.plugin.saveSettings(); 97 | } 98 | 99 | renameDirectory(virtualDirectoryName: string, newName: string): Promise { 100 | if (this.plugin.settings.virtualDirectories[newName]) { 101 | throw new Error(`Directory "${newName}" already exists`); 102 | } 103 | this.checkVirtualDirectoryName(newName); 104 | this.plugin.settings.virtualDirectories[newName] = this.plugin.settings.virtualDirectories[virtualDirectoryName]; 105 | delete this.plugin.settings.virtualDirectories[virtualDirectoryName]; 106 | return this.plugin.saveSettings(); 107 | } 108 | 109 | deleteDirectory(virtualDirectoryName: string): Promise { 110 | delete this.plugin.settings.virtualDirectories[virtualDirectoryName]; 111 | return this.plugin.saveSettings(); 112 | } 113 | 114 | getLocalDirectory(virtualDirectoryName: string): string | null { 115 | 116 | if(virtualDirectoryName === 'home'){ 117 | return process.env.HOME || process.env.USERPROFILE || ''; 118 | } 119 | if(virtualDirectoryName === 'vault'){ 120 | // @ts-ignore 121 | return this.plugin.app.vault.adapter.basePath; 122 | } 123 | if (!this.plugin.settings.virtualDirectories[virtualDirectoryName]) { 124 | return null; 125 | } 126 | if (!this.plugin.settings.virtualDirectories[virtualDirectoryName][this.deviceUUID]) { 127 | return null; 128 | } 129 | return this.plugin.settings.virtualDirectories[virtualDirectoryName][this.deviceUUID].path; 130 | } 131 | 132 | setLocalDirectory(virtualDirectoryName: string, directory: string): Promise { 133 | return this.setDirectory(virtualDirectoryName, this.deviceUUID, directory); 134 | } 135 | 136 | setDirectory(virtualDirectoryName: string, uuid: string, directory: string): Promise { 137 | if(uuid === this.deviceUUID){ 138 | // check if the directory exists 139 | if (!existsSync(directory)) { 140 | throw new Error(`Directory "${directory}" does not exist`); 141 | } 142 | } 143 | if (!this.plugin.settings.virtualDirectories[virtualDirectoryName]) { 144 | this.plugin.settings.virtualDirectories[virtualDirectoryName] = {}; 145 | } 146 | this.plugin.settings.virtualDirectories[virtualDirectoryName][uuid] = { 147 | path: directory 148 | }; 149 | return this.plugin.saveSettings(); 150 | } 151 | 152 | getAllDirectories(): VirtualDirectoriesMap { 153 | return this.plugin.settings.virtualDirectories; 154 | } 155 | 156 | getAllLocalDirectories(): Record { 157 | const result: Record = {}; 158 | result['home'] = this.getLocalDirectory('home') || ''; 159 | result['vault'] = this.getLocalDirectory('vault') || ''; 160 | Object.keys(this.plugin.settings.virtualDirectories).forEach(virtualDirectoryName => { 161 | const localDirectory = this.getLocalDirectory(virtualDirectoryName); 162 | if (localDirectory) { 163 | result[virtualDirectoryName] = localDirectory; 164 | } 165 | }); 166 | return result; 167 | } 168 | 169 | } -------------------------------------------------------------------------------- /src/__mocks__/VirtualDirectoryManager.ts: -------------------------------------------------------------------------------- 1 | export class VirtualDirectoryManager { 2 | constructor() {} 3 | } 4 | -------------------------------------------------------------------------------- /src/__mocks__/obsidian.ts: -------------------------------------------------------------------------------- 1 | export class Component { 2 | constructor() {} 3 | } 4 | 5 | export class Notice { 6 | constructor(message: string) {} 7 | } 8 | 9 | export class MarkdownPostProcessorContext { 10 | constructor() {} 11 | } 12 | -------------------------------------------------------------------------------- /src/__mocks__/setup.ts: -------------------------------------------------------------------------------- 1 | // Mock DOMParser 2 | export {}; 3 | 4 | declare global { 5 | interface DOMParser { 6 | parseFromString(string: string, contentType: string): Document; 7 | } 8 | } 9 | 10 | global.DOMParser = class DOMParser { 11 | parseFromString(string: string, contentType: string): Document { 12 | return { 13 | body: { 14 | children: [] 15 | } 16 | } as unknown as Document; 17 | } 18 | }; 19 | -------------------------------------------------------------------------------- /src/__mocks__/utils.ts: -------------------------------------------------------------------------------- 1 | export const ImageExtensions = ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp']; 2 | export const VideoExtensions = ['.mp4', '.webm', '.ogg', '.mov']; 3 | export const MarkdownExtensions = ['.md', '.markdown']; 4 | 5 | export const isImage = (path: string) => ImageExtensions.some(ext => path.toLowerCase().endsWith(ext)); 6 | export const isVideo = (path: string) => VideoExtensions.some(ext => path.toLowerCase().endsWith(ext)); 7 | export const isAudio = (path: string) => false; 8 | export const isPDF = (path: string) => path.toLowerCase().endsWith('.pdf'); 9 | export const isMarkdown = (path: string) => MarkdownExtensions.some(ext => path.toLowerCase().endsWith(ext)); 10 | 11 | export const openFileWithDefaultProgram = (path: string, callback: (error: Error | null) => void) => { 12 | callback(null); 13 | }; 14 | 15 | export const extractHeaderSection = async (content: string, header: string) => { 16 | return content; 17 | }; 18 | -------------------------------------------------------------------------------- /src/assets/pdfjs-5.2.133-dist/web/images/annotation-noicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | -------------------------------------------------------------------------------- /src/assets/pdfjs-5.2.133-dist/web/images/findbarButton-next.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/pdfjs-5.2.133-dist/web/images/findbarButton-previous.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/pdfjs-5.2.133-dist/web/images/loading-icon.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oylbin/obsidian-external-file-embed-and-link/27af4b64f4b8cb8ea609a08621628ec7f4906ca8/src/assets/pdfjs-5.2.133-dist/web/images/loading-icon.gif -------------------------------------------------------------------------------- /src/assets/pdfjs-5.2.133-dist/web/images/loading.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/pdfjs-5.2.133-dist/web/images/toolbarButton-currentOutlineItem.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/pdfjs-5.2.133-dist/web/images/toolbarButton-menuArrow.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/pdfjs-5.2.133-dist/web/images/toolbarButton-openFile.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/pdfjs-5.2.133-dist/web/images/toolbarButton-pageDown.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/pdfjs-5.2.133-dist/web/images/toolbarButton-pageUp.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/pdfjs-5.2.133-dist/web/images/toolbarButton-search.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/pdfjs-5.2.133-dist/web/images/toolbarButton-sidebarToggle.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/pdfjs-5.2.133-dist/web/images/toolbarButton-viewAttachments.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/pdfjs-5.2.133-dist/web/images/toolbarButton-viewLayers.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/pdfjs-5.2.133-dist/web/images/toolbarButton-viewOutline.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/pdfjs-5.2.133-dist/web/images/toolbarButton-viewThumbnail.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/pdfjs-5.2.133-dist/web/images/toolbarButton-zoomIn.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/pdfjs-5.2.133-dist/web/images/toolbarButton-zoomOut.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/pdfjs-5.2.133-dist/web/images/treeitem-collapsed.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/pdfjs-5.2.133-dist/web/images/treeitem-expanded.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/pdfjs-5.2.133-dist/web/viewer.html: -------------------------------------------------------------------------------- 1 |  2 | 23 | 24 | 25 | 26 | 27 | 28 | PDF.js viewer 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 42 | 43 | 44 | 45 | 46 |
47 | 48 |
49 |
50 |
51 |
52 | 55 | 58 | 61 | 64 |
65 |
66 | 67 |
68 |
69 |
70 | 71 | 74 |
75 |
76 |
77 |
78 |
79 |
80 | 82 | 84 | 86 |
87 |
88 |
89 | 90 |
91 |
92 |
93 |
94 |
95 | 98 |
99 |
100 | 103 | 106 | 148 |
149 |
150 | 153 |
154 | 157 |
158 |
159 | 160 | 161 | 162 | 163 |
164 |
165 |
166 |
167 | 170 |
171 | 174 |
175 | 176 | 191 | 192 |
193 |
194 |
195 | 207 |
208 | 211 | 231 |
232 |
233 | 236 | 248 |
249 |
250 | 253 | 269 |
270 |
271 | 274 | 281 |
282 |
283 | 284 |
285 | 286 |
287 | 290 | 291 | 294 |
295 | 296 |
297 | 298 |
299 | 302 | 401 |
402 |
403 |
404 |
405 |
406 |
407 |
408 |
409 |
410 |
411 |
412 | 413 |
414 |
415 |
416 |
417 | 418 |
419 | 420 |
421 | 422 |
423 |
424 | 425 |
426 |
427 | 428 | 429 |
430 |
431 | 432 |
433 | 434 |

-

435 |
436 |
437 | 438 |

-

439 |
440 |
441 |
442 | 443 |

-

444 |
445 |
446 | 447 |

-

448 |
449 |
450 | 451 |

-

452 |
453 |
454 | 455 |

-

456 |
457 |
458 | 459 |

-

460 |
461 |
462 | 463 |

-

464 |
465 |
466 | 467 |

-

468 |
469 |
470 |
471 | 472 |

-

473 |
474 |
475 | 476 |

-

477 |
478 |
479 | 480 |

-

481 |
482 |
483 | 484 |

-

485 |
486 |
487 |
488 | 489 |

-

490 |
491 |
492 | 493 |
494 |
495 | 496 |
497 |
498 | 499 | 500 |
501 |
502 |
503 |
504 | 505 | 506 |
507 |
508 | 509 |
510 |
511 |
512 | 513 |
514 |
515 |
516 |
517 |
518 | 519 | 520 |
521 |
522 | 523 |
524 |
525 |
526 |
527 | 528 | 529 |
530 |
531 |
532 | 533 |
534 |
535 | 536 |
537 |
538 |
539 |
540 |
541 |
542 | 543 |
544 | 545 |
546 |
547 |
548 | 549 | 550 |
551 | 554 |
555 |
556 |
557 |
558 |
559 |
560 | 561 | 562 |
563 | 564 |
565 |
566 |
567 | 568 | 569 | 570 |
571 |
572 |
573 | 574 | 575 |
576 |
577 | 578 |
579 |
580 | 581 |
582 |
583 |
584 | 585 | 586 |
587 |
588 | 589 |
590 |
591 |
592 |
593 | 594 |
595 | 596 |
597 |
598 | 599 | 600 |
601 |
602 |
603 |
604 |
605 | 606 |
607 |
608 | 609 | 610 |
611 |
612 | 613 |
614 |
615 |
616 |
617 | 618 |
619 |
620 |
621 | 622 | 623 | 624 |
625 |
626 | 627 |
628 |
629 | 630 | 631 | 632 |
633 |
634 |
635 | 636 |
637 |
638 | 639 | 640 |
641 |
642 | 643 | 644 |
645 |
646 |
647 |
648 | 649 |
650 | 651 | 654 | 655 |
656 |
657 |
658 |
659 |
660 | 661 | 662 | 663 | 664 | 665 |
666 | 667 |
668 |
669 | 670 | 671 | 672 | 673 |
674 |
675 | 684 |
685 | 686 | 687 |
688 |
689 |
690 |
691 | 692 | 693 |
694 |
695 | 696 |
697 |
698 |
699 | 700 | 701 | 702 | 703 | 704 |
705 | 706 |
707 |
708 | 709 | 710 |
711 |
712 |
713 | 714 | 715 |
716 | 717 |
718 |
719 | 720 | 0% 721 |
722 |
723 | 724 |
725 |
726 |
727 | 728 | 741 | 742 |
743 |
744 | 745 | 746 | -------------------------------------------------------------------------------- /src/assets/pdfjs-5.2.133-dist/web/wasm/openjpeg.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oylbin/obsidian-external-file-embed-and-link/27af4b64f4b8cb8ea609a08621628ec7f4906ca8/src/assets/pdfjs-5.2.133-dist/web/wasm/openjpeg.wasm -------------------------------------------------------------------------------- /src/assets/pdfjs-5.2.133-dist/web/wasm/qcms_bg.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oylbin/obsidian-external-file-embed-and-link/27af4b64f4b8cb8ea609a08621628ec7f4906ca8/src/assets/pdfjs-5.2.133-dist/web/wasm/qcms_bg.wasm -------------------------------------------------------------------------------- /src/assets/pdfjs-viewer-element-2.7.1.js: -------------------------------------------------------------------------------- 1 | const y=(v,t)=>new Promise(l=>{let i=t.querySelector(v);i?l(i):new MutationObserver((r,e)=>{Array.from(t.querySelectorAll(v)).forEach(s=>{l(s),e.disconnect()})}).observe(t,{childList:!0,subtree:!0})}),S={trailing:!0},D="/pdfjs",C="/web/viewer.html",A="",P="",k="",R="",V="",F="none",O="",L="",j="",I="",q="",M="",W="",z="",x="AUTOMATIC",$="",H="",N="",b={AUTOMATIC:0,LIGHT:1,DARK:2},E=["src","viewer-path","disable-worker","text-layer","disable-font-face","disable-range","disable-stream","disable-auto-fetch","verbosity","locale","viewer-css-theme","viewer-extra-styles","viewer-extra-styles-urls"];class p extends HTMLElement{constructor(){super(),this.onIframeReady=function(i,r=25,e={}){if(e={...S,...e},!Number.isFinite(r))throw new TypeError("Expected `wait` to be a finite number");let s,n,a,o,h=[];const f=(d,u)=>(a=async function(c,m,w){return await c.apply(m,w)}(i,d,u),a.finally(()=>{if(a=null,e.trailing&&o&&!n){const c=f(d,o);return o=null,c}}),a);return function(...d){return a?(e.trailing&&(o=d),a):new Promise(u=>{const c=!n&&e.leading;clearTimeout(n),n=setTimeout(()=>{n=null;const m=e.leading?s:f(this,d);for(const w of h)w(m);h=[]},r),c?(s=f(this,d),u(s)):h.push(u)})}}(async i=>{await y("iframe",this.shadowRoot),i()},0,{leading:!0}),this.setViewerExtraStyles=(i,r="extra")=>{var e,s,n,a,o;if(!i)return void((s=(e=this.iframe.contentDocument)==null?void 0:e.head.querySelector(`style[${r}]`))==null?void 0:s.remove());if(((a=(n=this.iframe.contentDocument)==null?void 0:n.head.querySelector(`style[${r}]`))==null?void 0:a.innerHTML)===i)return;const h=document.createElement("style");h.innerHTML=i,h.setAttribute(r,""),(o=this.iframe.contentDocument)==null||o.head.appendChild(h)},this.injectExtraStylesLinks=i=>{i&&i.replace(/'|]|\[/g,"").split(",").map(r=>r.trim()).forEach(r=>{var e,s;if((e=this.iframe.contentDocument)==null?void 0:e.head.querySelector(`link[href="${r}"]`))return;const n=document.createElement("link");n.rel="stylesheet",n.href=r,(s=this.iframe.contentDocument)==null||s.head.appendChild(n)})},this.initialize=()=>new Promise(async i=>{var r;await y("iframe",this.shadowRoot),(r=this.iframe)==null||r.addEventListener("load",async()=>{var e,s,n;await((s=(e=this.iframe.contentWindow)==null?void 0:e.PDFViewerApplication)==null?void 0:s.initializedPromise),i((n=this.iframe.contentWindow)==null?void 0:n.PDFViewerApplication)},{once:!0})});const t=this.attachShadow({mode:"open"}),l=document.createElement("template");l.innerHTML=` 2 | 3 | 4 | `,t.appendChild(l.content.cloneNode(!0))}static get observedAttributes(){return["src","viewer-path","page","search","phrase","zoom","pagemode","disable-worker","text-layer","disable-font-face","disable-range","disable-stream","disable-auto-fetch","verbosity","locale","viewer-css-theme","viewer-extra-styles","viewer-extra-styles-urls","nameddest"]}connectedCallback(){this.iframe=this.shadowRoot.querySelector("iframe"),document.addEventListener("webviewerloaded",async()=>{var t,l,i,r,e,s,n,a;this.setCssTheme(this.getCssThemeOption()),this.injectExtraStylesLinks(this.getAttribute("viewer-extra-styles-urls")??H),this.setViewerExtraStyles(this.getAttribute("viewer-extra-styles")??$),this.getAttribute("src")!==A&&((l=(t=this.iframe.contentWindow)==null?void 0:t.PDFViewerApplicationOptions)==null||l.set("defaultUrl","")),(r=(i=this.iframe.contentWindow)==null?void 0:i.PDFViewerApplicationOptions)==null||r.set("disablePreferences",!0),(s=(e=this.iframe.contentWindow)==null?void 0:e.PDFViewerApplicationOptions)==null||s.set("pdfBugEnabled",!0),(a=(n=this.iframe.contentWindow)==null?void 0:n.PDFViewerApplicationOptions)==null||a.set("eventBusDispatchToDOM",!0)})}attributeChangedCallback(t){E.includes(t)?this.onIframeReady(()=>this.mountViewer(this.getIframeSrc())):this.onIframeReady(()=>{this.iframe.src=this.getIframeSrc()})}getIframeSrc(){const t=this.getFullPath(this.getAttribute("src")||A),l=this.getFullPath(this.getAttribute("viewer-path")||D),i=this.getAttribute("page")||P,r=this.getAttribute("search")||k,e=this.getAttribute("phrase")||R,s=this.getAttribute("zoom")||V,n=this.getAttribute("pagemode")||F,a=this.getAttribute("disable-worker")||L,o=this.getAttribute("text-layer")||j,h=this.getAttribute("disable-font-face")||I,f=this.getAttribute("disable-range")||q,d=this.getAttribute("disable-stream")||M,u=this.getAttribute("disable-auto-fetch")||W,c=this.getAttribute("verbosity")||z,m=this.getAttribute("locale")||O,w=this.getAttribute("viewer-css-theme")||x,T=!!(this.getAttribute("viewer-extra-styles")||$),g=this.getAttribute("nameddest")||N;return` 5 | ${l}${C}?file= 6 | ${encodeURIComponent(t)}#page=${i}&zoom=${s}&pagemode=${n}&search=${r}&phrase=${e}&textLayer= 7 | ${o}&disableWorker= 8 | ${a}&disableFontFace= 9 | ${h}&disableRange= 10 | ${f}&disableStream= 11 | ${d}&disableAutoFetch= 12 | ${u}&verbosity= 13 | ${c} 14 | ${m?"&locale="+m:""}&viewerCssTheme= 15 | ${w}&viewerExtraStyles= 16 | ${T} 17 | ${g?"&nameddest="+g:""}`}mountViewer(t){t&&this.iframe&&(this.shadowRoot.replaceChild(this.iframe.cloneNode(),this.iframe),this.iframe=this.shadowRoot.querySelector("iframe"),this.iframe.src=t)}getFullPath(t){return t.startsWith("/")?`${window.location.origin}${t}`:t}getCssThemeOption(){const t=this.getAttribute("viewer-css-theme");return Object.keys(b).includes(t)?b[t]:b[x]}setCssTheme(t){var l,i,r;if(t===b.DARK){const e=(l=this.iframe.contentDocument)==null?void 0:l.styleSheets[0],s=(e==null?void 0:e.cssRules)||[],n=Object.keys(s).filter(a=>{var o;return((o=s[Number(a)])==null?void 0:o.conditionText)==="(prefers-color-scheme: dark)"}).map(a=>s[Number(a)].cssText.split(`@media (prefers-color-scheme: dark) { 18 | `)[1].split(` 19 | }`)[0]);this.setViewerExtraStyles(n.join(""),"theme")}else(r=(i=this.iframe.contentDocument)==null?void 0:i.head.querySelector("style[theme]"))==null||r.remove()}}window.customElements.get("pdfjs-viewer-element")||(window.PdfjsViewerElement=p,window.customElements.define("pdfjs-viewer-element",p));export default p;export{p as PdfjsViewerElement,b as ViewerCssTheme,E as hardRefreshAttributes}; 20 | -------------------------------------------------------------------------------- /src/embedProcessor.test.ts: -------------------------------------------------------------------------------- 1 | import { parseEmbedFolderArguments, filterFolderFiles, EmbedFolderArguments } from './embedProcessor'; 2 | import * as fs from 'fs'; 3 | 4 | describe('parseEmbedFolderArguments', () => { 5 | test('should parse empty arguments', () => { 6 | const result = parseEmbedFolderArguments(''); 7 | expect(result.extensions).toEqual([]); 8 | expect(result.includePatterns).toEqual([]); 9 | expect(result.excludePatterns).toEqual([]); 10 | }); 11 | 12 | test('should parse single extensions parameter', () => { 13 | const result = parseEmbedFolderArguments('extensions=pdf,txt'); 14 | expect(result.extensions).toEqual(['pdf', 'txt']); 15 | expect(result.includePatterns).toEqual([]); 16 | expect(result.excludePatterns).toEqual([]); 17 | }); 18 | 19 | test('should parse multiple extensions parameters', () => { 20 | const result = parseEmbedFolderArguments('extensions=pdf,txt&extensions=doc,docx'); 21 | expect(result.extensions).toEqual(['pdf', 'txt', 'doc', 'docx']); 22 | expect(result.includePatterns).toEqual([]); 23 | expect(result.excludePatterns).toEqual([]); 24 | }); 25 | 26 | test('should parse include patterns', () => { 27 | const result = parseEmbedFolderArguments('include=*.pdf,*.txt'); 28 | expect(result.extensions).toEqual([]); 29 | expect(result.includePatterns).toEqual(['*.pdf', '*.txt']); 30 | expect(result.excludePatterns).toEqual([]); 31 | }); 32 | 33 | test('should parse exclude patterns', () => { 34 | const result = parseEmbedFolderArguments('exclude=*.tmp,*.bak'); 35 | expect(result.extensions).toEqual([]); 36 | expect(result.includePatterns).toEqual([]); 37 | expect(result.excludePatterns).toEqual(['*.tmp', '*.bak']); 38 | }); 39 | 40 | test('should parse all parameters together', () => { 41 | const result = parseEmbedFolderArguments('extensions=pdf,txt&include=*.pdf,*.txt&exclude=*.tmp,*.bak'); 42 | expect(result.extensions).toEqual(['pdf', 'txt']); 43 | expect(result.includePatterns).toEqual(['*.pdf', '*.txt']); 44 | expect(result.excludePatterns).toEqual(['*.tmp', '*.bak']); 45 | }); 46 | 47 | test('should handle whitespace in values', () => { 48 | const result = parseEmbedFolderArguments('extensions= pdf , txt &include= *.pdf , *.txt '); 49 | expect(result.extensions).toEqual(['pdf', 'txt']); 50 | expect(result.includePatterns).toEqual(['*.pdf', '*.txt']); 51 | }); 52 | 53 | test('should handle case sensitivity in extensions', () => { 54 | const result = parseEmbedFolderArguments('extensions=PDF,TXT'); 55 | expect(result.extensions).toEqual(['pdf', 'txt']); 56 | }); 57 | }); 58 | 59 | describe('filterFolderFiles', () => { 60 | const createMockFile = (name: string, isDirectory = false): fs.Dirent => ({ 61 | name, 62 | isDirectory: () => isDirectory, 63 | isFile: () => !isDirectory, 64 | isBlockDevice: () => false, 65 | isCharacterDevice: () => false, 66 | isSymbolicLink: () => false, 67 | isFIFO: () => false, 68 | isSocket: () => false, 69 | }); 70 | 71 | test('should return all files when no filters are applied', () => { 72 | const files = [ 73 | createMockFile('test.pdf'), 74 | createMockFile('test.txt'), 75 | createMockFile('test.md'), 76 | ]; 77 | const args = new EmbedFolderArguments(); 78 | const result = filterFolderFiles(files, args); 79 | expect(result).toEqual(files); 80 | }); 81 | 82 | test('should filter by extensions', () => { 83 | const files = [ 84 | createMockFile('test.pdf'), 85 | createMockFile('test.txt'), 86 | createMockFile('test.md'), 87 | ]; 88 | const args = new EmbedFolderArguments(); 89 | args.extensions = ['pdf', 'txt']; 90 | const result = filterFolderFiles(files, args); 91 | expect(result).toHaveLength(2); 92 | expect(result.map(f => f.name)).toEqual(['test.pdf', 'test.txt']); 93 | }); 94 | 95 | test('should filter by include patterns', () => { 96 | const files = [ 97 | createMockFile('test.pdf'), 98 | createMockFile('test.txt'), 99 | createMockFile('test.md'), 100 | ]; 101 | const args = new EmbedFolderArguments(); 102 | args.includePatterns = ['*.pdf', '*.txt']; 103 | const result = filterFolderFiles(files, args); 104 | expect(result).toHaveLength(2); 105 | expect(result.map(f => f.name)).toEqual(['test.pdf', 'test.txt']); 106 | }); 107 | 108 | test('should filter by exclude patterns', () => { 109 | const files = [ 110 | createMockFile('test.pdf'), 111 | createMockFile('test.txt'), 112 | createMockFile('test.md'), 113 | ]; 114 | const args = new EmbedFolderArguments(); 115 | args.excludePatterns = ['*.pdf', '*.txt']; 116 | const result = filterFolderFiles(files, args); 117 | expect(result).toHaveLength(1); 118 | expect(result.map(f => f.name)).toEqual(['test.md']); 119 | }); 120 | 121 | test('should apply all filters in correct order', () => { 122 | const files = [ 123 | createMockFile('test1.pdf'), 124 | createMockFile('test2.pdf'), 125 | createMockFile('test.txt'), 126 | createMockFile('test.md'), 127 | ]; 128 | const args = new EmbedFolderArguments(); 129 | args.extensions = ['pdf', 'txt']; 130 | args.includePatterns = ['test1.*']; 131 | args.excludePatterns = ['*.txt']; 132 | const result = filterFolderFiles(files, args); 133 | expect(result).toHaveLength(1); 134 | expect(result.map(f => f.name)).toEqual(['test1.pdf']); 135 | }); 136 | 137 | test('should sort files alphabetically', () => { 138 | const files = [ 139 | createMockFile('z.pdf'), 140 | createMockFile('a.pdf'), 141 | createMockFile('m.pdf'), 142 | ]; 143 | const args = new EmbedFolderArguments(); 144 | const result = filterFolderFiles(files, args); 145 | expect(result.map(f => f.name)).toEqual(['a.pdf', 'm.pdf', 'z.pdf']); 146 | }); 147 | 148 | test('should handle case-insensitive filtering', () => { 149 | const files = [ 150 | createMockFile('TEST.PDF'), 151 | createMockFile('test.txt'), 152 | createMockFile('Test.md'), 153 | ]; 154 | const args = new EmbedFolderArguments(); 155 | args.extensions = ['pdf', 'txt']; 156 | const result = filterFolderFiles(files, args); 157 | expect(result).toHaveLength(2); 158 | expect(result.map(f => f.name)).toEqual(['TEST.PDF', 'test.txt']); 159 | }); 160 | 161 | test('should filter by exclude patterns', () => { 162 | const files = [ 163 | createMockFile('a.pdf'), 164 | createMockFile('test.txt'), 165 | createMockFile('test.md'), 166 | ]; 167 | const args = new EmbedFolderArguments(); 168 | args.excludePatterns = ['test.*', '*.txt']; 169 | const result = filterFolderFiles(files, args); 170 | expect(result).toHaveLength(1); 171 | expect(result.map(f => f.name)).toEqual(['a.pdf']); 172 | }); 173 | }); 174 | -------------------------------------------------------------------------------- /src/embedProcessor.ts: -------------------------------------------------------------------------------- 1 | import * as path from "path"; 2 | import { minimatch } from 'minimatch'; 3 | import { ImageExtensions, isAudio, isImage, isMarkdown, isPDF, isVideo, MarkdownExtensions, VideoExtensions } from "./utils"; 4 | import { MarkdownPostProcessorContext, Component } from 'obsidian'; 5 | import * as fs from 'fs'; 6 | import { openFileWithDefaultProgram } from './utils'; 7 | import { Notice } from 'obsidian'; 8 | import { extractHeaderSection } from './utils'; 9 | import { VirtualDirectoryManager } from "./VirtualDirectoryManager"; 10 | 11 | export class EmbedData { 12 | embedType: 'pdf' | 'image' | 'markdown' | 'audio' | 'video' | 'other' | 'folder'; 13 | embedArguments: string; 14 | embedFilePath: string; 15 | } 16 | 17 | export class EmbedArgumentWidthHeight { 18 | width: number | undefined; 19 | height: number | undefined; 20 | } 21 | 22 | export class EmbedPdfArguments { 23 | page = 1; 24 | width = '100%'; 25 | height = '80vh'; 26 | } 27 | 28 | export class EmbedFolderArguments { 29 | extensions: string[] = []; // can filter files by extensions, separated by comma, eg. pdf,txt 30 | includePatterns: string[] = []; // can filter files by glob patterns, separated by comma, eg. *.pdf,*.txt 31 | excludePatterns: string[] = []; // can exclude files by glob patterns, separated by comma, eg. *.pdf,*.txt 32 | } 33 | 34 | export function parseEmbedFolderArguments(embedArguments: string): EmbedFolderArguments { 35 | // console.log(`parseEmbedFolderArguments: ${embedArguments}`); 36 | const embedFolderArguments = new EmbedFolderArguments(); 37 | const params = embedArguments.split('&'); 38 | for (const param of params) { 39 | const [key, value] = param.split('='); 40 | if (key === 'extensions') { 41 | // Split the value by comma and add each extension to the array 42 | const extensions = value.split(',').map(ext => ext.trim().toLowerCase()); 43 | embedFolderArguments.extensions.push(...extensions); 44 | } 45 | if (key === 'include') { 46 | const includePatterns = value.split(',').map(pattern => pattern.trim()); 47 | embedFolderArguments.includePatterns.push(...includePatterns); 48 | } 49 | if (key === 'exclude') { 50 | const excludePatterns = value.split(',').map(pattern => pattern.trim()); 51 | embedFolderArguments.excludePatterns.push(...excludePatterns); 52 | } 53 | } 54 | return embedFolderArguments; 55 | } 56 | 57 | export function parseEmbedPdfArguments(embedArguments: string): EmbedPdfArguments { 58 | const embedPdfArguments = new EmbedPdfArguments(); 59 | // embedArguments can be in the following forms 60 | // width=100%&height=50vh&page=23 61 | // Parameters are optional and may not appear in any order. 62 | // However, parameters are in key=value format and separated by &. 63 | const params = embedArguments.split('&'); 64 | for (const param of params) { 65 | const [key, value] = param.split('='); 66 | if (key === 'width') { 67 | embedPdfArguments.width = value; 68 | } 69 | if (key === 'height') { 70 | embedPdfArguments.height = value; 71 | } 72 | if (key === 'page') { 73 | embedPdfArguments.page = parseInt(value); 74 | } 75 | } 76 | return embedPdfArguments; 77 | } 78 | 79 | export function parseEmbedArgumentWidthHeight(embedArguments: string): EmbedArgumentWidthHeight { 80 | // embedArguments can be in the following forms 81 | // 1. a single number 400, which represents width 82 | // 2. two numbers 400x300, which represents width and height 83 | const embedArgumentWidthHeight = new EmbedArgumentWidthHeight(); 84 | const dimensionMatch = embedArguments.match(/^(\d+)(?:x(\d+))?$/); 85 | if (dimensionMatch) { 86 | embedArgumentWidthHeight.width = parseInt(dimensionMatch[1]); 87 | if (dimensionMatch[2]) { 88 | embedArgumentWidthHeight.height = parseInt(dimensionMatch[2]); 89 | } 90 | } 91 | return embedArgumentWidthHeight; 92 | } 93 | 94 | export function filterFolderFiles(files: fs.Dirent[], embedFolderArguments: EmbedFolderArguments): fs.Dirent[] { 95 | // filter order: extensions -> includePatterns -> excludePatterns 96 | let filteredFiles = files; 97 | 98 | if (embedFolderArguments.extensions.length > 0) { 99 | filteredFiles = filteredFiles.filter(file => { 100 | const extension = path.extname(file.name).toLowerCase().slice(1); 101 | return embedFolderArguments.extensions.includes(extension); 102 | }); 103 | } 104 | 105 | if (embedFolderArguments.includePatterns.length > 0) { 106 | filteredFiles = filteredFiles.filter(file => { 107 | const fileName = file.name.toLowerCase(); 108 | return embedFolderArguments.includePatterns.some(pattern => { 109 | return minimatch(fileName, pattern); 110 | }); 111 | }); 112 | } 113 | 114 | if (embedFolderArguments.excludePatterns.length > 0) { 115 | filteredFiles = filteredFiles.filter(file => { 116 | const fileName = file.name.toLowerCase(); 117 | // exclude patterns are applied to all files, so if any pattern matches, the file should be excluded 118 | return !embedFolderArguments.excludePatterns.some(pattern => { 119 | return minimatch(fileName, pattern); 120 | }); 121 | }); 122 | } 123 | 124 | return filteredFiles.sort((a, b) => a.name.localeCompare(b.name)); 125 | } 126 | 127 | export function parseEmbedData(inputLine: string): EmbedData { 128 | // console.log("embed input: ", inputLine); 129 | inputLine = inputLine.trim(); 130 | if (inputLine.startsWith("/")) { 131 | inputLine = inputLine.substring(1); 132 | } 133 | const lowerCaseNameWithArguments = path.basename(inputLine).toLowerCase(); 134 | // console.log("lowerCaseNameWithArguments: ", lowerCaseNameWithArguments); 135 | 136 | /* 137 | refer to https://help.obsidian.md/Linking+notes+and+files/Embed+files 138 | possible forms of nameWithArguments 139 | 140 | test.pdf#page=3 141 | test.pdf#page=3&height=400 142 | 143 | test.md#headingName 144 | test.md#^blockID 145 | 146 | test.png|640x480 147 | test.png|640 148 | 149 | */ 150 | // FIXME I think use regex is better 151 | //console.log(lowerCaseNameWithArguments) 152 | //console.log(lowerCaseNameWithArguments.startsWith("#")) 153 | if (lowerCaseNameWithArguments.startsWith("#")) { 154 | const embedType = 'folder'; 155 | const embedArguments = lowerCaseNameWithArguments.split('#')[1]; 156 | const embedFilePath = inputLine.substring(0, inputLine.length - embedArguments.length - 1); 157 | return { 158 | embedType, 159 | embedArguments, 160 | embedFilePath, 161 | }; 162 | } 163 | if (lowerCaseNameWithArguments.includes(".pdf#")) { 164 | const embedType = 'pdf'; 165 | // TODO This is an inexact implementation, not considering the case where the file name contains #. 166 | const embedArguments = lowerCaseNameWithArguments.split('.pdf#')[1]; 167 | // Remove the last embedArguments length plus 1 character from inputLine to correctly preserve the case of the file name 168 | const embedFilePath = inputLine.substring(0, inputLine.length - embedArguments.length - 1); 169 | return { 170 | embedType, 171 | embedArguments, 172 | embedFilePath, 173 | }; 174 | } 175 | // for each of the extensions in MarkdownExtensions 176 | for (const ext of MarkdownExtensions) { 177 | const mark = ext + "#"; 178 | if (lowerCaseNameWithArguments.includes(mark)) { 179 | const embedType = 'markdown'; 180 | // can not use lowerCaseNameWithArguments as it have header name which will be used as embedArguments 181 | const embedArguments0 = lowerCaseNameWithArguments.split(mark)[1]; 182 | const embedFilePath = inputLine.substring(0, inputLine.length - embedArguments0.length - 1); 183 | const embedArguments = inputLine.substring(embedFilePath.length + 1); 184 | // console.log("inputLine", inputLine); 185 | // console.log("embedArguments", embedArguments); 186 | // console.log("embedFilePath", embedFilePath); 187 | return { 188 | embedType, 189 | embedArguments, 190 | embedFilePath, 191 | }; 192 | } 193 | } 194 | for (const ext of ImageExtensions) { 195 | const mark = ext + "|"; 196 | if (lowerCaseNameWithArguments.includes(mark)) { 197 | const embedType = 'image'; 198 | const embedArguments = lowerCaseNameWithArguments.split(mark)[1]; 199 | const embedFilePath = inputLine.substring(0, inputLine.length - embedArguments.length - 1); 200 | return { 201 | embedType, 202 | embedArguments, 203 | embedFilePath, 204 | }; 205 | } 206 | } 207 | for (const ext of VideoExtensions) { 208 | const mark = ext + "|"; 209 | if (lowerCaseNameWithArguments.includes(mark)) { 210 | const embedType = 'video'; 211 | const embedArguments = lowerCaseNameWithArguments.split(mark)[1]; 212 | const embedFilePath = inputLine.substring(0, inputLine.length - embedArguments.length - 1); 213 | return { 214 | embedType, 215 | embedArguments, 216 | embedFilePath, 217 | }; 218 | } 219 | } 220 | 221 | // ok, now is file without any embed arguments 222 | if (isImage(inputLine)) { 223 | const embedType = 'image'; 224 | const embedFilePath = inputLine; 225 | return { 226 | embedType, 227 | embedArguments: '', 228 | embedFilePath, 229 | }; 230 | } 231 | if (isVideo(inputLine)) { 232 | const embedType = 'video'; 233 | const embedFilePath = inputLine; 234 | return { 235 | embedType, 236 | embedArguments: '', 237 | embedFilePath, 238 | }; 239 | } 240 | if (isAudio(inputLine)) { 241 | const embedType = 'audio'; 242 | const embedFilePath = inputLine; 243 | return { 244 | embedType, 245 | embedArguments: '', 246 | embedFilePath, 247 | }; 248 | } 249 | if (isPDF(inputLine)) { 250 | const embedType = 'pdf'; 251 | const embedFilePath = inputLine; 252 | return { 253 | embedType, 254 | embedArguments: '', 255 | embedFilePath, 256 | }; 257 | } 258 | if (isMarkdown(inputLine)) { 259 | const embedType = 'markdown'; 260 | const embedFilePath = inputLine; 261 | return { 262 | embedType, 263 | embedArguments: '', 264 | embedFilePath, 265 | }; 266 | } 267 | return { 268 | embedType: 'other', 269 | embedArguments: '', 270 | embedFilePath: inputLine, 271 | }; 272 | 273 | } 274 | 275 | export class EmbedProcessor extends Component { 276 | constructor( 277 | private port: number, 278 | private directoryConfigManager: VirtualDirectoryManager 279 | ) { 280 | super(); 281 | } 282 | 283 | onload() { 284 | // console.log("EmbedProcessor onload"); 285 | // Register message event handler 286 | this.registerDomEvent(window, 'message', (event: MessageEvent) => { 287 | // console.log(event); 288 | if (event.data.type === 'openPdfFile') { 289 | const fullPath = event.data.fullPath; 290 | // console.log(`openPdfFile fullPath: ${fullPath}`); 291 | if (fullPath) { 292 | openFileWithDefaultProgram(fullPath, (error) => { 293 | if (error) { 294 | new Notice("Failed to open file: " + error.message); 295 | } 296 | }); 297 | } 298 | } 299 | }); 300 | } 301 | 302 | private embedPdfWithIframe(embedUrl: string, fullPath: string, embedArguments: string, element: HTMLElement, context: MarkdownPostProcessorContext) { 303 | const iframe = document.createElement("iframe"); 304 | const embedPdfArguments = parseEmbedPdfArguments(embedArguments); 305 | if (embedPdfArguments.page) { 306 | iframe.src = embedUrl + "&page=" + embedPdfArguments.page; 307 | } else { 308 | iframe.src = embedUrl; 309 | } 310 | iframe.classList.add("external-embed-pdf-iframe"); 311 | if (embedPdfArguments.width || embedPdfArguments.height) { 312 | iframe.classList.add("external-embed-pdf-iframe-custom-size"); 313 | if (embedPdfArguments.width) { 314 | iframe.style.setProperty("--iframe-width", embedPdfArguments.width); 315 | } 316 | if (embedPdfArguments.height) { 317 | iframe.style.setProperty("--iframe-height", embedPdfArguments.height); 318 | } 319 | } 320 | 321 | element.appendChild(iframe); 322 | } 323 | 324 | private embedImage(fileUrl: string, filePath: string, embedArguments: string, element: HTMLElement, context: MarkdownPostProcessorContext) { 325 | const embedArgumentWidthHeight = parseEmbedArgumentWidthHeight(embedArguments); 326 | 327 | // Create and configure image 328 | const img = document.createElement("img"); 329 | if (embedArgumentWidthHeight.width) { 330 | img.width = embedArgumentWidthHeight.width; 331 | } 332 | if (embedArgumentWidthHeight.height) { 333 | img.height = embedArgumentWidthHeight.height; 334 | } 335 | img.src = fileUrl; 336 | img.classList.add("external-embed-image"); 337 | img.title = "Click to open image with system default program"; 338 | 339 | // Add click handler to open URL 340 | img.addEventListener("click", () => { 341 | openFileWithDefaultProgram(filePath, (error) => { 342 | if (error) { 343 | new Notice("Failed to open file: " + error.message); 344 | } 345 | }); 346 | }); 347 | 348 | element.appendChild(img); 349 | } 350 | 351 | private embedVideo(fileUrl: string, filePath: string, embedArguments: string, element: HTMLElement, context: MarkdownPostProcessorContext) { 352 | const embedArgumentWidthHeight = parseEmbedArgumentWidthHeight(embedArguments); 353 | 354 | // Create container for video and button 355 | const container = document.createElement("div"); 356 | container.classList.add("external-embed-video-container"); 357 | 358 | // Create and configure video 359 | const video = document.createElement("video"); 360 | video.src = fileUrl; 361 | video.controls = true; 362 | if (embedArgumentWidthHeight.width) { 363 | video.width = embedArgumentWidthHeight.width; 364 | } 365 | if (embedArgumentWidthHeight.height) { 366 | video.height = embedArgumentWidthHeight.height; 367 | } 368 | container.appendChild(video); 369 | 370 | // Create open file button 371 | const openButton = document.createElement("button"); 372 | openButton.innerHTML = "🔗"; 373 | openButton.title = "Open with system default program"; 374 | openButton.classList.add("external-embed-open-button"); 375 | openButton.addEventListener("click", () => { 376 | openFileWithDefaultProgram(filePath, (error) => { 377 | if (error) { 378 | new Notice("Failed to open file: " + error.message); 379 | } 380 | }); 381 | }); 382 | 383 | container.appendChild(openButton); 384 | element.appendChild(container); 385 | } 386 | 387 | private embedAudio(fileUrl: string, filePath: string, element: HTMLElement, context: MarkdownPostProcessorContext) { 388 | // Create container for audio and button 389 | const container = document.createElement("div"); 390 | container.classList.add("external-embed-audio-container"); 391 | 392 | // Create and configure audio 393 | const audio = document.createElement("audio"); 394 | audio.src = fileUrl; 395 | audio.controls = true; 396 | container.appendChild(audio); 397 | 398 | // Create open file button 399 | const openButton = document.createElement("button"); 400 | openButton.innerHTML = "🔗"; 401 | openButton.title = "Open with system default program"; 402 | openButton.classList.add("external-embed-audio-open-button"); 403 | openButton.addEventListener("click", () => { 404 | openFileWithDefaultProgram(filePath, (error) => { 405 | if (error) { 406 | new Notice("Failed to open file: " + error.message); 407 | } 408 | }); 409 | }); 410 | container.appendChild(openButton); 411 | 412 | element.appendChild(container); 413 | } 414 | 415 | private embedFolder(fullPath: string, embedArguments: string, element: HTMLElement, context: MarkdownPostProcessorContext) { 416 | const folder = document.createElement("a"); 417 | folder.href = "#"; 418 | folder.textContent = path.basename(fullPath) + "/"; 419 | folder.classList.add("external-embed-folder-header"); 420 | folder.title = "Open folder with system default program"; 421 | folder.addEventListener("click", () => { 422 | openFileWithDefaultProgram(fullPath, (error) => { 423 | if (error) { 424 | new Notice("Failed to open folder: " + error.message); 425 | } 426 | }); 427 | }); 428 | element.appendChild(folder); 429 | 430 | const embedFolderArguments = parseEmbedFolderArguments(embedArguments); 431 | // console.log("embedFolderArguments", embedFolderArguments); 432 | const fileList = document.createElement("ul"); 433 | fileList.classList.add("external-embed-folder-list"); 434 | 435 | fs.readdir(fullPath, { withFileTypes: true }, (err, files) => { 436 | if (err) { 437 | const errorMsg = document.createElement("div"); 438 | errorMsg.textContent = `Error reading folder: ${err.message}`; 439 | errorMsg.classList.add("external-embed-folder-error"); 440 | element.appendChild(errorMsg); 441 | return; 442 | } 443 | 444 | const filteredFiles = filterFolderFiles(files, embedFolderArguments); 445 | // console.log("Filtered files", filteredFiles); 446 | 447 | filteredFiles.forEach(file => { 448 | const listItem = document.createElement("li"); 449 | const link = document.createElement("a"); 450 | const fullFilePath = path.join(fullPath, file.name); 451 | // check file or folder 452 | if (file.isDirectory()) { 453 | link.href = "#"; 454 | link.textContent = file.name + "/"; 455 | link.title = "Click to open folder with system default program"; 456 | link.classList.add("external-embed-folder-link"); 457 | } else { 458 | link.href = "#"; 459 | link.textContent = file.name; 460 | link.title = "Click to open file with system default program"; 461 | link.classList.add("external-embed-file-link"); 462 | } 463 | 464 | link.addEventListener("click", () => { 465 | openFileWithDefaultProgram(fullFilePath, (error) => { 466 | if (error) { 467 | new Notice("Failed to open file: " + error.message); 468 | } 469 | }); 470 | }); 471 | 472 | listItem.appendChild(link); 473 | fileList.appendChild(listItem); 474 | }); 475 | }); 476 | 477 | element.appendChild(fileList); 478 | } 479 | 480 | private embedOther(fullpath: string, element: HTMLElement, context: MarkdownPostProcessorContext) { 481 | const link = document.createElement("a"); 482 | link.href = '#'; 483 | link.textContent = path.basename(fullpath); 484 | link.addEventListener("click", () => { 485 | openFileWithDefaultProgram(fullpath, (error) => { 486 | if (error) { 487 | new Notice("Failed to open file: " + error.message); 488 | } 489 | }); 490 | }); 491 | element.appendChild(link); 492 | } 493 | 494 | public embedError(errorMessage: string[] | string, element: HTMLElement, context: MarkdownPostProcessorContext) { 495 | const errorDiv = document.createElement("div"); 496 | if (Array.isArray(errorMessage)) { 497 | errorMessage.forEach(msg => { 498 | const errorMsg = document.createElement("div"); 499 | errorMsg.textContent = msg; 500 | errorDiv.appendChild(errorMsg); 501 | }); 502 | } else { 503 | const errorMsg = document.createElement("div"); 504 | errorMsg.textContent = errorMessage; 505 | errorDiv.appendChild(errorMsg); 506 | } 507 | errorDiv.classList.add("external-embed-error"); 508 | element.appendChild(errorDiv); 509 | } 510 | 511 | private async embedMarkdown(fullPath: string, embedArguments: string, element: HTMLElement, context: MarkdownPostProcessorContext) { 512 | const header = document.createElement("a"); 513 | header.href = "#"; 514 | header.classList.add("external-embed-markdown-header"); 515 | header.title = "Open with system default program"; 516 | 517 | if (embedArguments === '') { 518 | header.textContent = path.basename(fullPath); 519 | } else { 520 | header.textContent = path.basename(fullPath) + "#" + embedArguments; 521 | } 522 | 523 | header.addEventListener("click", () => { 524 | openFileWithDefaultProgram(fullPath, (error) => { 525 | if (error) { 526 | new Notice("Failed to open file: " + error.message); 527 | } 528 | }); 529 | }); 530 | 531 | element.appendChild(header); 532 | 533 | const markdownContent = await fs.promises.readFile(fullPath, 'utf-8'); 534 | const htmlContent = await extractHeaderSection(markdownContent, embedArguments); 535 | 536 | const parser = new DOMParser(); 537 | const doc = parser.parseFromString(htmlContent, 'text/html'); 538 | const nodes = Array.from(doc.body.children); 539 | nodes.forEach(node => { 540 | const importedNode = document.importNode(node, true); 541 | element.appendChild(importedNode); 542 | }); 543 | element.classList.add("external-embed-markdown-element"); 544 | } 545 | 546 | public processEmbed(directoryId: string, relativePath: string, element: HTMLElement, context: MarkdownPostProcessorContext, directoryPath: string) { 547 | if (!directoryPath) { 548 | const errorMessage = [ 549 | `Can not embed file from "${directoryId}://${relativePath}"`, 550 | `You need to set the directory path for "${directoryId}" in the plugin settings.` 551 | ]; 552 | this.embedError(errorMessage, element, context); 553 | return; 554 | } 555 | 556 | let filePath = relativePath.trim(); 557 | if (filePath.startsWith("/")) { 558 | filePath = filePath.substring(1); 559 | } 560 | 561 | const embedData = parseEmbedData(filePath); 562 | const fullPath = path.join(directoryPath, embedData.embedFilePath); 563 | 564 | if(!fs.existsSync(fullPath)) { 565 | const errorMessage = [ 566 | `Can not embed file from "${directoryId}://${relativePath}"`, 567 | `The file "${fullPath}" does not exist.` 568 | ]; 569 | this.embedError(errorMessage, element, context); 570 | return; 571 | } 572 | 573 | const fileUrl = `http://127.0.0.1:${this.port}/download/${directoryId}?p=${encodeURIComponent(embedData.embedFilePath)}`; 574 | const embedUrl = `http://127.0.0.1:${this.port}/embed/${directoryId}?p=${encodeURIComponent(embedData.embedFilePath)}`; 575 | switch(embedData.embedType) { 576 | case 'pdf': 577 | this.embedPdfWithIframe(embedUrl, fullPath, embedData.embedArguments, element, context); 578 | break; 579 | case 'image': 580 | this.embedImage(fileUrl, fullPath, embedData.embedArguments, element, context); 581 | break; 582 | case 'video': 583 | this.embedVideo(fileUrl, fullPath, embedData.embedArguments, element, context); 584 | break; 585 | case 'audio': 586 | this.embedAudio(fileUrl, fullPath, element, context); 587 | break; 588 | case 'markdown': 589 | this.embedMarkdown(fullPath, embedData.embedArguments, element, context); 590 | break; 591 | case 'folder': 592 | this.embedFolder(fullPath, embedData.embedArguments, element, context); 593 | break; 594 | default: 595 | this.embedOther(fullPath, element, context); 596 | } 597 | } 598 | } 599 | -------------------------------------------------------------------------------- /src/local-settings.ts: -------------------------------------------------------------------------------- 1 | import * as os from 'os'; 2 | import * as path from 'path'; 3 | import * as fs from 'fs/promises'; 4 | import { v4 as uuidv4 } from 'uuid'; 5 | 6 | 7 | export const getLocalSettingsDirectory = (manifestId: string) => { 8 | const platform = os.platform(); 9 | let basePath; 10 | 11 | switch (platform) { 12 | case 'win32': // Windows 13 | basePath = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'); 14 | break; 15 | case 'darwin': // macOS 16 | basePath = path.join(os.homedir(), 'Library', 'Application Support'); 17 | break; 18 | case 'linux': // Linux 19 | basePath = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'); 20 | break; 21 | default: 22 | // Fallback to the home directory if the platform is not recognized 23 | basePath = os.homedir(); 24 | break; 25 | } 26 | return path.join(basePath, 'obsidian', 'plugins', manifestId); 27 | } 28 | 29 | 30 | // Function to get or generate the local machine ID 31 | export async function getLocalMachineId(manifestId: string) { 32 | const localSettingsDirectory = getLocalSettingsDirectory(manifestId); 33 | const localIdPath = path.join(localSettingsDirectory, 'local-id.txt'); 34 | try { 35 | const id = await fs.readFile(localIdPath, 'utf-8'); 36 | return id.trim(); 37 | } catch (error) { 38 | // If the file doesn't exist, generate a new ID and save it 39 | const newId = uuidv4(); 40 | // Ensure the directory exists before writing the file 41 | const dirPath = path.dirname(localIdPath); 42 | await fs.mkdir(dirPath, { recursive: true }); 43 | await fs.writeFile(localIdPath, newId, 'utf-8'); 44 | return newId; 45 | } 46 | } 47 | 48 | export async function loadLocalSettings(manifestId: string) { 49 | const localSettingsDirectory = getLocalSettingsDirectory(manifestId); 50 | const localSettingsPath = path.join(localSettingsDirectory, 'local-settings.json'); 51 | try { 52 | const data = await fs.readFile(localSettingsPath, 'utf-8'); 53 | return JSON.parse(data); 54 | } catch (e) { 55 | return {}; 56 | } 57 | } 58 | 59 | export async function saveLocalSettings(manifestId: string, localSettings: unknown) { 60 | const localSettingsDirectory = getLocalSettingsDirectory(manifestId); 61 | const localSettingsPath = path.join(localSettingsDirectory, 'local-settings.json'); 62 | await fs.writeFile(localSettingsPath, JSON.stringify(localSettings), 'utf-8'); 63 | } 64 | 65 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { Editor, MarkdownView, Notice, Plugin } from 'obsidian'; 2 | import * as path from 'path'; 3 | import { getRelativePath, openFileWithDefaultProgram } from './utils'; 4 | import { CrossComputerLinkPluginSettings, DEFAULT_SETTINGS, CrossComputerLinkSettingTab } from './settings'; 5 | import { VirtualDirectoryManagerImpl } from './VirtualDirectoryManager'; 6 | import { getLocalMachineId } from './local-settings'; 7 | // @ts-ignore 8 | import { remote } from 'electron'; 9 | import { existsSync } from 'fs'; 10 | import { DirectorySelectionModal } from './DirectorySelectionModal'; 11 | import { EmbedProcessor } from './embedProcessor'; 12 | import { LinkProcessor } from './LinkProcessor'; 13 | import { HttpServer } from './HttpServer'; 14 | import { CrossComputerLinkContext } from './server'; 15 | 16 | export default class CrossComputerLinkPlugin extends Plugin { 17 | settings: CrossComputerLinkPluginSettings; 18 | private cleanupDropHandler: (() => void) | null = null; 19 | context: CrossComputerLinkContext; 20 | private httpServer: HttpServer; 21 | private embedProcessor: EmbedProcessor; 22 | private linkProcessor: LinkProcessor; 23 | 24 | insertText(editor: Editor, text: string) { 25 | const cursor = editor.getCursor(); 26 | editor.replaceRange(text, cursor); 27 | editor.setCursor({ line: cursor.line, ch: cursor.ch + text.length }); 28 | } 29 | 30 | async onload() { 31 | await this.loadSettings(); 32 | this.context = new CrossComputerLinkContext(); 33 | this.context.homeDirectory = process.env.HOME || process.env.USERPROFILE || ''; 34 | // @ts-ignore Property 'basePath' exists at runtime but is not typed 35 | this.context.vaultDirectory = this.app.vault.adapter.basePath; 36 | this.context.port = 11411; 37 | if (this.manifest.dir) { 38 | this.context.pluginDirectory = this.manifest.dir; 39 | } else { 40 | this.context.pluginDirectory = this.app.vault.configDir + '/plugins/' + this.manifest.id; 41 | } 42 | 43 | this.httpServer = new HttpServer(this.context); 44 | await this.httpServer.start(); 45 | 46 | const localMachineId = await getLocalMachineId(this.manifest.id); 47 | this.context.directoryConfigManager = new VirtualDirectoryManagerImpl(this, localMachineId); 48 | this.addSettingTab(new CrossComputerLinkSettingTab(this.app, this, this.context.directoryConfigManager, localMachineId)); 49 | 50 | this.embedProcessor = new EmbedProcessor(this.context.port, this.context.directoryConfigManager); 51 | this.embedProcessor.load(); 52 | this.linkProcessor = new LinkProcessor( 53 | this.context.homeDirectory, 54 | this.context.vaultDirectory, 55 | this.context.directoryConfigManager 56 | ); 57 | 58 | this.addCommand({ 59 | id: 'add-external-embed', 60 | name: 'Add external embed', 61 | editorCallback: (editor: Editor, view: MarkdownView) => { 62 | this.handleAddExternalEmbed(editor); 63 | } 64 | }); 65 | 66 | this.addCommand({ 67 | id: 'add-external-inline-link', 68 | name: 'Add external inline link', 69 | editorCallback: (editor: Editor, view: MarkdownView) => { 70 | this.handleAddExternalInlineLink(editor); 71 | } 72 | }); 73 | 74 | this.registerMarkdownCodeBlockProcessor("LinkRelativeToHome", (source, el, ctx) => { 75 | this.linkProcessor.processCodeBlockLink("home", source, el, ctx); 76 | }); 77 | 78 | this.registerMarkdownCodeBlockProcessor("LinkRelativeToVault", (source, el, ctx) => { 79 | this.linkProcessor.processCodeBlockLink("vault", source, el, ctx); 80 | }); 81 | 82 | this.registerMarkdownCodeBlockProcessor("EmbedRelativeToHome", (source, el, ctx) => { 83 | this.embedProcessor.processEmbed("home", source, el, ctx, this.context.homeDirectory); 84 | }); 85 | 86 | this.registerMarkdownCodeBlockProcessor("EmbedRelativeToVault", (source, el, ctx) => { 87 | this.embedProcessor.processEmbed("vault", source, el, ctx, this.context.vaultDirectory); 88 | }); 89 | 90 | this.registerMarkdownCodeBlockProcessor("EmbedRelativeTo", (source, el, ctx) => { 91 | const fileUrl = source.trim(); 92 | let directoryId: string; 93 | let relativePath: string; 94 | if (fileUrl.startsWith("./")) { 95 | directoryId = "vault"; 96 | const directoryOfCurrentNote = path.dirname(ctx.sourcePath); 97 | relativePath = path.join(directoryOfCurrentNote, fileUrl.slice(2)); 98 | // console.log("relativePath", relativePath); 99 | }else{ 100 | [directoryId, relativePath] = fileUrl.split('://', 2); 101 | } 102 | const directoryPath = this.context.directoryConfigManager.getLocalDirectory(directoryId.toLowerCase()); 103 | if (directoryPath) { 104 | this.embedProcessor.processEmbed(directoryId.toLowerCase(), relativePath, el, ctx, directoryPath); 105 | } else { 106 | this.embedProcessor.embedError([`Virtual directory "${directoryId}" is not configured.`, `Please configure the directory in settings.`], el, ctx); 107 | } 108 | }); 109 | 110 | this.registerMarkdownPostProcessor((element, context) => { 111 | this.linkProcessor.processInlineLink(element, context); 112 | }); 113 | } 114 | private async selectFileAndCreateCode(editor: Editor, 115 | createCodeFn: (directoryId: string, filePath: string) => string) { 116 | const localDirectories = this.context.directoryConfigManager.getAllLocalDirectories(); 117 | if (Object.keys(localDirectories).length === 0) { 118 | new Notice("No local directories configured. Please configure directories in settings first."); 119 | return; 120 | } 121 | 122 | const modal = new DirectorySelectionModal(this.app, localDirectories); 123 | modal.open(); 124 | const selectedDirectoryId = await modal.waitForSelection(); 125 | 126 | if (selectedDirectoryId) { 127 | const selectedDirectoryPath = localDirectories[selectedDirectoryId]; 128 | 129 | if (!existsSync(selectedDirectoryPath)) { 130 | new Notice(`Virtual directory "${selectedDirectoryId}" points to "${selectedDirectoryPath}", but it does not exist. Please fix the directory in settings.`); 131 | return; 132 | } 133 | 134 | const result = await remote.dialog.showOpenDialog({ 135 | defaultPath: selectedDirectoryPath, 136 | properties: ['openFile', 'multiSelections'], 137 | filters: [ 138 | { name: 'All Files', extensions: ['*'] } 139 | ] 140 | }); 141 | 142 | if (!result.canceled && result.filePaths.length > 0) { 143 | result.filePaths.forEach((filePath: string) => { 144 | console.log("filePath", filePath); 145 | try { 146 | const relativePath = getRelativePath(selectedDirectoryPath, filePath); 147 | const embedCode = createCodeFn(selectedDirectoryId, relativePath); 148 | this.insertText(editor, embedCode); 149 | } catch (error) { 150 | new Notice(`Failed to create external embed or inline link for "${filePath}": ${error}`); 151 | } 152 | }); 153 | } 154 | } 155 | } 156 | 157 | private async handleAddExternalEmbed(editor: Editor) { 158 | await this.selectFileAndCreateCode(editor, (directoryId: string, filePath: string) => { 159 | return `\n\`\`\`EmbedRelativeTo\n${directoryId}://${filePath}\n\`\`\`\n`; 160 | }); 161 | } 162 | 163 | private async handleAddExternalInlineLink(editor: Editor) { 164 | await this.selectFileAndCreateCode(editor, (directoryId: string, filePath: string) => { 165 | return ` ${path.basename(filePath)} `; 166 | }); 167 | } 168 | 169 | onunload() { 170 | this.httpServer.stop(); 171 | this.embedProcessor.unload(); 172 | if (this.cleanupDropHandler) { 173 | this.cleanupDropHandler(); 174 | this.cleanupDropHandler = null; 175 | } 176 | } 177 | 178 | async loadSettings() { 179 | this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); 180 | } 181 | 182 | async saveSettings() { 183 | await this.saveData(this.settings); 184 | } 185 | } 186 | -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- 1 | import * as http from 'http'; 2 | import * as net from 'net'; 3 | import * as path from 'path'; 4 | import * as fs from 'fs'; 5 | import { getContentType, openFileWithDefaultProgram, parseUrlParams } from './utils'; 6 | 7 | import { VirtualDirectoryManager } from './VirtualDirectoryManager'; 8 | import { InlineAssetHandler } from './InlineAssetHandler'; 9 | 10 | const UNSUPPORTED_FILE_TEMPLATE = ` 11 | 12 | 13 |

Unsupported file type.

14 | FILENAME_TO_REPLACE 15 | 16 | 17 | ` 18 | 19 | export class CrossComputerLinkContext { 20 | homeDirectory: string; 21 | vaultDirectory: string; 22 | port: number; 23 | pluginDirectory: string; 24 | directoryConfigManager: VirtualDirectoryManager; 25 | } 26 | 27 | export async function getTemplate(extname: string) { 28 | extname = extname.toLowerCase(); 29 | const _map: { [key: string]: () => Promise } = { 30 | '.pdf': async () => { 31 | const template = await import('inline:./templates/pdf.html'); 32 | return template.default; 33 | }, 34 | }; 35 | if (_map[extname]) { 36 | return await _map[extname](); 37 | } 38 | return null; 39 | } 40 | 41 | export function findAvailablePort(startPort: number): Promise { 42 | return new Promise((resolve, reject) => { 43 | const server = net.createServer(); 44 | 45 | server.on('error', (err: NodeJS.ErrnoException) => { 46 | if (err.code === 'EADDRINUSE') { 47 | let nextPort = startPort + 1; 48 | if(nextPort > 65535) { 49 | nextPort = 11411; 50 | } 51 | // Port is in use, try the next one 52 | findAvailablePort(nextPort) 53 | .then(resolve) 54 | .catch(reject); 55 | } else { 56 | reject(err); 57 | } 58 | }); 59 | 60 | server.listen(startPort, '127.0.0.1', () => { 61 | const port = (server.address() as net.AddressInfo).port; 62 | server.close(() => resolve(port)); 63 | }); 64 | }); 65 | } 66 | 67 | function getFilePathFromUrl(url: string, context: CrossComputerLinkContext) { 68 | 69 | // url should be in the following format: 70 | // /download/{directoryId}?p={encodedFilePath} 71 | // /open/{directoryId}?p={encodedFilePath} 72 | // /embed/{directoryId}?p={encodedFilePath} 73 | 74 | const [urlWithoutParams, params] = url.split('?'); 75 | const parsedParams = parseUrlParams(params); 76 | 77 | const directoryId = urlWithoutParams.split('/')[2]; 78 | const decodedPath = decodeURIComponent(parsedParams.p); 79 | 80 | const directoryPath = context.directoryConfigManager.getLocalDirectory(directoryId); 81 | if(!directoryPath) { 82 | throw new Error("Invalid directory id: " + directoryId); 83 | } 84 | 85 | const filePath = path.join(directoryPath, decodedPath); 86 | return filePath; 87 | } 88 | export async function embedRequestHandler(url: string, req: http.IncomingMessage, res: http.ServerResponse, context: CrossComputerLinkContext) { 89 | res.setHeader('Content-Type', 'text/html; charset=utf-8'); 90 | // url may contain ? followed by parameters, split url and parameters 91 | const [urlWithoutParams, params] = url.split('?'); 92 | const parsedParams = parseUrlParams(params); 93 | const extname = path.extname(parsedParams.p).toLowerCase(); 94 | const template = await getTemplate(extname); 95 | if (template) { 96 | // we are handling a embed request, the url is in the following format: 97 | // url : /embed/home?p=relative/path/to/some.pdf&page=3 98 | // we are going to response with a html page, the page will embed a iframe, 99 | // the iframe will load the pdf file from the downloadUrl 100 | // downloadUrl : /download/home?p=relative/path/to/some.pdf&page=3 101 | const downloadUrl = url.replace("/embed/", "/download/"); 102 | // encode downloadUrl if it is passed as a url parameter 103 | // const encodedDownloadUrl = encodeURIComponent(downloadUrl); 104 | const encodedDownloadUrl = downloadUrl; 105 | // const relativePath = parsedParams.p; 106 | // console.log(`urlWithoutParams: ${urlWithoutParams}`); 107 | const directoryId = urlWithoutParams.split('/')[2]; 108 | const directoryPath = context.directoryConfigManager.getLocalDirectory(directoryId); 109 | if(!directoryPath) { 110 | throw new Error("Invalid directory id: " + directoryId); 111 | } 112 | const fullPath = path.join(directoryPath, parsedParams.p); 113 | const fullPathWithForwardSlashes = fullPath.replace(/\\/g, "/"); 114 | // console.log(`embedRequestHandler fullPath: ${fullPathWithForwardSlashes}`); 115 | let multiLineStr = template.replace("URL_TO_REPLACE", encodedDownloadUrl) 116 | .replace("FILENAME_TO_REPLACE", path.basename(url)) 117 | .replace("FULL_PATH_TO_REPLACE", fullPathWithForwardSlashes); 118 | if(extname === '.pdf'){ 119 | // TODO Currently only pdf embed is supported, and only pdf has parameters. If there are more embed types in the future, better organization of code is needed. 120 | // Here params should be in the form of page=123 121 | if(parsedParams.page === undefined){ 122 | parsedParams.page = "1"; 123 | } 124 | multiLineStr = multiLineStr.replace("PAGE_TO_REPLACE", parsedParams.page); 125 | multiLineStr = multiLineStr.replace(/PORT_TO_REPLACE/g, context.port.toString()); 126 | } 127 | res.end(multiLineStr); 128 | } else { 129 | const openUrl = url.replace("/embed/", "/open/"); 130 | const multiLineStr = UNSUPPORTED_FILE_TEMPLATE.replace("URL_TO_REPLACE", openUrl).replace("FILENAME_TO_REPLACE", path.basename(url)); 131 | res.end(multiLineStr); 132 | } 133 | } 134 | export function downloadRequestHandler(url: string, req: http.IncomingMessage, res: http.ServerResponse, context: CrossComputerLinkContext) { 135 | const filePath = getFilePathFromUrl(url, context); 136 | const extname = path.extname(filePath).toLowerCase(); 137 | const contentType = getContentType(extname); 138 | if (!fs.existsSync(filePath)) { 139 | res.writeHead(404, { 'Content-Type': 'text/plain' }); 140 | res.end('File not found'); 141 | return; 142 | } 143 | 144 | if(req.method === "OPTIONS") { 145 | // Some browsers send OPTIONS requests (pre-flight requests) before cross-domain requests. The server needs to correctly handle OPTIONS requests. 146 | res.setHeader('Access-Control-Allow-Origin', '*'); 147 | res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS'); 148 | res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization'); 149 | res.end(); 150 | return; 151 | } 152 | const stat = fs.statSync(filePath); 153 | 154 | if(req.method === "HEAD") { 155 | // Return only file type and size information, without returning data 156 | res.setHeader('Access-Control-Allow-Origin', '*'); 157 | res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS'); 158 | res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization'); 159 | res.setHeader('Content-Type', contentType); 160 | res.setHeader('Content-Length', stat.size); 161 | res.end(); 162 | return; 163 | } 164 | const range = req.headers.range; 165 | if(range) { 166 | // Parse Range request header 167 | const [start, end] = range.replace(/bytes=/, '').split('-').map(Number); 168 | const fileStart = start || 0; 169 | const fileEnd = end || stat.size - 1; 170 | const contentLength = fileEnd - fileStart + 1; 171 | 172 | res.writeHead(206, { 173 | 'Content-Range': `bytes ${fileStart}-${fileEnd}/${stat.size}`, 174 | 'Accept-Ranges': 'bytes', 175 | 'Content-Length': contentLength, 176 | 'Content-Type': contentType, 177 | }); 178 | 179 | const stream = fs.createReadStream(filePath, { start: fileStart, end: fileEnd }); 180 | stream.pipe(res); 181 | return; 182 | }else{ 183 | // Normal request, return the entire file 184 | res.writeHead(200, { 185 | 'Access-Control-Allow-Origin': '*', 186 | 'Access-Control-Allow-Methods': 'GET, OPTIONS', 187 | 'Access-Control-Allow-Headers': 'Content-Type, Authorization', 188 | 'Content-Length': stat.size, 189 | 'Content-Type': contentType, 190 | }); 191 | 192 | const stream = fs.createReadStream(filePath); 193 | stream.pipe(res); 194 | } 195 | } 196 | function openRequestHandler(url: string, req: http.IncomingMessage, res: http.ServerResponse, context: CrossComputerLinkContext) { 197 | const filePath = getFilePathFromUrl(url, context); 198 | openFileWithDefaultProgram(filePath, (error: Error) => { 199 | if(error){ 200 | console.error("Failed to open file:", error); 201 | } 202 | }); 203 | 204 | res.setHeader('Content-Type', 'text/html; charset=utf-8'); 205 | const multiLineStr = ` 206 | 207 | 208 |

Opening file: ${filePath}

209 |

This window may not be automatically closed. You may close it manually.

210 | 215 | 216 | 217 | `; 218 | res.end(multiLineStr); 219 | } 220 | async function assetRequestHandler(url: string, req: http.IncomingMessage, res: http.ServerResponse, context: CrossComputerLinkContext) { 221 | if(url.startsWith("/assets/pdfjs-5.2.133-dist/web/viewer.html")) { 222 | res.setHeader('Content-Type', 'text/html'); 223 | const content = await import('inline:./assets/pdfjs-5.2.133-dist/web/viewer.html'); 224 | res.end(content.default); 225 | }else if(url==="/assets/pdfjs-viewer-element-2.7.1.js") { 226 | res.setHeader('Content-Type', 'application/javascript'); 227 | const content = await import('inline:./assets/pdfjs-viewer-element-2.7.1.js'); 228 | res.end(content.default); 229 | 230 | }else if(url.endsWith(".map")) { 231 | // pdfjs viewer element uses source map files, but they are not needed for the viewer element 232 | res.writeHead(404); 233 | res.end(); 234 | return; 235 | }else{ 236 | await InlineAssetHandler(url, req, res); 237 | } 238 | } 239 | 240 | function errorResponse(res: http.ServerResponse, code: number, message: string) { 241 | res.writeHead(code); 242 | res.end(message); 243 | } 244 | export async function httpRequestHandler(req: http.IncomingMessage, res: http.ServerResponse, context: CrossComputerLinkContext) { 245 | // Read file content and return it via http response 246 | const url = req.url; 247 | if(!url) { 248 | return errorResponse(res, 404, "Invalid path"); 249 | } 250 | try{ 251 | if(url.startsWith("/embed/")) { 252 | await embedRequestHandler(url, req, res, context); 253 | return; 254 | }else if(url.startsWith("/download/")) { 255 | downloadRequestHandler(url, req, res, context); 256 | return; 257 | }else if(url.startsWith("/open/")) { 258 | openRequestHandler(url, req, res, context); 259 | return; 260 | }else if(url.startsWith("/assets/")) { 261 | await assetRequestHandler(url, req, res, context); 262 | return; 263 | } 264 | }catch(error){ 265 | console.error("Error in httpRequestHandler", error); 266 | return errorResponse(res, 500, `Internal error: ${error}`); 267 | } 268 | 269 | return errorResponse(res, 404, `Invalid path ${url}`); 270 | } 271 | 272 | -------------------------------------------------------------------------------- /src/settings.ts: -------------------------------------------------------------------------------- 1 | import CrossComputerLinkPlugin from 'main'; 2 | import { App, PluginSettingTab, Setting, TextComponent, ButtonComponent, Notice, Modal } from 'obsidian'; 3 | import { VirtualDirectoryManager } from 'VirtualDirectoryManager'; 4 | export enum DragAction { 5 | Default = 'default', 6 | LinkRelativeToHome = 'LinkRelativeToHome', 7 | LinkRelativeToVault = 'LinkRelativeToVault', 8 | EmbedRelativeToHome = 'EmbedRelativeToHome', 9 | EmbedRelativeToVault = 'EmbedRelativeToVault', 10 | InlineLinkRelativeToHome = 'InlineLinkRelativeToHome', 11 | InlineLinkRelativeToVault = 'InlineLinkRelativeToVault' 12 | } 13 | 14 | 15 | export interface DeviceInfo { 16 | uuid: string; 17 | name: string; 18 | os: string; 19 | } 20 | 21 | export interface DevicesMap { 22 | [uuid: string]: DeviceInfo; 23 | } 24 | 25 | export interface VirtualDirectoryItem { 26 | path: string; 27 | } 28 | 29 | export interface VirtualDirectoriesMap { 30 | [name: string]: Record; 31 | } 32 | 33 | export interface CrossComputerLinkPluginSettings { 34 | dragWithCtrl: DragAction; 35 | dragWithShift: DragAction; 36 | dragWithCtrlShift: DragAction; 37 | enableDragAndDrop: boolean; 38 | devices: DevicesMap; 39 | virtualDirectories: VirtualDirectoriesMap; 40 | } 41 | 42 | export const DEFAULT_SETTINGS: CrossComputerLinkPluginSettings = { 43 | dragWithCtrl: DragAction.Default, 44 | dragWithShift: DragAction.InlineLinkRelativeToHome, 45 | dragWithCtrlShift: DragAction.EmbedRelativeToHome, 46 | enableDragAndDrop: true, 47 | virtualDirectories: {}, 48 | devices: {}, 49 | } 50 | 51 | export class CrossComputerLinkSettingTab extends PluginSettingTab { 52 | plugin: CrossComputerLinkPlugin; 53 | virtualDirectoryManager: VirtualDirectoryManager; 54 | deviceUUID: string; 55 | 56 | constructor(app: App, plugin: CrossComputerLinkPlugin, virtualDirectoryManager: VirtualDirectoryManager, deviceUUID: string) { 57 | super(app, plugin); 58 | this.plugin = plugin; 59 | this.virtualDirectoryManager = virtualDirectoryManager; 60 | this.deviceUUID = deviceUUID; 61 | } 62 | 63 | private showConfirmDialog(title: string, message: string, onConfirm: () => Promise) { 64 | const confirmModal = new Modal(this.app); 65 | confirmModal.titleEl.setText(title); 66 | confirmModal.contentEl.createEl('p', { 67 | text: message 68 | }); 69 | 70 | const buttonContainer = confirmModal.contentEl.createDiv({ cls: 'external-embed-modal-button-container' }); 71 | 72 | const cancelButton = buttonContainer.createEl('button', { text: 'Cancel' }); 73 | cancelButton.addEventListener('click', () => { 74 | confirmModal.close(); 75 | }); 76 | 77 | const confirmButton = buttonContainer.createEl('button', { text: 'Confirm', cls: 'mod-warning' }); 78 | confirmButton.addEventListener('click', async () => { 79 | try { 80 | await onConfirm(); 81 | confirmModal.close(); 82 | } catch (error) { 83 | new Notice(error.message); 84 | } 85 | }); 86 | 87 | confirmModal.open(); 88 | } 89 | 90 | private displayDevices(containerEl: HTMLElement) { 91 | // Display devices section 92 | new Setting(containerEl) 93 | .setName('Devices') 94 | .setHeading() 95 | .setDesc('You can change the name of a device to make it more recognizable. New devices will be added automatically when the plugin is first loaded on a new device. '); 96 | 97 | const devicesSection = containerEl.createEl('div', { cls: 'external-embed-sub-section' }); 98 | 99 | this.virtualDirectoryManager.getAllDevices().forEach(device => { 100 | let settingName = `Device ID: ${device.uuid.substring(0, 8)}, OS: ${device.os}`; 101 | if (device.uuid === this.deviceUUID) { 102 | settingName += ' (Current device)'; 103 | } 104 | new Setting(devicesSection) 105 | .setName(settingName) 106 | .addText(text => text 107 | .setValue(device.name) 108 | .onChange(async (value) => { 109 | const oldName = device.name; 110 | try { 111 | await this.virtualDirectoryManager.setDeviceName(device.uuid, value); 112 | // refresh the device list 113 | this.updateDeviceNameDisplay(device.uuid, value); 114 | } catch (error) { 115 | new Notice(error.message); 116 | // revert the change 117 | this.virtualDirectoryManager.setDeviceName(device.uuid, oldName); 118 | // set the text back to the old name 119 | text.setValue(oldName); 120 | } 121 | })) 122 | .addExtraButton(button => button 123 | .setIcon('trash') 124 | .setTooltip('Delete device') 125 | .onClick(async () => { 126 | if(device.uuid === this.deviceUUID){ 127 | // show a dialog to tell user can not delete current device 128 | new Notice('Cannot delete current device.'); 129 | return; 130 | }else{ 131 | this.showConfirmDialog( 132 | 'Confirm Device Deletion', 133 | // FIXME list all the virtual directories for this device 134 | `If you delete this device (${device.name}), all the virtual directory settings of this device will be removed. Are you sure you want to continue?`, 135 | async () => { 136 | await this.virtualDirectoryManager.removeDevice(device.uuid); 137 | this.display(); 138 | } 139 | ); 140 | } 141 | })); 142 | }) 143 | } 144 | 145 | private displayDirectories(containerEl: HTMLElement) { 146 | // Display directories section 147 | const homeDirectory = this.virtualDirectoryManager.getLocalDirectory('home'); 148 | const vaultDirectory = this.virtualDirectoryManager.getLocalDirectory('vault'); 149 | 150 | new Setting(containerEl) 151 | .setName('Virtual Directories') 152 | .setHeading() 153 | .setDesc(createFragment(f => { 154 | f.createEl('p', {}, p => { 155 | p.appendText('Configure virtual directories that can be used to locate files on different devices. '); 156 | p.createEl('a', { 157 | text: 'Learn more', 158 | href: 'https://github.com/oylbin/obsidian-external-file-embed-and-link?tab=readme-ov-file#virtual-directories' 159 | }); 160 | }); 161 | 162 | f.createEl('code', { text: 'home' }); 163 | f.appendText(' and '); 164 | f.createEl('code', { text: 'vault' }); 165 | f.appendText(' are predefined virtual directories:'); 166 | const ul = f.createEl('ul'); 167 | ul.createEl('li', {}, li => { 168 | li.appendText('Virtual directory '); 169 | li.createEl('code', { text: 'home' }); 170 | li.appendText(' is linked to your home directory: '); 171 | li.createEl('code', { text: homeDirectory || 'Not set' }); 172 | }); 173 | ul.createEl('li', {}, li => { 174 | li.appendText('Virtual directory '); 175 | li.createEl('code', { text: 'vault' }); 176 | li.appendText(' is linked to your vault directory: '); 177 | li.createEl('code', { text: vaultDirectory || 'Not set' }); 178 | }); 179 | 180 | })); 181 | 182 | 183 | 184 | // Add new virtual directory button 185 | const addDirectorySetting = new Setting(containerEl) 186 | .setName('Add new virtual directory') 187 | .setDesc(createFragment(f => { 188 | f.appendText('Add a new virtual directory configuration, '); 189 | 190 | })); 191 | 192 | const nameInput = new TextComponent(addDirectorySetting.controlEl) 193 | .setPlaceholder('Virtual directory name') 194 | .setValue(''); 195 | 196 | new ButtonComponent(addDirectorySetting.controlEl) 197 | .setButtonText('Add') 198 | .onClick(async () => { 199 | try { 200 | const name = nameInput.getValue().trim(); 201 | await this.virtualDirectoryManager.addDirectory(name); 202 | this.display(); 203 | } catch (error) { 204 | new Notice(error.message); 205 | } 206 | }); 207 | 208 | // Display existing virtual directories 209 | const directories = this.virtualDirectoryManager.getAllDirectories(); 210 | 211 | Object.entries(directories).forEach(([dirName, devices]) => { 212 | const dirSection = containerEl.createEl('div', { cls: 'external-embed-sub-section' }); 213 | new Setting(dirSection) 214 | .setName(createFragment(f => { 215 | f.appendText('Virtual directory: '); 216 | f.createEl('strong', { text: dirName }); 217 | })) 218 | .setTooltip(`link to file like this: ${dirName}:relative/path/to/file`) 219 | // .setName(`Virtual directory: ${dirName}`) 220 | .addExtraButton(button => button 221 | .setIcon('trash') 222 | .setTooltip('Delete directory') 223 | .onClick(async () => { 224 | this.showConfirmDialog( 225 | 'Confirm Directory Deletion', 226 | `If you delete this virtual directory (${dirName}), all links using this directory in your notes will be broken. Are you sure you want to continue?`, 227 | async () => { 228 | await this.virtualDirectoryManager.deleteDirectory(dirName); 229 | this.display(); 230 | } 231 | ); 232 | })); 233 | this.virtualDirectoryManager.getAllDevices().forEach(device => { 234 | const currentDevice = device.uuid === this.deviceUUID; 235 | const item = new Setting(dirSection) 236 | .setName(createFragment(f => { 237 | f.createEl('span', { 238 | text: device.name, 239 | cls: `device-name-${device.uuid}` 240 | }); 241 | f.createEl('span', { text: ` ( Device ID: ${device.uuid.substring(0, 8)}, OS: ${device.os}${currentDevice ? ', Current device' : ''})` }); 242 | })); 243 | 244 | if(currentDevice){ 245 | item.addExtraButton(button => button 246 | .setIcon('folder') 247 | .setTooltip('Open file browser') 248 | .onClick(async () => { 249 | // @ts-ignore 250 | // eslint-disable-next-line @typescript-eslint/no-var-requires 251 | const { remote } = require('electron'); 252 | const dialog = remote.dialog; 253 | const result = await dialog.showOpenDialog({ 254 | properties: ['openDirectory'], 255 | }); 256 | if (!result.canceled && result.filePaths.length > 0) { 257 | const path = result.filePaths[0]; 258 | try { 259 | await this.virtualDirectoryManager.setLocalDirectory(dirName, path); 260 | this.display(); 261 | } catch (error) { 262 | new Notice(error.message); 263 | } 264 | } 265 | })); 266 | } 267 | item.addText(text => text 268 | .setValue(devices[device.uuid]?.path || '') 269 | .onChange(async (value) => { 270 | try { 271 | await this.virtualDirectoryManager.setDirectory(dirName, device.uuid, value); 272 | } catch (error) { 273 | new Notice(error.message); 274 | } 275 | })); 276 | }); 277 | }); 278 | 279 | 280 | } 281 | 282 | private updateDeviceNameDisplay(uuid: string, newName: string) { 283 | const deviceNameCells = document.querySelectorAll(`.device-name-${uuid}`); 284 | deviceNameCells.forEach(cell => { 285 | cell.setText(newName); 286 | }); 287 | } 288 | 289 | display(): void { 290 | this.virtualDirectoryManager.registerCurrentDevice(); 291 | const { containerEl } = this; 292 | containerEl.empty(); 293 | 294 | this.displayDirectories(containerEl); 295 | this.displayDevices(containerEl); 296 | 297 | } 298 | } 299 | -------------------------------------------------------------------------------- /src/templates/InlineAssetHandler.ts.jinja2: -------------------------------------------------------------------------------- 1 | import * as http from 'http'; 2 | 3 | export async function InlineAssetHandler(url: string, req: http.IncomingMessage, res: http.ServerResponse) { 4 | // console.log("InlineAssetHandler", url); 5 | 6 | {% for url, contentType in urls %} 7 | if(url === "{{ url }}") { 8 | res.setHeader('Content-Type', '{{ contentType }}'); 9 | const content = await import('inline:.{{ url }}'); 10 | res.end(content.default); 11 | return; 12 | } 13 | {% endfor %} 14 | res.writeHead(404); 15 | res.end(`Invalid path ${url}`); 16 | console.log("Invalid path", url); 17 | } -------------------------------------------------------------------------------- /src/templates/pdf.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | HTML + pdfjs-viewer-element (PDF.js default viewer) 8 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 69 | 70 | 71 | -------------------------------------------------------------------------------- /src/templates/pdf.html.d.ts: -------------------------------------------------------------------------------- 1 | declare const template: string; 2 | export default template; -------------------------------------------------------------------------------- /src/templates/pdf1.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | PDF Viewer 5 | 6 | 7 | 38 | 39 | 40 |
41 | 42 | 43 | Page: / 0 44 | 45 | 46 |
47 |
48 |
49 |
50 | 123 | 124 | -------------------------------------------------------------------------------- /src/templates/pdf1.html.d.ts: -------------------------------------------------------------------------------- 1 | declare const template: string; 2 | export default template; -------------------------------------------------------------------------------- /src/types/inline.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'inline:*' { 2 | const content: string; 3 | export default content; 4 | } -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { exec } from 'child_process'; 2 | import { Platform } from 'obsidian'; 3 | import * as path from 'path'; 4 | import { marked } from 'marked'; 5 | 6 | export function customEncodeURI(uri: string) { 7 | return uri.replace(/[ #&%?]/g, function (c) { 8 | return encodeURIComponent(c); 9 | }); 10 | } 11 | 12 | export function openFileWithDefaultProgram(filePath: string, onError: (error: Error) => void) { 13 | let command = ""; 14 | if (Platform.isWin) { 15 | command = `start "" "${filePath}"`; 16 | } else if (Platform.isMacOS) { 17 | command = `open "${filePath}"`; 18 | } else if (Platform.isLinux) { 19 | command = `xdg-open "${filePath}"`; 20 | }else{ 21 | onError(new Error("Unsupported platform to open file")); 22 | } 23 | exec(command, onError); 24 | } 25 | 26 | export function getRelativePath(from: string, to: string) { 27 | from = path.normalize(from); 28 | to = path.normalize(to); 29 | const relativePath = path.relative(from, to); 30 | return relativePath.replace(/\\/g, '/'); 31 | } 32 | export function getContentType(extname: string) { 33 | extname = extname.toLowerCase(); 34 | const _map: { [key: string]: string } = { 35 | '.png': 'image/png', 36 | '.jpg': 'image/jpeg', 37 | '.jpeg': 'image/jpeg', 38 | '.gif': 'image/gif', 39 | '.bmp': 'image/bmp', 40 | '.webp': 'image/webp', 41 | '.pdf': 'application/pdf', 42 | '.mp3': 'audio/mpeg', 43 | '.mp4': 'video/mp4', 44 | '.webm': 'video/webm', 45 | '.ogg': 'audio/ogg', 46 | }; 47 | if (_map[extname]) { 48 | return _map[extname]; 49 | } 50 | return 'application/octet-stream'; 51 | } 52 | 53 | export const ImageExtensions = ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp', '.svg', '.avif']; 54 | export const VideoExtensions = ['.mp4', '.webm', '.mkv', '.mov', '.ogv']; 55 | export const AudioExtensions = ['.mp3', '.ogg', '.wav', '.flac', '.m4a', '.webm']; 56 | export const MarkdownExtensions = ['.md', '.markdown', '.txt']; 57 | 58 | export function isImage(fullpath: string) { 59 | const extname = path.extname(fullpath).toLowerCase(); 60 | return ImageExtensions.includes(extname); 61 | } 62 | export function isVideo(fullpath: string) { 63 | const extname = path.extname(fullpath).toLowerCase(); 64 | return VideoExtensions.includes(extname); 65 | } 66 | 67 | export function isAudio(fullpath: string) { 68 | const extname = path.extname(fullpath).toLowerCase(); 69 | return AudioExtensions.includes(extname); 70 | } 71 | export function isMarkdown(fullpath: string) { 72 | const extname = path.extname(fullpath).toLowerCase(); 73 | return MarkdownExtensions.includes(extname); 74 | } 75 | 76 | export function isPDF(fullpath: string) { 77 | const extname = path.extname(fullpath).toLowerCase(); 78 | return extname === '.pdf'; 79 | } 80 | 81 | export function parseUrlParams(params: string|undefined): { [key: string]: string } { 82 | if(!params){ 83 | return {}; 84 | } 85 | const paramDict: { [key: string]: string } = {}; 86 | const paramPairs = params.split('&'); 87 | for (const pair of paramPairs) { 88 | const [key, value] = pair.split('='); 89 | if (key && value) { 90 | paramDict[decodeURIComponent(key)] = decodeURIComponent(value); 91 | } 92 | } 93 | return paramDict; 94 | } 95 | 96 | 97 | export async function extractHeaderSection(markdown: string, header: string) { 98 | if(header === ''){ 99 | return marked(markdown); 100 | } 101 | const tokens = marked.lexer(markdown); 102 | let capture = false; 103 | let result = ''; 104 | // console.log("header", header); 105 | tokens.forEach((token) => { 106 | // console.log("token", token); 107 | if (token.type === 'heading' && token.text === header) { 108 | capture = true; 109 | } else if (capture && token.type === 'heading') { 110 | capture = false; 111 | } else if (capture) { 112 | result += marked.parser([token]); 113 | } 114 | }); 115 | if(result === ''){ 116 | return `

failed to find header in markdown file: "${header}"

`; 117 | } 118 | return result; 119 | } 120 | 121 | -------------------------------------------------------------------------------- /styles.css: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | This CSS file will be included with your plugin, and 4 | available in the app when your plugin is enabled. 5 | 6 | If your plugin does not need CSS, delete this file. 7 | 8 | */ 9 | /* External File Embed and Link Plugin Styles */ 10 | 11 | /* PDF Embed Styles */ 12 | .external-embed-pdf-iframe { 13 | border: none; 14 | } 15 | 16 | .external-embed-pdf-iframe-custom-size { 17 | width: var(--iframe-width); 18 | height: var(--iframe-height); 19 | } 20 | 21 | /* Embedded Image Styles */ 22 | .external-embed-image { 23 | cursor: pointer; 24 | } 25 | 26 | /* Open File Button Styles */ 27 | .external-embed-video-container { 28 | position: relative; 29 | display: inline-block; 30 | } 31 | 32 | .external-embed-open-button { 33 | position: absolute; 34 | top: 5px; 35 | right: 5px; 36 | padding: 2px 6px; 37 | border: 1px solid var(--background-modifier-border); 38 | border-radius: 3px; 39 | background-color: rgba(255, 255, 255, 0.8); 40 | cursor: pointer; 41 | font-size: 12px; 42 | z-index: 1; 43 | opacity: 0; 44 | transition: opacity 0.2s ease; 45 | } 46 | 47 | .external-embed-video-container:hover .external-embed-open-button { 48 | opacity: 1; 49 | } 50 | 51 | .external-embed-open-button:hover { 52 | background-color: rgba(255, 255, 255, 1); 53 | } 54 | 55 | /* Audio Open Button Styles */ 56 | .external-embed-audio-container { 57 | display: flex; 58 | align-items: center; 59 | gap: 8px; 60 | } 61 | 62 | .external-embed-audio-open-button { 63 | padding: 2px 6px; 64 | margin-right: 32px; 65 | border: 1px solid var(--background-modifier-border); 66 | border-radius: 3px; 67 | background-color: rgba(255, 255, 255, 0.8); 68 | cursor: pointer; 69 | font-size: 12px; 70 | flex-shrink: 0; 71 | } 72 | 73 | .external-embed-audio-open-button:hover { 74 | background-color: rgba(255, 255, 255, 1); 75 | } 76 | 77 | /* Markdown Element Styles */ 78 | .external-embed-markdown-element { 79 | border: 1px dashed var(--background-modifier-border); 80 | padding: 1em; 81 | } 82 | 83 | .external-embed-markdown-header { 84 | text-decoration: none; 85 | color: var(--text-normal); 86 | font-weight: bold; 87 | display: block; 88 | margin-bottom: 8px; 89 | cursor: pointer; 90 | } 91 | 92 | .external-embed-markdown-header:hover { 93 | text-decoration: underline; 94 | } 95 | 96 | /* Folder View Styles */ 97 | .external-embed-folder-header { 98 | font-weight: bold; 99 | margin-bottom: 8px; 100 | text-decoration: none; 101 | color: var(--text-normal); 102 | cursor: pointer; 103 | display: inline-flex; 104 | align-items: center; 105 | } 106 | 107 | .external-embed-folder-header::before { 108 | content: ""; 109 | background-color: var(--text-normal); 110 | -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='24' height='24'%3E%3Cpath fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M3 7v10a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-6l-2-2H5a2 2 0 0 0-2 2z'%3E%3C/path%3E%3C/svg%3E"); 111 | mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='24' height='24'%3E%3Cpath fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M3 7v10a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-6l-2-2H5a2 2 0 0 0-2 2z'%3E%3C/path%3E%3C/svg%3E"); 112 | -webkit-mask-repeat: no-repeat; 113 | mask-repeat: no-repeat; 114 | -webkit-mask-size: contain; 115 | mask-size: contain; 116 | width: 16px; 117 | height: 16px; 118 | margin-right: 4px; 119 | display: inline-block; 120 | } 121 | 122 | .external-embed-folder-header:hover { 123 | text-decoration: underline; 124 | } 125 | 126 | .external-embed-folder-list { 127 | list-style-type: disc; 128 | padding-left: 20px; 129 | margin-top: 8px; 130 | } 131 | 132 | .external-embed-folder-list li { 133 | margin: 4px 0; 134 | padding: 2px 0; 135 | } 136 | 137 | .external-embed-folder-list a { 138 | text-decoration: none; 139 | color: var(--text-normal); 140 | } 141 | 142 | .external-embed-folder-list a:hover { 143 | text-decoration: underline; 144 | } 145 | 146 | .external-embed-folder-error { 147 | color: var(--text-error); 148 | margin-top: 8px; 149 | } 150 | 151 | /* Error Message Styles */ 152 | .external-embed-error { 153 | color: var(--text-error); 154 | border: 1px dashed var(--background-modifier-border); 155 | margin-top: 8px; 156 | } 157 | 158 | /* Settings Section Styles */ 159 | .external-embed-sub-section { 160 | border-top: 1px dashed var(--background-modifier-border); 161 | padding-left: 1em; 162 | padding-right: 1em; 163 | margin-bottom: 10px; 164 | } 165 | 166 | .external-embed-sub-section .setting-item { 167 | padding-top: 0; 168 | padding-bottom: 0; 169 | border-top: none; 170 | } 171 | 172 | .external-embed-sub-section .setting-item-name { 173 | font-size: var(--font-ui-small); 174 | } 175 | 176 | /* Modal Styles */ 177 | .external-embed-modal-title { 178 | margin-bottom: 16px; 179 | } 180 | 181 | .external-embed-modal-button-container { 182 | display: flex; 183 | flex-direction: column; 184 | gap: 8px; 185 | } 186 | 187 | .external-embed-directory-button { 188 | width: 100%; 189 | text-align: left; 190 | padding: 8px; 191 | } 192 | 193 | .external-embed-folder-link { 194 | font-weight: normal; 195 | display: inline-flex; 196 | align-items: center; 197 | } 198 | 199 | .external-embed-folder-link::before { 200 | content: ""; 201 | background-color: var(--text-normal); 202 | -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='24' height='24'%3E%3Cpath fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M3 7v10a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-6l-2-2H5a2 2 0 0 0-2 2z'%3E%3C/path%3E%3C/svg%3E"); 203 | mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='24' height='24'%3E%3Cpath fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M3 7v10a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-6l-2-2H5a2 2 0 0 0-2 2z'%3E%3C/path%3E%3C/svg%3E"); 204 | -webkit-mask-repeat: no-repeat; 205 | mask-repeat: no-repeat; 206 | -webkit-mask-size: contain; 207 | mask-size: contain; 208 | width: 16px; 209 | height: 16px; 210 | margin-right: 4px; 211 | display: inline-block; 212 | } 213 | 214 | .external-embed-file-link { 215 | font-weight: normal; 216 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "paths": { 5 | "*": [ 6 | "src/*" 7 | ] 8 | }, 9 | "inlineSourceMap": true, 10 | "inlineSources": true, 11 | "module": "ESNext", 12 | "target": "ES6", 13 | "allowJs": true, 14 | "noImplicitAny": true, 15 | "moduleResolution": "node", 16 | "importHelpers": true, 17 | "isolatedModules": true, 18 | "strictNullChecks": true, 19 | "typeRoots": [ 20 | "./node_modules/@types", 21 | "./types", 22 | "./src/types" 23 | ], 24 | "lib": [ 25 | "DOM", 26 | "ES5", 27 | "ES6", 28 | "ES7" 29 | ], 30 | "rootDir": "src" 31 | }, 32 | "include": [ 33 | "src/**/*.ts", 34 | "src/types/**/*.d.ts" 35 | ] 36 | } -------------------------------------------------------------------------------- /types/inline-import.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'inline:*' { 2 | const content: string; 3 | export default content; 4 | } 5 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "1.0.0": "0.15.0" 3 | } 4 | --------------------------------------------------------------------------------