Skip to content

fix(uri): preserve '+' in episode titles and paths for timestamp links - #181

Merged
chhoumann merged 1 commit into
masterfrom
chhoumann/164-timestamp-episode-switch
Jun 15, 2026
Merged

chhoumann merged 1 commit into
masterfrom
chhoumann/164-timestamp-episode-switch

Conversation

@chhoumann

@chhoumann chhoumann commented Jun 15, 2026 •

Copy link
Copy Markdown
Owner

Summary

Timestamp links (obsidian://podnotes, generated by the {{linktime}} template) failed to switch to — or resume — episodes whose title or local-file path contained a literal + (e.g. C++ Tips, A+ Players). Clicking such a link was a silent no-op.

Root cause: encodePodnotesURI used URLSearchParams, which encodes spaces as +. Obsidian decodes protocol query values with decodeURIComponent only (no +→space), so URIHandler compensated with a blanket .replace(/\+/g, " ") — which also collapsed real + characters into spaces. The corrupted title/path then failed to match, and FeedParser.findItemByTitle threw an unhandled rejection (silent no-op). This pre-dated the existing timestamp-switching behaviour and broke both switching-to and resume-on-click for any +-bearing episode.

The originally-reported #164 behaviours (switch to a non-loaded episode at the clicked time; resume when paused; removal of the "click timestamp again" notice) were already shipped in ac05f57 and verified still working. This PR fixes the residual +-decoding defect surfaced while validating that.

Changes

  • encodePodnotesURI now percent-encodes spaces as %20 (keeping a literal + as %2B), so links round-trip losslessly through Obsidian's decoder — literal + included.
  • URIHandler resolves the episode name/path via ordered, raw-first candidates with a +-as-space fallback, so links written by older versions keep resolving without corrupting current-format titles. Feed episodes are resolved via the existing FeedParser.getEpisodes; a feed-load failure or genuine miss now shows a Notice instead of rejecting silently.
  • Removed the now-unused FeedParser.findItemByTitle.

Backwards compatibility

  • Links already in users' notes (spaces as +) still resolve via the legacy fallback.
  • Only unrecoverable case: a legacy link whose title itself contained a literal + (ambiguous, and always was). New links are unambiguous.
  • Documented bounded edge: if one feed ships both X Y and X+Y as distinct episodes and the space-twin is loaded, a new X+Y link resumes the loaded twin via the same-episode fast path — accepted to preserve the common same-episode legacy-link path (no fetch-free way to disambiguate).

Verification

  • npm run lint, format:check, typecheck, build, test — all green (164 tests, Node 22).
  • New unit tests: encodePodnotesURI %20/%2B round-trip; URIHandler for +-title switch, +-title resume-when-paused, legacy +-as-space links, legacy same-episode resume, local file with + in path & name, raw-first ordering when a space-twin precedes the +-variant, and feed-miss / feed-error (no silent rejection).
  • e2e (tests/e2e/podnotes-runtime.test.ts): the capture test now derives its expected link from the production encoder and asserts episodeName=E2E%20Capture%20Episode.
  • Obsidian runtime: decoder behaviour (decodeURIComponent-only) confirmed against the shipped obsidian.asar; the %20/%2B round-trip is deterministic; the downstream seek path (restorePlaybackTime) is unchanged and was runtime-verified earlier. A full shared-dev-vault npm run test:e2e pass is pending a free vault.

Scope

No .svelte files touched; none of the PR #173-frozen Player/Episode-UI files are modified. No settings/storage migration. The URI wire-format change is backwards-compatible as above.

Closes #164


Open in Devin Review

Timestamp links (obsidian://podnotes, generated by {{linktime}}) failed to
switch to or resume episodes whose title — or local-file path — contained a
literal '+' (e.g. "C++ Tips", "A+ Players").

encodePodnotesURI used URLSearchParams, which encodes spaces as '+'. Obsidian
decodes protocol query values with decodeURIComponent only (no '+' -> space),
so the handler compensated with a blanket .replace(/\+/g, ' ') that also
collapsed real '+' characters into spaces. The corrupted title/path then
failed to match, and FeedParser.findItemByTitle threw an unhandled rejection
(a silent no-op for the user). This pre-dated the timestamp-switching fix and
broke both switching-to and resume-on-click for any '+'-bearing episode.

- encodePodnotesURI now percent-encodes spaces as %20, so links round-trip
  losslessly through Obsidian's decoder — literal '+' included.
- URIHandler resolves the episode name/path via ordered, raw-first candidates
  with a '+'-as-space fallback, so legacy links keep resolving without
  corrupting current-format titles. Feed episodes are resolved via getEpisodes
  and a miss now shows an "Episode not found" Notice instead of rejecting.
- Removed the now-unused FeedParser.findItemByTitle.
- Tests: encodePodnotesURI round-trip, URIHandler '+'/legacy/local/not-found
  cases, and the e2e capture test now uses the production encoder.

Refs #164
@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying podnotes with  Cloudflare Pages  Cloudflare Pages

Latest commit: e32bf20
Status: ✅  Deploy successful!
Preview URL: https://2c1b9267.podnotes.pages.dev
Branch Preview URL: https://chhoumann-164-timestamp-epis.podnotes.pages.dev

View logs

@devin-ai-integration devin-ai-integration 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.

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no bugs or issues to report.

Open in Devin Review

@chhoumann
chhoumann merged commit 8ad7aa5 into master Jun 15, 2026
2 checks passed
@chhoumann
chhoumann deleted the chhoumann/164-timestamp-episode-switch branch June 16, 2026 06:16
github-actions Bot pushed a commit that referenced this pull request Jun 22, 2026
# [2.17.0](2.16.0...2.17.0) (2026-06-22)

### Bug Fixes

* behavioral-audit logic and robustness fixes (back-end, 1/2) ([#213](#213)) ([4e2845d](4e2845d))
* **download:** default per-episode download path and migrate empty default ([#183](#183)) ([#186](#186)) ([46a6486](46a6486))
* **download:** prevent Android crash and create missing folders on download ([#178](#178)) ([ecd09d5](ecd09d5)), closes [#113](#113) [#86](#86) [#113](#113) [#86](#86)
* **lifecycle:** defer mobile podcast view startup ([#208](#208)) ([8bf9ce4](8bf9ce4))
* **notes:** cap note path length and harden folder creation ([#22](#22), [#87](#87)) ([#192](#192)) ([858e280](858e280)), closes [#87-class](#87)
* **playback:** persist listened time during playback ([#33](#33)) ([#190](#190)) ([e3433c2](e3433c2)), closes [#191](#191) [#108](#108) [#163](#163) [#183](#183)
* **playback:** play local files and downloads on iOS via resource path ([#100](#100)) ([#184](#184)) ([12c503a](12c503a))
* **player:** clear progress on episode switch to stop end-of-playback glitch ([#94](#94)) ([#194](#194)) ([59dccb3](59dccb3))
* **player:** reveal PodNotes view on Play with PodNotes so local files play ([#84](#84)) ([#198](#198)) ([5953625](5953625))
* **settings:** show labelled Add/Remove buttons in podcast search ([#109](#109)) ([#195](#195)) ([33399f5](33399f5))
* show downloaded episodes in the Local Files playlist ([#176](#176)) ([#177](#177)) ([184188c](184188c))
* **timestamps:** capture into the cursor's table cell without breaking the row ([#165](#165)) ([#203](#203)) ([964e342](964e342))
* **transcription:** always transcribe the currently playing episode ([#182](#182)) ([62b488a](62b488a)), closes [#107](#107)
* **uri:** preserve '+' in episode titles and paths for timestamp links ([#181](#181)) ([8ad7aa5](8ad7aa5)), closes [#164](#164)
* **view:** reliably reveal PodNotes view via command + ribbon icon ([#55](#55)) ([#199](#199)) ([fa1d708](fa1d708))

### Features

* add podcast segment links ([#205](#205)) ([d97e59e](d97e59e))
* **api:** expose generated episode transcripts ([e264465](e264465)), closes [#105](#105)
* behavioral-audit UI and interaction fixes (front-end, 2/2) ([#215](#215)) ([894d93d](894d93d))
* **commands:** add playback rate and media timestamp controls ([#206](#206)) ([a8bb44a](a8bb44a))
* **devx:** isolated per-worktree Obsidian E2E vault wrapper ([#188](#188)) ([a8b7a4a](a8b7a4a))
* **episodes:** add a setting to control the Latest Episodes list length ([#114](#114)) ([#200](#200)) ([7b3e3c6](7b3e3c6))
* **notes:** add {{episodelink}} template tag to resume an episode from its note ([#35](#35)) ([#193](#193)) ([8c1ddd6](8c1ddd6))
* **notes:** add podcast feed-level notes ([#163](#163)) ([#187](#187)) ([db0de47](db0de47)), closes [#161](#161) [#160](#160)
* **notes:** ship a Bases-friendly default episode note template ([#160](#160)) ([#201](#201)) ([209431d](209431d)), closes [#163](#163) [#183](#183)
* **player:** scale episode title font size to its length ([#81](#81)) ([#202](#202)) ([659b6b8](659b6b8))
* **player:** support video episode playback ([#209](#209)) ([f92a91f](f92a91f))
* **queue:** add setting to disable queue auto-population and auto-advance ([#108](#108)) ([#185](#185)) ([cf3d73c](cf3d73c))
* **queue:** allow reordering the playback queue ([#80](#80)) ([#179](#179)) ([d994d61](d994d61)), closes [#173](#173)
* **settings:** import/export settings & templates ([#180](#180)) ([a27d23d](a27d23d)), closes [#162](#162) [#162](#162)
* **templates:** add {{currentDate}}, {{episodeNumber}}, {{duration}} template variables ([#189](#189)) ([ec573eb](ec573eb)), closes [#75](#75) [#34](#34) [#88](#88) [163/#186](#186)
* **templates:** add episode chapters tag ([#207](#207)) ([9c98863](9c98863))
* **transcripts:** opt-in speaker diarization for transcripts ([#168](#168)) ([#204](#204)) ([a96e12f](a96e12f))
@github-actions

Copy link
Copy Markdown
Contributor

🎉 This PR is included in version 2.17.0 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature Request: Timestamp-Driven Episode Switching in PodNotes Player

1 participant