├── .github └── workflows │ ├── main.yml │ └── release-please.yml ├── .gitignore ├── CHANGELOG.md ├── README.md ├── jest.config.js ├── manifest.json ├── package.json ├── rollup.config.js ├── screenshots └── 1.gif ├── src ├── helpers │ ├── cm.ts │ ├── helpers.test.ts │ ├── helpers.ts │ ├── search-results.ts │ ├── string.test.ts │ ├── string.ts │ └── tfile.ts ├── main.ts └── sequences │ └── sequences.ts ├── tsconfig.json └── versions.json /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Build obsidian plugin 2 | 3 | on: 4 | push: 5 | # Sequence of patterns matched against refs/tags 6 | tags: 7 | - '*' # Push events to matching any tag format, i.e. 1.0, 20.15.10 8 | 9 | env: 10 | PLUGIN_NAME: mrj-text-expand # Change this to the name of your plugin-id folder 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v2 19 | - name: Use Node.js 20 | uses: actions/setup-node@v1 21 | with: 22 | node-version: '14.x' # You might need to adjust this value to your own version 23 | - name: Build 24 | id: build 25 | run: | 26 | npm install 27 | npm run build --if-present 28 | mkdir ${{ env.PLUGIN_NAME }} 29 | cp main.js manifest.json ${{ env.PLUGIN_NAME }} 30 | zip -r ${{ env.PLUGIN_NAME }}.zip ${{ env.PLUGIN_NAME }} 31 | ls 32 | echo "::set-output name=tag_name::$(git tag --sort version:refname | tail -n 1)" 33 | - name: Create Release 34 | id: create_release 35 | uses: actions/create-release@v1 36 | env: 37 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 38 | VERSION: ${{ github.ref }} 39 | with: 40 | tag_name: ${{ github.ref }} 41 | release_name: ${{ github.ref }} 42 | draft: false 43 | prerelease: false 44 | - name: Upload zip file 45 | id: upload-zip 46 | uses: actions/upload-release-asset@v1 47 | env: 48 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 49 | with: 50 | upload_url: ${{ steps.create_release.outputs.upload_url }} 51 | asset_path: ./${{ env.PLUGIN_NAME }}.zip 52 | asset_name: ${{ env.PLUGIN_NAME }}-${{ steps.build.outputs.tag_name }}.zip 53 | asset_content_type: application/zip 54 | - name: Upload main.js 55 | id: upload-main 56 | uses: actions/upload-release-asset@v1 57 | env: 58 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 59 | with: 60 | upload_url: ${{ steps.create_release.outputs.upload_url }} 61 | asset_path: ./main.js 62 | asset_name: main.js 63 | asset_content_type: text/javascript 64 | - name: Upload manifest.json 65 | id: upload-manifest 66 | uses: actions/upload-release-asset@v1 67 | env: 68 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 69 | with: 70 | upload_url: ${{ steps.create_release.outputs.upload_url }} 71 | asset_path: ./manifest.json 72 | asset_name: manifest.json 73 | asset_content_type: application/json 74 | # - name: Upload styles.css 75 | # id: upload-css 76 | # uses: actions/upload-release-asset@v1 77 | # env: 78 | # GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 79 | # with: 80 | # upload_url: ${{ steps.create_release.outputs.upload_url }} 81 | # asset_path: ./styles.css 82 | # asset_name: styles.css 83 | # asset_content_type: text/css 84 | -------------------------------------------------------------------------------- /.github/workflows/release-please.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - master 5 | 6 | env: 7 | PLUGIN_NAME: mrj-text-expand # Change this to the name of your plugin-id folder 8 | 9 | name: release-please 10 | jobs: 11 | release-please: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Build 15 | id: build 16 | run: | 17 | npm install 18 | npm run build --if-present 19 | mkdir ${{ env.PLUGIN_NAME }} 20 | cp main.js manifest.json ${{ env.PLUGIN_NAME }} 21 | zip -r ${{ env.PLUGIN_NAME }}.zip ${{ env.PLUGIN_NAME }} 22 | ls 23 | echo "::set-output name=tag_name::$(git tag --sort version:refname | tail -n 1)" 24 | 25 | - uses: google-github-actions/release-please-action@v3 26 | with: 27 | release-type: node 28 | package-name: ${{ env.PLUGIN_NAME }} 29 | extra-files: | 30 | manifest.json 31 | main.js 32 | ${{ env.PLUGIN_NAME }}.zip 33 | 34 | # - uses: actions/checkout@v2 35 | # if: ${{ steps.release.outputs.release_created }} 36 | 37 | # - name: Use Node.js 38 | # uses: actions/setup-node@v1 39 | # with: 40 | # node-version: '14.x' # You might need to adjust this value to your own version 41 | # if: ${{ steps.release.outputs.release_created }} 42 | 43 | # - name: Create Release 44 | # id: create_release 45 | # uses: actions/create-release@v1 46 | # env: 47 | # GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 48 | # VERSION: ${{ github.ref }} 49 | # with: 50 | # tag_name: ${{ github.ref }} 51 | # release_name: ${{ github.ref }} 52 | # draft: false 53 | # prerelease: false 54 | # if: ${{ steps.release.outputs.release_created }} 55 | 56 | # - name: Upload zip file 57 | # id: upload-zip 58 | # uses: actions/upload-release-asset@v1 59 | # env: 60 | # GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 61 | # with: 62 | # upload_url: ${{ steps.create_release.outputs.upload_url }} 63 | # asset_path: ./${{ env.PLUGIN_NAME }}.zip 64 | # asset_name: ${{ env.PLUGIN_NAME }}-${{ steps.build.outputs.tag_name }}.zip 65 | # asset_content_type: application/zip 66 | # if: ${{ steps.release.outputs.release_created }} 67 | 68 | # - name: Upload main.js 69 | # id: upload-main 70 | # uses: actions/upload-release-asset@v1 71 | # env: 72 | # GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 73 | # with: 74 | # upload_url: ${{ steps.create_release.outputs.upload_url }} 75 | # asset_path: ./main.js 76 | # asset_name: main.js 77 | # asset_content_type: text/javascript 78 | # if: ${{ steps.release.outputs.release_created }} 79 | 80 | # - name: Upload manifest.json 81 | # id: upload-manifest 82 | # uses: actions/upload-release-asset@v1 83 | # env: 84 | # GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 85 | # with: 86 | # upload_url: ${{ steps.create_release.outputs.upload_url }} 87 | # asset_path: ./manifest.json 88 | # asset_name: manifest.json 89 | # asset_content_type: application/json 90 | # if: ${{ steps.release.outputs.release_created }} 91 | # - name: Upload styles.css 92 | # id: upload-css 93 | # uses: actions/upload-release-asset@v1 94 | # env: 95 | # GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 96 | # with: 97 | # upload_url: ${{ steps.create_release.outputs.upload_url }} 98 | # asset_path: ./styles.css 99 | # asset_name: styles.css 100 | # asset_content_type: text/css 101 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Intellij 2 | *.iml 3 | .idea 4 | 5 | # npm 6 | node_modules 7 | package-lock.json 8 | yarn-error.log 9 | 10 | # build 11 | main.js 12 | *.js.map 13 | data.json 14 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [0.11.1](https://github.com/mrjackphil/obsidian-text-expand/compare/v0.11.0...v0.12.0) (2022-06-16) 4 | 5 | 6 | ### Bug Fixes 7 | 8 | * :bug: ability to put multiline default template ([4b4f362](https://github.com/mrjackphil/obsidian-text-expand/commit/4b4f362bc39ee8997db4cdffdd1cadd67e9f612a)), closes [#68](https://github.com/mrjackphil/obsidian-text-expand/issues/68) 9 | 10 | ## [0.11.0](https://github.com/mrjackphil/obsidian-text-expand/compare/0.10.8...v0.11.0) (2022-06-16) 11 | 12 | 13 | ### Features 14 | 15 | * :sparkles: add current file info to template ([0974ad6](https://github.com/mrjackphil/obsidian-text-expand/commit/0974ad667c3d38d900745a2cfe3e6679fb594095)) 16 | * :sparkles: add expand on button ([03dfca2](https://github.com/mrjackphil/obsidian-text-expand/commit/03dfca2ddbe5f8f541520f2b8b6a9866ec3d889a)) 17 | * :sparkles: change cmdoc getter ([0f693d0](https://github.com/mrjackphil/obsidian-text-expand/commit/0f693d0d7149cb70fc76d4017220a6267dbcdef9)) 18 | * :sparkles: do not search if query is empty ([6a80151](https://github.com/mrjackphil/obsidian-text-expand/commit/6a8015162edaa1b1346595576a5959e74a692206)) 19 | * :sparkles: template functionality using eta ([92ce1e0](https://github.com/mrjackphil/obsidian-text-expand/commit/92ce1e06b465ac3867fcab22f3b091386851a2f4)) 20 | * :sparkles: use results in search panel if no search query provided ([6a47f14](https://github.com/mrjackphil/obsidian-text-expand/commit/6a47f14a6c2a5ba06f6ddebbe36c418d8a0028e6)) 21 | 22 | 23 | ### Bug Fixes 24 | 25 | * :bug: apply changes on initial view ([0b417f5](https://github.com/mrjackphil/obsidian-text-expand/commit/0b417f5cff0802c3e12a27da891d1810f850f608)) 26 | * :bug: auto-expand unload on plugin disable ([7f35fd0](https://github.com/mrjackphil/obsidian-text-expand/commit/7f35fd089f687d10fd58667acf16919f7e57a28f)) 27 | * :bug: better search panel state restore ([7c713bc](https://github.com/mrjackphil/obsidian-text-expand/commit/7c713bcad9580d9736c646d42bb569240ea61a48)) 28 | * :bug: cursor jump on command palette trigger ([a7ca6d6](https://github.com/mrjackphil/obsidian-text-expand/commit/a7ca6d6d7df24503f96194981a93ba4821690ca3)) 29 | * :bug: markdown links generation ([be897a9](https://github.com/mrjackphil/obsidian-text-expand/commit/be897a982102adf83f5995e691752cc997aeccdc)) 30 | * :bug: pick function done right ([36de2ff](https://github.com/mrjackphil/obsidian-text-expand/commit/36de2fffbc304dd952dd77724c46aa3744d543f6)) 31 | * :bug: remove embed $link for pdf ([9efddca](https://github.com/mrjackphil/obsidian-text-expand/commit/9efddca1f7be64df546fbaea7de291f11e415f3b)) 32 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Text expand 2 | 3 | ![](./screenshots/1.gif) 4 | 5 | This plugin will search files using [Obsidian search functionality](https://publish.obsidian.md/help/Plugins/Search) 6 | and then paste the result. The output can be customized using [template feature](#template-engines). 7 | 8 | ## Table of Contents 9 | 10 | - [Basic usage](#how-to-use) 11 | - [Search functionality]() 12 | - [Template engines](#template-engines) 13 | - [eta](#eta-template-engine) 14 | - [sequences](#sequence-template-engine-legacy) 15 | - [Available sequences](#special-sequences) 16 | 17 | ## How to use 18 | - You should wrap your search request like that 19 | ``` 20 | ```expander 21 | SEARCH_QUERY 22 | ``` 23 | ``` 24 | - Open command palette (`Ctrl + P`) 25 | - Find and run `Text expand: expand` command 26 | - It should search and put results below the wrapped request 27 | 28 | ## Search functionality 29 | 30 | First line in expander code block is always a search request. 31 | You can leave it empty to use results from search panel as is. 32 | 33 | Once searching, plugin waits some time (configurable) and extract results from 34 | search panel to template engine. 35 | 36 | ## Template engines 37 | 38 | ### eta template engine 39 | 40 | You can use [eta](https://eta.js.org) template engine for managing results. 41 | 42 | ``` 43 | ## <%= it.current.frontmatter.title %> 44 | 45 | <% it.files.forEach(file => { %> 46 | - <%= file.link %> 47 | <% }) %> 48 | ``` 49 | 50 | Use `it` object to access search results and fields for current file. 51 | 52 | | Path | Type | Description | 53 | |--------------|-----------------------|----------------------------------| 54 | | `it.current` | FileParameters | Info about current file | 55 | | `it.files` | Array | Info about files in search panel | 56 | 57 | `FileParameters` type has those fields. 58 | 59 | | Name | Type | Description | Example | 60 | |-------------|--------|--------------------------------------------------|--------------------------------------------------------------| 61 | | basename | string | Name of the file | `Obsidian` | 62 | | name | string | Full name of the file with extension | `Obsidian.md` | 63 | | content | string | Content of the file | `Obsidian\nContent of the file.` | 64 | | extension | string | Extension of the file | `.md` | 65 | | link | string | Wiki or MD link (depends on Obsidian's settings) | `[[Obsidian]]` | 66 | | path | string | Relative to vault root path to the file | `resources/Obsidian.md` | 67 | | frontmatter | Object | Returns all values from frontmatter | `{ title: "Obsidian", author: MrJackphil }` | 68 | | stat | Object | File stats returned by Obsidian | `{ ctime: 1654089929073, mtime: 1654871855121, size: 1712 }` | 69 | | links | Array | Array with links in the file | | 70 | | headings | Array | Array with headings in the file | | 71 | | sections | Array | Array with section of the file | | 72 | | listItems | Array | Array with list items of the file | | 73 | 74 | ### sequence template engine (LEGACY) 75 | Using template feature you can customize an output. 76 | - Put template below the SEARCH_QUERY line 77 | - Put a cursor inside code block with a template 78 | - Open command palette (`Ctrl+P`) and find `Text expand: expand` command 79 | 80 | To create a list: 81 | 82 | ```expander 83 | SEARCH_QUERY 84 | - [[$filename]] 85 | ``` 86 | 87 | or to create a table: 88 | 89 | ```expander 90 | SEARCH_QUERY 91 | ^|Filename|Content| 92 | ^|---|---| 93 | |$filename|$lines:1| 94 | ``` 95 | 96 | 97 | Syntax looks like that: 98 | 99 | ```expander 100 | SEARCH_QUERY 101 | ^This is a header 102 | This line will be repeated for each file 103 | Also, [[$filename]] <- this will be a link 104 | >This is a footer 105 | ``` 106 | 107 | - Line prepended with `^` is a header. It will be added at the top of the list 108 | - Line prepended with `>` is a footer. It will be added at the bottom of the list 109 | - Line with no special symbol at start will be repeated for each file. Also, all special sequences will be replaced. 110 | 111 | #### Special sequences 112 | 113 | | Regexp | Description | Usage example | 114 | |--------------------------|------------------------------------------------------------------------------------------------------------------------|------------------------------------------------------------| 115 | | `$filename` | a basename of a file | `$filename` | 116 | | `$link` | wikilink | `$link` | 117 | | `$searchresult` | the context displayed in the Obsidian search, depending on the amount of context that is selected in the search window | `$searchresult` | 118 | | `$matchline` | the line which contains the search query | `$matchline` | 119 | | `$matchline:NUMBER` | the line which contains the search query and NUMBER lines after and before matched line | `$matchline:10` | 120 | | `$matchline:+NUMBER` | the line which contains the search query and NUMBER lines after matched line | `$matchline:+10` | 121 | | `$matchline:COUNT:LIMIT` | the line which contains the search query and NUMBER lines around and limit line by LIMIT characters | `$matchline:0:10` | 122 | | `$lines` | the full content of the file | `$lines` | 123 | | `$lines:NUMBER` | NUMBER lines from the file | `$lines:10` | 124 | | `$ext` | extension of the file | | 125 | | `$created` | | | 126 | | `$size` | | | 127 | | `$parent` | parent folder | | 128 | | `$path` | path to file | | 129 | | `$frontmatter:NAME` | frontmatter value from field `NAME` | | 130 | | `$header:##` | all headers as links | | 131 | | `$header:###HEADER` | headers as links | `$header:##Ideas`
`$header:"## Plugins for Obsidian"` | 132 | | `$blocks` | all blocks paths from the note as links | | 133 | 134 | ## Settings 135 | - Delay (default: `100ms`) - the plugin don't wait until search completed. It waits for a delay and paste result after that. 136 | - Line ending (default: `<-->`) - how will looks like the line below the expanded content 137 | - Default template (default: `- [[$filename]]`) - how will look the expanded content when no template provided 138 | - Prefixes - which prefix to use to recognize header/footer in template section 139 | 140 | ## Install 141 | - Just use built-in plugin manager and find `Text expand` plugin 142 | 143 | ### Manually 144 | - You need Obsidian v0.9.18+ for latest version of plugin 145 | - Get the [Latest release](https://github.com/mrjackphil/obsidian-text-expand/releases/latest) 146 | - Extract files and place them to your vault's plugins folder: `/.obsidian/plugins/` 147 | - Reload Obsidian 148 | - If prompted about Safe Mode, you can disable safe mode and enable the plugin. Otherwise, head to Settings, third-party plugins, make sure safe mode is off and enable the plugin from there. 149 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'node', 4 | moduleDirectories: [ 5 | "node_modules" 6 | ], 7 | }; -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "mrj-text-expand", 3 | "name": "Text expand", 4 | "version": "0.11.5", 5 | "description": "Search and paste/transclude links to located files.", 6 | "isDesktopOnly": false, 7 | "author": "MrJackphil", 8 | "authorUrl": "https://mrjackphil.com" 9 | } 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mrj-text-expand", 3 | "version": "0.11.5", 4 | "description": "", 5 | "main": "main.js", 6 | "scripts": { 7 | "dev": "rollup --config rollup.config.js -w", 8 | "build": "rollup --config rollup.config.js", 9 | "test": "jest" 10 | }, 11 | "keywords": [], 12 | "author": "MrJackphil", 13 | "license": "MIT", 14 | "devDependencies": { 15 | "@rollup/plugin-commonjs": "^15.1.0", 16 | "@rollup/plugin-node-resolve": "^9.0.0", 17 | "@rollup/plugin-typescript": "^6.0.0", 18 | "@types/jest": "^26.0.20", 19 | "@types/node": "^14.14.2", 20 | "jest": "^26.6.3", 21 | "obsidian": "https://github.com/obsidianmd/obsidian-api/tarball/master", 22 | "rollup": "^2.32.1", 23 | "ts-jest": "^26.5.2", 24 | "tslib": "^2.0.3", 25 | "typescript": "^4.0.3" 26 | }, 27 | "dependencies": { 28 | "eta": "^1.12.3" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import typescript from '@rollup/plugin-typescript'; 2 | import {nodeResolve} from '@rollup/plugin-node-resolve'; 3 | import commonjs from '@rollup/plugin-commonjs'; 4 | 5 | export default { 6 | input: 'src/main.ts', 7 | output: { 8 | file: 'main.js', 9 | sourcemap: 'inline', 10 | format: 'cjs', 11 | exports: 'default' 12 | }, 13 | external: ['obsidian'], 14 | plugins: [ 15 | typescript(), 16 | nodeResolve({browser: true}), 17 | commonjs(), 18 | ] 19 | }; -------------------------------------------------------------------------------- /screenshots/1.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mrjackphil/obsidian-text-expand/16814a9798ed2c919503ff4fa4cf904c721b1e9f/screenshots/1.gif -------------------------------------------------------------------------------- /src/helpers/cm.ts: -------------------------------------------------------------------------------- 1 | // CodeMirror specific helpers 2 | -------------------------------------------------------------------------------- /src/helpers/helpers.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ExpanderQuery, 3 | getAllExpandersQuery, 4 | getClosestQuery, 5 | getLastLineToReplace 6 | } from './helpers' 7 | import {splitByLines, trimContent} from "./string"; 8 | 9 | const content = [ 10 | '```expander', 11 | '{{template}}', 12 | '```' 13 | ].join('\n') 14 | 15 | const content2 = [ 16 | '```expander', 17 | '{{template}}', 18 | '```', 19 | '', 20 | '', 21 | '', 22 | '```expander', 23 | '{{template}}', 24 | '- [[$filename]]', 25 | '```' 26 | ].join('\n') 27 | 28 | describe('test query getter', () => { 29 | test('one query', () => { 30 | const formattedContent = splitByLines(content) 31 | const result = getAllExpandersQuery(formattedContent) 32 | expect(result.length).toBe(1) 33 | }) 34 | 35 | test('two queries', () => { 36 | const formattedContent = splitByLines(content2) 37 | const result = getAllExpandersQuery(formattedContent) 38 | expect(result.length).toBe(2) 39 | }) 40 | test('should have query', () => { 41 | const formattedContent = splitByLines(content) 42 | const result = getAllExpandersQuery(formattedContent) 43 | expect(result[0].query).toBe('{{template}}') 44 | }) 45 | 46 | test('should have template', () => { 47 | const formattedContent = splitByLines(content2) 48 | const result = getAllExpandersQuery(formattedContent) 49 | expect(result[1].template).toBe('- [[$filename]]') 50 | }) 51 | 52 | test('should have multiline template', () => { 53 | const formattedContent = splitByLines(`\`\`\`expander\n{{template}}\nhead\nbody\nfooter\n\`\`\`\notherline\n`) 54 | const result = getAllExpandersQuery(formattedContent) 55 | expect(result[0].template).toBe('head\nbody\nfooter') 56 | }) 57 | 58 | test('should get line after query', () => { 59 | const excontent = [ 60 | '```expander', 61 | 'test', 62 | '```', 63 | 'some', 64 | 'some', 65 | 'some' 66 | ] 67 | const query: ExpanderQuery = { 68 | start: 0, 69 | end: 2, 70 | query: 'test', 71 | template: '' 72 | } 73 | 74 | const endline = '<-->' 75 | 76 | const result = getLastLineToReplace(excontent, query, endline) 77 | expect(result).toBe(3) 78 | }) 79 | 80 | test('should get last line', () => { 81 | const excontent = [ 82 | '```expander', 83 | 'test', 84 | '```', 85 | 'some', 86 | 'some', 87 | 'some', 88 | '<-->' 89 | ] 90 | const query: ExpanderQuery = { 91 | start: 0, 92 | end: 2, 93 | query: 'test', 94 | template: '' 95 | } 96 | 97 | const endline = '<-->' 98 | 99 | const result = getLastLineToReplace(excontent, query, endline) 100 | expect(result).toBe(6) 101 | }) 102 | }) 103 | 104 | describe('test closest query getter', () => { 105 | test('should get first query', () => { 106 | const expanders: ExpanderQuery[] = [ 107 | { 108 | start: 0, 109 | template: '', 110 | query: 'first', 111 | end: 2, 112 | }, 113 | { 114 | start: 4, 115 | end: 6, 116 | template: '', 117 | query: 'second', 118 | }, 119 | ] 120 | 121 | expect(getClosestQuery(expanders, 2).query).toBe('first') 122 | }) 123 | 124 | test('should get second query', () => { 125 | const expanders: ExpanderQuery[] = [ 126 | { 127 | start: 0, 128 | template: '', 129 | query: 'first', 130 | end: 2, 131 | }, 132 | { 133 | start: 4, 134 | end: 6, 135 | template: '', 136 | query: 'second', 137 | }, 138 | ] 139 | 140 | expect(getClosestQuery(expanders, 7).query).toBe('second') 141 | }) 142 | 143 | test('should not throw error on empty array', () => { 144 | expect(getClosestQuery([], 7)).toBe(undefined) 145 | }) 146 | }) 147 | 148 | describe('test trim content helper', () => { 149 | test('remove empty lines', () => { 150 | const result = trimContent(` \ntest\n test2\n`) 151 | 152 | expect(result).toBe(`test\ntest2\n`) 153 | }) 154 | 155 | test('remove frontmatter lines', () => { 156 | const result = trimContent(`---\nbla-bla\n---\nresult`) 157 | 158 | expect(result).toBe(`result`) 159 | }) 160 | }) 161 | -------------------------------------------------------------------------------- /src/helpers/helpers.ts: -------------------------------------------------------------------------------- 1 | export interface ExpanderQuery { 2 | start: number 3 | end: number 4 | template: string 5 | query: string 6 | } 7 | 8 | export function getAllExpandersQuery(content: string[]): ExpanderQuery[] { 9 | let accum: ExpanderQuery[] = [] 10 | for (var i = 0; i < content.length; i++) { 11 | const line = content[i] 12 | 13 | if (line === '```expander') { 14 | for (var e = 0; e < content.length - i; e++) { 15 | const nextline = content[i + e] 16 | if (nextline === '```') { 17 | accum.push( 18 | { 19 | start: i, 20 | end: i + e, 21 | query: content[i + 1], 22 | template: e > 2 ? content.slice(i + 2, i + e).join('\n') : '' 23 | } 24 | ) 25 | break 26 | } 27 | } 28 | } 29 | } 30 | 31 | return accum 32 | } 33 | 34 | export function getClosestQuery(queries: ExpanderQuery[], lineNumber: number): ExpanderQuery | undefined { 35 | if (queries.length === 0) { 36 | return undefined 37 | } 38 | 39 | return queries.reduce((a, b) => { 40 | return Math.abs(b.start - lineNumber) < Math.abs(a.start - lineNumber) ? b : a; 41 | }); 42 | } 43 | 44 | export function getLastLineToReplace(content: string[], query: ExpanderQuery, endline: string) { 45 | const lineFrom = query.end 46 | 47 | for (var i = lineFrom + 1; i < content.length; i++) { 48 | if (content[i] === endline) { 49 | return i 50 | } 51 | } 52 | 53 | return lineFrom + 1 54 | } 55 | 56 | type LooseObject = { [key: string]: T } 57 | 58 | export const pick = (obj: {[k: string]: any}, arr: string[]) => 59 | arr.reduce((acc, curr) => { 60 | return (curr in obj) 61 | ? Object.assign({}, acc, { [curr]: obj[curr] }) 62 | : acc 63 | }, {}); 64 | 65 | 66 | -------------------------------------------------------------------------------- /src/helpers/search-results.ts: -------------------------------------------------------------------------------- 1 | import {TFile} from "obsidian"; 2 | import {SearchDetails} from "../main"; 3 | 4 | export function extractFilesFromSearchResults(searchResults: Map, currentFileName: string, excludeCurrent: boolean = true) { 5 | const files = Array.from(searchResults.keys()) 6 | 7 | return excludeCurrent 8 | ? files.filter(file => file.basename !== currentFileName) 9 | : files; 10 | } -------------------------------------------------------------------------------- /src/helpers/string.test.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mrjackphil/obsidian-text-expand/16814a9798ed2c919503ff4fa4cf904c721b1e9f/src/helpers/string.test.ts -------------------------------------------------------------------------------- /src/helpers/string.ts: -------------------------------------------------------------------------------- 1 | // Functions for string processing 2 | export function splitByLines(content: string): string[] { 3 | return content.split('\n') 4 | } 5 | 6 | function removeEmptyLines(s: string): string { 7 | const lines = s.split('\n').map(e => e.trim()) 8 | 9 | if (lines.length < 2) { 10 | return s 11 | } else if (lines.indexOf('') === 0) { 12 | return removeEmptyLines(lines.slice(1).join('\n')) 13 | } 14 | 15 | return s 16 | } 17 | 18 | function removeFrontMatter (s: string, lookEnding: boolean = false): string { 19 | const lines = s.split('\n') 20 | 21 | if (lookEnding && lines.indexOf('---') === 0) { 22 | return lines.slice(1).join('\n') 23 | } else if (lookEnding) { 24 | return removeFrontMatter(lines.slice(1).join('\n'), true) 25 | } else if (lines.indexOf('---') === 0) { 26 | return removeFrontMatter(lines.slice(1).join('\n'), true) 27 | } 28 | 29 | return s 30 | } 31 | 32 | export function trimContent(content: string): string { 33 | return removeFrontMatter(removeEmptyLines(content)) 34 | } 35 | -------------------------------------------------------------------------------- /src/helpers/tfile.ts: -------------------------------------------------------------------------------- 1 | import {Plugin, TFile} from "obsidian"; 2 | import {pick} from "./helpers"; 3 | import {FileParameters} from "../main"; 4 | 5 | export function getFrontMatter(file: TFile, plugin: Plugin, s: string) { 6 | const {frontmatter = null} = plugin.app.metadataCache.getCache(file.path) 7 | 8 | if (frontmatter) { 9 | return frontmatter[s.split(':')[1]] || ''; 10 | } 11 | 12 | return '' 13 | } 14 | 15 | export async function getFileInfo(this: void, plugin: Plugin, file: TFile): Promise { 16 | const info = Object.assign({}, file, { 17 | content: file.extension === 'md' ? await plugin.app.vault.cachedRead(file) : '', 18 | link: plugin.app.fileManager.generateMarkdownLink(file, file.name).replace(/^!/, '') 19 | }, 20 | plugin.app.metadataCache.getFileCache(file) 21 | ) 22 | return pick(info, [ 23 | 'basename', 24 | 'content', 25 | 'extension', 26 | 'headings', 27 | 'link', 'name', 28 | 'path', 'sections', 'stat', 29 | 'frontmatter', 30 | 'links', 31 | 'listItems' 32 | ]) 33 | } -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ExpanderQuery, 3 | getAllExpandersQuery, 4 | getClosestQuery, 5 | getLastLineToReplace 6 | } from 'src/helpers/helpers'; 7 | import { 8 | App, Editor, 9 | FileView, 10 | MarkdownView, 11 | Plugin, 12 | PluginManifest, 13 | PluginSettingTab, 14 | Setting, 15 | TFile, View, WorkspaceLeaf 16 | } from 'obsidian'; 17 | import sequences, {Sequences} from "./sequences/sequences"; 18 | import {splitByLines} from "./helpers/string"; 19 | import {extractFilesFromSearchResults} from "./helpers/search-results"; 20 | import {render} from "eta"; 21 | import {getFileInfo} from "./helpers/tfile"; 22 | 23 | interface PluginSettings { 24 | delay: number 25 | lineEnding: string 26 | defaultTemplate: string 27 | excludeCurrent: boolean 28 | autoExpand: boolean 29 | prefixes: { 30 | header: string 31 | footer: string 32 | } 33 | } 34 | 35 | interface SearchLeaf extends WorkspaceLeaf { 36 | view: View & { 37 | searchComponent: { 38 | getValue: () => string 39 | setValue: (s: string) => void 40 | } 41 | } 42 | } 43 | 44 | export interface FileParameters { 45 | basename: string 46 | content: string 47 | extension: string 48 | headings: Array 49 | link: string 50 | name: string 51 | path: string 52 | sections: Array 53 | stat: {} 54 | frontmatter: { [k: string]: any } 55 | links: Array 56 | listItems: Array 57 | } 58 | 59 | type NumberTuple = [number, number] 60 | 61 | export interface SearchDetails { 62 | app: App 63 | children: any[] 64 | childrenEl: HTMLElement 65 | collapseEl: HTMLElement 66 | collapsed: boolean 67 | collapsible: boolean 68 | containerEl: HTMLElement 69 | content: string 70 | dom: any 71 | el: HTMLElement 72 | extraContext: () => boolean 73 | file: TFile 74 | info: any 75 | onMatchRender: any 76 | pusherEl: HTMLElement 77 | result: { 78 | filename?: NumberTuple[] 79 | content?: NumberTuple[] 80 | } 81 | } 82 | 83 | export default class TextExpander extends Plugin { 84 | cm: Editor 85 | 86 | config: PluginSettings = { 87 | autoExpand: false, 88 | defaultTemplate: '- $link', 89 | delay: 300, 90 | excludeCurrent: true, 91 | lineEnding: '<-->', 92 | prefixes: { 93 | header: '^', 94 | footer: '>' 95 | } 96 | } 97 | 98 | seqs: Sequences[] = sequences 99 | 100 | leftPanelInfo: { 101 | collapsed: boolean 102 | tab: number 103 | text: string 104 | } = { 105 | collapsed: false, 106 | tab: 0, 107 | text: '' 108 | } 109 | 110 | constructor(app: App, plugin: PluginManifest) { 111 | super(app, plugin); 112 | 113 | this.search = this.search.bind(this); 114 | this.init = this.init.bind(this); 115 | this.autoExpand = this.autoExpand.bind(this); 116 | } 117 | 118 | async autoExpand() { 119 | if (!this.config.autoExpand) { 120 | return 121 | } 122 | 123 | const activeLeaf = this.app.workspace.activeLeaf 124 | if (!activeLeaf) { 125 | return 126 | } 127 | 128 | const activeView = activeLeaf.view 129 | const isAllowedView = activeView instanceof MarkdownView 130 | if (!isAllowedView) { 131 | return 132 | } 133 | 134 | await this.init(true) 135 | } 136 | 137 | async onload() { 138 | this.addSettingTab(new SettingTab(this.app, this)); 139 | 140 | this.registerMarkdownCodeBlockProcessor('expander', (source, el, ctx) => { 141 | el 142 | .createDiv() 143 | .createEl('button', {text: 'Run expand query'}) 144 | .addEventListener('click', this.init.bind(this, false, ctx.getSectionInfo(el).lineStart)) 145 | }); 146 | 147 | this.addCommand({ 148 | id: 'editor-expand', 149 | name: 'expand', 150 | callback: this.init, 151 | hotkeys: [] 152 | }); 153 | 154 | this.addCommand({ 155 | id: 'editor-expand-all', 156 | name: 'expand all', 157 | callback: () => this.init(true), 158 | hotkeys: [] 159 | }); 160 | 161 | this.app.workspace.on('file-open', this.autoExpand); 162 | 163 | const data = await this.loadData() as PluginSettings 164 | if (data) { 165 | this.config = { 166 | ...this.config, 167 | ...data 168 | } 169 | } 170 | } 171 | 172 | onunload() { 173 | console.log('unloading plugin'); 174 | this.app.workspace.off('file-open', this.autoExpand); 175 | } 176 | 177 | async saveSettings() { 178 | await this.saveData(this.config) 179 | } 180 | 181 | private async init(proceedAllQueriesOnPage = false, lineToStart?: number) { 182 | const currentView = this.app.workspace.activeLeaf.view 183 | 184 | // Is on editable view 185 | if (!(currentView instanceof MarkdownView)) { 186 | return 187 | } 188 | 189 | const cmDoc: Editor = this.cm = currentView.editor 190 | 191 | const curNum = lineToStart || cmDoc.getCursor().line 192 | const content = cmDoc.getValue() 193 | 194 | if (lineToStart) { 195 | cmDoc.setCursor(lineToStart ? lineToStart - 1 : 0) 196 | } 197 | 198 | const formatted = splitByLines(content) 199 | const findQueries = getAllExpandersQuery(formatted) 200 | const closestQuery = getClosestQuery(findQueries, curNum) 201 | 202 | if (proceedAllQueriesOnPage) { 203 | await findQueries.reduce((promise, query, i) => 204 | promise.then(() => { 205 | const newContent = splitByLines(cmDoc.getValue()) 206 | const updatedQueries = getAllExpandersQuery(newContent) 207 | 208 | return this.runExpanderCodeBlock(updatedQueries[i], newContent, currentView) 209 | }), Promise.resolve() 210 | ) 211 | } else { 212 | await this.runExpanderCodeBlock(closestQuery, formatted, currentView) 213 | } 214 | } 215 | 216 | private async runExpanderCodeBlock(query: ExpanderQuery, content: string[], view: MarkdownView) { 217 | const {lineEnding, prefixes} = this.config 218 | 219 | if (!query) { 220 | new Notification('Expand query not found') 221 | return Promise.resolve() 222 | } 223 | 224 | this.clearOldResultsInFile(content, query, lineEnding); 225 | 226 | const newContent = splitByLines(this.cm.getValue()); 227 | 228 | this.saveLeftPanelState(); 229 | 230 | if (query.query !== '') { 231 | this.search(query.query) 232 | } 233 | return await this.runTemplateProcessing(query, getLastLineToReplace(newContent, query, this.config.lineEnding), prefixes, view) 234 | } 235 | 236 | private async runTemplateProcessing(query: ExpanderQuery, lastLine: number, prefixes: PluginSettings["prefixes"], currentView: MarkdownView) { 237 | let currentFileName = '' 238 | 239 | const templateContent = query.template.split('\n') 240 | 241 | const {heading, footer, repeatableContent} = this.parseTemplate(prefixes, templateContent); 242 | 243 | if (currentView instanceof FileView) { 244 | currentFileName = currentView.file.basename 245 | } 246 | 247 | const searchResults = await this.getFoundAfterDelay(query.query === ''); 248 | const files = extractFilesFromSearchResults(searchResults, currentFileName, this.config.excludeCurrent); 249 | 250 | this.restoreLeftPanelState(); 251 | 252 | currentView.editor.focus(); 253 | 254 | const currentFileInfo: {} = (currentView instanceof FileView) 255 | ? await getFileInfo(this, currentView.file) 256 | : {} 257 | const filesInfo = await Promise.all( 258 | files.map(file => getFileInfo(this, file)) 259 | ) 260 | 261 | let changed; 262 | 263 | if (query.template.contains("<%")) { 264 | const templateToRender = repeatableContent.join('\n') 265 | const dataToRender = { 266 | current: currentFileInfo, 267 | files: filesInfo 268 | } 269 | 270 | changed = await render(templateToRender, dataToRender, {autoEscape: false}) 271 | // changed = doT.template(templateToRender, {strip: false})(dataToRender) 272 | } else { 273 | changed = await this.generateTemplateFromSequences(files, repeatableContent, searchResults); 274 | } 275 | 276 | let result = [ 277 | heading, 278 | changed, 279 | footer, 280 | this.config.lineEnding 281 | ].filter(e => e).join('\n') 282 | 283 | // Do not paste generated content if used changed activeLeaf 284 | const viewBeforeReplace = this.app.workspace.activeLeaf.view 285 | if (!(viewBeforeReplace instanceof MarkdownView) || viewBeforeReplace.file.basename !== currentFileName) { 286 | return 287 | } 288 | 289 | currentView.editor.replaceRange(result, 290 | {line: query.end + 1, ch: 0}, 291 | {line: lastLine, ch: this.cm.getLine(lastLine)?.length || 0}) 292 | 293 | return Promise.resolve() 294 | } 295 | 296 | private async generateTemplateFromSequences(files: TFile[], repeatableContent: string[], searchResults?: Map): Promise { 297 | if (!searchResults) { 298 | return '' 299 | } 300 | 301 | const changed = await Promise.all( 302 | files 303 | .map(async (file, i) => { 304 | const result = await Promise.all(repeatableContent.map(async (s) => await this.applyTemplateToSearchResults(searchResults, file, s, i))) 305 | return result.join('\n') 306 | }) 307 | ) 308 | 309 | return changed.join('\n'); 310 | } 311 | 312 | private parseTemplate(prefixes: { header: string; footer: string }, templateContent: string[]) { 313 | const isHeader = (line: string) => line.startsWith(prefixes.header) 314 | const isFooter = (line: string) => line.startsWith(prefixes.footer) 315 | const isRepeat = (line: string) => !isHeader(line) && !isFooter(line) 316 | 317 | const heading = templateContent.filter(isHeader).map((s) => s.slice(1)).join('\n') 318 | const footer = templateContent.filter(isFooter).map((s) => s.slice(1)).join('\n') 319 | const repeatableContent = 320 | templateContent.filter(isRepeat).filter(e => e).length === 0 321 | ? [this.config.defaultTemplate] 322 | : templateContent.filter(isRepeat).filter(e => e) 323 | return {heading, footer, repeatableContent}; 324 | } 325 | 326 | private saveLeftPanelState(): void { 327 | this.leftPanelInfo = { 328 | collapsed: this.app.workspace.leftSplit.collapsed, 329 | // @ts-ignore 330 | tab: this.app.workspace.leftSplit.children[0].currentTab, 331 | text: this.getSearchValue(), 332 | } 333 | } 334 | 335 | private restoreLeftPanelState() { 336 | const {collapsed, tab, text} = this.leftPanelInfo; 337 | const splitChildren = this.getLeftSplitElement() 338 | 339 | this.getSearchView().searchComponent.setValue(text) 340 | 341 | if (tab !== splitChildren.currentTab) { 342 | splitChildren.selectTabIndex(tab) 343 | } 344 | 345 | if (collapsed) { 346 | this.app.workspace.leftSplit.collapse() 347 | } 348 | } 349 | 350 | private search(s: string) { 351 | // @ts-ignore 352 | const globalSearchFn = this.app.internalPlugins.getPluginById('global-search').instance.openGlobalSearch.bind(this) 353 | const search = (query: string) => globalSearchFn(query) 354 | 355 | search(s) 356 | } 357 | 358 | private getLeftSplitElement(): { 359 | currentTab: number 360 | selectTabIndex: (n: number) => void 361 | children: Array 362 | } { 363 | // @ts-ignore 364 | return this.app.workspace.leftSplit.children[0]; 365 | } 366 | 367 | private getLeftSplitElementOfViewStateType(viewStateType): { 368 | currentTab: number 369 | selectTabIndex: (n: number) => void 370 | children: Array 371 | } { 372 | // @ts-ignore 373 | for (const child of this.app.workspace.leftSplit.children) { 374 | const filterForSearchResult = child.children.filter(e => e.getViewState().type === viewStateType); 375 | if (filterForSearchResult === undefined || filterForSearchResult.length < 1) { 376 | continue; 377 | } 378 | 379 | return filterForSearchResult[0]; 380 | } 381 | return undefined; 382 | } 383 | 384 | private getSearchView(): SearchLeaf['view'] { 385 | const searchElement = this.getLeftSplitElementOfViewStateType('search'); 386 | if (undefined == searchElement) { 387 | return undefined; 388 | } 389 | 390 | const view = searchElement.view; 391 | if ('searchComponent' in view) { 392 | return view; 393 | } 394 | return undefined; 395 | } 396 | 397 | private getSearchValue(): string { 398 | const view = this.getSearchView(); 399 | 400 | if (view) { 401 | return view.searchComponent.getValue() 402 | } 403 | 404 | return '' 405 | } 406 | 407 | private async getFoundAfterDelay(immediate: boolean): Promise> { 408 | const searchLeaf = this.app.workspace.getLeavesOfType('search')[0] 409 | const view = await searchLeaf.open(searchLeaf.view) 410 | 411 | if (immediate) { 412 | // @ts-ignore 413 | return Promise.resolve(view.dom.resultDomLookup as Map); 414 | } 415 | 416 | return new Promise(resolve => { 417 | setTimeout(() => { 418 | // @ts-ignore 419 | return resolve(view.dom.resultDomLookup as Map) 420 | }, this.config.delay) 421 | }) 422 | } 423 | 424 | private async applyTemplateToSearchResults(searchResults: Map, file: TFile, template: string, index: number) { 425 | const fileContent = (new RegExp(this.seqs.filter(e => e.readContent).map(e => e.name).join('|')).test(template)) 426 | ? await this.app.vault.cachedRead(file) 427 | : '' 428 | 429 | return this.seqs.reduce((acc, seq) => 430 | acc.replace(new RegExp(seq.name, 'gu'), replace => seq.format(this, replace, fileContent, file, searchResults.get(file), index)), template) 431 | } 432 | 433 | private clearOldResultsInFile(content: string[], query: ExpanderQuery, lineEnding: string) { 434 | const lastLine = getLastLineToReplace(content, query, this.config.lineEnding) 435 | this.cm.replaceRange('\n' + lineEnding, 436 | {line: query.end + 1, ch: 0}, 437 | {line: lastLine, ch: this.cm.getLine(lastLine)?.length || 0}) 438 | } 439 | } 440 | 441 | class SettingTab extends PluginSettingTab { 442 | plugin: TextExpander 443 | 444 | constructor(app: App, plugin: TextExpander) { 445 | super(app, plugin); 446 | 447 | this.app = app 448 | this.plugin = plugin 449 | } 450 | 451 | display(): void { 452 | let {containerEl} = this; 453 | 454 | containerEl.empty(); 455 | 456 | containerEl.createEl('h2', {text: 'Settings for Text Expander'}); 457 | 458 | new Setting(containerEl) 459 | .setName('Auto Expand') 460 | .setDesc('Expand all queries in a file once you open it') 461 | .addToggle(toggle => { 462 | toggle 463 | .setValue(this.plugin.config.autoExpand) 464 | .onChange(value => { 465 | this.plugin.config.autoExpand = value 466 | this.plugin.saveSettings() 467 | }) 468 | }) 469 | 470 | new Setting(containerEl) 471 | .setName('Delay') 472 | .setDesc('Text expander don\' wait until search completed. It waits for a delay and paste result after that.') 473 | .addSlider(slider => { 474 | slider.setLimits(100, 10000, 100) 475 | slider.setValue(this.plugin.config.delay) 476 | slider.onChange(value => { 477 | this.plugin.config.delay = value 478 | this.plugin.saveSettings() 479 | }) 480 | slider.setDynamicTooltip() 481 | }) 482 | 483 | new Setting(containerEl) 484 | .setName('Line ending') 485 | .setDesc('You can specify the text which will appear at the bottom of the generated text.') 486 | .addText(text => { 487 | text.setValue(this.plugin.config.lineEnding) 488 | .onChange(val => { 489 | this.plugin.config.lineEnding = val 490 | this.plugin.saveSettings() 491 | }) 492 | }) 493 | 494 | new Setting(containerEl) 495 | .setName('Default template') 496 | .setDesc('You can specify default template') 497 | .addTextArea(text => { 498 | text.setValue(this.plugin.config.defaultTemplate) 499 | .onChange(val => { 500 | this.plugin.config.defaultTemplate = val 501 | this.plugin.saveSettings() 502 | }) 503 | }) 504 | 505 | new Setting(containerEl) 506 | .setName('Exclude current file') 507 | .setDesc('You can specify should text expander exclude results from current file or not') 508 | .addToggle(toggle => { 509 | toggle 510 | .setValue(this.plugin.config.excludeCurrent) 511 | .onChange(value => { 512 | this.plugin.config.excludeCurrent = value 513 | this.plugin.saveSettings() 514 | }) 515 | }) 516 | 517 | new Setting(containerEl) 518 | .setHeading() 519 | .setName('Prefixes') 520 | 521 | new Setting(containerEl) 522 | .setName('Header') 523 | .setDesc('Line prefixed by this symbol will be recognized as header') 524 | .addText(text => { 525 | text.setValue(this.plugin.config.prefixes.header) 526 | .onChange(val => { 527 | this.plugin.config.prefixes.header = val 528 | this.plugin.saveSettings() 529 | }) 530 | }) 531 | 532 | new Setting(containerEl) 533 | .setName('Footer') 534 | .setDesc('Line prefixed by this symbol will be recognized as footer') 535 | .addText(text => { 536 | text.setValue(this.plugin.config.prefixes.footer) 537 | .onChange(val => { 538 | this.plugin.config.prefixes.footer = val 539 | this.plugin.saveSettings() 540 | }) 541 | }) 542 | 543 | new Setting(containerEl) 544 | .setName('Sequences') 545 | .setDesc('REGEXP - DESCRIPTION') 546 | .setDesc( 547 | (() => { 548 | const fragment = new DocumentFragment() 549 | const div = fragment.createEl('div') 550 | this.plugin.seqs 551 | .map(e => e.name + ' - ' + (e.desc || '')) 552 | .map(e => { 553 | const el = fragment.createEl('div') 554 | el.setText(e) 555 | el.setAttribute('style', ` 556 | border-bottom: 1px solid rgba(255, 255, 255, 0.1); 557 | margin-bottom: 0.5rem; 558 | padding-bottom: 0.5rem; 559 | `) 560 | return el 561 | }).forEach(el => { 562 | div.appendChild(el) 563 | }) 564 | fragment.appendChild(div) 565 | 566 | return fragment 567 | })() 568 | ) 569 | } 570 | } 571 | -------------------------------------------------------------------------------- /src/sequences/sequences.ts: -------------------------------------------------------------------------------- 1 | import {TFile} from "obsidian"; 2 | import TextExpander, {SearchDetails} from "../main"; 3 | import {trimContent} from "../helpers/string"; 4 | import {getFrontMatter} from "../helpers/tfile"; 5 | 6 | export interface Sequences { 7 | loop: boolean 8 | name: string 9 | format: (plugin: TextExpander, s: string, content: string, file: TFile, results?: SearchDetails, index?: number) => string 10 | desc: string 11 | readContent?: boolean 12 | usingSearch?: boolean 13 | } 14 | 15 | interface LineInfo { 16 | text: string 17 | num: number 18 | start: number 19 | end: number 20 | } 21 | 22 | function highlight(lineStart: number, lineEnd: number, matchStart: number, matchEnd: number, lineContent: string) { 23 | return [ 24 | ...lineContent.slice(0, matchStart - lineStart), 25 | '==', 26 | ...lineContent.slice(matchStart - lineStart, (matchStart - lineStart) + (matchEnd - matchStart)), 27 | '==', 28 | ...lineContent.slice((matchStart - lineStart) + (matchEnd - matchStart)), 29 | ].join('') 30 | } 31 | 32 | const sequences: Sequences[] = [ 33 | { 34 | name: '\\$count', 35 | loop: true, 36 | format: (_p, _s: string, _content: string, _file: TFile, _d, index) => index ? String(index + 1) : String(1), 37 | desc: 'add index number to each produced file' 38 | }, 39 | { 40 | name: '\\$filename', 41 | loop: true, 42 | format: (_p, _s: string, _content: string, file: TFile) => file.basename, 43 | desc: 'name of the founded file' 44 | }, 45 | { 46 | name: '\\$link', 47 | loop: true, 48 | format: (p, _s: string, _content: string, file: TFile) => p.app.fileManager.generateMarkdownLink(file, file.path).replace('![[', '[['), 49 | desc: 'link based on Obsidian settings' 50 | }, 51 | { 52 | name: '\\$lines:\\d+', 53 | loop: true, 54 | readContent: true, 55 | format: (p, s: string, content: string, _file: TFile) => { 56 | const digits = Number(s.split(':')[1]) 57 | 58 | return trimContent(content) 59 | .split('\n') 60 | .filter((_: string, i: number) => i < digits) 61 | .join('\n') 62 | .replace(new RegExp(p.config.lineEnding, 'g'), '') 63 | }, 64 | desc: 'specified count of lines from the found file' 65 | }, 66 | { 67 | name: '\\$characters:\\d+', 68 | loop: true, 69 | readContent: true, 70 | format: (p, s: string, content: string, _file: TFile) => { 71 | const digits = Number(s.split(':')[1]) 72 | 73 | return trimContent(content) 74 | .split('') 75 | .filter((_: string, i: number) => i < digits) 76 | .join('') 77 | .replace(new RegExp(p.config.lineEnding, 'g'), '') 78 | }, 79 | desc: 'specified count of lines from the found file' 80 | }, 81 | { 82 | name: '\\$frontmatter:[\\p\{L\}_-]+', 83 | loop: true, 84 | format: (p, s: string, _content: string, file: TFile) => getFrontMatter(file, p, s), 85 | desc: 'value from the frontmatter key in the found file' 86 | }, 87 | { 88 | name: '\\$lines+', 89 | loop: true, 90 | readContent: true, 91 | format: (p, s: string, content: string, _file: TFile) => content.replace(new RegExp(p.config.lineEnding, 'g'), ''), 92 | desc: 'all content from the found file' 93 | }, 94 | { 95 | name: '\\$ext', 96 | loop: true, 97 | format: (_p, s: string, content: string, file: TFile) => file.extension, 98 | desc: 'return file extension' 99 | }, 100 | { 101 | name: '\\$created:format:date', 102 | loop: true, 103 | format: (_p, s: string, content: string, file: TFile) => String(new Date(file.stat.ctime).toISOString()).split('T')[0], 104 | desc: 'created time formatted' 105 | }, 106 | { 107 | name: '\\$created:format:time', 108 | loop: true, 109 | format: (_p, s: string, content: string, file: TFile) => String(new Date(file.stat.ctime).toISOString()).split(/([.T])/)[2], 110 | desc: 'created time formatted' 111 | }, 112 | { 113 | name: '\\$created:format', 114 | loop: true, 115 | format: (_p, s: string, content: string, file: TFile) => String(new Date(file.stat.ctime).toISOString()), 116 | desc: 'created time formatted' 117 | }, 118 | { 119 | name: '\\$created', 120 | loop: true, 121 | format: (_p, s: string, content: string, file: TFile) => String(file.stat.ctime), 122 | desc: 'created time' 123 | }, 124 | { 125 | name: '\\$size', 126 | loop: true, 127 | format: (_p, s: string, content: string, file: TFile) => String(file.stat.size), 128 | desc: 'size of the file' 129 | }, 130 | { 131 | name: '\\$path', 132 | loop: true, 133 | format: (_p, s: string, content: string, file: TFile) => file.path, 134 | desc: 'path to the found file' 135 | }, 136 | { 137 | name: '\\$parent', 138 | loop: true, 139 | format: (_p, s: string, content: string, file: TFile) => file.parent.name, 140 | desc: 'parent folder name' 141 | }, 142 | { 143 | name: '^(.+|)\\$header:.+', 144 | loop: true, 145 | format: (p, s: string, content: string, file: TFile) => { 146 | const prefix = s.slice(0, s.indexOf('$')) 147 | const header = s.slice(s.indexOf('$')).replace('$header:', '').replace(/"/g, '') 148 | const neededLevel = header.split("#").length - 1 149 | const neededTitle = header.replace(/^#+/g, '').trim() 150 | 151 | const metadata = p.app.metadataCache.getFileCache(file) 152 | 153 | return metadata.headings?.filter(e => { 154 | const tests = [ 155 | [neededTitle, e.heading.includes(neededTitle)], 156 | [neededLevel, e.level === neededLevel] 157 | ].filter(e => e[0]) 158 | 159 | if (tests.length) { 160 | return tests.map(e => e[1]).every(e => e === true) 161 | } 162 | 163 | return true 164 | }) 165 | .map(h => p.app.fileManager.generateMarkdownLink(file, file.basename, '#' + h.heading)) 166 | .map(link => prefix + link) 167 | .join('\n') || '' 168 | 169 | }, 170 | desc: 'headings from founded files. $header:## - return all level 2 headings. $header:Title - return all heading which match the string. Can be prepended like: - !$header:## to transclude the headings.' 171 | }, 172 | { 173 | name: '^(.+|)\\$blocks', 174 | readContent: true, 175 | loop: true, 176 | format: (p, s: string, content: string, file: TFile) => { 177 | const prefix = s.slice(0, s.indexOf('$')) 178 | 179 | return content 180 | .split('\n') 181 | .filter(e => /\^\w+$/.test(e)) 182 | .map(e => 183 | prefix + p.app.fileManager.generateMarkdownLink(file, file.basename, '#' + e.replace(/^.+?(\^\w+$)/, '$1')) 184 | ) 185 | .join('\n') 186 | }, 187 | desc: 'block ids from the found files. Can be prepended.' 188 | }, 189 | { 190 | name: '^(.+|)\\$match:header', loop: true, format: (p, s: string, content: string, file: TFile, results) => { 191 | const prefix = s.slice(0, s.indexOf('$')) 192 | const metadata = p.app.metadataCache.getFileCache(file) 193 | 194 | const headings = metadata.headings 195 | ?.filter(h => results.result.content.filter(c => h.position.end.offset < c[0]).some(e => e)) 196 | .slice(-1) 197 | 198 | return headings 199 | .map(h => p.app.fileManager.generateMarkdownLink(file, file.basename, '#' + h.heading)) 200 | .map(link => prefix + link) 201 | .join('\n') || '' 202 | }, desc: 'extract found selections' 203 | }, 204 | { 205 | name: '^(.+|)\\$matchline(:(\\+|-|)\\d+:\\d+|:(\\+|-|)\\d+|)', 206 | loop: true, 207 | format: (_p, s: string, content: string, file: TFile, results) => { 208 | const prefix = s.slice(0, s.indexOf('$matchline')); 209 | const [keyword, context, limit] = s.slice(s.indexOf('$matchline')).split(':') 210 | const value = context || ''; 211 | const limitValue = Number(limit) 212 | const isPlus = value.contains('+'); 213 | const isMinus = value.contains('-'); 214 | const isContext = !isPlus && !isMinus; 215 | const offset = Number(value.replace(/[+-]/, '')); 216 | 217 | const lines = results.content.split('\n'); 218 | 219 | // Grab info about line content, index, text length and start/end character position 220 | const lineInfos: Array = [] 221 | for (let i = 0; i < lines.length; i++) { 222 | const text = lines[i] 223 | 224 | if (i === 0) { 225 | lineInfos.push({ 226 | num: 0, 227 | start: 0, 228 | end: text.length, 229 | text 230 | }) 231 | 232 | continue 233 | } 234 | 235 | const start = lineInfos[i-1].end + 1 236 | lineInfos.push({ 237 | num: i, 238 | start, 239 | text, 240 | end: text.length + start 241 | }) 242 | } 243 | 244 | return results.result.content.map(([from, to]) => { 245 | const matchedLines = lineInfos 246 | .filter(({ start, end }) => start <= from && end >= to) 247 | .map((line) => { 248 | return { 249 | ...line, 250 | text: highlight(line.start, line.end, from, to, line.text) 251 | } 252 | }) 253 | 254 | const resultLines: LineInfo[] = [] 255 | for (const matchedLine of matchedLines) { 256 | const prevLines = isMinus || isContext 257 | ? lineInfos.filter(l => matchedLine.num - l.num > 0 && matchedLine.num - l.num < offset) 258 | : [] 259 | const nextLines = isPlus || isContext 260 | ? lineInfos.filter(l => l.num - matchedLine.num > 0 && l.num - matchedLine.num < offset) 261 | : [] 262 | 263 | resultLines.push( ...prevLines, matchedLine, ...nextLines ) 264 | } 265 | 266 | return prefix + resultLines.map(e => e.text).join('\n') 267 | }).map(line => limitValue ? line.slice(0, limitValue) : line).join('\n') 268 | }, desc: 'extract line with matches' 269 | }, 270 | { 271 | name: '^(.+|)\\$searchresult', 272 | loop: true, 273 | desc: '', 274 | format: (_p, s: string, content: string, file: TFile, results) => { 275 | const prefix = s.slice(0, s.indexOf('$searchresult')); 276 | return results.vChildren.children.map(matchedFile => { 277 | return prefix + matchedFile.el.innerText 278 | }).join('\n') 279 | } 280 | }, 281 | { 282 | name: '^(.+|)\\$match', loop: true, format: (_p, s: string, content: string, file: TFile, results) => { 283 | 284 | if (!results.result.content) { 285 | console.warn('There is no content in results') 286 | return '' 287 | } 288 | 289 | function appendPrefix(prefix: string, line: string) { 290 | return prefix + line; 291 | } 292 | 293 | const prefixContent = s.slice(0, s.indexOf('$')) 294 | return results.result.content 295 | .map(([from, to]) => results.content.slice(from, to)) 296 | .map(line => appendPrefix(prefixContent, line)) 297 | .join('\n') 298 | }, desc: 'extract found selections' 299 | }, 300 | ] 301 | 302 | export default sequences 303 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "inlineSourceMap": true, 5 | "inlineSources": true, 6 | "module": "ESNext", 7 | "target": "es6", 8 | "esModuleInterop": true, 9 | "downlevelIteration": true, 10 | "allowJs": true, 11 | "noImplicitAny": true, 12 | "moduleResolution": "node", 13 | "importHelpers": true, 14 | "lib": [ 15 | "dom", 16 | "es6", 17 | "scripthost", 18 | "es2015" 19 | ] 20 | }, 21 | "include": [ 22 | "**/*.ts" 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "0.6.0": "0.9.18", 3 | "0.5.2": "0.9.7" 4 | } --------------------------------------------------------------------------------