├── .github └── workflows │ └── release.yml ├── .gitignore ├── README.md ├── assets └── example.gif ├── main.ts ├── manifest.json ├── package.json ├── rollup.config.js └── tsconfig.json /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release Obsidian Plugin 2 | on: 3 | push: 4 | tags: 5 | - '*' 6 | jobs: 7 | build: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v2 11 | with: 12 | fetch-depth: 0 # otherwise, you will failed to push refs to dest repo 13 | - name: Use Node.js 14 | uses: actions/setup-node@v1 15 | with: 16 | node-version: '14.x' 17 | - name: Get Version 18 | id: version 19 | run: | 20 | echo "::set-output name=tag::$(git describe --abbrev=0)" 21 | # Build the plugin 22 | - name: Build 23 | id: build 24 | run: | 25 | npm install 26 | npm run build --if-present 27 | # Package the required files into a zip 28 | - name: Package 29 | run: | 30 | mkdir ${{ github.event.repository.name }} 31 | cp main.js manifest.json README.md ${{ github.event.repository.name }} 32 | zip -r ${{ github.event.repository.name }}.zip ${{ github.event.repository.name }} 33 | # Create the release on github 34 | - name: Create Release 35 | id: create_release 36 | uses: actions/create-release@v1 37 | env: 38 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 39 | VERSION: ${{ github.ref }} 40 | with: 41 | tag_name: ${{ github.ref }} 42 | release_name: ${{ github.ref }} 43 | draft: false 44 | prerelease: false 45 | # Upload the packaged release file 46 | - name: Upload zip file 47 | id: upload-zip 48 | uses: actions/upload-release-asset@v1 49 | env: 50 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 51 | with: 52 | upload_url: ${{ steps.create_release.outputs.upload_url }} 53 | asset_path: ./${{ github.event.repository.name }}.zip 54 | asset_name: ${{ github.event.repository.name }}-${{ steps.version.outputs.tag }}.zip 55 | asset_content_type: application/zip 56 | # Upload the main.js 57 | - name: Upload main.js 58 | id: upload-main 59 | uses: actions/upload-release-asset@v1 60 | env: 61 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 62 | with: 63 | upload_url: ${{ steps.create_release.outputs.upload_url }} 64 | asset_path: ./main.js 65 | asset_name: main.js 66 | asset_content_type: text/javascript 67 | # Upload the manifest.json 68 | - name: Upload manifest.json 69 | id: upload-manifest 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: ./manifest.json 76 | asset_name: manifest.json 77 | asset_content_type: application/json -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Intellij 2 | *.iml 3 | .idea 4 | 5 | # npm 6 | node_modules 7 | package-lock.json 8 | 9 | # build 10 | main.js 11 | *.js.map -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Sort and Permute lines 2 | 3 | ## Features 4 | - Sort alphabetically 5 | - Sort by length of line 6 | - Reverse line order 7 | - Shuffle line order 8 | - Sort headings while preserving parents 9 | 10 | ## How to use 11 | You can either just call a command (via `Ctrl + P` (or `Cmd + P` on macOS)) and the whole file will be changed, or select text and then call a command to just change that range. 12 | 13 | ![Demo](https://raw.githubusercontent.com/Vinzent03/obsidian-sort-and-permute-lines/master/assets/example.gif) 14 | 15 | ## Compatibility 16 | Custom plugins are only available for Obsidian v0.9.7+. 17 | 18 | ## Installing 19 | 20 | ### From Obsidian 21 | 1. Open settings -> Third party plugin 22 | 2. Disable Safe mode 23 | 3. Click Browse community plugins 24 | 4. Search for "Sort & Permute lines" 25 | 5. Install it 26 | 6. Activate it under Installed plugins 27 | 28 | 29 | ### From GitHub 30 | 1. Download the [latest release](https://github.com/Vinzent03/obsidian-sort-and-permute-lines/releases/latest) 31 | 2. Move `manifest.json` and `main.js` to `/.obsidian/plugins/obsidian-sort-and-permute-lines` 32 | 3. Go to settings and disable safe mode 33 | 4. Enable `Sort & Permute lines` 34 | 35 | If you find this plugin useful and would like to support its development, you can support me on [Ko-fi](https://Ko-fi.com/Vinzent). 36 | 37 | [![Ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/F1F195IQ5) 38 | -------------------------------------------------------------------------------- /assets/example.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Vinzent03/obsidian-sort-and-permute-lines/82b3062a8b8b0fac5439aeae87e533595919a81f/assets/example.gif -------------------------------------------------------------------------------- /main.ts: -------------------------------------------------------------------------------- 1 | import { ListItemCache, MarkdownView, Plugin } from 'obsidian'; 2 | 3 | interface sortMethod { 4 | (x: string, y: string): number; 5 | } 6 | 7 | interface MyLine { 8 | source: string; 9 | formatted: string; 10 | headingLevel: number | undefined; 11 | lineNumber: number; 12 | } 13 | 14 | interface HeadingPart { 15 | to: number; 16 | title: MyLine; 17 | lines: MyLine[]; 18 | headings: HeadingPart[]; 19 | } 20 | 21 | interface ListPart { 22 | children: ListPart[]; 23 | title: MyLine; 24 | lastLine: number; 25 | } 26 | const checkboxRegex = /^(\s*)- \[[^ ]\]/gi; 27 | 28 | export default class MyPlugin extends Plugin { 29 | compare: sortMethod; 30 | async onload() { 31 | console.log('loading ' + this.manifest.name); 32 | 33 | const { compare } = new Intl.Collator(navigator.language, { 34 | usage: 'sort', 35 | sensitivity: 'base', 36 | numeric: true, 37 | ignorePunctuation: true, 38 | }); 39 | this.compare = compare; 40 | this.addCommand({ 41 | id: 'sort-alphabetically-with-checkboxes', 42 | name: 'Sort alphabetically with checkboxes', 43 | callback: (() => this.sortAlphabetically(false, false)), 44 | }); 45 | this.addCommand({ 46 | id: 'sort-list-alphabetically-with-checkboxes', 47 | name: 'Sort current list alphabetically with checkboxes', 48 | callback: (() => this.sortAlphabetically(true, false)), 49 | }); 50 | this.addCommand({ 51 | id: 'sort-alphabetically', 52 | name: 'Sort alphabetically', 53 | callback: (() => this.sortAlphabetically(false, true)), 54 | }); 55 | this.addCommand({ 56 | id: 'sort-list-alphabetically', 57 | name: 'Sort current list alphabetically', 58 | callback: (() => this.sortAlphabetically(true, true)), 59 | }); 60 | this.addCommand({ 61 | id: 'sort-checkboxes', 62 | name: 'Sort current list by checkboxes', 63 | callback: () => this.sortListRecursively(true, (a: ListPart, b: ListPart) => { 64 | if (checkboxRegex.test(a.title.source) && !checkboxRegex.test(b.title.source)) return 1; 65 | if (!checkboxRegex.test(a.title.source) && checkboxRegex.test(b.title.source)) return -1; 66 | return 0; 67 | }), 68 | }); 69 | this.addCommand({ 70 | id: 'sort-length', 71 | name: 'Sort by length of line', 72 | callback: (() => this.sortLengthOfLine()), 73 | }); 74 | this.addCommand({ 75 | id: 'sort-headings', 76 | name: 'Sort headings', 77 | callback: (() => this.sortHeadings()), 78 | }); 79 | this.addCommand({ 80 | id: 'permute-reverse', 81 | name: 'Reverse lines', 82 | callback: (() => this.permuteReverse()), 83 | }); 84 | this.addCommand({ 85 | id: 'permute-shuffle', 86 | name: 'Shuffle lines', 87 | callback: (() => this.permuteShuffle()), 88 | }); 89 | 90 | const comp = (a: ListPart, b: ListPart) => this.compare(a.title.formatted.trim(), b.title.formatted.trim()); 91 | this.addCommand({ 92 | id: 'sort-list-recursively', 93 | name: 'Sort current list recursively', 94 | callback: (() => this.sortListRecursively(true, comp)), 95 | }); 96 | this.addCommand({ 97 | id: 'sort-list-recursively-with-checkboxes', 98 | name: 'Sort current list recursively with checkboxes', 99 | callback: (() => this.sortListRecursively(false, comp)), 100 | }); 101 | 102 | } 103 | 104 | onunload() { 105 | console.log('unloading ' + this.manifest.name); 106 | } 107 | 108 | 109 | sortAlphabetically(fromCurrentList = false, ignoreCheckboxes = true) { 110 | const lines = this.getLines(fromCurrentList, ignoreCheckboxes); 111 | if (lines.length === 0) return; 112 | let sortFunc = (a: MyLine, b: MyLine) => this.compare(a.formatted.trim(), b.formatted.trim()); 113 | 114 | lines.sort(sortFunc); 115 | this.setLines(lines, fromCurrentList); 116 | } 117 | 118 | sortListRecursively(ignoreCheckboxes: boolean, compareFn: (a: ListPart, b: ListPart) => number) { 119 | const inputLines = this.getLines(true, ignoreCheckboxes); 120 | 121 | if (inputLines.length === 0 || inputLines.find(line => line.source.trim() == "")) return; 122 | const firstLineNumber = inputLines.first().lineNumber; 123 | const lines = [...new Array(firstLineNumber).fill(undefined), ...inputLines]; 124 | let index = firstLineNumber; 125 | 126 | const cache = this.app.metadataCache.getFileCache(this.app.workspace.getActiveFile()); 127 | const children: ListPart[] = []; 128 | 129 | while (index < lines.length) { 130 | const newChild = this.getSortedListParts(lines, cache.listItems, index, compareFn); 131 | children.push(newChild); 132 | index = newChild.lastLine; 133 | 134 | index++; 135 | } 136 | children.sort(compareFn); 137 | 138 | const res = children.reduce((acc, cur) => acc.concat(this.listPartToList(cur)), []); 139 | this.setLines(res, true); 140 | } 141 | 142 | getLineCacheFromLine(line: number, linesCache: ListItemCache[]): ListItemCache | undefined { 143 | return linesCache.find(cacheItem => cacheItem.position.start.line === line); 144 | } 145 | 146 | getSortedListParts(lines: MyLine[], linesCache: ListItemCache[], index: number, compareFn: (a: ListPart, b: ListPart) => number): ListPart { 147 | const children: ListPart[] = []; 148 | const startListCache = this.getLineCacheFromLine(index, linesCache); 149 | 150 | const title = lines[index]; 151 | 152 | while (startListCache.parent < this.getLineCacheFromLine(index + 1, linesCache)?.parent || (startListCache.parent < 0 && this.getLineCacheFromLine(index + 1, linesCache)?.parent >= 0)) { 153 | index++; 154 | 155 | const newChild = this.getSortedListParts(lines, linesCache, index, compareFn); 156 | 157 | index = newChild.lastLine ?? index; 158 | children.push(newChild); 159 | }; 160 | const lastLine = children.last()?.lastLine ?? index; 161 | 162 | children.sort(compareFn); 163 | return { 164 | children: children, 165 | title: title, 166 | lastLine: lastLine, 167 | }; 168 | } 169 | 170 | listPartToList(list: ListPart): MyLine[] { 171 | return list.children.reduce((acc, cur) => acc.concat(this.listPartToList(cur)), [list.title]); 172 | } 173 | 174 | sortHeadings() { 175 | const lines = this.getLines(); 176 | const res = this.getSortedHeadings(lines, 0, { headingLevel: 0, formatted: "", source: "", lineNumber: -1 }); 177 | this.setLines(this.headingsToString(res).slice(1)); 178 | } 179 | 180 | headingsToString(heading: HeadingPart): MyLine[] { 181 | const list = [ 182 | heading.title, 183 | ...heading.lines 184 | ]; 185 | heading.headings.forEach((e) => list.push(...this.headingsToString(e))); 186 | return list; 187 | } 188 | 189 | getSortedHeadings(lines: MyLine[], from: number, heading: MyLine): HeadingPart { 190 | let headings: HeadingPart[] = []; 191 | let contentLines: MyLine[] = []; 192 | let currentIndex = from; 193 | while (currentIndex < lines.length) { 194 | const current = lines[currentIndex]; 195 | if (current.headingLevel <= heading.headingLevel) { 196 | break; 197 | } 198 | 199 | if (current.headingLevel) { 200 | 201 | 202 | headings.push(this.getSortedHeadings(lines, currentIndex + 1, current)); 203 | currentIndex = headings.last().to; 204 | 205 | 206 | } else { 207 | contentLines.push(current); 208 | } 209 | 210 | currentIndex++; 211 | } 212 | 213 | return { 214 | lines: contentLines, 215 | to: headings.length > 0 ? headings.last().to : (currentIndex - 1), 216 | headings: headings.sort((a, b) => { 217 | //First sort by heading level then alphabetically 218 | const res = a.title.headingLevel - b.title.headingLevel; 219 | if (res == 0) { 220 | return this.compare(a.title.formatted.trim(), b.title.formatted.trim()); 221 | } else { 222 | return res; 223 | } 224 | }), 225 | title: heading, 226 | }; 227 | } 228 | 229 | 230 | sortLengthOfLine() { 231 | const lines = this.getLines(); 232 | if (lines.length === 0) return; 233 | lines.sort((a, b) => a.formatted.length - b.formatted.length); 234 | 235 | this.setLines(lines); 236 | } 237 | 238 | permuteReverse() { 239 | const lines = this.getLines(); 240 | if (lines.length === 0) return; 241 | lines.reverse(); 242 | this.setLines(lines); 243 | } 244 | 245 | permuteShuffle() { 246 | const lines = this.getLines(); 247 | if (lines.length === 0) return; 248 | lines.shuffle(); 249 | this.setLines(lines); 250 | } 251 | 252 | getLines(fromCurrentList = false, ignoreCheckboxes = true): MyLine[] { 253 | const view = this.app.workspace.getActiveViewOfType(MarkdownView); 254 | if (!view) 255 | return; 256 | const editor = view.editor; 257 | const file = view.file; 258 | let lines = editor.getValue().split("\n"); 259 | const cache = this.app.metadataCache.getFileCache(file); 260 | const { start, end } = this.getPosition(view, fromCurrentList); 261 | 262 | const headings = cache.headings; 263 | const links = [...cache?.links ?? [], ...cache?.embeds ?? []]; 264 | const myLines = lines.map((line, index) => { 265 | const myLine: MyLine = { source: line, formatted: line, headingLevel: undefined, lineNumber: index }; 266 | links.forEach(e => { 267 | if (e.position.start.line != index) return; 268 | const start = e.position.start; 269 | const end = e.position.end; 270 | myLine.formatted = myLine.formatted.replace(line.substring(start.col, end.col), e.displayText); 271 | }); 272 | 273 | // Regex of cehckbox styles 274 | if (ignoreCheckboxes) { 275 | myLine.formatted = myLine.formatted.replace(checkboxRegex, "$1"); 276 | } else { 277 | // Just a little bit dirty... 278 | myLine.formatted = myLine.formatted.replace(checkboxRegex, "$1ZZZZZZZZZZZZZZZZZZZZZZZZZ"); 279 | } 280 | 281 | return myLine; 282 | }); 283 | 284 | headings?.map((heading) => myLines[heading.position.start.line].headingLevel = heading.level); 285 | 286 | if (start != end) { 287 | return myLines.slice(start, end + 1); 288 | } else { 289 | return myLines; 290 | } 291 | } 292 | 293 | setLines(lines: MyLine[], fromCurrentList: boolean = false) { 294 | const view = this.app.workspace.getActiveViewOfType(MarkdownView); 295 | const res = this.getPosition(view, fromCurrentList); 296 | 297 | const editor = view.editor; 298 | if (res.start != res.end) { 299 | editor.replaceRange(lines.map(e => e.source).join("\n"), { line: res.start, ch: 0 }, { line: res.end, ch: res.endLineLength }); 300 | } else { 301 | editor.setValue(lines.map(e => e.source).join("\n")); 302 | } 303 | } 304 | 305 | getPosition(view: MarkdownView, fromCurrentList: boolean = false): { start: number; end: number; endLineLength: number; } | undefined { 306 | const cache = this.app.metadataCache.getFileCache(view.file); 307 | const editor = view.editor; 308 | 309 | let cursorStart = editor.getCursor("from").line; 310 | let cursorEnd = editor.getCursor("to").line; 311 | if (fromCurrentList) { 312 | const list = cache.sections.find((e) => { 313 | return e.position.start.line <= cursorStart && e.position.end.line >= cursorEnd; 314 | }); 315 | if (list) { 316 | cursorStart = list.position.start.line; 317 | cursorEnd = list.position.end.line; 318 | } 319 | 320 | } 321 | const curserEndLineLength = editor.getLine(cursorEnd).length; 322 | let frontStart = cache.frontmatter?.position?.end?.line + 1; 323 | if (isNaN(frontStart)) { 324 | frontStart = 0; 325 | } 326 | 327 | const frontEnd = editor.lastLine(); 328 | const frontEndLineLength = editor.getLine(frontEnd).length; 329 | 330 | if (cursorStart != cursorEnd) { 331 | return { 332 | start: cursorStart, 333 | end: cursorEnd, 334 | endLineLength: curserEndLineLength, 335 | }; 336 | } else { 337 | return { 338 | start: frontStart, 339 | end: frontEnd, 340 | endLineLength: frontEndLineLength, 341 | }; 342 | } 343 | } 344 | } -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "obsidian-sort-and-permute-lines", 3 | "name": "Sort & Permute lines", 4 | "version": "0.7.0", 5 | "description": "", 6 | "author": "Vinzent", 7 | "authorUrl": "https://github.com/Vinzent03", 8 | "fundingUrl": "https://ko-fi.com/vinzent", 9 | "isDesktopOnly": false 10 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "my-plugin", 3 | "version": "0.9.7", 4 | "description": "This is a sample plugin for Obsidian (https://obsidian.md)", 5 | "main": "main.js", 6 | "scripts": { 7 | "dev": "rollup --config rollup.config.js -w", 8 | "build": "rollup --config rollup.config.js" 9 | }, 10 | "keywords": [], 11 | "author": "", 12 | "license": "MIT", 13 | "devDependencies": { 14 | "@rollup/plugin-commonjs": "^15.1.0", 15 | "@rollup/plugin-node-resolve": "^9.0.0", 16 | "@rollup/plugin-typescript": "^6.0.0", 17 | "@types/node": "^14.14.2", 18 | "obsidian": "^0.16.3", 19 | "rollup": "^2.32.1", 20 | "tslib": "^2.0.3", 21 | "typescript": "4.0.3" 22 | }, 23 | "dependencies": {} 24 | } -------------------------------------------------------------------------------- /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: 'main.ts', 7 | output: { 8 | dir: '.', 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 | }; -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "inlineSourceMap": true, 5 | "inlineSources": true, 6 | "module": "ESNext", 7 | "target": "es5", 8 | "allowJs": true, 9 | "noImplicitAny": true, 10 | "moduleResolution": "node", 11 | "importHelpers": true, 12 | "lib": [ 13 | "dom", 14 | "es5", 15 | "scripthost", 16 | "es2015" 17 | ] 18 | }, 19 | "include": [ 20 | "**/*.ts" 21 | ] 22 | } 23 | --------------------------------------------------------------------------------