Skip to content

feat: add heading-filename-sync rule for H1/filename synchronization#1443

Open
tjmgregory wants to merge 10 commits intoplaters:masterfrom
tjmgregory:feature/heading-filename-sync
Open

feat: add heading-filename-sync rule for H1/filename synchronization#1443
tjmgregory wants to merge 10 commits intoplaters:masterfrom
tjmgregory:feature/heading-filename-sync

Conversation

@tjmgregory
Copy link
Copy Markdown

@tjmgregory tjmgregory commented Dec 31, 2025

Summary

Implements the long-requested feature to keep H1 headings and filenames synchronized:

  • New rule: heading-filename-sync - Keeps the first H1 heading synchronized with the filename
  • Three sync directions:
    • heading-to-filename (default): Rename the file to match the H1 heading
    • filename-to-heading: Update the H1 heading to match the filename
    • bidirectional: Keep both in sync (heading wins on conflict)
  • Regex prefix/suffix patterns for Zettelkasten and similar workflows (e.g., strip ^\d{12}_ timestamps)
  • Conflict detection with the existing file-name-heading rule
  • User notification on rename failures
  • Ignore files using regex

Related Issues

Closes #192 - FR: Keep first H1 heading in sync with filename (opened 2022)
Closes #1418 - FR: 3-way-sync (H1, Title, Filename) with regex prefix handling

Changes

  • New rule: src/rules/heading-filename-sync.ts
  • Tests: __tests__/heading-filename-sync.test.ts (36 test cases)
  • Translations: Updated src/lang/locale/en.ts
  • Rules runner: Added PendingRename mechanism for file rename signaling
  • Main plugin: Added file rename handling via app.fileManager.renameFile()
  • Conflict detection between heading-filename-sync and file-name-heading

Implementation Details

File Renaming Architecture

Rules can only return modified text, so file renaming uses a callback pattern:

  1. Rule signals rename via setPendingRename callback passed in options
  2. RulesRunner stores the pending rename in this.pendingRename
  3. After lintText() returns, main.ts checks for pending rename and executes it

Sync Direction Behavior

Direction Behavior
heading-to-filename Renames file to match H1 (default, preserves prefix/suffix)
filename-to-heading Updates H1 to match filename
bidirectional No H1? Insert from filename. H1 differs? Rename file to match.

Integration with Existing Rules

  • yaml-title: Enable both for 3-way sync (H1, filename, YAML title)
  • file-name-heading: Conflict detection added - users prompted to choose one

Test Plan

  • All 1211 tests pass (including 36 new tests)
  • Build passes (npm run build)
  • Linting passes (npm run lint)

🤖 Generated with Claude Code

