Compare commits

..

35 commits
1.0.0 ... main

Author SHA1 Message Date
Keath Milligan
02e538218d
chore: version 1.4.2 2026-06-01 17:54:53 -05:00
Keath Milligan
b20b44f218
Fix transform & paste handling issues (#24)
* fix: extra blank lines being added to initial html transform

* fix: ignore selected texted when determining contextual heading level

* fix: ignore events already handled by another plugin

* feat(ci): add CI workflow

* chore: add package-lock.json
2026-06-01 14:15:34 -05:00
Keath Milligan
a05bdc269e
chore: cleanup openspec 2026-05-31 21:47:46 -05:00
Keath Milligan
6be4d6ec56
fix: skip reformatting when pasting in codeblock (#19)
- Add isCursorInCodeBlock() helper that detects
  fenced code blocks by scanning from line 0 to
  cursor, toggling on backtick/tilde fences
- Add early-return guard in onPaste() to bypass
  reformatting when cursor is inside a code block
- Archive openspec change proposal and update
  paste-interception spec with new requirement
2026-02-10 21:30:10 -06:00
Keath Milligan
4f539dc990
fix: update release workflow 2026-02-10 20:09:21 -06:00
Keath Milligan
c8f5c24e92
fix: accumulate heading cascade flag (#18)
- Fix appliedTransformations flag being overwritten on
  each heading iteration instead of accumulated
- Prevents double-paste when last heading needs no
  adjustment by using logical OR assignment
- Apply same fix to both contextual cascade and
  maxHeadingLevel cascade paths
- Add openspec proposal, spec, and task tracking for
  the heading cascade flag fix
- Add paste-interception spec with heading cascade
  transformation flag requirements
2026-02-10 20:04:38 -06:00
Keath Milligan
62a1893a97
build cleanup (#17)
* style: cleanup file formatting

* chore: simplify build

* chore: cleanup package.json
2026-02-10 18:31:53 -06:00
Asa Reynolds
7435a160b7
Add toggle for notifications (#15) 2026-02-10 18:13:36 -06:00
Keath Milligan
b80d494bed
fix: handle special characters in replacement strings 2025-11-25 22:08:46 -06:00
Keath Milligan
ec8685eddc
feat: add single-spacing transformer option
- Add convertToSingleSpaced setting to collapse multiple
  consecutive blank lines into single blank lines
- Add "Convert to single-spaced" toggle in settings UI
  positioned above "Remove empty lines"
- Implement conditional disabling when "Remove empty
  lines" is enabled with visual indication
- Add transformer logic that processes before empty line
  removal with optimization to skip when not needed
- Add OpenSpec documentation for markdown-transformations
  capability
- Update version to 1.3.0
2025-11-25 19:19:17 -06:00
Keath Milligan
7c4cc6dec9
chore: add openspec 2025-11-25 17:09:55 -06:00
Keath Milligan
73506e74b6
chore: bump version 2025-11-18 22:07:37 -06:00
Keath Milligan
fffeead4a8
refactor: improve empty line removal logic
- Replace placeholder approach with sliding window processing
- Add context-aware rules for table and horizontal rule preservation
- Consolidate line break handling into single processing path
- Convert preserve markers directly to empty lines
- Fix appliedTransformations tracking logic
2025-11-18 21:58:21 -06:00
Keath Milligan
2a1e1bb462
Bump version, update README screenshot 2025-08-23 18:33:28 -05:00
Keath Milligan
f659fb0144
Replacement list improvements (#11)
* Fix scrolling behavior when adding replacement items

* Improve replacement list management UX
2025-08-23 16:40:00 -05:00
Keath Milligan
0a3b45f9cd
Update version 2025-04-27 23:08:02 -05:00
Keath Milligan
6d5ab0807f
Update README 2025-04-27 23:05:47 -05:00
Keath Milligan
2447abe449
Merge pull request #9 from keathmilligan/issue/1-auto-escape-markdown
Add command to escape markdown
2025-04-27 23:01:37 -05:00
Keath Milligan
a051adefb1
Merge pull request #8 from keathmilligan/issue/7-notification-false-positives
Notification false positives
2025-04-27 23:01:20 -05:00
Keath Milligan
127266acfe
Refactor clipboard handling and enhance markdown transformation with escape option 2025-04-27 22:58:45 -05:00
Keath Milligan
63610697ed
Notification false positives
Fixes #7
2025-04-27 22:22:15 -05:00
Keath Milligan
1320ccc231
Update package version 2025-04-26 14:57:26 -05:00
Keath Milligan
1b2ec43b3e
Update manifest 2025-04-26 14:49:53 -05:00
Keath Milligan
83716e7901
Update manifest 2025-04-26 14:48:15 -05:00
Keath Milligan
e064ec61b2
Update release.yml 2025-04-26 14:31:19 -05:00
Keath Milligan
0bfadc300e
Update release.yml 2025-04-26 14:26:24 -05:00
Keath Milligan
ecffc1e750
Update release.yml 2025-04-26 14:25:45 -05:00
Keath Milligan
4c8217ace6
Update release.yml 2025-04-26 14:24:06 -05:00
Keath Milligan
c2aa482ebd
Merge pull request #6 from keathmilligan/update-readme
Update README
2025-04-26 14:16:55 -05:00
Keath Milligan
f6080102a0
Update README 2025-04-26 14:16:31 -05:00
Keath Milligan
7fa51ee4d3
Merge pull request #5 from keathmilligan/paste-override-option
Enhance paste override behavior
2025-04-26 13:43:05 -05:00
Keath Milligan
feda67ff5b
Enhance paste override behavior
Reduce confliction with other paste handlers:
- Add discrete "Reformat and Paste" command
- Don't prevent default paste behavior if no reformatting took place
2025-04-26 13:42:29 -05:00
Keath Milligan
3f1fe690d2
Merge pull request #4 from keathmilligan/notification-fix
Issue notification only when text is actually transformed
2025-04-26 10:58:54 -05:00
Keath Milligan
b200557921
Issue notification only when text is actually transformed 2025-04-26 10:57:52 -05:00
Keath Milligan
68cba39d9b
Add release workflow 2025-04-26 09:22:02 -05:00
17 changed files with 3616 additions and 873 deletions

28
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,28 @@
name: CI
on:
push:
branches:
- main
tags-ignore:
- "*"
pull_request:
branches:
- "**"
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Use Node.js
uses: actions/setup-node@v3
with:
node-version: "18.x"
- name: Install dependencies
run: npm ci
- name: Build plugin
run: npm run build

35
.github/workflows/release.yml vendored Normal file
View file

@ -0,0 +1,35 @@
name: Release Obsidian plugin
on:
push:
tags:
- "*"
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v3
- name: Use Node.js
uses: actions/setup-node@v3
with:
node-version: "18.x"
- name: Build plugin
run: |
npm install
npm run build
- name: Create release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
tag="${GITHUB_REF#refs/tags/}"
gh release create "$tag" \
--title="$tag" \
--draft \
main.js manifest.json styles.css

11
.gitignore vendored
View file

@ -21,11 +21,6 @@ dist/
dist
build/
# Dependency lock files (keep package.json)
package-lock.json
yarn.lock
pnpm-lock.yaml
# Testing
coverage/
.nyc_output/
@ -33,3 +28,9 @@ coverage/
# Obsidian
.obsidian/
.trash/
# Gen AI tools
.roo/
.opencode/
.claude/

73
AGENTS.md Normal file
View file

@ -0,0 +1,73 @@
# Obsidian Plugin Development Guidelines
This is an Obsidian plugin project. Follow these guidelines when working with the codebase.
## Reference Documentation
- Official guide: https://docs.obsidian.md/Plugins/Getting+started/Build+a+plugin
- API reference: https://docs.obsidian.md/Reference/TypeScript+API/Plugin
- TypeScript API: Use `obsidian` module types
## Project Structure
- `main.ts` - Main plugin entry point (extends `Plugin` class)
- `manifest.json` - Plugin metadata (id, name, version, minAppVersion)
- `styles.css` - Plugin styles (optional)
- `src/` - Additional source files and modules
## Best Practices
### Security & DOM Manipulation
- **Never** use `innerHTML` or `outerHTML` (security risk)
- Use DOM APIs (`createElement`, `appendChild`, etc.) or Obsidian helper functions
- Sanitize user input before rendering
- Prefer `createEl()` and `createDiv()` methods from Obsidian API
### Styling
- Avoid assigning styles via JavaScript or inline HTML
- Move all styles to CSS files for better theme compatibility
- Use CSS variables for colors and spacing when possible
- Follow Obsidian's design language
- Use `app.workspace.containerEl.win` to access the window object for styles
### Settings
- Use the `Setting` API for all settings components
- Include proper headings and dividers using the Settings API
- Use sentence case for all UI text and headings
- Organize settings logically into sections
- Store settings in a dedicated settings object
- Call `saveData()` after settings changes
### Code Organization
- Keep main plugin file lean, delegate to separate modules
- Use TypeScript for better type safety
- Follow async/await patterns for asynchronous operations
- Clean up resources in `onunload()` method
- Use `addCommand()` for command palette integration
- Use `registerEvent()` for event listeners to ensure cleanup
### Plugin Lifecycle
- `onload()` - Initialize plugin, register events, commands, settings
- `onunload()` - Clean up resources, remove event listeners
- Use `this.register()` to register cleanup callbacks
- Use `this.registerEvent()` for automatic event cleanup
### Common Patterns
- Access app: `this.app`
- Access vault: `this.app.vault`
- Access workspace: `this.app.workspace`
- Read files: `this.app.vault.read(file)`
- Modify files: `this.app.vault.modify(file, content)`
- Create notices: `new Notice("message")`
### Development Workflow
- Use `npm run dev` to watch and rebuild on changes
- Hot reload: Copy built `main.js` to vault's `.obsidian/plugins/` folder
- Test in actual Obsidian vault for best results
- Check console for errors: View > Toggle Developer Tools
### Performance
- Avoid blocking the main thread
- Use debouncing for frequent operations
- Be mindful of large vault operations
- Cache expensive computations when appropriate

View file

@ -2,6 +2,11 @@
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.
* Use RegEx to transform HTML and Markdown.
* Reformat HTML before converting to Markdown for better formatting results.
* Automatically adjust pasted heading levels to match content.
* Strip blank lines and elements.
## Installation
### From Obsidian Community Plugins
@ -31,13 +36,35 @@ The Paste Reformatter plugin automatically processes content when you paste it i
4. Applies Markdown transformations (heading adjustments, line break handling, regex string replacement)
5. Inserts the transformed content at the cursor position
A notification will appear briefly indicating whether HTML or plain text content was reformatted.
A notification will appear briefly indicating whether HTML or plain text content was reformatted. This can be disabled in settings.
### Commands
> Note: commands are prefixed with "Paste Reformatter:" in the hot-key list.
|Command|Description|
|-|-|
|**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.|
|**Paste with Escaped Markdown**|Pastes text with all markdown escaped. For example, `[Data]` becomes `\[Data]`.
### Potential Conflicts
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.
## Configuration
![alt text](image.png)
The plugin settings are divided into two main categories that mirror the transformation pipeline:
### General Settings
#### Override default paste behavior
When this setting is enabled, the default behavior of Obsidian's Paste function will be enhanced. Otherwise,
**Reformat and Paste** command can be bound to an alternative hot-key to get enhanced paste behavior.
#### Show paste notifications
When enabled, a notice appears after reformatting content. Disable this to hide notifications.
### HTML Transformations

View file

@ -1,158 +0,0 @@
# Heading Cascade Rules
## Scanario: Cascade Heading Levels is Disabled
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.
### Example
If Max heading Level is set to H3, then the following text when pasted:
```
# Heading 1
## Heading 2
### Heading 3
#### Heading 4
##### Heading 5
###### Heading 6
# Heading 1
## Heading 2
### Heading 3
#### Heading 4
##### Heading 5
###### Heading 6
```
Will be transformed into:
```
### Heading 1
### Heading 2
### Heading 3
#### Heading 4
##### Heading 5
###### Heading 6
### Heading 1
### Heading 2
### Heading 3
#### Heading 4
##### Heading 5
###### Heading 6
```
## Scenario: Cascade Heading Levels is Enabled
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.
In addition subsequent headings values are demoted ("cascaded") to the heading level below.
### Example
When max heading level is set to H3 and the following text was pasted:
```
# Heading 1
## Heading 2
### Heading 3
#### Heading 4
##### Heading 5
###### Heading 6
# Heading 1
## Heading 2
### Heading 3
#### Heading 4
##### Heading 5
###### Heading 6
```
The result would be:
```
### Heading 1
#### Heading 2
##### Heading 3
###### Heading 4
###### Heading 5
###### Heading 6
### Heading 1
#### Heading 2
##### Heading 3
###### Heading 4
###### Heading 5
###### Heading 6
```
(heading levels will never be demoted below H6, e.g. "######")
### Another Example
When Max Heading Level is set to H2. The following should remain unchanged:
```
## Heading 2
### Heading 3
#### Heading 4
##### Heading 5
###### Heading 6
## Heading 2
### Heading 3
#### Heading 4
##### Heading 5
###### Heading 6
```
# Contextual Cascade Rules
Contextual Cascade is completely independent of Cascade Heading Levels.
## Scenario: Contextual Cascade is Disabled
When Contextual Cascade is disabled, then the normal Max Heading Level and Cascade Heading Levels rules apply as usual.
## Scenario: Contextual Cascade is Enabled
When Contextual Cascade is enabled, then the Max Heading Level and Cascade Heading Levels rules are superceded by the Contextual Cascade rules:
### Example
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:
```
# Heading 1
## Heading 2
### Heading 3
#### Heading 4
##### Heading 5
###### Heading 6
# Heading 1
## Heading 2
### Heading 3
#### Heading 4
##### Heading 5
###### Heading 6
```
The result would be:
```
### Heading 1
#### Heading 2
##### Heading 3
###### Heading 4
###### Heading 5
###### Heading 6
### Heading 1
#### Heading 2
##### Heading 3
###### Heading 4
###### Heading 5
###### Heading 6
```
As with regular cascading, the heading levels will never be demoted below H6, e.g. "######".

View file

@ -1,34 +0,0 @@
// copy-styles.mjs
import { promises as fs } from 'fs';
import path from 'path';
// Ensure the dist directory exists
async function ensureDir(dir) {
try {
await fs.mkdir(dir, { recursive: true });
} catch (error) {
if (error.code !== 'EEXIST') {
throw error;
}
}
}
async function copyFiles() {
const distDir = path.resolve('./dist');
await ensureDir(distDir);
try {
// Copy styles.css to dist directory
await fs.copyFile('./styles.css', path.join(distDir, 'styles.css'));
console.log('Successfully copied styles.css to dist directory');
// Copy manifest.json to dist directory
await fs.copyFile('./manifest.json', path.join(distDir, 'manifest.json'));
console.log('Successfully copied manifest.json to dist directory');
} catch (error) {
console.error('Error copying files:', error);
process.exit(1);
}
}
copyFiles();

View file

@ -3,7 +3,7 @@ import process from "process";
import builtins from "builtin-modules";
const banner =
`/*
`/*
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
if you want to view the source, please visit the github repository of this plugin
*/
@ -12,38 +12,38 @@ if you want to view the source, please visit the github repository of this plugi
const prod = (process.argv[2] === "production");
const context = await esbuild.context({
banner: {
js: banner,
},
entryPoints: ["src/main.ts"],
bundle: true,
external: [
"obsidian",
"electron",
"@codemirror/autocomplete",
"@codemirror/collab",
"@codemirror/commands",
"@codemirror/language",
"@codemirror/lint",
"@codemirror/search",
"@codemirror/state",
"@codemirror/view",
"@lezer/common",
"@lezer/highlight",
"@lezer/lr",
...builtins],
format: "cjs",
target: "es2018",
logLevel: "info",
sourcemap: prod ? false : "inline",
treeShaking: true,
outfile: "dist/main.js",
minify: prod,
banner: {
js: banner,
},
entryPoints: ["src/main.ts"],
bundle: true,
external: [
"obsidian",
"electron",
"@codemirror/autocomplete",
"@codemirror/collab",
"@codemirror/commands",
"@codemirror/language",
"@codemirror/lint",
"@codemirror/search",
"@codemirror/state",
"@codemirror/view",
"@lezer/common",
"@lezer/highlight",
"@lezer/lr",
...builtins],
format: "cjs",
target: "es2018",
logLevel: "info",
sourcemap: prod ? false : "inline",
treeShaking: true,
outfile: "main.js",
minify: prod,
});
if (prod) {
await context.rebuild();
process.exit(0);
await context.rebuild();
process.exit(0);
} else {
await context.watch();
await context.watch();
}

BIN
image.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 487 KiB

After

Width:  |  Height:  |  Size: 253 KiB

View file

@ -1,10 +1,10 @@
{
"id": "paste-reformatter",
"name": "Paste Reformatter",
"version": "1.0.0",
"version": "1.4.2",
"minAppVersion": "0.15.0",
"description": "Reformat pasted text for precise control.",
"author": "Keath Milligan",
"authorUrl": "https://github.com/keathmilligan",
"isDesktopOnly": false
}
}

2330
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,24 +1,24 @@
{
"name": "obsidian-sample-plugin",
"version": "1.0.0",
"description": "This is a sample plugin for Obsidian (https://obsidian.md)",
"main": "dist/main.js",
"scripts": {
"dev": "node esbuild.config.mjs && node copy-styles.mjs",
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production && node copy-styles.mjs",
"version": "node version-bump.mjs && git add manifest.json versions.json"
},
"keywords": [],
"author": "",
"license": "MIT",
"devDependencies": {
"@types/node": "^16.11.6",
"@typescript-eslint/eslint-plugin": "5.29.0",
"@typescript-eslint/parser": "5.29.0",
"builtin-modules": "3.3.0",
"esbuild": "0.17.3",
"obsidian": "latest",
"tslib": "2.4.0",
"typescript": "4.7.4"
}
"name": "obsidian-paste-reformatter",
"version": "1.4.2",
"description": "Reformat pasted text for precise control.",
"main": "main.js",
"scripts": {
"dev": "node esbuild.config.mjs",
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
"version": "node version-bump.mjs && git add manifest.json versions.json"
},
"keywords": [],
"author": "",
"license": "MIT",
"devDependencies": {
"@types/node": "^16.11.6",
"@typescript-eslint/eslint-plugin": "5.29.0",
"@typescript-eslint/parser": "5.29.0",
"builtin-modules": "3.3.0",
"esbuild": "0.17.3",
"obsidian": "latest",
"tslib": "2.4.0",
"typescript": "4.7.4"
}
}

View file

@ -5,32 +5,38 @@
* Transforms the HTML content before converting it to Markdown
* @param html The HTML content to transform
* @param settings The settings to use for transformation
* @returns The transformed HTML content
* @returns An object containing the transformed HTML content and whether any transformations were applied
*/
export function transformHTML(
html: string,
settings: {
htmlRegexReplacements: Array<{pattern: string, replacement: string}>,
stripLineBreaks: boolean,
removeEmptyElements: boolean
html: string,
settings: {
htmlRegexReplacements: Array<{ pattern: string, replacement: string }>,
stripLineBreaks: boolean,
removeEmptyElements: boolean
}
): string {
): { html: string, appliedTransformations: boolean } {
let appliedTransformations = false;
// Apply regex replacements first
if (settings.htmlRegexReplacements && settings.htmlRegexReplacements.length > 0) {
for (const replacement of settings.htmlRegexReplacements) {
try {
const regex = new RegExp(replacement.pattern, 'g');
const originalHtml = html;
html = html.replace(regex, replacement.replacement);
if (originalHtml !== html) {
appliedTransformations = true;
}
} catch (error) {
console.error(`Error applying regex replacement: ${error}`);
}
}
}
// Create a temporary DOM element to parse the HTML
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
// Process line breaks if strip line breaks is enabled
if (settings.stripLineBreaks) {
// Find all <br> elements and remove them
@ -38,26 +44,9 @@ export function transformHTML(
brElements.forEach(br => {
br.remove();
});
} else {
// If we're not stripping line breaks, convert them to special paragraph tags
// that will be preserved even if empty elements are removed
const brElements = doc.querySelectorAll('br');
brElements.forEach(br => {
// Create a special paragraph with a class that marks it as a line break
const lineBreakP = doc.createElement('p');
lineBreakP.className = 'preserve-line-break';
lineBreakP.setAttribute('data-preserve', 'true');
// Add a non-breaking space as placeholder content
// This ensures the paragraph isn't considered empty when Remove Empty Lines is enabled
// The unicode character will be invisible but ensures the line isn't empty
lineBreakP.textContent = '\u200B'; // Zero-width space
// Replace the <br> with our special paragraph
br.parentNode?.replaceChild(lineBreakP, br);
});
appliedTransformations = true;
}
// Remove empty elements if enabled
if (settings.removeEmptyElements) {
// Function to check if an element is empty (no text content and no meaningful children)
@ -66,50 +55,54 @@ export function transformHTML(
if (['img', 'hr', 'br', 'input', 'iframe'].includes(element.tagName.toLowerCase())) {
return false;
}
// Skip our special line break paragraphs
if (element.hasAttribute('data-preserve')) {
return false;
}
// Check if it has any text content (excluding whitespace)
if (element.textContent && element.textContent.trim().length > 0) {
// Check if it has any text content (include whitespace)
if (element.textContent && element.textContent.length > 0) {
return false;
}
// Check if it has any non-empty children
for (let i = 0; i < element.children.length; i++) {
if (!isElementEmpty(element.children[i])) {
return false;
}
}
return true;
};
// Find and remove empty elements
// We need to use a while loop because the DOM changes as we remove elements
let emptyElementsFound = true;
while (emptyElementsFound) {
emptyElementsFound = false;
// Target common empty elements
const potentialEmptyElements = doc.querySelectorAll('p:not([data-preserve]), div, span, li, ul, ol, table, tr, td, th');
const potentialEmptyElements = doc.querySelectorAll('p, div, span, li, ul, ol, table, tr, td, th');
potentialEmptyElements.forEach(element => {
if (isElementEmpty(element)) {
element.remove();
emptyElementsFound = true;
appliedTransformations = true;
}
});
// If no more empty elements are found, exit the loop
if (!emptyElementsFound) {
break;
}
}
}
// Return the modified HTML
// Return the modified HTML and transformation status
const serializer = new XMLSerializer();
return serializer.serializeToString(doc.body);
return {
html: serializer.serializeToString(doc.body),
appliedTransformations
};
}

File diff suppressed because it is too large Load diff

View file

@ -6,139 +6,231 @@
* @param markdown The markdown content to transform
* @param settings The settings to use for transformation
* @param contextLevel The current heading level for contextual cascade (0 if not in a heading section)
* @returns The transformed markdown content
* @returns An object containing the transformed markdown content and whether any transformations were applied
*/
export function transformMarkdown(
markdown: string,
settings: {
markdownRegexReplacements: Array<{pattern: string, replacement: string}>,
contextualCascade: boolean,
maxHeadingLevel: number,
cascadeHeadingLevels: boolean,
stripLineBreaks: boolean,
removeEmptyLines: boolean
},
contextLevel: number = 0
): string {
markdown: string,
settings: {
markdownRegexReplacements: Array<{ pattern: string, replacement: string }>,
contextualCascade: boolean,
maxHeadingLevel: number,
cascadeHeadingLevels: boolean,
stripLineBreaks: boolean,
convertToSingleSpaced: boolean,
removeEmptyLines: boolean
},
contextLevel: number = 0,
escapeMarkdown: boolean = false
): { markdown: string, appliedTransformations: boolean } {
let appliedTransformations = false;
console.log(`original: ${markdown}`);
// Apply regex replacements if defined
if (settings.markdownRegexReplacements && settings.markdownRegexReplacements.length > 0) {
for (const replacement of settings.markdownRegexReplacements) {
for (const regex_replacement of settings.markdownRegexReplacements) {
try {
const regex = new RegExp(replacement.pattern, 'g');
markdown = markdown.replace(regex, replacement.replacement);
const regex = new RegExp(regex_replacement.pattern, 'g');
const replacement = regex_replacement.replacement
.replace(/\\r\\n/g, '\r\n')
.replace(/\\n/g, '\n')
.replace(/\\r/g, '\r')
.replace(/\\t/g, '\t')
.replace(/\\'/g, "'")
.replace(/\\"/g, '"')
.replace(/\\\\/g, '\\');
const originalMarkdown = markdown;
// console.log(`applying ${JSON.stringify(regex_replacement.pattern)} replacement ${JSON.stringify(replacement)}`); console.log(JSON.stringify(markdown));
markdown = markdown.replace(regex, replacement);
if (originalMarkdown !== markdown) {
appliedTransformations = true;
console.log(`regex replacements: ${markdown}`);
}
} catch (error) {
console.error(`Error applying markdown regex replacement: ${error}`);
}
}
}
// Find all heading lines
const headingRegex = /^(#{1,6})\s/gm;
// Process headings based on settings
if (settings.contextualCascade && contextLevel > 0) {
let delta = -1;
let cascading = false;
// Contextual cascade is enabled and we have a context level
markdown = markdown.replace(headingRegex, (match, hashes) => {
const currentLevel = hashes.length;
let newLevel = currentLevel;
if (!escapeMarkdown) {
// Find all heading lines
const headingRegex = /^(#{1,6})\s/gm;
if (cascading) {
// Cascade subsequent levels below the context level
newLevel = Math.min(currentLevel + delta, 6);
console.log(`contextual cascade: delta ${delta}`);
} else if (currentLevel <= contextLevel) {
// Intiate contextual cascading
newLevel = Math.min(contextLevel + 1, 6);
delta = newLevel - currentLevel;
cascading = true;
console.log(`contextual cascade initiated: delta: ${delta}`);
} // else nothing to do
// Process headings based on settings
if (settings.contextualCascade && contextLevel > 0) {
let delta = -1;
let cascading = false;
console.log(`result: current level: ${currentLevel}, new level: ${newLevel}`);
// Return the new heading with the adjusted level
return '#'.repeat(newLevel) + ' ';
});
} else if (settings.maxHeadingLevel > 1) {
let delta = -1;
let cascading = false;
// Contextual cascade is enabled and we have a context level
markdown = markdown.replace(headingRegex, (match, hashes) => {
const currentLevel = hashes.length;
let newLevel = currentLevel;
markdown = markdown.replace(headingRegex, (match, hashes) => {
const currentLevel = hashes.length;
let newLevel = currentLevel;
if (settings.cascadeHeadingLevels) {
// If cascading is enabled, start cascading subsequent headings down if needed
if (cascading) {
// Cascade subsequent headers down, don't go deeper than H6
// Cascade subsequent levels below the context level
newLevel = Math.min(currentLevel + delta, 6);
console.log(`cascading: delta: ${delta}`);
} else if (currentLevel < settings.maxHeadingLevel) {
newLevel = settings.maxHeadingLevel;
console.log(`contextual cascade: delta ${delta}`);
} else if (currentLevel <= contextLevel) {
// Intiate contextual cascading
newLevel = Math.min(contextLevel + 1, 6);
delta = newLevel - currentLevel;
cascading = true; // we need to cascade
console.log(`cascade initiated: delta: ${delta}`);
} // else nothing to do, heading is good as is
} else {
// Cascading not enabled, just cap heading levels at max
newLevel = Math.max(currentLevel, settings.maxHeadingLevel)
}
console.log(`result: current level: ${currentLevel}, new level: ${newLevel}`);
// Return the new heading with the adjusted level
return '#'.repeat(newLevel) + ' ';
});
cascading = true;
console.log(`*** contextual cascade initiated: delta: ${delta}`);
} // else nothing to do
console.log(`result: current level: ${currentLevel}, new level: ${newLevel}`);
appliedTransformations = appliedTransformations || (newLevel !== currentLevel);
// Return the new heading with the adjusted level
return '#'.repeat(newLevel) + ' ';
});
} else if (settings.maxHeadingLevel > 1) {
let delta = -1;
let cascading = false;
markdown = markdown.replace(headingRegex, (match, hashes) => {
const currentLevel = hashes.length;
let newLevel = currentLevel;
if (settings.cascadeHeadingLevels) {
// If cascading is enabled, start cascading subsequent headings down if needed
if (cascading) {
// Cascade subsequent headers down, don't go deeper than H6
newLevel = Math.min(currentLevel + delta, 6);
console.log(`cascading: delta: ${delta}`);
} else if (currentLevel < settings.maxHeadingLevel) {
newLevel = settings.maxHeadingLevel;
delta = newLevel - currentLevel;
cascading = true; // we need to cascade
console.log(`cascade initiated: delta: ${delta}`);
} // else nothing to do, heading is good as is
} else {
// Cascading not enabled, just cap heading levels at max
newLevel = Math.max(currentLevel, settings.maxHeadingLevel)
}
console.log(`result: current level: ${currentLevel}, new level: ${newLevel}`);
appliedTransformations = appliedTransformations || (newLevel !== currentLevel);
// Return the new heading with the adjusted level
return '#'.repeat(newLevel) + ' ';
});
}
} else {
// If escaping markdown, we don't want to change headings
// Just escape the markdown content
const originalMarkdown = markdown;
// Escape all Markdown syntax that Obsidian recognizes
// Headings, bold/italic, lists, links, images, code blocks, blockquotes, etc.
markdown = markdown
// Escape headings
.replace(/^(#{1,6}\s)/gm, '\\$1')
// Escape bold/italic
.replace(/(\*\*|__|\*|_)/g, '\\$1')
// Escape lists
.replace(/^(\s*[-+*]\s)/gm, '\\$1')
// Escape numbered lists
.replace(/^(\s*\d+\.\s)/gm, '\\$1')
// Escape links and images
.replace(/(!?\[)/g, '\\$1')
// Escape code blocks and inline code
.replace(/(`{1,3})/g, '\\$1')
// Escape blockquotes
.replace(/^(\s*>\s)/gm, '\\$1')
// Escape horizontal rules
.replace(/^(\s*[-*_]{3,}\s*)$/gm, '\\$1')
// Escape table syntax
.replace(/(\|)/g, '\\$1')
// Escape task lists
.replace(/^(\s*- \[ \])/gm, '\\$1')
// Escape HTML tags that might be interpreted, but only if they're not already inside backticks
.replace(/(`.*?`)|(<\/?[a-z][^>]*>)/gi, (match, codeContent, htmlTag) => {
// If this is a code block (first capture group), return it unchanged
if (codeContent) return codeContent;
// Otherwise it's an HTML tag that needs escaping
return htmlTag ? '`' + htmlTag + '`' : match;
});
appliedTransformations = (originalMarkdown !== markdown);
}
console.log(`processed: ${markdown}`);
// First handle the special line break markers
let preserveLineBreaks = !settings.stripLineBreaks;
// If we're not stripping line breaks, we need to handle the special markers
if (preserveLineBreaks) {
// Replace special line break markers with a unique placeholder that won't be affected by empty line removal
const lineBreakPlaceholder = '___LINE_BREAK_PLACEHOLDER___';
markdown = markdown.replace(/<p class="preserve-line-break"[^>]*>.*?<\/p>/g, lineBreakPlaceholder);
markdown = markdown.replace(/<p data-preserve="true"[^>]*>.*?<\/p>/g, lineBreakPlaceholder);
// Remove empty lines if enabled
if (settings.removeEmptyLines) {
// First, normalize line endings to ensure consistent processing
markdown = markdown.replace(/\r\n/g, '\n');
// Split the content into lines
const lines = markdown.split('\n');
// Filter out empty lines, but keep our placeholders
const filteredLines = lines.filter(line => {
return line.trim() !== '' || line.includes(lineBreakPlaceholder);
});
// Join the filtered lines back together
markdown = filteredLines.join('\n');
}
// Now replace our placeholders with actual line breaks
markdown = markdown.replace(new RegExp(lineBreakPlaceholder, 'g'), '\n');
} else {
// If we're stripping line breaks, just remove empty lines normally
if (settings.removeEmptyLines) {
// First, normalize line endings to ensure consistent processing
markdown = markdown.replace(/\r\n/g, '\n');
// Split the content into lines
const lines = markdown.split('\n');
// Filter out all empty lines
const filteredLines = lines.filter(line => line.trim() !== '');
// Join the filtered lines back together
markdown = filteredLines.join('\n');
// Convert multiple consecutive blank lines to single blank line if enabled
// Skip this if removeEmptyLines is enabled (optimization - lines will be removed anyway)
if (settings.convertToSingleSpaced && !settings.removeEmptyLines) {
// Normalize line endings to ensure consistent processing
markdown = markdown.replace(/\r\n/g, '\n');
// Replace 2 or more consecutive newlines with exactly 2 newlines (1 blank line)
const originalMarkdown = markdown;
markdown = markdown.replace(/\n{3,}/g, '\n\n');
if (originalMarkdown !== markdown) {
appliedTransformations = true;
}
}
return markdown;
// Process empty lines with a sliding window approach
if (settings.removeEmptyLines) {
// First, normalize line endings to ensure consistent processing
markdown = markdown.replace(/\r\n/g, '\n');
// Split the content into lines
const lines = markdown.split('\n');
const filteredLines: string[] = [];
// Sliding window processing with peek capability
for (let i = 0; i < lines.length; i++) {
const currentLine = lines[i];
const nextLine = i + 1 < lines.length ? lines[i + 1] : null;
const isCurrentLineEmpty = currentLine.trim() === '';
// Rule 1: Preserve line breaks - check for special markers
if (preserveLineBreaks) {
const hasPreserveMarker =
/<p class="preserve-line-break"[^>]*>.*?<\/p>/.test(currentLine) ||
/<p data-preserve="true"[^>]*>.*?<\/p>/.test(currentLine);
if (hasPreserveMarker) {
// Insert an empty line instead of the marker
filteredLines.push('');
continue;
}
}
// Rule 2: Keep empty line if next line is a horizontal rule (3+ dashes)
if (isCurrentLineEmpty && nextLine !== null && /^\s*-{3,}\s*$/.test(nextLine)) {
filteredLines.push(currentLine);
continue;
}
// Rule 3: Keep empty line if next line is the beginning of a table
if (isCurrentLineEmpty && nextLine !== null && /^\s*\|.*\|/.test(nextLine)) {
filteredLines.push(currentLine);
continue;
}
// Default: Remove empty lines unless they meet the above criteria
if (!isCurrentLineEmpty) {
filteredLines.push(currentLine);
}
}
// Join the filtered lines back together
const originalMarkdown = markdown;
markdown = filteredLines.join('\n');
appliedTransformations = appliedTransformations || (originalMarkdown !== markdown);
}
console.log(`final: ${markdown}`);
return {
markdown,
appliedTransformations
};
}

View file

@ -9,7 +9,7 @@ If your plugin does not need CSS, delete this file.
/* Regex Replacement Container Styles */
.regex-replacements-container {
margin-left: 40px;
margin-left: 0px;
margin-bottom: 20px;
}
@ -26,11 +26,11 @@ If your plugin does not need CSS, delete this file.
}
.regex-th-pattern, .regex-th-replacement {
width: 40%;
width: 48%;
}
.regex-th-actions {
width: 20%;
width: 4%;
}
/* Table Cell Styles */
@ -55,3 +55,74 @@ If your plugin does not need CSS, delete this file.
color: var(--text-muted);
opacity: 0.7;
}
/* Remove Icon Styles */
.regex-remove-icon {
cursor: pointer;
padding: 4px;
border-radius: 3px;
color: #dc3545;
display: inline-flex;
align-items: center;
justify-content: center;
transition: background-color 0.2s ease;
}
.regex-remove-icon:hover {
background-color: rgba(220, 53, 69, 0.1);
}
.regex-remove-icon:active {
background-color: rgba(220, 53, 69, 0.2);
}
.regex-remove-icon:focus {
outline: 2px solid rgba(220, 53, 69, 0.5);
outline-offset: 1px;
}
/* Add Icon Styles */
.regex-add-icon {
cursor: pointer;
padding: 8px;
border-radius: 50%;
/* color: var(--interactive-accent); */
display: flex;
align-items: center;
justify-content: flex-end;
/* transition: background-color 0.2s ease; */
margin-top: 8px;
margin-bottom: 8px;
width: 100%;
}
.regex-add-icon svg {
width: 20px;
height: 20px;
}
.regex-add-icon:hover {
/* background-color: var(--interactive-accent-hover); */
color: var(--text-on-accent);
}
.regex-add-icon:active {
/* background-color: var(--interactive-accent); */
color: var(--text-on-accent);
}
/* .regex-add-icon:focus {
outline: 2px solid var(--interactive-accent);
outline-offset: 2px;
} */
/* Disabled Setting Styles */
.paste-reformatter-disabled-setting {
opacity: 0.5;
pointer-events: none;
}
.paste-reformatter-disabled-setting .setting-item-name,
.paste-reformatter-disabled-setting .setting-item-description {
color: var(--text-muted);
}

View file

@ -1,3 +1,11 @@
{
"1.0.0": "0.15.0"
}
"1.0.0": "0.15.0",
"1.1.0": "0.15.0",
"1.1.1": "0.15.0",
"1.2.0": "0.15.0",
"1.2.1": "0.15.0",
"1.2.2": "0.15.0",
"1.3.0": "0.15.0",
"1.4.0": "0.15.0",
"1.4.2": "0.15.0"
}