Repository navigation
fix(uri): preserve '+' in episode titles and paths for timestamp links - #181
Merged
Merged
Conversation
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
Deploying podnotes with
|
| Latest commit: |
e32bf20
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://2c1b9267.podnotes.pages.dev |
| Branch Preview URL: | https://chhoumann-164-timestamp-epis.podnotes.pages.dev |
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))
Contributor
|
🎉 This PR is included in version 2.17.0 🎉 The release is available on GitHub release Your semantic-release bot 📦🚀 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
encodePodnotesURIusedURLSearchParams, which encodes spaces as+. Obsidian decodes protocol query values withdecodeURIComponentonly (no+→space), soURIHandlercompensated with a blanket.replace(/\+/g, " ")— which also collapsed real+characters into spaces. The corrupted title/path then failed to match, andFeedParser.findItemByTitlethrew 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.Changes
encodePodnotesURInow percent-encodes spaces as%20(keeping a literal+as%2B), so links round-trip losslessly through Obsidian's decoder — literal+included.URIHandlerresolves 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 existingFeedParser.getEpisodes; a feed-load failure or genuine miss now shows aNoticeinstead of rejecting silently.FeedParser.findItemByTitle.Backwards compatibility
+) still resolve via the legacy fallback.+(ambiguous, and always was). New links are unambiguous.X YandX+Yas distinct episodes and the space-twin is loaded, a newX+Ylink 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).encodePodnotesURI%20/%2Bround-trip;URIHandlerfor+-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).tests/e2e/podnotes-runtime.test.ts): the capture test now derives its expected link from the production encoder and assertsepisodeName=E2E%20Capture%20Episode.decodeURIComponent-only) confirmed against the shippedobsidian.asar; the%20/%2Bround-trip is deterministic; the downstream seek path (restorePlaybackTime) is unchanged and was runtime-verified earlier. A full shared-dev-vaultnpm run test:e2epass is pending a free vault.Scope
No
.sveltefiles 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