tjmgregory and others added 7 commits December 31, 2025 12:01
Implements the long-requested heading/filename sync feature (issue platers#192
opened in 2022, platers#1418 for Zettelkasten support).

- Adds new heading-filename-sync rule
- Supports filename-to-heading sync direction (Phase 1)
- Supports regex patterns to strip prefixes/suffixes from filename
  (e.g., Zettelkasten timestamps like 202312151030_)
- Optional YAML title sync integration (placeholder for Phase 2)
- Heading-to-filename and bidirectional modes (placeholder for Phase 2)
- Adds deprecation notice to file-name-heading rule description

Closes platers#192
Closes platers#1418

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Implements the full heading/filename sync feature:
- heading-to-filename: renames file to match H1 (preserving prefix/suffix)
- bidirectional: keeps both in sync (heading wins on conflict)
- Removes updateYamlTitle option (use existing yaml-title rule instead)
- Adds PendingRename mechanism for file renaming via rules
- Adds comprehensive tests for all sync directions

The rule now signals file renames via a callback pattern, with actual
renames handled in main.ts using app.fileManager.renameFile().

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Add file rename handling to runLinterEditor (critical fix)
- Add conflict detection between heading-filename-sync and file-name-heading
- Improve filename sanitization (collapse consecutive dashes, trim edges)
- Add Notice for rename failures instead of silent console warning
- Add 10 new tests for rename callback functionality
- Add rename-failed translation key

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
These changes were generated by npm run docs but are unrelated to the
heading-filename-sync feature and should be in a separate PR.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
DRY up the duplicate file rename handling code in runLinterFile and
runLinterEditor by extracting it into a shared helper method.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Use single-line imports like other rules
- Use snake_case for yaml_end variable to match file-name-heading.ts

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Most users want their heading to be the source of truth for the filename,
not the other way around. This makes the more common use case the default.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@tjmgregory tjmgregory changed the title feat: Add heading and filename sync rule feat: add heading-filename-sync rule for H1/filename synchronization Dec 31, 2025
@tjmgregory tjmgregory marked this pull request as ready for review December 31, 2025 14:33
tjmgregory and others added 2 commits January 4, 2026 17:58
Adds a new 'Ignore Files for Rename' option that allows users to specify
regex patterns for file paths that should be excluded from file renaming.
Files matching these patterns will still have their headings synchronized
if applicable, but the file itself will not be renamed.

This is useful for:
- Template files that shouldn't be renamed
- Daily notes with date-based filenames
- Any files in specific folders that should keep their original names

Changes:
- Add `ignoreRenameFiles` option to HeadingFilenameSyncOptions
- Add `shouldIgnoreFileForRename()` helper method
- Update syncHeadingToFilename to check ignore patterns before renaming
- Add translations for the new option
- Add 8 test cases for ignore functionality
- Regenerate documentation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
When linting an entire folder, files were processed in parallel using
Promise.all. The pendingRename was stored as a shared instance property
on RulesRunner, causing one file's rename to overwrite another's.

This resulted in:
- File titles not being updated correctly
- One file's heading accidentally renaming a different file

Fix: Changed lintText() to return a LintResult object containing both
the linted text and the pendingRename. Each parallel operation now gets
its own isolated result, eliminating the race condition.

Changes:
- Added LintResult type: {text: string, pendingRename: PendingRename | null}
- Removed pendingRename instance property from RulesRunner
- Updated lintText() to return LintResult instead of string
- Updated handlePendingRename() to accept pendingRename as parameter
- Updated runLinterFile() and runLinterEditor() to use LintResult

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@tjmgregory
Copy link
Copy Markdown
Author

Bug Fix: Race Condition in Folder Linting (commit d71dc1e)

Fixed a critical bug where "Lint folder" operations would cause incorrect file renaming.

The Problem

When linting an entire folder, files were processed in parallel using Promise.all. The pendingRename was stored as a shared instance property on RulesRunner, causing one file's rename to overwrite another's.

This resulted in:

  • File titles not being updated correctly
  • One file's heading accidentally renaming a different file

The Solution

Changed lintText() to return a LintResult object containing both the linted text and the pendingRename. Each parallel operation now gets its own isolated result, eliminating the race condition.

Changes

  • Added LintResult type: {text: string, pendingRename: PendingRename | null}
  • Removed pendingRename instance property from RulesRunner
  • Updated lintText() to return LintResult instead of string
  • Updated handlePendingRename() to accept pendingRename as parameter
  • Updated runLinterFile() and runLinterEditor() to use LintResult

On macOS and Windows (case-insensitive by default), renaming a file
where only the case changes (e.g., "file.md" -> "File.md") fails because
the OS sees them as the same file and reports "file already exists".

Fix: Detect case-only renames by comparing lowercase paths. When detected,
use a two-step rename via a temporary intermediate name:
1. file.md -> file.md.linter-temp-rename
2. file.md.linter-temp-rename -> File.md

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@tjmgregory
Copy link
Copy Markdown
Author

Bug Fix: Case-Only Renames on macOS/Windows (commit 1e5db79)

Fixed an issue where renaming files with only case changes would fail on case-insensitive filesystems.

The Problem

On macOS and Windows (case-insensitive by default), renaming a file where only the case changes fails:

  • Rules for showing the checklist.mdRules for Showing the Checklist.md

The OS sees these as the "same" file and returns "file already exists".

The Solution

Detect case-only renames by comparing lowercase paths. When detected, use a two-step rename via a temporary intermediate name:

1. file.md → file.md.linter-temp-rename
2. file.md.linter-temp-rename → File.md

This workaround is commonly used to handle case-insensitive filesystem limitations.

@tjmgregory
Copy link
Copy Markdown
Author

@pjkaufman How can I help to get this reviewed/merged? 🙂

@pjkaufman
Copy link
Copy Markdown
Collaborator

My current concern is that this looks to be AI generated. I need to take a closer look at the code, but how is it handling changes from outside Obsidian? For example, what happens when someone changes the file name, YAML title, and H1 all to be different values outside of Obsidian or while the Linter is disabled. When the Linter runs against the file, which of those 3 values will be used for the heading, YAML title, and file name?

Copy link
Copy Markdown
Collaborator

@pjkaufman pjkaufman left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is an initial review of just the rule and not the other things it affects. I have yet to be able to review most of the changes outside the new rule itself. So that still needs to be looked at. Some of this looks fine, but other parts make me question whether to consider this out of scope for the Linter since touching the file name has traditionally been considered out of scope for the Linter. It can read it, but changing it opens a whole can of worms that I am not currently available to tackle.

Comment on lines +40 to +41
const fileNameHeadingOptions = rulesDict['file-name-heading'];
if (!fileNameHeadingOptions) return;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see that we are checking whether this is empty or not. However I am not sure how this would happen given we fill out missing rules when the plugin loads and the app is in the "onlayoutready" stage.

What does this check actually do beyond exit early for a situation we should not encounter? Or is this scenario actually happening?

Comment on lines +91 to +94
try {
const prefixRegex = new RegExp(prefixPattern);
result = result.replace(prefixRegex, '');
} catch (e) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am probably missing something here, but what about this is guarantying that the prefix regex only affects the start of the file name?

const prefixRegex = new RegExp(prefixPattern);
result = result.replace(prefixRegex, '');
} catch (e) {
// Invalid regex, skip prefix stripping
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the regex is invalid, we should at the very least console log it so the user knows that the prefix regex is invalid.

const suffixRegex = new RegExp(suffixPattern + '$');
result = result.replace(suffixRegex, '');
} catch (e) {
// Invalid regex, skip suffix stripping
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the regex is invalid, we should at least log that it is invalid so the user can at least know that they need to try to fix it.

}

