Skip to content

fix(notes): cap note path length and harden folder creation (#22, #87)#192

Merged
chhoumann merged 4 commits into
masterfrom
chhoumann/22-max-path-length
Jun 16, 2026
Merged

fix(notes): cap note path length and harden folder creation (#22, #87)#192
chhoumann merged 4 commits into
masterfrom
chhoumann/22-max-path-length

Conversation

@chhoumann

@chhoumann chhoumann commented Jun 16, 2026

Copy link
Copy Markdown
Owner

Summary

Creating notes for podcasts with very long titles silently failed: the generated path's filename component exceeded the OS limit, so app.vault.create threw ENAMETOOLONG and the user only saw "Failed to create note" (#22). Separately, note creation could fail spuriously with a console Folder already exists (#87).

This PR caps generated paths safely (platform-aware) and hardens folder creation, across every path the plugin builds from a title.

Closes #22.
Closes #87.

What changed

New src/utility/enforceMaxPathLength.ts — caps each path component at the platform's real per-component limit, detected via Obsidian's native Platform:

  • NTFS / APFS / HFS+ (Windows, macOS, iOS) → 255 UTF-16 code units
  • ext4 / F2FS (Linux, Android) → 255 bytes

It truncates only the title (the base name) on code-point boundaries (never splitting a surrogate pair), preserves the extension, substitutes Untitled when a title sanitizes to nothing, trims trailing dots/spaces a cut exposes, and drops empty path segments. The limit is injectable for testing.

APFS counting UTF-16 units, not bytes was verified empirically (a 255-unit / ~759-byte CJK name is accepted; 256 is rejected). A fixed byte cap over-truncated CJK titles on macOS; the platform-aware cap uses the full legal length on each OS.

Applied everywhere a path is built from a title (each via a single shared resolver, so the existence-check and the write always agree on the capped path):

  • episode notes (createPodcastNote)
  • feed notes (createFeedNote) and the {{podcastlink}} wikilink that targets them (getFeedNoteWikilink)
  • transcripts (TranscriptionService, extension taken from the user's template — not forced to .md)
  • downloads (downloadEpisode, shared by the pre-download check and the write)

Folder creation hardenedensureFolderExists now tolerates Folder already exists (thrown when a case-sensitive lookup misses an existing folder on a case-insensitive FS, or on a concurrent create) while still rethrowing genuine errors. This is #87's root cause. TranscriptionService's hand-rolled loop was replaced with it (passing its injected vault), and createFeedNote/downloadEpisode are transitively hardened.

Runtime verification (real Obsidian vault on macOS)

Driven through the actual commands:

Case Result
303-char filename, raw app.vault.create ENAMETOOLONG (baseline)
382-char ASCII / 600-byte CJK / emoji episode titles created, valid ≤-limit notes, no split surrogate ✓
CJK title on macOS now uses full 255 UTF-16 units (759 bytes), not over-truncated ✓
all-illegal-character title Untitled.md
createFolder on existing folder / lookup miss created, 0 errors ✓
{{podcastlink}} for a 400-char feed title emits 252-char capped link matching the feed note ✓

dev:errors clean throughout.

Tests & gates

New enforceMaxPathLength.test.ts (both platform modes, multibyte, surrogate safety, fallback, edge-trim, deterministic collision, lastSegmentExtension guard) and extended tests for ensureFolderExists (already-exists tolerance, injected vault), createFeedNote (cap), downloadEpisode (safeDownloadFilePath), and getFeedNoteWikilink (cap). Gates: lintformat:checktypecheckbuildtest ✓ (404 passing). docs:build not run (mkdocs not installed locally).

Review

Three rounds of an ultracode multi-lens workflow + opposite-model (Codex) adversarial reviewers. Round 1 → byte-vs-char cap (made platform-aware). Round 2 → {{podcastlink}} mismatch, transcript extension, vault-instance split, short-folder trim (all fixed). Round 3 → a dotted-title-as-pseudo-extension edge (fixed); workflow 0 findings, architect PASS.

Design notes / out of scope

  • Local-OS maximum, not sync-portable: the cap uses the current device's limit. A vault synced from a UTF-16-limit OS (macOS/Windows) to a byte-limit OS (Android/Linux) could, for heavily multibyte titles, exceed the destination's per-component limit. The byte mode is the portable floor.
  • Per-component only: total path length (Windows legacy MAX_PATH) is not bounded (it needs the absolute vault root); a long title trips the per-component limit, which is what this fixes.
  • Pre-existing, separate: Windows reserved device names (CON, PRN, …) remain unhandled by the sanitizer; downloads keep their existing episode fallback (Default download.path is empty, breaking the Download command (writes ".mp3" to vault root, 2nd download fails) #183). Deterministic collisions (two titles sharing the capped prefix) resolve to the same note and open the existing one (no data loss) — matching the issue's deterministic-naming goal.

@chhoumann chhoumann mentioned this pull request Jun 16, 2026
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jun 16, 2026

Copy link
Copy Markdown

Deploying podnotes with  Cloudflare Pages  Cloudflare Pages

Latest commit: 6e47b57
Status: ✅  Deploy successful!
Preview URL: https://b1a63169.podnotes.pages.dev
Branch Preview URL: https://chhoumann-22-max-path-length.podnotes.pages.dev

View logs

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 604a3e498f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/createPodcastNote.ts
);

const filePathDotMd = addExtension(filePath, "md");
return enforceMaxPathLength(addExtension(filePath, "md"));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve lookups for existing long Unicode notes

When users already have episode notes created on APFS/NTFS whose filename is valid there but exceeds 255 UTF-8 bytes (for example, 100 CJK characters plus .md is over the new byte cap but under those filesystems' character limits), this resolver now always rewrites the path to the truncated 255-byte version. getPodcastNote and the context-menu existence check therefore miss the existing file and createPodcastNote can create a duplicate truncated note instead of opening it; consider checking the uncapped path as a fallback before creating.

Useful? React with 👍 / 👎.

Long episode titles produced a note path whose filename component exceeded
the OS per-component limit, so app.vault.create failed with ENAMETOOLONG and
note creation silently failed (#22).

- Add enforceMaxPathLength: caps every path component at 255 BYTES (the most
  restrictive mainstream limit — ext4/Android count bytes, NTFS/APFS count
  255 chars; a byte budget satisfies all), truncating the title on code-point
  boundaries (never splitting a surrogate pair) while preserving the .md
  extension and folders, trimming trailing dots/spaces a cut exposes, and
  substituting "Untitled" when a title sanitizes to nothing (the empty-name
  edge flagged when #99's illegal-char handling landed).
- Apply it through a single shared path resolver so the existence check,
  open, and create all agree on the capped path.
- Route folder creation through ensureFolderExists and make it tolerant of
  "Folder already exists" (case-insensitive FS / races) — folds in #87's
  spurious "Failed to create note". This also transitively hardens the feed
  note and download paths, which already call ensureFolderExists.

Verified in a real Obsidian vault: a 382-char ASCII title, a 600-byte CJK
title, and a surrogate-pair emoji title all create valid <=255-byte notes;
an all-illegal title creates Untitled.md; a simulated folder-lookup miss no
longer fails note creation.

Tests cover the helper (byte budget, surrogate safety, fallback, collisions)
and the folder-tolerance regression.
…wnload

Per-component filename limits differ by unit, not just value: NTFS (Windows)
and APFS/HFS+ (macOS, iOS) cap at 255 UTF-16 code units, while ext4/F2FS
(Linux, Android) cap at 255 bytes. Detect the OS via Obsidian's native
Platform API and budget in the matching unit, so a long non-ASCII title uses
the full legal length on each platform instead of being needlessly truncated
(a byte cap shortened a CJK title to 84 chars on macOS, where APFS accepts
255 units / ~750 bytes — verified empirically).

- enforceMaxPathLength now takes a platform-derived FilenameLimit (injectable
  for tests); truncation stays code-point-safe in both units.
- Extend the same cap to the other note/file creators that build paths from a
  title: feed notes (createFeedNote), transcripts (TranscriptionService), and
  downloads (downloadEpisode, via a shared safeDownloadFilePath used by both
  the existence check and the write).
- Replace TranscriptionService's hand-rolled createFolder loop with the
  hardened ensureFolderExists, removing the last #87-class folder bug.

Verified in a real Obsidian vault on macOS: a 300-char CJK title and an emoji
title now create full 255-UTF-16-unit notes (valid UTF-8, no split surrogate).
…nsion, vault, folder trim

Adversarial review (round 2) found real follow-on gaps from extending the cap:

- {{podcastlink}} built its wikilink from the UNCAPPED feed path, so a long feed
  title produced a link that didn't match the capped feed note (broken link).
  getFeedNoteWikilink now mirrors createFeedNote's enforceMaxPathLength(addExtension)
  derivation.
- getTranscriptPath force-appended .md, silently changing custom non-.md transcript
  paths (orphaning existing transcripts, '.txt' -> '.txt.md'). It now preserves the
  configured extension via lastSegmentExtension.
- ensureFolderExists gained an optional vault param so TranscriptionService creates
  folders and writes the file through the same (injected) vault, not a global/instance
  split.
- capFolderName now trims trailing dots/spaces even on short folder segments (illegal
  on Windows), matching the documented behavior.

Adds tests for each (incl. createFeedNote/integration caps) and runtime-verified in a
real vault that {{podcastlink}} for a 400-char feed title emits a 252-char capped link.
…tension

Round-3 review found lastSegmentExtension naively took everything after the last
dot as the extension. A no-extension transcript template with a dotted title
(e.g. "Episode.Part.<400 chars>") made the 400-char tail the 'extension', which
capFileName then reattached past the cap — still tripping ENAMETOOLONG. Restrict
it to a short alphanumeric suffix so a dotted title (or a leftover template
token) is treated as part of the name and the whole segment is capped.
@chhoumann chhoumann force-pushed the chhoumann/22-max-path-length branch from d19e1b4 to 6e47b57 Compare June 16, 2026 06:08
@chhoumann chhoumann merged commit 858e280 into master Jun 16, 2026
2 checks passed
@chhoumann chhoumann deleted the chhoumann/22-max-path-length branch June 16, 2026 06:17
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.

Unable to create note [BUG] Handling of max path length when creating notes

1 participant