feat: add heading-filename-sync rule for H1/filename synchronization#1443
feat: add heading-filename-sync rule for H1/filename synchronization#1443tjmgregory wants to merge 10 commits intoplaters:masterfrom
Conversation
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>
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>
Bug Fix: Race Condition in Folder Linting (commit d71dc1e)Fixed a critical bug where "Lint folder" operations would cause incorrect file renaming. The ProblemWhen linting an entire folder, files were processed in parallel using This resulted in:
The SolutionChanged Changes
|
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>
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 ProblemOn macOS and Windows (case-insensitive by default), renaming a file where only the case changes fails:
The OS sees these as the "same" file and returns "file already exists". The SolutionDetect case-only renames by comparing lowercase paths. When detected, use a two-step rename via a temporary intermediate name: This workaround is commonly used to handle case-insensitive filesystem limitations. |
|
@pjkaufman How can I help to get this reviewed/merged? 🙂 |
|
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? |
pjkaufman
left a comment
There was a problem hiding this comment.
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.
| const fileNameHeadingOptions = rulesDict['file-name-heading']; | ||
| if (!fileNameHeadingOptions) return; |
There was a problem hiding this comment.
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?
| try { | ||
| const prefixRegex = new RegExp(prefixPattern); | ||
| result = result.replace(prefixRegex, ''); | ||
| } catch (e) { |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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?
| 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', | ||
| }, |
There was a problem hiding this comment.
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.
| let header = `${newHeading}\n`; | ||
| if (text.length < yaml_end) { | ||
| header = '\n' + header; | ||
| } |
There was a problem hiding this comment.
If I am understanding correctly, this just adds the new heading on its own line.
| value: 'bidirectional', | ||
| description: 'Keep heading and filename in sync (heading wins on conflict)', |
There was a problem hiding this comment.
Personally, I think that the user should decide which wins in the case of a conflict.
| new TextAreaOptionBuilder({ | ||
| OptionsClass: HeadingFilenameSyncOptions, | ||
| nameKey: 'rules.heading-filename-sync.ignore-rename-files.name', | ||
| descriptionKey: 'rules.heading-filename-sync.ignore-rename-files.description', | ||
| optionsKey: 'ignoreRenameFiles', | ||
| }), |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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.
Summary
Implements the long-requested feature to keep H1 headings and filenames synchronized:
heading-filename-sync- Keeps the first H1 heading synchronized with the filenameheading-to-filename(default): Rename the file to match the H1 headingfilename-to-heading: Update the H1 heading to match the filenamebidirectional: Keep both in sync (heading wins on conflict)^\d{12}_timestamps)file-name-headingruleRelated 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
src/rules/heading-filename-sync.ts__tests__/heading-filename-sync.test.ts(36 test cases)src/lang/locale/en.tsPendingRenamemechanism for file rename signalingapp.fileManager.renameFile()heading-filename-syncandfile-name-headingImplementation Details
File Renaming Architecture
Rules can only return modified text, so file renaming uses a callback pattern:
setPendingRenamecallback passed in optionsRulesRunnerstores the pending rename inthis.pendingRenamelintText()returns,main.tschecks for pending rename and executes itSync Direction Behavior
heading-to-filenamefilename-to-headingbidirectionalIntegration 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 oneTest Plan
npm run build)npm run lint)🤖 Generated with Claude Code