├── image.png ├── versions.json ├── manifest.json ├── tsconfig.json ├── .gitignore ├── LICENSE ├── version-bump.mjs ├── package.json ├── .github └── workflows │ └── release.yml ├── copy-styles.mjs ├── esbuild.config.mjs ├── openspec ├── changes │ └── archive │ │ └── 2025-11-26-add-single-spacing-transformer │ │ ├── proposal.md │ │ ├── tasks.md │ │ └── specs │ │ └── markdown-transformations │ │ └── spec.md ├── specs │ └── markdown-transformations │ │ └── spec.md ├── project.md └── AGENTS.md ├── styles.css ├── AGENTS.md ├── cascade-rules.md ├── test.html ├── src ├── htmlTransformer.ts ├── markdownTransformer.ts └── main.ts └── README.md /image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/keathmilligan/obsidian-paste-reformatter/HEAD/image.png -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "1.0.0": "0.15.0", 3 | "1.1.0": "0.15.0", 4 | "1.1.1": "0.15.0", 5 | "1.2.0": "0.15.0", 6 | "1.2.1": "0.15.0", 7 | "1.2.2": "0.15.0", 8 | "1.3.0": "0.15.0" 9 | } -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "paste-reformatter", 3 | "name": "Paste Reformatter", 4 | "version": "1.3.0", 5 | "minAppVersion": "0.15.0", 6 | "description": "Reformat pasted text for precise control.", 7 | "author": "Keath Milligan", 8 | "authorUrl": "https://github.com/keathmilligan", 9 | "isDesktopOnly": false 10 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "inlineSourceMap": true, 5 | "inlineSources": true, 6 | "module": "ESNext", 7 | "target": "ES6", 8 | "allowJs": true, 9 | "noImplicitAny": true, 10 | "moduleResolution": "node", 11 | "importHelpers": true, 12 | "isolatedModules": true, 13 | "strictNullChecks": true, 14 | "lib": [ 15 | "DOM", 16 | "ES5", 17 | "ES6", 18 | "ES7" 19 | ] 20 | }, 21 | "include": [ 22 | "**/*.ts" 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Build output 2 | main.js 3 | *.js.map 4 | data.json 5 | 6 | # Development environment 7 | node_modules/ 8 | .DS_Store 9 | .vscode/ 10 | .idea/ 11 | .history/ 12 | 13 | # Temporary files 14 | *.log 15 | *.tmp 16 | *.temp 17 | .env 18 | 19 | # Distribution files 20 | dist/ 21 | dist 22 | build/ 23 | 24 | # Dependency lock files (keep package.json) 25 | package-lock.json 26 | yarn.lock 27 | pnpm-lock.yaml 28 | 29 | # Testing 30 | coverage/ 31 | .nyc_output/ 32 | 33 | # Obsidian 34 | .obsidian/ 35 | .trash/ 36 | 37 | # Gen AI tools 38 | .roo/ 39 | .opencode/ 40 | .claude/ 41 | 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (C) 2025 by Keath Milligan. 2 | 3 | Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted. 4 | 5 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "obsidian-sample-plugin", 3 | "version": "1.3.0", 4 | "description": "This is a sample plugin for Obsidian (https://obsidian.md)", 5 | "main": "dist/main.js", 6 | "scripts": { 7 | "dev": "node esbuild.config.mjs && node copy-styles.mjs", 8 | "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production && node copy-styles.mjs", 9 | "version": "node version-bump.mjs && git add manifest.json versions.json" 10 | }, 11 | "keywords": [], 12 | "author": "", 13 | "license": "MIT", 14 | "devDependencies": { 15 | "@types/node": "^16.11.6", 16 | "@typescript-eslint/eslint-plugin": "5.29.0", 17 | "@typescript-eslint/parser": "5.29.0", 18 | "builtin-modules": "3.3.0", 19 | "esbuild": "0.17.3", 20 | "obsidian": "latest", 21 | "tslib": "2.4.0", 22 | "typescript": "4.7.4" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /.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 build 25 | 26 | - name: Create release 27 | env: 28 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 29 | run: | 30 | tag="${GITHUB_REF#refs/tags/}" 31 | 32 | gh release create "$tag" \ 33 | --title="$tag" \ 34 | --draft \ 35 | dist/main.js manifest.json styles.css 36 | -------------------------------------------------------------------------------- /copy-styles.mjs: -------------------------------------------------------------------------------- 1 | // copy-styles.mjs 2 | import { promises as fs } from 'fs'; 3 | import path from 'path'; 4 | 5 | // Ensure the dist directory exists 6 | async function ensureDir(dir) { 7 | try { 8 | await fs.mkdir(dir, { recursive: true }); 9 | } catch (error) { 10 | if (error.code !== 'EEXIST') { 11 | throw error; 12 | } 13 | } 14 | } 15 | 16 | async function copyFiles() { 17 | const distDir = path.resolve('./dist'); 18 | await ensureDir(distDir); 19 | 20 | try { 21 | // Copy styles.css to dist directory 22 | await fs.copyFile('./styles.css', path.join(distDir, 'styles.css')); 23 | console.log('Successfully copied styles.css to dist directory'); 24 | 25 | // Copy manifest.json to dist directory 26 | await fs.copyFile('./manifest.json', path.join(distDir, 'manifest.json')); 27 | console.log('Successfully copied manifest.json to dist directory'); 28 | } catch (error) { 29 | console.error('Error copying files:', error); 30 | process.exit(1); 31 | } 32 | } 33 | 34 | copyFiles(); 35 | -------------------------------------------------------------------------------- /esbuild.config.mjs: -------------------------------------------------------------------------------- 1 | import esbuild from "esbuild"; 2 | import process from "process"; 3 | import builtins from "builtin-modules"; 4 | 5 | const banner = 6 | `/* 7 | THIS IS A GENERATED/BUNDLED FILE BY ESBUILD 8 | if you want to view the source, please visit the github repository of this plugin 9 | */ 10 | `; 11 | 12 | const prod = (process.argv[2] === "production"); 13 | 14 | const context = await esbuild.context({ 15 | banner: { 16 | js: banner, 17 | }, 18 | entryPoints: ["src/main.ts"], 19 | bundle: true, 20 | external: [ 21 | "obsidian", 22 | "electron", 23 | "@codemirror/autocomplete", 24 | "@codemirror/collab", 25 | "@codemirror/commands", 26 | "@codemirror/language", 27 | "@codemirror/lint", 28 | "@codemirror/search", 29 | "@codemirror/state", 30 | "@codemirror/view", 31 | "@lezer/common", 32 | "@lezer/highlight", 33 | "@lezer/lr", 34 | ...builtins], 35 | format: "cjs", 36 | target: "es2018", 37 | logLevel: "info", 38 | sourcemap: prod ? false : "inline", 39 | treeShaking: true, 40 | outfile: "dist/main.js", 41 | minify: prod, 42 | }); 43 | 44 | if (prod) { 45 | await context.rebuild(); 46 | process.exit(0); 47 | } else { 48 | await context.watch(); 49 | } 50 | -------------------------------------------------------------------------------- /openspec/changes/archive/2025-11-26-add-single-spacing-transformer/proposal.md: -------------------------------------------------------------------------------- 1 | # Change: Add single-spacing transformer for markdown 2 | 3 | ## Why 4 | 5 | Users may want to collapse multiple consecutive blank lines in pasted content into single blank lines without removing all blank lines entirely. This provides a middle-ground option between preserving all blank lines as-is and removing them completely. 6 | 7 | ## What Changes 8 | 9 | - Add a new markdown transformer that collapses multiple consecutive blank lines (2+) into a single blank line 10 | - Add a new setting "Convert to single-spaced" that appears above "Remove empty lines" in the Markdown transformations section 11 | - The setting defaults to Off (false) to preserve existing behavior 12 | - When "Remove empty lines" is enabled, the "Convert to single-spaced" option is disabled (not applicable) since all blank lines are removed anyway 13 | - The transformer is applied in the markdown transformation pipeline before empty line removal 14 | 15 | ## Impact 16 | 17 | - Affected specs: `markdown-transformations` 18 | - Affected code: 19 | - `src/markdownTransformer.ts` - Add single-spacing logic 20 | - `src/main.ts` - Add `convertToSingleSpaced` setting and UI toggle with conditional disabling 21 | - No breaking changes 22 | - Backward compatible: defaults to Off, existing behavior unchanged 23 | -------------------------------------------------------------------------------- /openspec/changes/archive/2025-11-26-add-single-spacing-transformer/tasks.md: -------------------------------------------------------------------------------- 1 | # Implementation Tasks 2 | 3 | ## 1. Update settings interface and defaults 4 | - [x] 1.1 Add `convertToSingleSpaced: boolean` property to `PasteReformmatterSettings` interface in `src/main.ts` 5 | - [x] 1.2 Add `convertToSingleSpaced: false` to `DEFAULT_SETTINGS` in `src/main.ts` 6 | 7 | ## 2. Implement single-spacing transformer 8 | - [x] 2.1 Add `convertToSingleSpaced` parameter to `transformMarkdown` function signature in `src/markdownTransformer.ts` 9 | - [x] 2.2 Implement single-spacing logic that collapses 2+ consecutive blank lines into 1 blank line 10 | - [x] 2.3 Apply transformer before the `removeEmptyLines` logic in the transformation pipeline 11 | - [x] 2.4 Skip single-spacing transformer if `removeEmptyLines` is enabled (optimization - no point processing if lines will be removed) 12 | - [x] 2.5 Set `appliedTransformations` flag when single-spacing makes changes 13 | 14 | ## 3. Add settings UI 15 | - [x] 3.1 Add "Convert to single-spaced" toggle in settings UI, positioned above "Remove empty lines" setting 16 | - [x] 3.2 Set description text: "Collapse multiple consecutive blank lines into a single blank line" 17 | - [x] 3.3 Implement conditional disabling: when `removeEmptyLines` is true, disable the toggle and show it as disabled/grayed 18 | - [x] 3.4 Update onChange handler to save settings 19 | - [x] 3.5 When `removeEmptyLines` changes, refresh display to update disabled state of "Convert to single-spaced" toggle 20 | 21 | ## 4. Integration 22 | - [x] 4.1 Pass `convertToSingleSpaced` setting to `transformMarkdown` call in `src/main.ts:doPaste()` 23 | - [x] 4.2 Verify backward compatibility with existing settings (defaults to false) 24 | 25 | ## 5. Testing 26 | - [x] 5.1 Build project: `npm run build` 27 | - [x] 5.2 Test single-spacing with text containing 2+ consecutive blank lines 28 | - [x] 5.3 Test that single-spacing is skipped when "Remove empty lines" is enabled 29 | - [x] 5.4 Test UI toggle behavior and disabled state interaction 30 | - [x] 5.5 Test that existing behavior is preserved when feature is disabled (default) 31 | -------------------------------------------------------------------------------- /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 | 10 | /* Regex Replacement Container Styles */ 11 | .regex-replacements-container { 12 | margin-left: 0px; 13 | margin-bottom: 20px; 14 | } 15 | 16 | /* Regex Replacement Table Styles */ 17 | .regex-table { 18 | width: 100%; 19 | border-collapse: collapse; 20 | } 21 | 22 | /* Table Header Styles */ 23 | .regex-th { 24 | text-align: left; 25 | padding: 5px; 26 | } 27 | 28 | .regex-th-pattern, .regex-th-replacement { 29 | width: 48%; 30 | } 31 | 32 | .regex-th-actions { 33 | width: 4%; 34 | } 35 | 36 | /* Table Cell Styles */ 37 | .regex-td { 38 | padding: 5px; 39 | } 40 | 41 | .regex-td-actions { 42 | text-align: center; 43 | } 44 | 45 | /* Input Field Styles */ 46 | .regex-input { 47 | width: 100%; 48 | } 49 | 50 | /* Empty Message Styles */ 51 | .regex-empty-message { 52 | text-align: center; 53 | padding: 10px; 54 | font-style: italic; 55 | color: var(--text-muted); 56 | opacity: 0.7; 57 | } 58 | 59 | /* Remove Icon Styles */ 60 | .regex-remove-icon { 61 | cursor: pointer; 62 | padding: 4px; 63 | border-radius: 3px; 64 | color: #dc3545; 65 | display: inline-flex; 66 | align-items: center; 67 | justify-content: center; 68 | transition: background-color 0.2s ease; 69 | } 70 | 71 | .regex-remove-icon:hover { 72 | background-color: rgba(220, 53, 69, 0.1); 73 | } 74 | 75 | .regex-remove-icon:active { 76 | background-color: rgba(220, 53, 69, 0.2); 77 | } 78 | 79 | .regex-remove-icon:focus { 80 | outline: 2px solid rgba(220, 53, 69, 0.5); 81 | outline-offset: 1px; 82 | } 83 | 84 | /* Add Icon Styles */ 85 | .regex-add-icon { 86 | cursor: pointer; 87 | padding: 8px; 88 | border-radius: 50%; 89 | /* color: var(--interactive-accent); */ 90 | display: flex; 91 | align-items: center; 92 | justify-content: flex-end; 93 | /* transition: background-color 0.2s ease; */ 94 | margin-top: 8px; 95 | margin-bottom: 8px; 96 | width: 100%; 97 | } 98 | 99 | .regex-add-icon svg { 100 | width: 20px; 101 | height: 20px; 102 | } 103 | 104 | .regex-add-icon:hover { 105 | /* background-color: var(--interactive-accent-hover); */ 106 | color: var(--text-on-accent); 107 | } 108 | 109 | .regex-add-icon:active { 110 | /* background-color: var(--interactive-accent); */ 111 | color: var(--text-on-accent); 112 | } 113 | 114 | /* .regex-add-icon:focus { 115 | outline: 2px solid var(--interactive-accent); 116 | outline-offset: 2px; 117 | } */ 118 | 119 | /* Disabled Setting Styles */ 120 | .paste-reformatter-disabled-setting { 121 | opacity: 0.5; 122 | pointer-events: none; 123 | } 124 | 125 | .paste-reformatter-disabled-setting .setting-item-name, 126 | .paste-reformatter-disabled-setting .setting-item-description { 127 | color: var(--text-muted); 128 | } 129 | -------------------------------------------------------------------------------- /AGENTS.md: -------------------------------------------------------------------------------- 1 | 2 | # OpenSpec Instructions 3 | 4 | These instructions are for AI assistants working in this project. 5 | 6 | Always open `@/openspec/AGENTS.md` when the request: 7 | - Mentions planning or proposals (words like proposal, spec, change, plan) 8 | - Introduces new capabilities, breaking changes, architecture shifts, or big performance/security work 9 | - Sounds ambiguous and you need the authoritative spec before coding 10 | 11 | Use `@/openspec/AGENTS.md` to learn: 12 | - How to create and apply change proposals 13 | - Spec format and conventions 14 | - Project structure and guidelines 15 | 16 | Keep this managed block so 'openspec update' can refresh the instructions. 17 | 18 | 19 | 20 | # Obsidian Plugin Development Guidelines 21 | 22 | This is an Obsidian plugin project. Follow these guidelines when working with the codebase. 23 | 24 | ## Reference Documentation 25 | 26 | - Official guide: https://docs.obsidian.md/Plugins/Getting+started/Build+a+plugin 27 | - API reference: https://docs.obsidian.md/Reference/TypeScript+API/Plugin 28 | - TypeScript API: Use `obsidian` module types 29 | 30 | ## Project Structure 31 | 32 | - `main.ts` - Main plugin entry point (extends `Plugin` class) 33 | - `manifest.json` - Plugin metadata (id, name, version, minAppVersion) 34 | - `styles.css` - Plugin styles (optional) 35 | - `src/` - Additional source files and modules 36 | 37 | ## Best Practices 38 | 39 | ### Security & DOM Manipulation 40 | - **Never** use `innerHTML` or `outerHTML` (security risk) 41 | - Use DOM APIs (`createElement`, `appendChild`, etc.) or Obsidian helper functions 42 | - Sanitize user input before rendering 43 | - Prefer `createEl()` and `createDiv()` methods from Obsidian API 44 | 45 | ### Styling 46 | - Avoid assigning styles via JavaScript or inline HTML 47 | - Move all styles to CSS files for better theme compatibility 48 | - Use CSS variables for colors and spacing when possible 49 | - Follow Obsidian's design language 50 | - Use `app.workspace.containerEl.win` to access the window object for styles 51 | 52 | ### Settings 53 | - Use the `Setting` API for all settings components 54 | - Include proper headings and dividers using the Settings API 55 | - Use sentence case for all UI text and headings 56 | - Organize settings logically into sections 57 | - Store settings in a dedicated settings object 58 | - Call `saveData()` after settings changes 59 | 60 | ### Code Organization 61 | - Keep main plugin file lean, delegate to separate modules 62 | - Use TypeScript for better type safety 63 | - Follow async/await patterns for asynchronous operations 64 | - Clean up resources in `onunload()` method 65 | - Use `addCommand()` for command palette integration 66 | - Use `registerEvent()` for event listeners to ensure cleanup 67 | 68 | ### Plugin Lifecycle 69 | - `onload()` - Initialize plugin, register events, commands, settings 70 | - `onunload()` - Clean up resources, remove event listeners 71 | - Use `this.register()` to register cleanup callbacks 72 | - Use `this.registerEvent()` for automatic event cleanup 73 | 74 | ### Common Patterns 75 | - Access app: `this.app` 76 | - Access vault: `this.app.vault` 77 | - Access workspace: `this.app.workspace` 78 | - Read files: `this.app.vault.read(file)` 79 | - Modify files: `this.app.vault.modify(file, content)` 80 | - Create notices: `new Notice("message")` 81 | 82 | ### Development Workflow 83 | - Use `npm run dev` to watch and rebuild on changes 84 | - Hot reload: Copy built `main.js` to vault's `.obsidian/plugins/` folder 85 | - Test in actual Obsidian vault for best results 86 | - Check console for errors: View > Toggle Developer Tools 87 | 88 | ### Performance 89 | - Avoid blocking the main thread 90 | - Use debouncing for frequent operations 91 | - Be mindful of large vault operations 92 | - Cache expensive computations when appropriate -------------------------------------------------------------------------------- /cascade-rules.md: -------------------------------------------------------------------------------- 1 | # Heading Cascade Rules 2 | 3 | ## Scanario: Cascade Heading Levels is Disabled 4 | 5 | When Cascade Heading Levels is not enabled, then the Max Heading Level setting simply caps the maximum heading value that is allowed. For instance, if it is set to H3, then H1 and H2 would simply be changed to H3. Otherwise, heading values are not affected. 6 | 7 | ### Example 8 | 9 | If Max heading Level is set to H3, then the following text when pasted: 10 | 11 | ``` 12 | # Heading 1 13 | ## Heading 2 14 | ### Heading 3 15 | #### Heading 4 16 | ##### Heading 5 17 | ###### Heading 6 18 | 19 | # Heading 1 20 | ## Heading 2 21 | ### Heading 3 22 | #### Heading 4 23 | ##### Heading 5 24 | ###### Heading 6 25 | ``` 26 | 27 | Will be transformed into: 28 | 29 | ``` 30 | ### Heading 1 31 | ### Heading 2 32 | ### Heading 3 33 | #### Heading 4 34 | ##### Heading 5 35 | ###### Heading 6 36 | ​ 37 | ### Heading 1 38 | ### Heading 2 39 | ### Heading 3 40 | #### Heading 4 41 | ##### Heading 5 42 | ###### Heading 6 43 | ``` 44 | 45 | ## Scenario: Cascade Heading Levels is Enabled 46 | 47 | When Cascade Heading Levels is enabled, the heading values that are greater (in this case "greater" means H1 is greater than H2, H2 is greater than H3, etc.) than the max heading level setting are changed to the max heading level. 48 | In addition subsequent headings values are demoted ("cascaded") to the heading level below. 49 | 50 | ### Example 51 | 52 | When max heading level is set to H3 and the following text was pasted: 53 | 54 | ``` 55 | # Heading 1 56 | ## Heading 2 57 | ### Heading 3 58 | #### Heading 4 59 | ##### Heading 5 60 | ###### Heading 6 61 | 62 | # Heading 1 63 | ## Heading 2 64 | ### Heading 3 65 | #### Heading 4 66 | ##### Heading 5 67 | ###### Heading 6 68 | ``` 69 | 70 | The result would be: 71 | 72 | ``` 73 | ### Heading 1 74 | #### Heading 2 75 | ##### Heading 3 76 | ###### Heading 4 77 | ###### Heading 5 78 | ###### Heading 6 79 | 80 | ### Heading 1 81 | #### Heading 2 82 | ##### Heading 3 83 | ###### Heading 4 84 | ###### Heading 5 85 | ###### Heading 6 86 | ``` 87 | 88 | (heading levels will never be demoted below H6, e.g. "######") 89 | 90 | ### Another Example 91 | 92 | When Max Heading Level is set to H2. The following should remain unchanged: 93 | 94 | ``` 95 | ## Heading 2 96 | ### Heading 3 97 | #### Heading 4 98 | ##### Heading 5 99 | ###### Heading 6 100 | 101 | ## Heading 2 102 | ### Heading 3 103 | #### Heading 4 104 | ##### Heading 5 105 | ###### Heading 6 106 | ``` 107 | 108 | # Contextual Cascade Rules 109 | 110 | Contextual Cascade is completely independent of Cascade Heading Levels. 111 | 112 | ## Scenario: Contextual Cascade is Disabled 113 | 114 | When Contextual Cascade is disabled, then the normal Max Heading Level and Cascade Heading Levels rules apply as usual. 115 | 116 | ## Scenario: Contextual Cascade is Enabled 117 | 118 | When Contextual Cascade is enabled, then the Max Heading Level and Cascade Heading Levels rules are superceded by the Contextual Cascade rules: 119 | 120 | ### Example 121 | 122 | When text is pasted into an H2 section, for example, then headings are cascaded down from H3. For example, if the following text was pasted into an H2 section: 123 | 124 | ``` 125 | # Heading 1 126 | ## Heading 2 127 | ### Heading 3 128 | #### Heading 4 129 | ##### Heading 5 130 | ###### Heading 6 131 | 132 | # Heading 1 133 | ## Heading 2 134 | ### Heading 3 135 | #### Heading 4 136 | ##### Heading 5 137 | ###### Heading 6 138 | ``` 139 | 140 | The result would be: 141 | 142 | ``` 143 | ### Heading 1 144 | #### Heading 2 145 | ##### Heading 3 146 | ###### Heading 4 147 | ###### Heading 5 148 | ###### Heading 6 149 | 150 | ### Heading 1 151 | #### Heading 2 152 | ##### Heading 3 153 | ###### Heading 4 154 | ###### Heading 5 155 | ###### Heading 6 156 | ``` 157 | 158 | As with regular cascading, the heading levels will never be demoted below H6, e.g. "######". 159 | -------------------------------------------------------------------------------- /openspec/changes/archive/2025-11-26-add-single-spacing-transformer/specs/markdown-transformations/spec.md: -------------------------------------------------------------------------------- 1 | # Markdown Transformations 2 | 3 | ## ADDED Requirements 4 | 5 | ### Requirement: Single-spacing transformation 6 | The system SHALL provide an option to collapse multiple consecutive blank lines into a single blank line in the markdown output. 7 | 8 | #### Scenario: Multiple blank lines collapsed to single 9 | - **WHEN** `convertToSingleSpaced` is enabled (true) 10 | - **AND** the markdown content contains 2 or more consecutive blank lines 11 | - **THEN** the consecutive blank lines SHALL be replaced with exactly 1 blank line 12 | - **AND** the `appliedTransformations` flag SHALL be set to true 13 | 14 | #### Scenario: Single blank lines preserved 15 | - **WHEN** `convertToSingleSpaced` is enabled (true) 16 | - **AND** the markdown content contains single blank lines (not consecutive) 17 | - **THEN** those single blank lines SHALL be preserved as-is 18 | - **AND** no transformation is applied to those lines 19 | 20 | #### Scenario: Non-blank lines unaffected 21 | - **WHEN** `convertToSingleSpaced` is enabled (true) 22 | - **THEN** all non-blank lines SHALL remain unchanged 23 | 24 | #### Scenario: Feature disabled preserves original behavior 25 | - **WHEN** `convertToSingleSpaced` is disabled (false) 26 | - **THEN** all blank lines SHALL remain unchanged regardless of how many are consecutive 27 | - **AND** the `appliedTransformations` flag SHALL NOT be set based on spacing 28 | 29 | #### Scenario: Interaction with removeEmptyLines 30 | - **WHEN** `removeEmptyLines` is enabled (true) 31 | - **THEN** the single-spacing transformer SHALL be skipped (not applied) 32 | - **AND** the empty line removal logic SHALL be applied instead 33 | 34 | ### Requirement: Single-spacing configuration 35 | The system SHALL provide a setting to enable or disable the single-spacing transformation. 36 | 37 | #### Scenario: Setting defaults to disabled 38 | - **WHEN** the plugin is initialized with no prior settings 39 | - **THEN** the `convertToSingleSpaced` setting SHALL default to false 40 | 41 | #### Scenario: Setting persists across sessions 42 | - **WHEN** a user enables or disables the `convertToSingleSpaced` setting 43 | - **AND** the settings are saved 44 | - **THEN** the setting value SHALL be persisted and restored on plugin reload 45 | 46 | ### Requirement: Single-spacing UI control 47 | The system SHALL provide a user interface control for the single-spacing transformation setting. 48 | 49 | #### Scenario: Toggle positioned above "Remove empty lines" 50 | - **WHEN** the settings UI is displayed 51 | - **THEN** the "Convert to single-spaced" toggle SHALL appear in the Markdown transformations section 52 | - **AND** it SHALL be positioned above the "Remove empty lines" toggle 53 | 54 | #### Scenario: Toggle description 55 | - **WHEN** the "Convert to single-spaced" toggle is displayed 56 | - **THEN** the description SHALL read "Collapse multiple consecutive blank lines into a single blank line" 57 | 58 | #### Scenario: Toggle disabled when removeEmptyLines is enabled 59 | - **WHEN** the "Remove empty lines" setting is enabled (true) 60 | - **THEN** the "Convert to single-spaced" toggle SHALL be disabled (non-interactive) 61 | - **AND** it SHALL be visually indicated as disabled (grayed out or similar) 62 | 63 | #### Scenario: Toggle enabled when removeEmptyLines is disabled 64 | - **WHEN** the "Remove empty lines" setting is disabled (false) 65 | - **THEN** the "Convert to single-spaced" toggle SHALL be enabled (interactive) 66 | - **AND** users SHALL be able to toggle it on or off 67 | 68 | #### Scenario: UI updates when removeEmptyLines changes 69 | - **WHEN** the "Remove empty lines" setting is toggled 70 | - **THEN** the disabled/enabled state of the "Convert to single-spaced" toggle SHALL update accordingly 71 | 72 | ### Requirement: Transform pipeline order 73 | The system SHALL apply transformations in a defined order to ensure consistent behavior. 74 | 75 | #### Scenario: Single-spacing before empty line removal 76 | - **WHEN** both `convertToSingleSpaced` and `removeEmptyLines` are configured (regardless of values) 77 | - **THEN** the single-spacing transformation logic SHALL be evaluated before the empty line removal logic in the code 78 | - **AND** if `removeEmptyLines` is true, single-spacing SHALL be skipped as an optimization 79 | -------------------------------------------------------------------------------- /openspec/specs/markdown-transformations/spec.md: -------------------------------------------------------------------------------- 1 | # markdown-transformations Specification 2 | 3 | ## Purpose 4 | TBD - created by archiving change add-single-spacing-transformer. Update Purpose after archive. 5 | ## Requirements 6 | ### Requirement: Single-spacing transformation 7 | The system SHALL provide an option to collapse multiple consecutive blank lines into a single blank line in the markdown output. 8 | 9 | #### Scenario: Multiple blank lines collapsed to single 10 | - **WHEN** `convertToSingleSpaced` is enabled (true) 11 | - **AND** the markdown content contains 2 or more consecutive blank lines 12 | - **THEN** the consecutive blank lines SHALL be replaced with exactly 1 blank line 13 | - **AND** the `appliedTransformations` flag SHALL be set to true 14 | 15 | #### Scenario: Single blank lines preserved 16 | - **WHEN** `convertToSingleSpaced` is enabled (true) 17 | - **AND** the markdown content contains single blank lines (not consecutive) 18 | - **THEN** those single blank lines SHALL be preserved as-is 19 | - **AND** no transformation is applied to those lines 20 | 21 | #### Scenario: Non-blank lines unaffected 22 | - **WHEN** `convertToSingleSpaced` is enabled (true) 23 | - **THEN** all non-blank lines SHALL remain unchanged 24 | 25 | #### Scenario: Feature disabled preserves original behavior 26 | - **WHEN** `convertToSingleSpaced` is disabled (false) 27 | - **THEN** all blank lines SHALL remain unchanged regardless of how many are consecutive 28 | - **AND** the `appliedTransformations` flag SHALL NOT be set based on spacing 29 | 30 | #### Scenario: Interaction with removeEmptyLines 31 | - **WHEN** `removeEmptyLines` is enabled (true) 32 | - **THEN** the single-spacing transformer SHALL be skipped (not applied) 33 | - **AND** the empty line removal logic SHALL be applied instead 34 | 35 | ### Requirement: Single-spacing configuration 36 | The system SHALL provide a setting to enable or disable the single-spacing transformation. 37 | 38 | #### Scenario: Setting defaults to disabled 39 | - **WHEN** the plugin is initialized with no prior settings 40 | - **THEN** the `convertToSingleSpaced` setting SHALL default to false 41 | 42 | #### Scenario: Setting persists across sessions 43 | - **WHEN** a user enables or disables the `convertToSingleSpaced` setting 44 | - **AND** the settings are saved 45 | - **THEN** the setting value SHALL be persisted and restored on plugin reload 46 | 47 | ### Requirement: Single-spacing UI control 48 | The system SHALL provide a user interface control for the single-spacing transformation setting. 49 | 50 | #### Scenario: Toggle positioned above "Remove empty lines" 51 | - **WHEN** the settings UI is displayed 52 | - **THEN** the "Convert to single-spaced" toggle SHALL appear in the Markdown transformations section 53 | - **AND** it SHALL be positioned above the "Remove empty lines" toggle 54 | 55 | #### Scenario: Toggle description 56 | - **WHEN** the "Convert to single-spaced" toggle is displayed 57 | - **THEN** the description SHALL read "Collapse multiple consecutive blank lines into a single blank line" 58 | 59 | #### Scenario: Toggle disabled when removeEmptyLines is enabled 60 | - **WHEN** the "Remove empty lines" setting is enabled (true) 61 | - **THEN** the "Convert to single-spaced" toggle SHALL be disabled (non-interactive) 62 | - **AND** it SHALL be visually indicated as disabled (grayed out or similar) 63 | 64 | #### Scenario: Toggle enabled when removeEmptyLines is disabled 65 | - **WHEN** the "Remove empty lines" setting is disabled (false) 66 | - **THEN** the "Convert to single-spaced" toggle SHALL be enabled (interactive) 67 | - **AND** users SHALL be able to toggle it on or off 68 | 69 | #### Scenario: UI updates when removeEmptyLines changes 70 | - **WHEN** the "Remove empty lines" setting is toggled 71 | - **THEN** the disabled/enabled state of the "Convert to single-spaced" toggle SHALL update accordingly 72 | 73 | ### Requirement: Transform pipeline order 74 | The system SHALL apply transformations in a defined order to ensure consistent behavior. 75 | 76 | #### Scenario: Single-spacing before empty line removal 77 | - **WHEN** both `convertToSingleSpaced` and `removeEmptyLines` are configured (regardless of values) 78 | - **THEN** the single-spacing transformation logic SHALL be evaluated before the empty line removal logic in the code 79 | - **AND** if `removeEmptyLines` is true, single-spacing SHALL be skipped as an optimization 80 | 81 | -------------------------------------------------------------------------------- /test.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Paste Reformatter Test Document 7 | 22 | 23 | 24 |

Heading Level 1

25 |

This is a paragraph under heading level 1. This text should be preserved when pasted into Obsidian.

26 | 27 |

Heading Level 2

28 |

This is a paragraph under heading level 2. The plugin should adjust this heading based on your settings.

29 | 30 |

Heading Level 3

31 |

This is a paragraph under heading level 3. Testing how the plugin handles this level.

32 | 33 |

Heading Level 4

34 |

This is a paragraph under heading level 4. Testing deeper heading levels.

35 | 36 |
Heading Level 5
37 |

This is a paragraph under heading level 5. Testing even deeper heading levels.

38 | 39 |
Heading Level 6
40 |

This is a paragraph under heading level 6. This is the deepest heading level in HTML.

41 | 42 | 43 |
44 |

45 |
46 |

47 |
48 | 49 | 50 |
51 |

Nested Heading Level 2

52 |

This is nested content with its own heading hierarchy.

53 | 54 |

Nested Heading Level 3

55 |

More nested content to test how the plugin handles nested structures.

56 |
57 | 58 | 59 |

Lists Test

60 | 70 | 71 |
    72 |
  1. Ordered list item 1
  2. 73 |
  3. Ordered list item 2 74 |
      75 |
    1. Nested ordered list item 1
    2. 76 |
    3. Nested ordered list item 2
    4. 77 |
    78 |
  4. 79 |
  5. Ordered list item 3
  6. 80 |
81 | 82 | 83 |

Formatting Test

84 |

This text has bold, italic, and link formatting.

85 |

This text has inline code formatting.

86 | 87 |
// This is a code block
 88 | function testFunction() {
 89 |     console.log("Hello, world!");
 90 | }
 91 | 
92 | 93 | 94 |

Table Test

95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 |
Header 1Header 2Header 3
Row 1, Cell 1Row 1, Cell 2Row 1, Cell 3
Row 2, Cell 1Row 2, Cell 2Row 2, Cell 3
116 | 117 | 118 |

Text before empty lines.

119 | 120 | 121 | 122 | 123 |

Text after empty lines.

124 | 125 | 126 |

Text before horizontal rule.

127 |
128 |

Text after horizontal rule.

129 | 130 | 131 |
132 |

This is a blockquote.

133 |
134 | 135 | 136 |
console.log("Hello, world!");
137 | 138 | 139 |

Text before line break.

140 |
141 |

Text after line break.

142 | 143 |

Text with an embedded
line break.

144 |

145 | Another example of a
146 | line break. 147 |

148 | 149 | 150 | 151 | -------------------------------------------------------------------------------- /src/htmlTransformer.ts: -------------------------------------------------------------------------------- 1 | // Paste Reformatter - A plugin that re-formats pasted HTML text in Obsidian. 2 | // Copyright (C) 2025 by Keath Milligan. 3 | 4 | /** 5 | * Transforms the HTML content before converting it to Markdown 6 | * @param html The HTML content to transform 7 | * @param settings The settings to use for transformation 8 | * @returns An object containing the transformed HTML content and whether any transformations were applied 9 | */ 10 | export function transformHTML( 11 | html: string, 12 | settings: { 13 | htmlRegexReplacements: Array<{pattern: string, replacement: string}>, 14 | stripLineBreaks: boolean, 15 | removeEmptyElements: boolean 16 | } 17 | ): { html: string, appliedTransformations: boolean } { 18 | let appliedTransformations = false; 19 | 20 | // Apply regex replacements first 21 | if (settings.htmlRegexReplacements && settings.htmlRegexReplacements.length > 0) { 22 | for (const replacement of settings.htmlRegexReplacements) { 23 | try { 24 | const regex = new RegExp(replacement.pattern, 'g'); 25 | const originalHtml = html; 26 | html = html.replace(regex, replacement.replacement); 27 | if (originalHtml !== html) { 28 | appliedTransformations = true; 29 | } 30 | } catch (error) { 31 | console.error(`Error applying regex replacement: ${error}`); 32 | } 33 | } 34 | } 35 | 36 | // Create a temporary DOM element to parse the HTML 37 | const parser = new DOMParser(); 38 | const doc = parser.parseFromString(html, 'text/html'); 39 | 40 | // Process line breaks if strip line breaks is enabled 41 | if (settings.stripLineBreaks) { 42 | // Find all
elements and remove them 43 | const brElements = doc.querySelectorAll('br'); 44 | brElements.forEach(br => { 45 | br.remove(); 46 | }); 47 | appliedTransformations = true; 48 | } else { 49 | // If we're not stripping line breaks, convert them to special paragraph tags 50 | // that will be preserved even if empty elements are removed 51 | const brElements = doc.querySelectorAll('br'); 52 | brElements.forEach(br => { 53 | // Create a special paragraph with a class that marks it as a line break 54 | const lineBreakP = doc.createElement('p'); 55 | lineBreakP.className = 'preserve-line-break'; 56 | lineBreakP.setAttribute('data-preserve', 'true'); 57 | 58 | // Add a non-breaking space as placeholder content 59 | // This ensures the paragraph isn't considered empty when Remove Empty Lines is enabled 60 | // The unicode character will be invisible but ensures the line isn't empty 61 | lineBreakP.textContent = '\u200B'; // Zero-width space 62 | 63 | // Replace the
with our special paragraph 64 | br.parentNode?.replaceChild(lineBreakP, br); 65 | }); 66 | } 67 | 68 | // Remove empty elements if enabled 69 | if (settings.removeEmptyElements) { 70 | // Function to check if an element is empty (no text content and no meaningful children) 71 | const isElementEmpty = (element: Element): boolean => { 72 | // Skip certain elements that are meaningful even when empty 73 | if (['img', 'hr', 'br', 'input', 'iframe'].includes(element.tagName.toLowerCase())) { 74 | return false; 75 | } 76 | 77 | // Skip our special line break paragraphs 78 | if (element.hasAttribute('data-preserve')) { 79 | return false; 80 | } 81 | 82 | // Check if it has any text content (include whitespace) 83 | if (element.textContent && element.textContent.length > 0) { 84 | return false; 85 | } 86 | 87 | // Check if it has any non-empty children 88 | for (let i = 0; i < element.children.length; i++) { 89 | if (!isElementEmpty(element.children[i])) { 90 | return false; 91 | } 92 | } 93 | 94 | return true; 95 | }; 96 | 97 | // Find and remove empty elements 98 | // We need to use a while loop because the DOM changes as we remove elements 99 | let emptyElementsFound = true; 100 | while (emptyElementsFound) { 101 | emptyElementsFound = false; 102 | 103 | // Target common empty elements 104 | const potentialEmptyElements = doc.querySelectorAll('p:not([data-preserve]), div, span, li, ul, ol, table, tr, td, th'); 105 | potentialEmptyElements.forEach(element => { 106 | if (isElementEmpty(element)) { 107 | element.remove(); 108 | emptyElementsFound = true; 109 | appliedTransformations = true; 110 | } 111 | }); 112 | 113 | // If no more empty elements are found, exit the loop 114 | if (!emptyElementsFound) { 115 | break; 116 | } 117 | } 118 | } 119 | 120 | // Return the modified HTML and transformation status 121 | const serializer = new XMLSerializer(); 122 | return { 123 | html: serializer.serializeToString(doc.body), 124 | appliedTransformations 125 | }; 126 | } 127 | -------------------------------------------------------------------------------- /openspec/project.md: -------------------------------------------------------------------------------- 1 | # Project Context 2 | 3 | ## Purpose 4 | 5 | Paste Reformatter is an Obsidian plugin that gives users precise control over how pasted HTML and plain text content is transformed when pasted into notes. The plugin automatically processes clipboard content to: 6 | 7 | - Transform HTML content before converting to Markdown for better formatting results 8 | - Apply customizable regex transformations to both HTML and Markdown 9 | - Automatically adjust heading levels to match document context 10 | - Clean up unwanted formatting (empty elements, hard line breaks, blank lines) 11 | - Escape markdown syntax when needed 12 | 13 | ## Tech Stack 14 | 15 | - **TypeScript 4.7.4** - Primary language (ES6 target) 16 | - **Obsidian API** - Plugin framework and editor integration 17 | - **esbuild 0.17.3** - Build tool and bundler 18 | - **DOM APIs** - HTML parsing and manipulation 19 | - **Obsidian's htmlToMarkdown** - Built-in HTML to Markdown conversion 20 | 21 | ## Project Conventions 22 | 23 | ### Code Style 24 | 25 | - TypeScript with strict null checks enabled 26 | - ES6+ syntax with ESNext module format 27 | - 4-space tabs for indentation 28 | - Camel case for variables and functions, PascalCase for classes/interfaces 29 | - Comprehensive JSDoc comments for public functions 30 | - Descriptive variable names (e.g., `appliedTransformations`, `contextLevel`) 31 | 32 | ### Architecture Patterns 33 | 34 | The codebase follows a modular architecture with clear separation of concerns: 35 | 36 | **Core Plugin Structure (`src/main.ts`)**: 37 | - `PasteReformatter` class extends Obsidian's `Plugin` class 38 | - Registers clipboard event handlers and commands 39 | - Manages settings persistence via `loadData()`/`saveData()` 40 | - Delegates transformation logic to specialized modules 41 | 42 | **HTML Transformation Module (`src/htmlTransformer.ts`)**: 43 | - Pure function: `transformHTML(html, settings) => {html, appliedTransformations}` 44 | - Uses DOMParser for safe HTML manipulation (never uses innerHTML) 45 | - Applies transformations in sequence: regex → line breaks → empty elements 46 | - Returns transformation status to determine if paste should be intercepted 47 | 48 | **Markdown Transformation Module (`src/markdownTransformer.ts`)**: 49 | - Pure function: `transformMarkdown(markdown, settings, contextLevel, escapeMarkdown) => {markdown, appliedTransformations}` 50 | - Handles heading level adjustments with three modes: 51 | - Contextual cascade (based on cursor position) 52 | - Standard cascade (based on max heading level) 53 | - Simple capping (no cascade) 54 | - Applies regex replacements and empty line removal 55 | - Supports markdown escaping for special paste mode 56 | 57 | **Settings Management**: 58 | - `PasteReformmatterSettings` interface defines all configuration 59 | - `DEFAULT_SETTINGS` constant for initialization 60 | - Custom settings tab with rich UI (tables, icons, accessibility features) 61 | - Settings UI built entirely with DOM APIs (no innerHTML) 62 | 63 | **Key Design Patterns**: 64 | - **Transformation Pipeline**: HTML → Markdown → Final transformations 65 | - **Non-invasive Override**: Only prevents default paste if transformations were applied 66 | - **Safe DOM Manipulation**: Exclusively uses createElement/appendChild patterns 67 | - **Accessibility First**: All UI elements include ARIA labels and keyboard support 68 | 69 | ### Testing Strategy 70 | 71 | Currently, the project does not have automated tests. Testing is performed manually: 72 | 73 | - Use `npm run build` to verify TypeScript compilation 74 | - Manual testing in Obsidian vault with various paste scenarios 75 | - Test different content sources (web pages, Google Docs, Word, plain text) 76 | - Verify heading cascade behavior with cascade-rules.md examples 77 | - Check settings UI behavior and persistence 78 | 79 | When adding tests, prioritize: 80 | 1. Transformation logic (htmlTransformer, markdownTransformer) 81 | 2. Heading cascade algorithms (all three modes) 82 | 3. Regex replacement handling and error cases 83 | 4. Settings persistence and migration 84 | 85 | ### Git Workflow 86 | 87 | - **Main branch**: Production-ready code 88 | - **Commit style**: Conventional commits format 89 | - `feat:` - New features 90 | - `fix:` - Bug fixes 91 | - `refactor:` - Code improvements without behavior change 92 | - `chore:` - Maintenance tasks (version bumps, dependencies) 93 | - `docs:` - Documentation updates 94 | - **Version bumping**: Automated via `version-bump.mjs` script 95 | - **Release process**: GitHub Actions workflow (`.github/workflows/release.yml`) 96 | 97 | ## Domain Context 98 | 99 | ### Obsidian Plugin Ecosystem 100 | 101 | - Plugins extend Obsidian's markdown editor functionality 102 | - Must work across desktop and mobile (this plugin works on both) 103 | - Need to respect user themes and CSS variables 104 | - Should integrate cleanly with other plugins (non-invasive event handling) 105 | 106 | ### Clipboard Data Handling 107 | 108 | The plugin processes clipboard data in this order of priority: 109 | 1. **HTML format** (`text/html`) - Preferred for rich content from web/apps 110 | 2. **Plain text** (`text/plain`) - Treated as already being Markdown 111 | 112 | ### Heading Cascade Logic 113 | 114 | Three distinct modes with different behaviors (see `cascade-rules.md`): 115 | 116 | 1. **No Cascade** (maxHeadingLevel=1 OR cascadeHeadingLevels=false): 117 | - Simple capping: H1→H3, H2→H3 if max is H3 118 | 119 | 2. **Standard Cascade** (cascadeHeadingLevels=true, contextualCascade=false): 120 | - Preserves hierarchy: H1→H3, H2→H4, H3→H5 if max is H3 121 | - Caps at H6 122 | 123 | 3. **Contextual Cascade** (contextualCascade=true): 124 | - Overrides other settings 125 | - Adjusts based on cursor's current heading section 126 | - In H2 section: H1→H3, H2→H4, H3→H5, etc. 127 | 128 | ### Special Markers 129 | 130 | - `data-preserve="true"` attribute marks line breaks to preserve during empty element removal 131 | - Zero-width space (`\u200B`) used as placeholder to prevent paragraph collapse 132 | 133 | ## Important Constraints 134 | 135 | ### Security 136 | 137 | - **Never use innerHTML or outerHTML** - XSS vulnerability risk 138 | - Always use DOMParser, createElement, appendChild patterns 139 | - Sanitize user-provided regex patterns with try-catch blocks 140 | 141 | ### Obsidian API Compatibility 142 | 143 | - Minimum supported version: 0.15.0 (defined in manifest.json) 144 | - Must use current (non-deprecated) API methods 145 | - Use `app.workspace.getActiveViewOfType(MarkdownView)` not deprecated alternatives 146 | - Register events with `registerEvent()` for automatic cleanup 147 | 148 | ### Performance 149 | 150 | - Transformations must be fast (paste should feel instant) 151 | - Regex replacements loop through array - keep replacement lists reasonable 152 | - DOM parsing happens on every paste when enabled - keep HTML clean 153 | 154 | ### Browser Compatibility 155 | 156 | - Must work in Electron (Chromium-based) 157 | - Uses modern APIs: DOMParser, XMLSerializer, navigator.clipboard 158 | - Targets ES2018 in build output 159 | 160 | ## External Dependencies 161 | 162 | ### Runtime Dependencies 163 | 164 | - **Obsidian API** (`obsidian` module) - Provides: 165 | - `Plugin` base class 166 | - `MarkdownView` for editor access 167 | - `Setting` for settings UI 168 | - `htmlToMarkdown()` conversion function 169 | - `Notice` for user notifications 170 | - `setIcon()` for UI icons 171 | 172 | ### Development Dependencies 173 | 174 | - **esbuild** - Fast TypeScript bundler 175 | - **TypeScript** - Type checking and compilation 176 | - **@types/node** - Node.js type definitions 177 | - **tslib** - TypeScript runtime helpers 178 | - **builtin-modules** - List of Node.js built-in modules to exclude from bundle 179 | 180 | ### Build Process 181 | 182 | 1. TypeScript compilation check (`tsc -noEmit -skipLibCheck`) 183 | 2. esbuild bundles `src/main.ts` → `dist/main.js` 184 | 3. Styles copied from `styles.css` to dist 185 | 4. External dependencies (obsidian, electron, codemirror) marked as external 186 | 5. Production builds are minified, dev builds include inline sourcemaps 187 | 188 | ### Distribution 189 | 190 | - Main files: `dist/main.js`, `manifest.json`, `styles.css` 191 | - Distributed via Obsidian Community Plugins marketplace 192 | - GitHub releases include ZIP with required files 193 | - Version managed in: `manifest.json`, `package.json`, `versions.json` 194 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Paste Reformatter 2 | 3 | A plugin for [Obsidian](https://obsidian.md) that reformats pasted HTML and plain text content, giving you precise control over how content is transformed when pasted into your notes. 4 | 5 | * Use RegEx to transform HTML and Markdown. 6 | * Reformat HTML before converting to Markdown for better formatting results. 7 | * Automatically adjust pasted heading levels to match content. 8 | * Strip blank lines and elements. 9 | 10 | ## Installation 11 | 12 | ### From Obsidian Community Plugins 13 | 14 | 1. Open Obsidian and go to **Settings** 15 | 2. Navigate to **Community plugins** and disable **Safe mode** if it's enabled 16 | 3. Click **Browse** and search for "Paste Reformatter" 17 | 4. Click **Install**, then **Enable** to activate the plugin 18 | 19 | ### Manual Installation 20 | 21 | 1. Download the latest release from the [GitHub releases page](https://github.com/keathmilligan/obsidian-paste-reformatter/releases) 22 | 2. Extract the ZIP file to your Obsidian plugins folder: `/.obsidian/plugins/` 23 | 3. Reload Obsidian or restart the app 24 | 4. Go to **Settings** > **Community plugins** and enable "Paste Reformatter" 25 | 26 | ## Usage 27 | 28 | The Paste Reformatter plugin automatically processes content when you paste it into your notes using `Ctrl+V` (or `Cmd+V` on macOS). The plugin: 29 | 30 | 1. Detects whether the clipboard contains HTML or plain text 31 | 2. For HTML content: 32 | - Applies HTML transformations (optionally removing empty elements or hard line breaks, regex string replacement) 33 | - Converts the HTML to Markdown 34 | 3. For plain text: 35 | - Treats it as already being in Markdown format 36 | 4. Applies Markdown transformations (heading adjustments, line break handling, regex string replacement) 37 | 5. Inserts the transformed content at the cursor position 38 | 39 | A notification will appear briefly indicating whether HTML or plain text content was reformatted. 40 | 41 | ### Commands 42 | 43 | > Note: commands are prefixed with "Paste Reformatter:" in the hot-key list. 44 | 45 | |Command|Description| 46 | |-|-| 47 | |**Reformat and Paste**|By default, Paste Reformatter overrides Obsidian's normal paste behavior. Alternatively, you can disable this behavior (see below) and bind a hot-key to this command.| 48 | |**Paste with Escaped Markdown**|Pastes text with all markdown escaped. For example, `[Data]` becomes `\[Data]`. 49 | 50 | ### Potential Conflicts 51 | 52 | Paste Reformatter can potentially conflict with other Obsidian plugins that override the default paste behavior. To mitigate this, Paste Reformatter will only prevent the default handling of the paste event if it actually performs a transformation on the pasted text. Otherwise, it will allow the default behavior to take place. This won't prevent all potential collisions, however. So if you run into problems, disable "Override default paste behavior" and bind a hotkey to the "Paste and Reformat" command. 53 | 54 | ## Configuration 55 | 56 | ![alt text](image.png) 57 | 58 | ### General Settings 59 | 60 | #### Override default paste behavior 61 | 62 | When this setting is enabled, the default behavior of Obsidian's Paste function will be enhanced. Otherwise, 63 | **Reformat and Paste** command can be bound to an alternative hot-key to get enhanced paste behavior. 64 | 65 | ### HTML Transformations 66 | 67 | These settings control how HTML content is processed before being converted to Markdown. 68 | 69 | #### Remove Empty Elements 70 | 71 | When enabled, this setting removes empty elements (such as empty paragraphs, divs, spans) from the pasted HTML. This helps clean up content that might contain unnecessary structural elements. 72 | 73 | Elements with meaningful content (including images, horizontal rules, etc.) are preserved even when empty. 74 | 75 | #### Strip Hard Line Breaks 76 | 77 | When enabled, this setting removes `
` tags from the HTML, resulting in paragraph-style text flow. When disabled, line breaks are preserved in the resulting Markdown. 78 | 79 | #### HTML Regex Replacements 80 | 81 | This feature allows you to define custom regular expression patterns and replacements to apply to the HTML content before it's converted to Markdown. 82 | 83 | Each replacement consists of: 84 | - **Pattern**: A regular expression to match in the HTML 85 | - **Replacement**: The text to replace the matched pattern with 86 | 87 | You can use capture groups in your patterns and reference them in the replacement using `$1`, `$2`, etc. 88 | 89 | ### Markdown Transformations 90 | 91 | These settings control how the Markdown content is processed after HTML conversion (or directly for plain text). 92 | 93 | #### Max Heading Level 94 | 95 | This setting controls the maximum heading level that will be applied to pasted headings. 96 | 97 | The value is an integer between 1 and 6, where: 98 | - H1 (level 1) is treated as disabled (no heading adjustments) 99 | - H2-H6 (levels 2-6) will enforce that heading level as the minimum 100 | 101 | When set to a value other than H1 (disabled), headings that are shallower than the specified level will be increased to that level. 102 | 103 | #### Cascade Heading Levels 104 | 105 | When enabled, this option preserves the heading hierarchy by cascading levels based on the Max Heading Level setting. 106 | 107 | For example, if Max Heading Level is set to H2: 108 | 109 | - H1 (#) becomes H2 (##) 110 | - H2 (##) becomes H3 (###) 111 | - H3 (###) becomes H4 (####) 112 | - H4 (####) becomes H5 (#####) 113 | - H5 (#####) becomes H6 (######) 114 | - H6 (######) remains H6 (######) (capped at H6) 115 | 116 | This preserves the relative structure of your headings while ensuring they fit within your document's hierarchy. 117 | 118 | This setting is only available when Max Heading Level is set to something other than H1 (disabled). 119 | 120 | #### Contextual Cascade 121 | 122 | When enabled, this option adjusts heading levels based on the current context in your document. The plugin detects the heading level at your cursor position and cascades pasted headings relative to that context. 123 | 124 | For example, if your cursor is positioned in a section with an H2 heading: 125 | 126 | - Pasted H1 headings will become H3 (one level deeper than the context) 127 | - Pasted H2 headings will become H4 128 | - Pasted H3 headings will become H5 129 | - And so on (capped at H6) 130 | 131 | This is particularly useful when pasting content into different sections of your document and wanting the heading structure to fit naturally within the current section's hierarchy. 132 | 133 | When used together with Cascade Heading Levels, the relative hierarchy of headings in the pasted content will be preserved. 134 | 135 | #### Remove Empty Lines 136 | 137 | When enabled, this setting removes blank lines from the Markdown output, resulting in more compact content. When disabled, empty lines are preserved, maintaining the original spacing. 138 | 139 | #### Markdown Regex Replacements 140 | 141 | Similar to HTML Regex Replacements, this feature allows you to define custom regular expression patterns and replacements to apply to the Markdown content after conversion. 142 | 143 | This is useful for: 144 | - Standardizing formatting 145 | - Removing unwanted patterns 146 | - Transforming specific content structures 147 | - Adding custom Obsidian-specific syntax 148 | 149 | ## Examples 150 | 151 | ### Example 1: Cleaning Up Web Content 152 | 153 | When copying content from websites, you often get unwanted elements. With these settings: 154 | - Remove Empty Elements: Enabled 155 | - Strip Hard Line Breaks: Enabled 156 | - Max Heading Level: H2 157 | - Cascade Heading Levels: Enabled 158 | 159 | A complex web article with various heading levels and formatting will be transformed into clean, hierarchical Markdown with H2 as the top-level heading. 160 | 161 | ### Example 2: Preserving Document Structure 162 | 163 | When copying content from a document with specific formatting: 164 | - Remove Empty Elements: Disabled 165 | - Strip Hard Line Breaks: Disabled 166 | - Max Heading Level: H1 (Disabled) 167 | - Contextual Cascade: Enabled 168 | 169 | The content will maintain its original structure, but headings will be adjusted to fit within the current document's hierarchy based on your cursor position. 170 | 171 | ### HTML Regex Replacement Examples 172 | 173 | 1. **Remove image width/height attributes** 174 | - Pattern: `(]*)(width|height)="[^"]*"` 175 | - Replacement: `$1` 176 | - Description: Removes width and height attributes from image tags, allowing them to be sized by CSS instead 177 | 2. **Convert Google Docs span styles to semantic elements** 178 | - Pattern: `(.*?)` 179 | - Replacement: `$1` 180 | - Description: Converts Google Docs styled spans to proper HTML elements 181 | 3. **Fix Microsoft Word's special quotes** 182 | - Pattern: `(“|”)` 183 | - Replacement: `"` 184 | - Description: Replaces curly quotes with straight quotes for better compatibility 185 | 4. **Remove class attributes** 186 | - Pattern: `\s*class="[^"]*"` 187 | - Replacement: `` 188 | - Description: Strips class attributes that might contain website-specific styling 189 | 5. **Convert div tags to paragraphs** 190 | - Pattern: `]*)>(.*?)` 191 | - Replacement: `$2