try {
const prefixRegex = new RegExp(prefixPattern);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should create the regexp once and reuse it across the functions. Besides, if we already pulled the regex prior, why are we once again running the prefix regex again? Can't we grab the value in one step?

Comment on lines +303 to +317
new ExampleBuilder({
description: 'Strips Zettelkasten prefix from filename',
before: dedent`
# Old Title
Content here.
`,
after: dedent`
# Meeting Notes
Content here.
`,
options: {
fileName: '202312151030_Meeting Notes',
filenamePrefix: '^\\d{12}_',
syncDirection: 'filename-to-heading',
},
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: this functionality could be added to the existing rule instead of as a new rule given it only seems to be on this rule because it was meant to be a 2 way sync.

Comment on lines +160 to +163
let header = `${newHeading}\n`;
if (text.length < yaml_end) {
header = '\n' + header;
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I am understanding correctly, this just adds the new heading on its own line.

Comment on lines +358 to +359
value: 'bidirectional',
description: 'Keep heading and filename in sync (heading wins on conflict)',
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Personally, I think that the user should decide which wins in the case of a conflict.

Comment on lines +375 to +380
new TextAreaOptionBuilder({
OptionsClass: HeadingFilenameSyncOptions,
nameKey: 'rules.heading-filename-sync.ignore-rename-files.name',
descriptionKey: 'rules.heading-filename-sync.ignore-rename-files.description',
optionsKey: 'ignoreRenameFiles',
}),
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you explain what the point of this is? We can already ignore files if we don't want any rules enabled. If we don't want a rule to run, we have the ability to specify it in the frontmatter. Is this just meant to be a convenience thing to avoid having to specify in the frontmatter which files should have this rule disabled?

If so, is there a better way to handle this that would benefit the other rules as well instead of just making a one off solution for this one rule?

header = '\n' + header;
}

return insert(text, yaml_end, header);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This may be a nitpick, but I don't see a point in using insert in the cases where you are just adding text to the end of the file's contents.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

FR: 3-way-sync (H1, Title, Filename) with regex prefix handling FR: Keep first H1 heading in sync with filename

2 participants