` 192 | - Description: Converts div elements to paragraph elements for cleaner Markdown conversion 193 | 194 | ### Markdown Regex Replacement Examples 195 | 196 | 1. **Add callouts to blockquotes starting with Note:** 197 | - Pattern: `> Note:(.*?)(\n\n|\n$)` 198 | - Replacement: `> [!note]$1$2` 199 | - Description: Converts simple note blockquotes to Obsidian callouts 200 | 2. **Convert URL references to Obsidian wiki links** 201 | - Pattern: `\[([^\]]+)\]\(https://en.wikipedia.org/wiki/([^\)]+)\)` 202 | - Replacement: `[[$2|$1]]` 203 | - Description: Transforms Wikipedia links to Obsidian wiki links 204 | 3. **Add tags to headings with specific keywords** 205 | - Pattern: `^(#+\s*.*?)(TODO|REVIEW|IMPORTANT)(.*)$` 206 | - Replacement: `$1$2$3 #task` 207 | - Description: Adds a task tag to headings containing action keywords 208 | 4. **Format dates consistently** 209 | - Pattern: `(\d{1,2})/(\d{1,2})/(\d{4})` 210 | - Replacement: `$3-$1-$2` 211 | - Description: Converts MM/DD/YYYY dates to YYYY-MM-DD format 212 | 5. **Convert asterisk lists to dash lists** 213 | - Pattern: `^(\s*)\*` 214 | - Replacement: `$1-` 215 | - Description: Standardizes list formatting to use dashes instead of asterisks 216 | 217 | ## License 218 | 219 | This project is licensed under the BSD Zero Clause License - see the LICENSE file for details. 220 | -------------------------------------------------------------------------------- /src/markdownTransformer.ts: -------------------------------------------------------------------------------- 1 | // Paste Reformatter - A plugin that re-formats pasted HTML text in Obsidian. 2 | // Copyright (C) 2025 by Keath Milligan. 3 | 4 | /** 5 | * Transforms the markdown content based on the plugin settings 6 | * @param markdown The markdown content to transform 7 | * @param settings The settings to use for transformation 8 | * @param contextLevel The current heading level for contextual cascade (0 if not in a heading section) 9 | * @returns An object containing the transformed markdown content and whether any transformations were applied 10 | */ 11 | export function transformMarkdown( 12 | markdown: string, 13 | settings: { 14 | markdownRegexReplacements: Array<{pattern: string, replacement: string}>, 15 | contextualCascade: boolean, 16 | maxHeadingLevel: number, 17 | cascadeHeadingLevels: boolean, 18 | stripLineBreaks: boolean, 19 | convertToSingleSpaced: boolean, 20 | removeEmptyLines: boolean 21 | }, 22 | contextLevel: number = 0, 23 | escapeMarkdown: boolean = false 24 | ): { markdown: string, appliedTransformations: boolean } { 25 | let appliedTransformations = false; 26 | 27 | // Apply regex replacements if defined 28 | if (settings.markdownRegexReplacements && settings.markdownRegexReplacements.length > 0) { 29 | for (const regex_replacement of settings.markdownRegexReplacements) { 30 | try { 31 | const regex = new RegExp(regex_replacement.pattern, 'g'); 32 | const replacement = regex_replacement.replacement 33 | .replace(/\\r\\n/g, '\r\n') 34 | .replace(/\\n/g, '\n') 35 | .replace(/\\r/g, '\r') 36 | .replace(/\\t/g, '\t') 37 | .replace(/\\'/g, "'") 38 | .replace(/\\"/g, '"') 39 | .replace(/\\\\/g, '\\'); 40 | const originalMarkdown = markdown; 41 | // console.log(`applying ${JSON.stringify(regex_replacement.pattern)} replacement ${JSON.stringify(replacement)}`); console.log(JSON.stringify(markdown)); 42 | markdown = markdown.replace(regex, replacement); 43 | if (originalMarkdown !== markdown) { 44 | appliedTransformations = true; 45 | // console.log("replaced text") 46 | } 47 | } catch (error) { 48 | console.error(`Error applying markdown regex replacement: ${error}`); 49 | } 50 | } 51 | } 52 | 53 | if (!escapeMarkdown) { 54 | // Find all heading lines 55 | const headingRegex = /^(#{1,6})\s/gm; 56 | 57 | // Process headings based on settings 58 | if (settings.contextualCascade && contextLevel > 0) { 59 | let delta = -1; 60 | let cascading = false; 61 | 62 | // Contextual cascade is enabled and we have a context level 63 | markdown = markdown.replace(headingRegex, (match, hashes) => { 64 | const currentLevel = hashes.length; 65 | let newLevel = currentLevel; 66 | 67 | if (cascading) { 68 | // Cascade subsequent levels below the context level 69 | newLevel = Math.min(currentLevel + delta, 6); 70 | console.log(`contextual cascade: delta ${delta}`); 71 | } else if (currentLevel <= contextLevel) { 72 | // Intiate contextual cascading 73 | newLevel = Math.min(contextLevel + 1, 6); 74 | delta = newLevel - currentLevel; 75 | cascading = true; 76 | console.log(`contextual cascade initiated: delta: ${delta}`); 77 | } // else nothing to do 78 | 79 | console.log(`result: current level: ${currentLevel}, new level: ${newLevel}`); 80 | 81 | appliedTransformations = (newLevel !== currentLevel); 82 | // Return the new heading with the adjusted level 83 | return '#'.repeat(newLevel) + ' '; 84 | }); 85 | } else if (settings.maxHeadingLevel > 1) { 86 | let delta = -1; 87 | let cascading = false; 88 | 89 | markdown = markdown.replace(headingRegex, (match, hashes) => { 90 | const currentLevel = hashes.length; 91 | let newLevel = currentLevel; 92 | 93 | if (settings.cascadeHeadingLevels) { 94 | // If cascading is enabled, start cascading subsequent headings down if needed 95 | if (cascading) { 96 | // Cascade subsequent headers down, don't go deeper than H6 97 | newLevel = Math.min(currentLevel + delta, 6); 98 | console.log(`cascading: delta: ${delta}`); 99 | } else if (currentLevel < settings.maxHeadingLevel) { 100 | newLevel = settings.maxHeadingLevel; 101 | delta = newLevel - currentLevel; 102 | cascading = true; // we need to cascade 103 | console.log(`cascade initiated: delta: ${delta}`); 104 | } // else nothing to do, heading is good as is 105 | } else { 106 | // Cascading not enabled, just cap heading levels at max 107 | newLevel = Math.max(currentLevel, settings.maxHeadingLevel) 108 | } 109 | 110 | console.log(`result: current level: ${currentLevel}, new level: ${newLevel}`); 111 | 112 | appliedTransformations = (newLevel !== currentLevel); 113 | 114 | // Return the new heading with the adjusted level 115 | return '#'.repeat(newLevel) + ' '; 116 | }); 117 | } 118 | } else { 119 | // If escaping markdown, we don't want to change headings 120 | // Just escape the markdown content 121 | const originalMarkdown = markdown; 122 | 123 | // Escape all Markdown syntax that Obsidian recognizes 124 | // Headings, bold/italic, lists, links, images, code blocks, blockquotes, etc. 125 | markdown = markdown 126 | // Escape headings 127 | .replace(/^(#{1,6}\s)/gm, '\\$1') 128 | // Escape bold/italic 129 | .replace(/(\*\*|__|\*|_)/g, '\\$1') 130 | // Escape lists 131 | .replace(/^(\s*[-+*]\s)/gm, '\\$1') 132 | // Escape numbered lists 133 | .replace(/^(\s*\d+\.\s)/gm, '\\$1') 134 | // Escape links and images 135 | .replace(/(!?\[)/g, '\\$1') 136 | // Escape code blocks and inline code 137 | .replace(/(`{1,3})/g, '\\$1') 138 | // Escape blockquotes 139 | .replace(/^(\s*>\s)/gm, '\\$1') 140 | // Escape horizontal rules 141 | .replace(/^(\s*[-*_]{3,}\s*)$/gm, '\\$1') 142 | // Escape table syntax 143 | .replace(/(\|)/g, '\\$1') 144 | // Escape task lists 145 | .replace(/^(\s*- \[ \])/gm, '\\$1') 146 | // Escape HTML tags that might be interpreted, but only if they're not already inside backticks 147 | .replace(/(`.*?`)|(<\/?[a-z][^>]*>)/gi, (match, codeContent, htmlTag) => { 148 | // If this is a code block (first capture group), return it unchanged 149 | if (codeContent) return codeContent; 150 | // Otherwise it's an HTML tag that needs escaping 151 | return htmlTag ? '`' + htmlTag + '`' : match; 152 | }); 153 | 154 | appliedTransformations = (originalMarkdown !== markdown); 155 | } 156 | 157 | // First handle the special line break markers 158 | let preserveLineBreaks = !settings.stripLineBreaks; 159 | 160 | // Convert multiple consecutive blank lines to single blank line if enabled 161 | // Skip this if removeEmptyLines is enabled (optimization - lines will be removed anyway) 162 | if (settings.convertToSingleSpaced && !settings.removeEmptyLines) { 163 | // Normalize line endings to ensure consistent processing 164 | markdown = markdown.replace(/\r\n/g, '\n'); 165 | 166 | // Replace 2 or more consecutive newlines with exactly 2 newlines (1 blank line) 167 | const originalMarkdown = markdown; 168 | markdown = markdown.replace(/\n{3,}/g, '\n\n'); 169 | 170 | if (originalMarkdown !== markdown) { 171 | appliedTransformations = true; 172 | } 173 | } 174 | 175 | // Process empty lines with a sliding window approach 176 | if (settings.removeEmptyLines) { 177 | // First, normalize line endings to ensure consistent processing 178 | markdown = markdown.replace(/\r\n/g, '\n'); 179 | 180 | // Split the content into lines 181 | const lines = markdown.split('\n'); 182 | const filteredLines: string[] = []; 183 | 184 | // Sliding window processing with peek capability 185 | for (let i = 0; i < lines.length; i++) { 186 | const currentLine = lines[i]; 187 | const nextLine = i + 1 < lines.length ? lines[i + 1] : null; 188 | const isCurrentLineEmpty = currentLine.trim() === ''; 189 | 190 | // Rule 1: Preserve line breaks - check for special markers 191 | if (preserveLineBreaks) { 192 | const hasPreserveMarker = 193 | /

]*>.*?<\/p>/.test(currentLine) || 194 | /

]*>.*?<\/p>/.test(currentLine); 195 | 196 | if (hasPreserveMarker) { 197 | // Insert an empty line instead of the marker 198 | filteredLines.push(''); 199 | continue; 200 | } 201 | } 202 | 203 | // Rule 2: Keep empty line if next line is a horizontal rule (3+ dashes) 204 | if (isCurrentLineEmpty && nextLine !== null && /^\s*-{3,}\s*$/.test(nextLine)) { 205 | filteredLines.push(currentLine); 206 | continue; 207 | } 208 | 209 | // Rule 3: Keep empty line if next line is the beginning of a table 210 | if (isCurrentLineEmpty && nextLine !== null && /^\s*\|.*\|/.test(nextLine)) { 211 | filteredLines.push(currentLine); 212 | continue; 213 | } 214 | 215 | // Default: Remove empty lines unless they meet the above criteria 216 | if (!isCurrentLineEmpty) { 217 | filteredLines.push(currentLine); 218 | } 219 | } 220 | 221 | // Join the filtered lines back together 222 | const originalMarkdown = markdown; 223 | markdown = filteredLines.join('\n'); 224 | appliedTransformations = appliedTransformations || (originalMarkdown !== markdown); 225 | } 226 | 227 | return { 228 | markdown, 229 | appliedTransformations 230 | }; 231 | } 232 | -------------------------------------------------------------------------------- /openspec/AGENTS.md: -------------------------------------------------------------------------------- 1 | # OpenSpec Instructions 2 | 3 | Instructions for AI coding assistants using OpenSpec for spec-driven development. 4 | 5 | ## TL;DR Quick Checklist 6 | 7 | - Search existing work: `openspec spec list --long`, `openspec list` (use `rg` only for full-text search) 8 | - Decide scope: new capability vs modify existing capability 9 | - Pick a unique `change-id`: kebab-case, verb-led (`add-`, `update-`, `remove-`, `refactor-`) 10 | - Scaffold: `proposal.md`, `tasks.md`, `design.md` (only if needed), and delta specs per affected capability 11 | - Write deltas: use `## ADDED|MODIFIED|REMOVED|RENAMED Requirements`; include at least one `#### Scenario:` per requirement 12 | - Validate: `openspec validate [change-id] --strict` and fix issues 13 | - Request approval: Do not start implementation until proposal is approved 14 | 15 | ## Three-Stage Workflow 16 | 17 | ### Stage 1: Creating Changes 18 | Create proposal when you need to: 19 | - Add features or functionality 20 | - Make breaking changes (API, schema) 21 | - Change architecture or patterns 22 | - Optimize performance (changes behavior) 23 | - Update security patterns 24 | 25 | Triggers (examples): 26 | - "Help me create a change proposal" 27 | - "Help me plan a change" 28 | - "Help me create a proposal" 29 | - "I want to create a spec proposal" 30 | - "I want to create a spec" 31 | 32 | Loose matching guidance: 33 | - Contains one of: `proposal`, `change`, `spec` 34 | - With one of: `create`, `plan`, `make`, `start`, `help` 35 | 36 | Skip proposal for: 37 | - Bug fixes (restore intended behavior) 38 | - Typos, formatting, comments 39 | - Dependency updates (non-breaking) 40 | - Configuration changes 41 | - Tests for existing behavior 42 | 43 | **Workflow** 44 | 1. Review `openspec/project.md`, `openspec list`, and `openspec list --specs` to understand current context. 45 | 2. Choose a unique verb-led `change-id` and scaffold `proposal.md`, `tasks.md`, optional `design.md`, and spec deltas under `openspec/changes//`. 46 | 3. Draft spec deltas using `## ADDED|MODIFIED|REMOVED Requirements` with at least one `#### Scenario:` per requirement. 47 | 4. Run `openspec validate --strict` and resolve any issues before sharing the proposal. 48 | 49 | ### Stage 2: Implementing Changes 50 | Track these steps as TODOs and complete them one by one. 51 | 1. **Read proposal.md** - Understand what's being built 52 | 2. **Read design.md** (if exists) - Review technical decisions 53 | 3. **Read tasks.md** - Get implementation checklist 54 | 4. **Implement tasks sequentially** - Complete in order 55 | 5. **Confirm completion** - Ensure every item in `tasks.md` is finished before updating statuses 56 | 6. **Update checklist** - After all work is done, set every task to `- [x]` so the list reflects reality 57 | 7. **Approval gate** - Do not start implementation until the proposal is reviewed and approved 58 | 59 | ### Stage 3: Archiving Changes 60 | After deployment, create separate PR to: 61 | - Move `changes/[name]/` → `changes/archive/YYYY-MM-DD-[name]/` 62 | - Update `specs/` if capabilities changed 63 | - Use `openspec archive --skip-specs --yes` for tooling-only changes (always pass the change ID explicitly) 64 | - Run `openspec validate --strict` to confirm the archived change passes checks 65 | 66 | ## Before Any Task 67 | 68 | **Context Checklist:** 69 | - [ ] Read relevant specs in `specs/[capability]/spec.md` 70 | - [ ] Check pending changes in `changes/` for conflicts 71 | - [ ] Read `openspec/project.md` for conventions 72 | - [ ] Run `openspec list` to see active changes 73 | - [ ] Run `openspec list --specs` to see existing capabilities 74 | 75 | **Before Creating Specs:** 76 | - Always check if capability already exists 77 | - Prefer modifying existing specs over creating duplicates 78 | - Use `openspec show [spec]` to review current state 79 | - If request is ambiguous, ask 1–2 clarifying questions before scaffolding 80 | 81 | ### Search Guidance 82 | - Enumerate specs: `openspec spec list --long` (or `--json` for scripts) 83 | - Enumerate changes: `openspec list` (or `openspec change list --json` - deprecated but available) 84 | - Show details: 85 | - Spec: `openspec show --type spec` (use `--json` for filters) 86 | - Change: `openspec show --json --deltas-only` 87 | - Full-text search (use ripgrep): `rg -n "Requirement:|Scenario:" openspec/specs` 88 | 89 | ## Quick Start 90 | 91 | ### CLI Commands 92 | 93 | ```bash 94 | # Essential commands 95 | openspec list # List active changes 96 | openspec list --specs # List specifications 97 | openspec show [item] # Display change or spec 98 | openspec validate [item] # Validate changes or specs 99 | openspec archive [--yes|-y] # Archive after deployment (add --yes for non-interactive runs) 100 | 101 | # Project management 102 | openspec init [path] # Initialize OpenSpec 103 | openspec update [path] # Update instruction files 104 | 105 | # Interactive mode 106 | openspec show # Prompts for selection 107 | openspec validate # Bulk validation mode 108 | 109 | # Debugging 110 | openspec show [change] --json --deltas-only 111 | openspec validate [change] --strict 112 | ``` 113 | 114 | ### Command Flags 115 | 116 | - `--json` - Machine-readable output 117 | - `--type change|spec` - Disambiguate items 118 | - `--strict` - Comprehensive validation 119 | - `--no-interactive` - Disable prompts 120 | - `--skip-specs` - Archive without spec updates 121 | - `--yes`/`-y` - Skip confirmation prompts (non-interactive archive) 122 | 123 | ## Directory Structure 124 | 125 | ``` 126 | openspec/ 127 | ├── project.md # Project conventions 128 | ├── specs/ # Current truth - what IS built 129 | │ └── [capability]/ # Single focused capability 130 | │ ├── spec.md # Requirements and scenarios 131 | │ └── design.md # Technical patterns 132 | ├── changes/ # Proposals - what SHOULD change 133 | │ ├── [change-name]/ 134 | │ │ ├── proposal.md # Why, what, impact 135 | │ │ ├── tasks.md # Implementation checklist 136 | │ │ ├── design.md # Technical decisions (optional; see criteria) 137 | │ │ └── specs/ # Delta changes 138 | │ │ └── [capability]/ 139 | │ │ └── spec.md # ADDED/MODIFIED/REMOVED 140 | │ └── archive/ # Completed changes 141 | ``` 142 | 143 | ## Creating Change Proposals 144 | 145 | ### Decision Tree 146 | 147 | ``` 148 | New request? 149 | ├─ Bug fix restoring spec behavior? → Fix directly 150 | ├─ Typo/format/comment? → Fix directly 151 | ├─ New feature/capability? → Create proposal 152 | ├─ Breaking change? → Create proposal 153 | ├─ Architecture change? → Create proposal 154 | └─ Unclear? → Create proposal (safer) 155 | ``` 156 | 157 | ### Proposal Structure 158 | 159 | 1. **Create directory:** `changes/[change-id]/` (kebab-case, verb-led, unique) 160 | 161 | 2. **Write proposal.md:** 162 | ```markdown 163 | # Change: [Brief description of change] 164 | 165 | ## Why 166 | [1-2 sentences on problem/opportunity] 167 | 168 | ## What Changes 169 | - [Bullet list of changes] 170 | - [Mark breaking changes with **BREAKING**] 171 | 172 | ## Impact 173 | - Affected specs: [list capabilities] 174 | - Affected code: [key files/systems] 175 | ``` 176 | 177 | 3. **Create spec deltas:** `specs/[capability]/spec.md` 178 | ```markdown 179 | ## ADDED Requirements 180 | ### Requirement: New Feature 181 | The system SHALL provide... 182 | 183 | #### Scenario: Success case 184 | - **WHEN** user performs action 185 | - **THEN** expected result 186 | 187 | ## MODIFIED Requirements 188 | ### Requirement: Existing Feature 189 | [Complete modified requirement] 190 | 191 | ## REMOVED Requirements 192 | ### Requirement: Old Feature 193 | **Reason**: [Why removing] 194 | **Migration**: [How to handle] 195 | ``` 196 | If multiple capabilities are affected, create multiple delta files under `changes/[change-id]/specs//spec.md`—one per capability. 197 | 198 | 4. **Create tasks.md:** 199 | ```markdown 200 | ## 1. Implementation 201 | - [ ] 1.1 Create database schema 202 | - [ ] 1.2 Implement API endpoint 203 | - [ ] 1.3 Add frontend component 204 | - [ ] 1.4 Write tests 205 | ``` 206 | 207 | 5. **Create design.md when needed:** 208 | Create `design.md` if any of the following apply; otherwise omit it: 209 | - Cross-cutting change (multiple services/modules) or a new architectural pattern 210 | - New external dependency or significant data model changes 211 | - Security, performance, or migration complexity 212 | - Ambiguity that benefits from technical decisions before coding 213 | 214 | Minimal `design.md` skeleton: 215 | ```markdown 216 | ## Context 217 | [Background, constraints, stakeholders] 218 | 219 | ## Goals / Non-Goals 220 | - Goals: [...] 221 | - Non-Goals: [...] 222 | 223 | ## Decisions 224 | - Decision: [What and why] 225 | - Alternatives considered: [Options + rationale] 226 | 227 | ## Risks / Trade-offs 228 | - [Risk] → Mitigation 229 | 230 | ## Migration Plan 231 | [Steps, rollback] 232 | 233 | ## Open Questions 234 | - [...] 235 | ``` 236 | 237 | ## Spec File Format 238 | 239 | ### Critical: Scenario Formatting 240 | 241 | **CORRECT** (use #### headers): 242 | ```markdown 243 | #### Scenario: User login success 244 | - **WHEN** valid credentials provided 245 | - **THEN** return JWT token 246 | ``` 247 | 248 | **WRONG** (don't use bullets or bold): 249 | ```markdown 250 | - **Scenario: User login** ❌ 251 | **Scenario**: User login ❌ 252 | ### Scenario: User login ❌ 253 | ``` 254 | 255 | Every requirement MUST have at least one scenario. 256 | 257 | ### Requirement Wording 258 | - Use SHALL/MUST for normative requirements (avoid should/may unless intentionally non-normative) 259 | 260 | ### Delta Operations 261 | 262 | - `## ADDED Requirements` - New capabilities 263 | - `## MODIFIED Requirements` - Changed behavior 264 | - `## REMOVED Requirements` - Deprecated features 265 | - `## RENAMED Requirements` - Name changes 266 | 267 | Headers matched with `trim(header)` - whitespace ignored. 268 | 269 | #### When to use ADDED vs MODIFIED 270 | - ADDED: Introduces a new capability or sub-capability that can stand alone as a requirement. Prefer ADDED when the change is orthogonal (e.g., adding "Slash Command Configuration") rather than altering the semantics of an existing requirement. 271 | - MODIFIED: Changes the behavior, scope, or acceptance criteria of an existing requirement. Always paste the full, updated requirement content (header + all scenarios). The archiver will replace the entire requirement with what you provide here; partial deltas will drop previous details. 272 | - RENAMED: Use when only the name changes. If you also change behavior, use RENAMED (name) plus MODIFIED (content) referencing the new name. 273 | 274 | Common pitfall: Using MODIFIED to add a new concern without including the previous text. This causes loss of detail at archive time. If you aren’t explicitly changing the existing requirement, add a new requirement under ADDED instead. 275 | 276 | Authoring a MODIFIED requirement correctly: 277 | 1) Locate the existing requirement in `openspec/specs//spec.md`. 278 | 2) Copy the entire requirement block (from `### Requirement: ...` through its scenarios). 279 | 3) Paste it under `## MODIFIED Requirements` and edit to reflect the new behavior. 280 | 4) Ensure the header text matches exactly (whitespace-insensitive) and keep at least one `#### Scenario:`. 281 | 282 | Example for RENAMED: 283 | ```markdown 284 | ## RENAMED Requirements 285 | - FROM: `### Requirement: Login` 286 | - TO: `### Requirement: User Authentication` 287 | ``` 288 | 289 | ## Troubleshooting 290 | 291 | ### Common Errors 292 | 293 | **"Change must have at least one delta"** 294 | - Check `changes/[name]/specs/` exists with .md files 295 | - Verify files have operation prefixes (## ADDED Requirements) 296 | 297 | **"Requirement must have at least one scenario"** 298 | - Check scenarios use `#### Scenario:` format (4 hashtags) 299 | - Don't use bullet points or bold for scenario headers 300 | 301 | **Silent scenario parsing failures** 302 | - Exact format required: `#### Scenario: Name` 303 | - Debug with: `openspec show [change] --json --deltas-only` 304 | 305 | ### Validation Tips 306 | 307 | ```bash 308 | # Always use strict mode for comprehensive checks 309 | openspec validate [change] --strict 310 | 311 | # Debug delta parsing 312 | openspec show [change] --json | jq '.deltas' 313 | 314 | # Check specific requirement 315 | openspec show [spec] --json -r 1 316 | ``` 317 | 318 | ## Happy Path Script 319 | 320 | ```bash 321 | # 1) Explore current state 322 | openspec spec list --long 323 | openspec list 324 | # Optional full-text search: 325 | # rg -n "Requirement:|Scenario:" openspec/specs 326 | # rg -n "^#|Requirement:" openspec/changes 327 | 328 | # 2) Choose change id and scaffold 329 | CHANGE=add-two-factor-auth 330 | mkdir -p openspec/changes/$CHANGE/{specs/auth} 331 | printf "## Why\n...\n\n## What Changes\n- ...\n\n## Impact\n- ...\n" > openspec/changes/$CHANGE/proposal.md 332 | printf "## 1. Implementation\n- [ ] 1.1 ...\n" > openspec/changes/$CHANGE/tasks.md 333 | 334 | # 3) Add deltas (example) 335 | cat > openspec/changes/$CHANGE/specs/auth/spec.md << 'EOF' 336 | ## ADDED Requirements 337 | ### Requirement: Two-Factor Authentication 338 | Users MUST provide a second factor during login. 339 | 340 | #### Scenario: OTP required 341 | - **WHEN** valid credentials are provided 342 | - **THEN** an OTP challenge is required 343 | EOF 344 | 345 | # 4) Validate 346 | openspec validate $CHANGE --strict 347 | ``` 348 | 349 | ## Multi-Capability Example 350 | 351 | ``` 352 | openspec/changes/add-2fa-notify/ 353 | ├── proposal.md 354 | ├── tasks.md 355 | └── specs/ 356 | ├── auth/ 357 | │ └── spec.md # ADDED: Two-Factor Authentication 358 | └── notifications/ 359 | └── spec.md # ADDED: OTP email notification 360 | ``` 361 | 362 | auth/spec.md 363 | ```markdown 364 | ## ADDED Requirements 365 | ### Requirement: Two-Factor Authentication 366 | ... 367 | ``` 368 | 369 | notifications/spec.md 370 | ```markdown 371 | ## ADDED Requirements 372 | ### Requirement: OTP Email Notification 373 | ... 374 | ``` 375 | 376 | ## Best Practices 377 | 378 | ### Simplicity First 379 | - Default to <100 lines of new code 380 | - Single-file implementations until proven insufficient 381 | - Avoid frameworks without clear justification 382 | - Choose boring, proven patterns 383 | 384 | ### Complexity Triggers 385 | Only add complexity with: 386 | - Performance data showing current solution too slow 387 | - Concrete scale requirements (>1000 users, >100MB data) 388 | - Multiple proven use cases requiring abstraction 389 | 390 | ### Clear References 391 | - Use `file.ts:42` format for code locations 392 | - Reference specs as `specs/auth/spec.md` 393 | - Link related changes and PRs 394 | 395 | ### Capability Naming 396 | - Use verb-noun: `user-auth`, `payment-capture` 397 | - Single purpose per capability 398 | - 10-minute understandability rule 399 | - Split if description needs "AND" 400 | 401 | ### Change ID Naming 402 | - Use kebab-case, short and descriptive: `add-two-factor-auth` 403 | - Prefer verb-led prefixes: `add-`, `update-`, `remove-`, `refactor-` 404 | - Ensure uniqueness; if taken, append `-2`, `-3`, etc. 405 | 406 | ## Tool Selection Guide 407 | 408 | | Task | Tool | Why | 409 | |------|------|-----| 410 | | Find files by pattern | Glob | Fast pattern matching | 411 | | Search code content | Grep | Optimized regex search | 412 | | Read specific files | Read | Direct file access | 413 | | Explore unknown scope | Task | Multi-step investigation | 414 | 415 | ## Error Recovery 416 | 417 | ### Change Conflicts 418 | 1. Run `openspec list` to see active changes 419 | 2. Check for overlapping specs 420 | 3. Coordinate with change owners 421 | 4. Consider combining proposals 422 | 423 | ### Validation Failures 424 | 1. Run with `--strict` flag 425 | 2. Check JSON output for details 426 | 3. Verify spec file format 427 | 4. Ensure scenarios properly formatted 428 | 429 | ### Missing Context 430 | 1. Read project.md first 431 | 2. Check related specs 432 | 3. Review recent archives 433 | 4. Ask for clarification 434 | 435 | ## Quick Reference 436 | 437 | ### Stage Indicators 438 | - `changes/` - Proposed, not yet built 439 | - `specs/` - Built and deployed 440 | - `archive/` - Completed changes 441 | 442 | ### File Purposes 443 | - `proposal.md` - Why and what 444 | - `tasks.md` - Implementation steps 445 | - `design.md` - Technical decisions 446 | - `spec.md` - Requirements and behavior 447 | 448 | ### CLI Essentials 449 | ```bash 450 | openspec list # What's in progress? 451 | openspec show [item] # View details 452 | openspec validate --strict # Is it correct? 453 | openspec archive [--yes|-y] # Mark complete (add --yes for automation) 454 | ``` 455 | 456 | Remember: Specs are truth. Changes are proposals. Keep them in sync. 457 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | // Paste Reformatter - A plugin that re-formats pasted HTML text in Obsidian. 2 | // Copyright (C) 2025 by Keath Milligan. 3 | 4 | import { App, MarkdownView, Plugin, PluginSettingTab, Setting, htmlToMarkdown, Notice, setIcon } from 'obsidian'; 5 | import { transformHTML } from './htmlTransformer'; 6 | import { transformMarkdown } from './markdownTransformer'; 7 | 8 | interface RegexReplacement { 9 | pattern: string; 10 | replacement: string; 11 | } 12 | 13 | interface PasteReformmatterSettings { 14 | pasteOverride: boolean; // Whether to override the default paste behavior 15 | maxHeadingLevel: number; // The maximum heading level to allow (1-6, where 1 is disabled) 16 | removeEmptyElements: boolean; // Whether to remove empty elements when reformatting pasted content 17 | cascadeHeadingLevels: boolean; // Whether to cascade heading levels (e.g., H1→H2→H3 becomes H2→H3→H4 when max level is H2) 18 | contextualCascade: boolean; // Whether to cascade headings based on the current context (e.g., if cursor is in an H2 section, headings will start from H3) 19 | stripLineBreaks: boolean; // Whether to strip hard line breaks (br tags) when reformatting pasted content 20 | convertToSingleSpaced: boolean; // Whether to collapse multiple consecutive blank lines into a single blank line 21 | removeEmptyLines: boolean; // Whether to remove blank lines in the Markdown output 22 | htmlRegexReplacements: RegexReplacement[]; // Regular expression replacements to apply to the HTML content before converting to Markdown 23 | markdownRegexReplacements: RegexReplacement[]; // Regular expression replacements to apply to the Markdown content after HTML conversion 24 | } 25 | 26 | const DEFAULT_SETTINGS: PasteReformmatterSettings = { 27 | pasteOverride: true, 28 | maxHeadingLevel: 1, 29 | removeEmptyElements: false, 30 | cascadeHeadingLevels: true, 31 | contextualCascade: true, 32 | stripLineBreaks: false, 33 | convertToSingleSpaced: false, 34 | removeEmptyLines: false, 35 | htmlRegexReplacements: [], 36 | markdownRegexReplacements: [] 37 | } 38 | 39 | export default class PasteReformatter extends Plugin { 40 | settings: PasteReformmatterSettings; 41 | 42 | async onload() { 43 | await this.loadSettings(); 44 | 45 | // This adds a settings tab so the user can configure various aspects of the plugin 46 | this.addSettingTab(new PasteReformmatterSettingsTab(this.app, this)); 47 | 48 | // Register paste event 49 | this.registerEvent(this.app.workspace.on("editor-paste", event => this.onPaste(event))); 50 | 51 | // Register discrete command for paste reformatting 52 | this.addCommand({ 53 | id: 'reformat-and-paste', 54 | name: 'Reformat and Paste', 55 | callback: async () => { 56 | try { 57 | // Get clipboard data 58 | const dataTransfer = await this.getClipboardData(); 59 | if (dataTransfer) { 60 | // Paste it into the active editor 61 | this.doPaste(dataTransfer); 62 | } else { 63 | new Notice("Clipboard does not contain HTML or plain text content."); 64 | } 65 | } catch (error) { 66 | console.error("Error accessing clipboard:", error); 67 | new Notice("Error accessing clipboard. Try using regular paste instead."); 68 | } 69 | } 70 | }); 71 | 72 | // Register command to paste with all markdown escaped 73 | this.addCommand({ 74 | id: 'paste-with-escaped-markdown', 75 | name: 'Paste with Escaped Markdown', 76 | callback: async () => { 77 | // Get clipboard data 78 | const dataTransfer = await this.getClipboardData(); 79 | if (dataTransfer) { 80 | // Paste it into the active editor 81 | this.doPaste(dataTransfer, true); 82 | } else { 83 | new Notice("Clipboard does not contain HTML or plain text content."); 84 | } 85 | } 86 | }); 87 | } 88 | 89 | onunload() { 90 | } 91 | 92 | async loadSettings() { 93 | this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); 94 | } 95 | 96 | async saveSettings() { 97 | await this.saveData(this.settings); 98 | } 99 | 100 | async getClipboardData(): Promise { 101 | // Use the Clipboard API to read clipboard data 102 | try { 103 | // Get clipboard data using navigator API 104 | const clipboardItems = await navigator.clipboard.read(); 105 | 106 | // Create a DataTransfer object 107 | const dataTransfer = new DataTransfer(); 108 | 109 | // Process clipboard items 110 | for (const item of clipboardItems) { 111 | // Check for HTML content 112 | if (item.types.includes('text/html')) { 113 | const blob = await item.getType('text/html'); 114 | const html = await blob.text(); 115 | dataTransfer.setData('text/html', html); 116 | } 117 | 118 | // Check for plain text content 119 | if (item.types.includes('text/plain')) { 120 | const blob = await item.getType('text/plain'); 121 | const text = await blob.text(); 122 | dataTransfer.setData('text/plain', text); 123 | } 124 | } 125 | 126 | // Process the clipboard data 127 | if (dataTransfer.types.includes('text/html') || dataTransfer.types.includes('text/plain')) { 128 | return dataTransfer; 129 | } else { 130 | new Notice("Clipboard does not contain HTML or plain text content."); 131 | } 132 | 133 | } catch (error) { 134 | console.error("Error accessing clipboard:", error); 135 | new Notice("Error accessing clipboard. Try using regular paste instead."); 136 | } 137 | return null; // Return null if clipboard data is not available 138 | } 139 | 140 | doPaste(clipboardData: DataTransfer, escapeMarkdown: boolean = false): boolean { 141 | // Get the active editor using non-deprecated API 142 | const activeView = this.app.workspace.getActiveViewOfType(MarkdownView); 143 | if (!activeView) { 144 | return false; 145 | } 146 | 147 | const editor = activeView.editor; 148 | if (!editor) { 149 | return false; 150 | } 151 | 152 | try { 153 | let originalMarkdown = ''; 154 | let appliedHTMLTransformations = false; 155 | let appliedMarkdownTransformations = false; 156 | 157 | // Check if HTML format is available 158 | if (clipboardData.types.includes('text/html')) { 159 | // Process as HTML 160 | const html = clipboardData.getData('text/html'); 161 | 162 | // Transform HTML before converting to Markdown 163 | const result = transformHTML(html, this.settings); 164 | console.debug(`Original HTML: ${html}`); 165 | console.debug(`Transformed HTML: ${result.html}`); 166 | 167 | // Use Obsidian's built-in htmlToMarkdown function 168 | originalMarkdown = htmlToMarkdown(result.html); 169 | 170 | appliedHTMLTransformations = result.appliedTransformations; 171 | } else if (clipboardData.types.includes('text/plain')) { 172 | // Process as plain text - treat it as already being Markdown 173 | originalMarkdown = clipboardData.getData('text/plain'); 174 | } else { 175 | // No supported format found 176 | console.debug("No HTML or plain text content found in clipboard"); 177 | return false; 178 | } 179 | 180 | // Get the current context for contextual cascade 181 | let contextLevel = 0; 182 | if (this.settings.contextualCascade) { 183 | contextLevel = this.getCurrentHeadingLevel(editor); 184 | } 185 | 186 | // Apply settings to transform the markdown 187 | const markdownResult = transformMarkdown(originalMarkdown, this.settings, contextLevel, escapeMarkdown); 188 | appliedMarkdownTransformations = markdownResult.appliedTransformations; 189 | 190 | // Show notification 191 | if (appliedHTMLTransformations || appliedMarkdownTransformations) { 192 | // Replace the current selection with the converted markdown 193 | editor.replaceSelection(markdownResult.markdown); 194 | new Notice(`Reformatted pasted content`); 195 | return true; 196 | } else { 197 | return false; 198 | } 199 | } catch (error) { 200 | console.error("Error processing paste content:", error); 201 | new Notice("Error processing paste content"); 202 | return false 203 | } 204 | } 205 | 206 | onPaste(event: ClipboardEvent) { 207 | if (this.settings.pasteOverride) { 208 | // Check if there's content in the clipboard 209 | const clipboardData = event.clipboardData; 210 | if (!clipboardData) { 211 | return; 212 | } 213 | 214 | // Process the clipboard data 215 | if (this.doPaste(clipboardData)) { 216 | // Prevent the default paste behavior 217 | console.debug("Default paste behavior overridden by Paste Reformatter plugin"); 218 | event.preventDefault(); 219 | } else { 220 | // If the plugin didn't handle the paste, allow the default behavior 221 | console.debug("Paste Reformatter plugin did not handle paste, allowing default behavior"); 222 | } 223 | } 224 | } 225 | 226 | /** 227 | * Determines the current heading level at the cursor position 228 | * @param editor The editor instance 229 | * @returns The heading level (1-6) or 0 if not in a heading section 230 | */ 231 | getCurrentHeadingLevel(editor: any): number { 232 | // Get the current cursor position 233 | const cursor = editor.getCursor(); 234 | const currentLine = cursor.line; 235 | 236 | // Look backward from the current line to find the nearest heading 237 | for (let line = currentLine; line >= 0; line--) { 238 | const lineText = editor.getLine(line); 239 | const headingMatch = lineText.match(/^(#{1,6})\s/); 240 | 241 | if (headingMatch) { 242 | // Return the heading level (number of # characters) 243 | return headingMatch[1].length; 244 | } 245 | } 246 | 247 | // No heading found above the cursor 248 | return 0; 249 | } 250 | } 251 | 252 | class PasteReformmatterSettingsTab extends PluginSettingTab { 253 | plugin: PasteReformatter; 254 | 255 | constructor(app: App, plugin: PasteReformatter) { 256 | super(app, plugin); 257 | this.plugin = plugin; 258 | } 259 | 260 | private scrollNewRowIntoView(type: 'html' | 'markdown'): void { 261 | // Find the appropriate table based on type 262 | const containerClass = type === 'html' ? 'regex-replacements-container' : 'regex-replacements-container'; 263 | const containers = this.containerEl.querySelectorAll('.regex-replacements-container'); 264 | 265 | // Get the correct container (HTML is first, Markdown is second) 266 | const targetContainer = type === 'html' ? containers[0] : containers[1]; 267 | 268 | if (targetContainer) { 269 | const table = targetContainer.querySelector('table'); 270 | if (table) { 271 | const tbody = table.querySelector('tbody'); 272 | if (tbody) { 273 | const rows = tbody.querySelectorAll('tr'); 274 | const lastRow = rows[rows.length - 1]; 275 | 276 | if (lastRow) { 277 | // Check if the row is already visible 278 | const containerRect = this.containerEl.getBoundingClientRect(); 279 | const rowRect = lastRow.getBoundingClientRect(); 280 | 281 | // If the row is not fully visible, scroll it into view 282 | if (rowRect.bottom > containerRect.bottom || rowRect.top < containerRect.top) { 283 | lastRow.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); 284 | } 285 | } 286 | } 287 | } 288 | } 289 | } 290 | 291 | display(): void { 292 | const {containerEl} = this; 293 | 294 | containerEl.empty(); 295 | 296 | new Setting(containerEl) 297 | .setName('Override default paste behavior') 298 | .setDesc('Alter the behavior of the default paste action to reformat pasted content.') 299 | .addToggle(toggle => toggle 300 | .setValue(this.plugin.settings.pasteOverride) 301 | .onChange(async (value) => { 302 | this.plugin.settings.pasteOverride = value; 303 | await this.plugin.saveSettings(); 304 | })); 305 | 306 | // HTML Transformations 307 | new Setting(containerEl) 308 | .setName('HTML transformations') 309 | .setHeading() 310 | .setDesc('Control how the HTML content is processed before being converted to Markdown.'); 311 | 312 | new Setting(containerEl) 313 | .setName('Remove empty elements') 314 | .setDesc('Remove empty elements when reformatting pasted content') 315 | .addToggle(toggle => toggle 316 | .setValue(this.plugin.settings.removeEmptyElements) 317 | .onChange(async (value) => { 318 | this.plugin.settings.removeEmptyElements = value; 319 | await this.plugin.saveSettings(); 320 | })); 321 | 322 | new Setting(containerEl) 323 | .setName('Strip hard line breaks') 324 | .setDesc('Remove line breaks (br tags) when reformatting pasted content') 325 | .addToggle(toggle => toggle 326 | .setValue(this.plugin.settings.stripLineBreaks) 327 | .onChange(async (value) => { 328 | this.plugin.settings.stripLineBreaks = value; 329 | await this.plugin.saveSettings(); 330 | })); 331 | 332 | new Setting(containerEl) 333 | .setName('HTML regex replacements') 334 | .setDesc('Apply regular expression replacements to the HTML content before converting to Markdown. You can use $1, $2, etc. to reference capture groups.'); 335 | 336 | // Create a container for the regex replacement rows 337 | const regexContainer = containerEl.createDiv(); 338 | regexContainer.addClass('regex-replacements-container'); 339 | 340 | // Create a table for the regex replacements 341 | const table = regexContainer.createEl('table'); 342 | table.addClass('regex-table'); 343 | 344 | // Create the header row 345 | const thead = table.createEl('thead'); 346 | const headerRow = thead.createEl('tr'); 347 | 348 | // Pattern header 349 | const patternHeader = headerRow.createEl('th'); 350 | patternHeader.setText('Pattern'); 351 | patternHeader.addClass('regex-th'); 352 | patternHeader.addClass('regex-th-pattern'); 353 | 354 | // Replacement header 355 | const replacementHeader = headerRow.createEl('th'); 356 | replacementHeader.setText('Replacement'); 357 | replacementHeader.addClass('regex-th'); 358 | replacementHeader.addClass('regex-th-replacement'); 359 | 360 | // Actions header 361 | const actionsHeader = headerRow.createEl('th'); 362 | actionsHeader.addClass('regex-th'); 363 | actionsHeader.addClass('regex-th-actions'); 364 | 365 | // Create the table body 366 | const tbody = table.createEl('tbody'); 367 | 368 | // Add a row for each replacement 369 | this.plugin.settings.htmlRegexReplacements.forEach((replacement, index) => { 370 | const row = tbody.createEl('tr'); 371 | 372 | // Pattern cell 373 | const patternCell = row.createEl('td'); 374 | patternCell.addClass('regex-td'); 375 | 376 | // Pattern input 377 | const patternInput = document.createElement('input'); 378 | patternInput.type = 'text'; 379 | patternInput.value = replacement.pattern; 380 | patternInput.placeholder = 'Regular expression pattern'; 381 | patternInput.addClass('regex-input'); 382 | patternInput.addEventListener('change', async () => { 383 | this.plugin.settings.htmlRegexReplacements[index].pattern = patternInput.value; 384 | await this.plugin.saveSettings(); 385 | }); 386 | patternCell.appendChild(patternInput); 387 | 388 | // Replacement cell 389 | const replacementCell = row.createEl('td'); 390 | replacementCell.addClass('regex-td'); 391 | 392 | // Replacement input 393 | const replacementInput = document.createElement('input'); 394 | replacementInput.type = 'text'; 395 | replacementInput.value = replacement.replacement; 396 | replacementInput.placeholder = 'Replacement value (can use $1, $2, etc.)'; 397 | replacementInput.addClass('regex-input'); 398 | replacementInput.addEventListener('change', async () => { 399 | this.plugin.settings.htmlRegexReplacements[index].replacement = replacementInput.value; 400 | await this.plugin.saveSettings(); 401 | }); 402 | replacementCell.appendChild(replacementInput); 403 | 404 | // Actions cell 405 | const actionsCell = row.createEl('td'); 406 | actionsCell.addClass('regex-td'); 407 | actionsCell.addClass('regex-td-actions'); 408 | 409 | // Remove icon 410 | const removeIcon = document.createElement('span'); 411 | removeIcon.className = 'regex-remove-icon'; 412 | removeIcon.setAttribute('title', 'Delete'); 413 | removeIcon.setAttribute('aria-label', 'Delete'); 414 | removeIcon.setAttribute('role', 'button'); 415 | removeIcon.setAttribute('tabindex', '0'); 416 | setIcon(removeIcon, 'trash-2'); 417 | removeIcon.addEventListener('click', async () => { 418 | this.plugin.settings.htmlRegexReplacements.splice(index, 1); 419 | await this.plugin.saveSettings(); 420 | this.display(); // Refresh the display 421 | }); 422 | // Add keyboard support for accessibility 423 | removeIcon.addEventListener('keydown', (e) => { 424 | if (e.key === 'Enter' || e.key === ' ') { 425 | e.preventDefault(); 426 | removeIcon.click(); 427 | } 428 | }); 429 | actionsCell.appendChild(removeIcon); 430 | }); 431 | 432 | // Add a message if no replacements are defined 433 | if (this.plugin.settings.htmlRegexReplacements.length === 0) { 434 | const emptyRow = tbody.createEl('tr'); 435 | const emptyCell = emptyRow.createEl('td'); 436 | emptyCell.colSpan = 3; 437 | emptyCell.addClass('regex-empty-message'); 438 | emptyCell.setText('No replacements defined. Click the + icon below to add one.'); 439 | } 440 | 441 | // Add plus-circle icon for adding new HTML replacements 442 | const htmlAddIcon = regexContainer.createEl('div'); 443 | htmlAddIcon.className = 'regex-add-icon'; 444 | htmlAddIcon.setAttribute('title', 'Add replacement'); 445 | htmlAddIcon.setAttribute('aria-label', 'Add replacement'); 446 | htmlAddIcon.setAttribute('role', 'button'); 447 | htmlAddIcon.setAttribute('tabindex', '0'); 448 | setIcon(htmlAddIcon, 'plus-circle'); 449 | htmlAddIcon.addEventListener('click', () => { 450 | // Check if there's already an empty row 451 | const hasEmptyRow = this.plugin.settings.htmlRegexReplacements.some( 452 | replacement => replacement.pattern === '' && replacement.replacement === '' 453 | ); 454 | 455 | // Only add a new row if there isn't already an empty one 456 | if (!hasEmptyRow) { 457 | // Add a new empty replacement 458 | this.plugin.settings.htmlRegexReplacements.push({ 459 | pattern: '', 460 | replacement: '' 461 | }); 462 | // Save settings and refresh display, preserving scroll position and scrolling new row into view if needed 463 | const scrollTop = this.containerEl.scrollTop; 464 | this.plugin.saveSettings().then(() => { 465 | this.display(); 466 | // Restore original scroll position 467 | this.containerEl.scrollTop = scrollTop; 468 | // Then scroll the newly added row into view if it's not visible 469 | this.scrollNewRowIntoView('html'); 470 | }); 471 | } 472 | }); 473 | // Add keyboard support for accessibility 474 | htmlAddIcon.addEventListener('keydown', (e) => { 475 | if (e.key === 'Enter' || e.key === ' ') { 476 | e.preventDefault(); 477 | htmlAddIcon.click(); 478 | } 479 | }); 480 | 481 | new Setting(containerEl) 482 | .setName('Markdown transformations') 483 | .setDesc('Control how the Markdown content is adjusted after HTML conversion or when pasted as plain text.') 484 | .setHeading(); 485 | 486 | new Setting(containerEl) 487 | .setName('Max heading level') 488 | .setDesc('The maximum heading level to allow when reformatting pasted content (H1 is treated as disabled)') 489 | .addDropdown(dropdown => dropdown 490 | .addOptions({ 491 | '1': 'Disabled (H1)', 492 | '2': 'H2', 493 | '3': 'H3', 494 | '4': 'H4', 495 | '5': 'H5', 496 | '6': 'H6' 497 | }) 498 | .setValue(this.plugin.settings.maxHeadingLevel.toString()) 499 | .onChange(async (value) => { 500 | this.plugin.settings.maxHeadingLevel = parseInt(value); 501 | await this.plugin.saveSettings(); 502 | 503 | // Always refresh the display to update the cascade heading levels toggle visibility 504 | this.display(); 505 | })); 506 | 507 | // Only show cascade heading levels setting if max heading level is not H1 (disabled) 508 | if (this.plugin.settings.maxHeadingLevel > 1) { 509 | new Setting(containerEl) 510 | .setName('Cascade heading levels') 511 | .setDesc('Preserve the heading hierarchy by cascading levels (e.g., H1→H2→H3 becomes H2→H3→H4 when max level is H2)') 512 | .addToggle(toggle => toggle 513 | .setValue(this.plugin.settings.cascadeHeadingLevels) 514 | .onChange(async (value) => { 515 | this.plugin.settings.cascadeHeadingLevels = value; 516 | await this.plugin.saveSettings(); 517 | })); 518 | } 519 | 520 | new Setting(containerEl) 521 | .setName('Contextual cascade') 522 | .setDesc('Cascade headings based on the current context (e.g., if cursor is in an H2 section, headings will start from H3)') 523 | .addToggle(toggle => toggle 524 | .setValue(this.plugin.settings.contextualCascade) 525 | .onChange(async (value) => { 526 | this.plugin.settings.contextualCascade = value; 527 | await this.plugin.saveSettings(); 528 | })); 529 | 530 | const singleSpacedSetting = new Setting(containerEl) 531 | .setName('Convert to single-spaced') 532 | .setDesc('Collapse multiple consecutive blank lines into a single blank line') 533 | .addToggle(toggle => toggle 534 | .setValue(this.plugin.settings.convertToSingleSpaced) 535 | .setDisabled(this.plugin.settings.removeEmptyLines) 536 | .onChange(async (value) => { 537 | this.plugin.settings.convertToSingleSpaced = value; 538 | await this.plugin.saveSettings(); 539 | })); 540 | 541 | // Add visual indication when disabled 542 | if (this.plugin.settings.removeEmptyLines) { 543 | singleSpacedSetting.settingEl.addClass('paste-reformatter-disabled-setting'); 544 | } 545 | 546 | new Setting(containerEl) 547 | .setName('Remove empty lines') 548 | .setDesc('Remove blank lines in the Markdown output') 549 | .addToggle(toggle => toggle 550 | .setValue(this.plugin.settings.removeEmptyLines) 551 | .onChange(async (value) => { 552 | this.plugin.settings.removeEmptyLines = value; 553 | await this.plugin.saveSettings(); 554 | 555 | // Refresh the display to update the disabled state of "Convert to single-spaced" 556 | this.display(); 557 | })); 558 | 559 | new Setting(containerEl) 560 | .setName('Markdown regex replacements') 561 | .setDesc('Apply regular expression replacements to the Markdown content after HTML conversion. You can use $1, $2, etc. to reference capture groups.'); 562 | 563 | // Create a container for the regex replacement rows 564 | const markdownRegexContainer = containerEl.createDiv(); 565 | markdownRegexContainer.addClass('regex-replacements-container'); 566 | 567 | // Create a table for the regex replacements 568 | const markdownRegexTable = markdownRegexContainer.createEl('table'); 569 | markdownRegexTable.addClass('regex-table'); 570 | 571 | // Create the header row 572 | const markdownRegexThead = markdownRegexTable.createEl('thead'); 573 | const markdownRegexHeaderRow = markdownRegexThead.createEl('tr'); 574 | 575 | // Pattern header 576 | const markdownRegexPatternHeader = markdownRegexHeaderRow.createEl('th'); 577 | markdownRegexPatternHeader.setText('Pattern'); 578 | markdownRegexPatternHeader.addClass('regex-th'); 579 | markdownRegexPatternHeader.addClass('regex-th-pattern'); 580 | 581 | // Replacement header 582 | const markdownRegexReplacementHeader = markdownRegexHeaderRow.createEl('th'); 583 | markdownRegexReplacementHeader.setText('Replacement'); 584 | markdownRegexReplacementHeader.addClass('regex-th'); 585 | markdownRegexReplacementHeader.addClass('regex-th-replacement'); 586 | 587 | // Actions header 588 | const markdownRegexActionsHeader = markdownRegexHeaderRow.createEl('th'); 589 | markdownRegexActionsHeader.addClass('regex-th'); 590 | markdownRegexActionsHeader.addClass('regex-th-actions'); 591 | 592 | // Create the table body 593 | const markdownRegexTbody = markdownRegexTable.createEl('tbody'); 594 | 595 | // Add a row for each replacement 596 | this.plugin.settings.markdownRegexReplacements.forEach((replacement, index) => { 597 | const row = markdownRegexTbody.createEl('tr'); 598 | 599 | // Pattern cell 600 | const patternCell = row.createEl('td'); 601 | patternCell.addClass('regex-td'); 602 | 603 | // Pattern input 604 | const patternInput = document.createElement('input'); 605 | patternInput.type = 'text'; 606 | patternInput.value = replacement.pattern; 607 | patternInput.placeholder = 'Regular expression pattern'; 608 | patternInput.addClass('regex-input'); 609 | patternInput.addEventListener('change', async () => { 610 | this.plugin.settings.markdownRegexReplacements[index].pattern = patternInput.value; 611 | await this.plugin.saveSettings(); 612 | }); 613 | patternCell.appendChild(patternInput); 614 | 615 | // Replacement cell 616 | const replacementCell = row.createEl('td'); 617 | replacementCell.addClass('regex-td'); 618 | 619 | // Replacement input 620 | const replacementInput = document.createElement('input'); 621 | replacementInput.type = 'text'; 622 | replacementInput.value = replacement.replacement; 623 | replacementInput.placeholder = 'Replacement value (can use $1, $2, etc.)'; 624 | replacementInput.addClass('regex-input'); 625 | replacementInput.addEventListener('change', async () => { 626 | this.plugin.settings.markdownRegexReplacements[index].replacement = replacementInput.value; 627 | await this.plugin.saveSettings(); 628 | }); 629 | replacementCell.appendChild(replacementInput); 630 | 631 | // Actions cell 632 | const actionsCell = row.createEl('td'); 633 | actionsCell.addClass('regex-td'); 634 | actionsCell.addClass('regex-td-actions'); 635 | 636 | // Remove icon 637 | const removeIcon = document.createElement('span'); 638 | removeIcon.className = 'regex-remove-icon'; 639 | removeIcon.setAttribute('title', 'Delete'); 640 | removeIcon.setAttribute('aria-label', 'Delete'); 641 | removeIcon.setAttribute('role', 'button'); 642 | removeIcon.setAttribute('tabindex', '0'); 643 | setIcon(removeIcon, 'trash-2'); 644 | removeIcon.addEventListener('click', async () => { 645 | this.plugin.settings.markdownRegexReplacements.splice(index, 1); 646 | await this.plugin.saveSettings(); 647 | this.display(); // Refresh the display 648 | }); 649 | // Add keyboard support for accessibility 650 | removeIcon.addEventListener('keydown', (e) => { 651 | if (e.key === 'Enter' || e.key === ' ') { 652 | e.preventDefault(); 653 | removeIcon.click(); 654 | } 655 | }); 656 | actionsCell.appendChild(removeIcon); 657 | }); 658 | 659 | // Add a message if no replacements are defined 660 | if (this.plugin.settings.markdownRegexReplacements.length === 0) { 661 | const emptyRow = markdownRegexTbody.createEl('tr'); 662 | const emptyCell = emptyRow.createEl('td'); 663 | emptyCell.colSpan = 3; 664 | emptyCell.addClass('regex-empty-message'); 665 | emptyCell.setText('No replacements defined. Click the + icon below to add one.'); 666 | } 667 | 668 | // Add plus-circle icon for adding new Markdown replacements 669 | const markdownAddIcon = markdownRegexContainer.createEl('div'); 670 | markdownAddIcon.className = 'regex-add-icon'; 671 | markdownAddIcon.setAttribute('title', 'Add replacement'); 672 | markdownAddIcon.setAttribute('aria-label', 'Add replacement'); 673 | markdownAddIcon.setAttribute('role', 'button'); 674 | markdownAddIcon.setAttribute('tabindex', '0'); 675 | setIcon(markdownAddIcon, 'plus-circle'); 676 | markdownAddIcon.addEventListener('click', () => { 677 | // Check if there's already an empty row 678 | const hasEmptyRow = this.plugin.settings.markdownRegexReplacements.some( 679 | replacement => replacement.pattern === '' && replacement.replacement === '' 680 | ); 681 | 682 | // Only add a new row if there isn't already an empty one 683 | if (!hasEmptyRow) { 684 | // Add a new empty replacement 685 | this.plugin.settings.markdownRegexReplacements.push({ 686 | pattern: '', 687 | replacement: '' 688 | }); 689 | // Save settings and refresh display, preserving scroll position and scrolling new row into view if needed 690 | const scrollTop = this.containerEl.scrollTop; 691 | this.plugin.saveSettings().then(() => { 692 | this.display(); 693 | // Restore original scroll position 694 | this.containerEl.scrollTop = scrollTop; 695 | // Then scroll the newly added row into view if it's not visible 696 | this.scrollNewRowIntoView('markdown'); 697 | }); 698 | } 699 | }); 700 | // Add keyboard support for accessibility 701 | markdownAddIcon.addEventListener('keydown', (e) => { 702 | if (e.key === 'Enter' || e.key === ' ') { 703 | e.preventDefault(); 704 | markdownAddIcon.click(); 705 | } 706 | }); 707 | } 708 | 709 | } 710 | --------------------------------------------------------------------------------