PodNotes is an Obsidian community plugin for listening to podcasts, tracking playback progress, creating podcast notes, capturing timestamps, downloading episodes, using local audio files, and exposing a small API for workflow plugins.
Source code lives in src/. Core plugin registration and lifecycle wiring live
in src/main.ts; public API code is under src/API/; parsing code is under
src/parser/; stores and controllers are under src/store*; utility functions
are under src/utility/; Svelte UI lives under src/ui/; shared types live in
src/types/.
Tests are colocated with source files as *.test.ts where practical. Shared
test mocks live in tests/mocks/. User-facing documentation lives in docs/
and is built with Astro Starlight.
Generated plugin artifacts such as main.js and source maps are ignored by git
and should not be hand-edited. Production builds write main.js at the repo
root for release packaging; development builds write into build/ and maintain
root symlinks for local Obsidian loading.
- Use Node 22. The repo has
.nvmrc,.npmrc, andpackage.jsonengines for this. - Use npm for package management and scripts. Do not introduce another package manager unless the migration is intentional and removes the old lockfile.
- Use Conventional Commits (
feat:,fix:,test:,docs:,chore:) so the automated release planner can determine versions and generate release notes. - If work resolves a GitHub issue, prefer an issue-linked branch workflow before implementation.
npm ci: install the locked dependencies for local development.npm run dev: watch-mode development build via Vite.npm run typecheck: run TypeScript 7's nativetsc --noEmitfor source and test projects.npm run lint: run oxlint against TypeScript sources and tooling scripts (includes the obsidianmd guideline rules and thesvelte-policyinternals guard via jsPlugins), then thesvelte-policycomponent checker, configured bysvelte-policy.json.npm run format:check: run the configured oxfmt check (npm run formatto write).npm run check:a11y: patch svelte-check if needed, runlint:tsconfig, then runsvelte-check --tsgo-experimental-api --fail-on-warningsfor source and test projects.npm run lint:tsconfig: fail with TS18003 whentsconfig.jsonortsconfig.test.jsonmatches no files, using the native compiler without type checking.npm run test: run Svelte checks and the application Vitest suite.npm run test:unit: run the application Vitest suite without the Svelte checks.npm run test:tooling: run the real-process Svelte checker failure tests and the build's syntax floor test, including rejection of tagged templates that disable Obsidian's eval cache. CI runs these separately on every PR.npm run build: type-check and produce the production plugin bundle.npm run docs:build: build the Starlight documentation and check its internal links.npm run docs:deploy: build docs and deploydocs/distto Cloudflare Pages.
@typescript/native aliases TypeScript 7. typecheck and lint:tsconfig call
its compiler by path because npm can link the shared tsc command to either
compiler after an incremental install. svelte-check type-checks through that
compiler's API (--tsgo-experimental-api), but it still loads the typescript
package, as do the TypeScript ESLint packages loaded by oxlint's Obsidian rules.
Keep typescript on version 6.
svelte-check 4.7.6 exits 0 when its check throws, for example when the native
compiler cannot start. patches/svelte-check.mjs applies the upstream fix,
sveltejs/language-tools#3133,
to exactly that version. It runs on postinstall and again at the start of
check:a11y, so an install with --ignore-scripts is patched before the check
runs. It fails on any other svelte-check version or an unrecognized file, so
svelte-check is pinned and Dependabot skips it. Once a svelte-check release
includes the fix, upgrade to it and delete the script, its two calls and the
patches lint/format paths in package.json, and the Dependabot ignore. The native API path also passes when
a tsconfig matches no files, which lint:tsconfig catches.
tests/tooling/svelteCheck.test.ts runs the real check:a11y in temporary
workspaces and requires it to fail on each of these cases.
After npm run build, npm run size enforces 330,000 raw bytes and 103,000 gzip bytes at level 9 for root main.js in the Test workflow's Build and sizes worker. npm run size:data reports a synthetic v2 stress library with 50 feeds, 2,000 history entries, and 200 saved episodes from frozen NPR metadata, including duplicated snapshots. Its 1.5 MB document goal is advisory. Pass an additive candidate JSON path to report optional-field overhead without changing existing data. In the isolated Obsidian instance, npm run count:writes -- start, report, and stop record writes to PodNotes' exact data.json path. Stop the recorder before stopping the instance, because recorder commands start a stopped instance; plugin reloads invalidate its counts.
Before opening a PR or cutting a release, run the CI-equivalent checks locally:
npm run lint
npm run format:check
npm run typecheck
npm run build
npm run test
npm run test:tooling
npm run docs:buildThe Platforms workflow runs npm run test:unit and npm run test:tooling on
macOS and Windows for every PR and master push. It is not a required check.
Vitest runs in Node and aliases obsidian to tests/mocks/obsidian.ts. A test
file that needs a DOM or loads Svelte code starts with
// @vitest-environment jsdom; leave it off otherwise, because jsdom setup is
the suite's largest cost. A Node test that loads Svelte code or reads window
or document fails with that hint. Prefer unit tests for pure utility, parser,
store, API, and component behavior. Use Testing Library for Svelte component
behavior instead of asserting on implementation details.
When a bug depends on real Obsidian runtime behavior, reproduce it in Obsidian before changing code and verify it there after the fix. Timestamp links, URI handling, playback restore, downloaded/local media, file writes, settings migrations, and workspace/view behavior are runtime-sensitive and should not be trusted to jsdom alone.
For runtime verification, record the exact Obsidian version, platform, vault setup, feed or local file used, command or URI invoked, console/runtime errors, and observed plugin state before and after the action.
Prove runtime behavior in a real Obsidian whose PodNotes plugin folder links this
checkout's build. Prefer scripted, repeatable checks, and for commands or URIs
test both the user-facing path and the direct command/URI path. The project
verify skill (.claude/skills/verify/SKILL.md) shows how to seed a feed and a
local file, drive each feature, and capture evidence.
The canonical /Users/christian/Developer/PodNotes checkout uses the shared
dev vault at /Users/christian/Developer/dev_vault/dev, whose
.obsidian/plugins/podnotes symlinks point at that checkout's artifacts:
npm run dev
obsidian vault=dev plugin:reload id=podnotes
obsidian vault=dev eval code='app.plugins.plugins.podnotes?.manifest?.version'Only one checkout can own those symlinks, so worktrees never touch the dev
vault. They use the isolated instance below.
scripts/obsidian-e2e.mjs wraps the obsidian-e2e instance runner, configured
by obsidian-e2e.config.mjs. Each worktree gets its own vault
(.obsidian-e2e-vaults/podnotes-<worktree>, git-ignored, seeded with
DEFAULT_SETTINGS on first provision), private HOME, CLI socket, and
Obsidian process. start, run, test:e2e, and a test that restarts Obsidian
all launch it through scripts/e2e-instance.mjs, which adds
--host-resolver-rules=MAP podnotes.test 127.0.0.1,MAP priv3.supercast.com 127.0.0.1.
The fixture feeds live at http://podnotes.test:<port>, and the Supercast-shaped
private feed at http://priv3.supercast.com:<port>, so they need no DNS. The
same commands work on both platforms:
npm run build # the vault links the root main.js
npm run start:e2e-obsidian # start, or reuse and reload PodNotes
npm run obsidian:e2e -- eval code='Boolean(app.plugins.plugins.podnotes)' # => true
npm run obsidian:e2e -- dev:errors
npm run build && npm run obsidian:e2e -- --reload eval code='app.plugins.plugins.podnotes.manifest.version'| macOS | Linux (headless) | |
|---|---|---|
| App | Obsidian.app via open |
~/.local/share/obsidian-versions/obsidian-1.14.4/obsidian, under xvfb-run when DISPLAY is unset |
| CLI | obsidian on PATH |
obsidian-cli next to the app |
| Profile | /private/tmp/podnotes-obsidian-e2e/<vault>-<hash>/ |
~/.cache/podnotes-e2e/<vault>-<hash>/ |
| Evidence | npx obsidian-e2e capture launch (a separate instance) |
the capture commands below, against this instance |
obsidian:e2ebrings the instance up when needed. A warm instance is used as is: since obsidian-e2e 0.11rundoes not reload the plugin, so pass--reloadafter every rebuild or you test the old bundle.npm run start:e2e-obsidian -- --print-envprints onlyexportlines (OBSIDIAN_E2E_VAULT,_VAULT_PATH,_OBSIDIAN_HOME, legacyPODNOTES_E2E_*aliases). On macOS, aftereval "$(npm run --silent start:e2e-obsidian -- --print-env)"andexport HOME="$OBSIDIAN_E2E_OBSIDIAN_HOME", the rawobsidian vault="$OBSIDIAN_E2E_VAULT" ...CLI reaches the instance. On Linux it does not, so usenpm run obsidian:e2e -- <command>.- The CLI socket sits in the profile, and Linux limits its path to 107 bytes.
startfails before it launches anything when the path does not fit.
The isolated instance runs Obsidian 1.14.4 by default. manifest.json
minAppVersion is 1.13.0, so the suite must also pass on 1.13.x, which the
.deb installs at /opt/Obsidian. Install 1.14.4 once, without root, from the
official release asset, and check its SHA-256:
dir="$HOME/.local/share/obsidian-versions"
mkdir -p "$dir"
curl -fL -o "$dir/obsidian-1.14.4.tar.gz" \
https://github.com/obsidianmd/obsidian-releases/releases/download/v1.14.4/obsidian-1.14.4.tar.gz
echo "e70b7cd63787e7f798a84fec453f5c446b91877bd3f612e8fb98e9b76cc81120 $dir/obsidian-1.14.4.tar.gz" | sha256sum -c -
tar -xzf "$dir/obsidian-1.14.4.tar.gz" -C "$dir" && rm "$dir/obsidian-1.14.4.tar.gz"The archive unpacks to obsidian-1.14.4/, with obsidian and obsidian-cli
side by side. Two variables select another build:
PODNOTES_OBSIDIAN_APPis the Obsidian executable.PODNOTES_OBSIDIAN_BINis the CLI client. It defaults to theobsidian-clinext toPODNOTES_OBSIDIAN_APP, so set it only when the client lives elsewhere.
Stop this worktree's instance before you switch builds. The runner refuses to reuse an instance that a different build launched, and its error calls that a mid-session update. Run the suite on 1.13.x with:
npm run stop:e2e-obsidian
PODNOTES_OBSIDIAN_APP=/opt/Obsidian/obsidian npm run test:e2etest:e2e stops the instance it launched, so the next command starts 1.14.4
again. Any other command against a 1.13.x instance needs the same variable.
npm run test:e2e is the one command. Vitest arguments pass through
(npm run test:e2e -- -t "timestamp"), every test is listed, and a filter that
matches no test fails the run.
- On both platforms it builds, starts or reuses this worktree's isolated
instance, runs
tests/e2eagainst it, and stops the instance afterwards only if this run launched it. It never uses the shareddevvault. - The suite fails before any test runs when the instance lacks these
resolver rules. Stop the instance, then run
npm run start:e2e-obsidian. - The suite fails when Obsidian has more than one window open, because a second vault window keeps Obsidian from quitting. Close it, or stop the instance, before you rerun.
- The harness snapshots the workspace, app settings in
app.json, the core plugins withcore-plugins.json, and PodNotes'data.jsonbefore each test, and restores them after it. A test can turn on a core plugin, such as the file explorer, and leave its restore to the harness. - A temporary plugin, loaded before PodNotes, writes every uncaught renderer error to a journal that survives reloads and restarts. The harness fails the test that raised the error, or the next test, or the suite when its final cleanup raised it. The next run removes a recorder that an interrupted run left behind.
- A collapsed or narrow left sidebar opens at 280 px for the run. The suite restores the original layout after its last test.
- Settings must open in the main window. A test fails at once, naming the
settingsPopoutWindowconfig, when Obsidian would open it in a popout. The harness reapplies the runner's required app settings on every restart, because a quit can leaveapp.jsonempty and Obsidian then opens Settings in a popout. Workspace cleanup restores the original app settings in memory and the exactapp.jsonbytes. - Build code for Obsidian to evaluate with
jsLiteralfromtests/e2e/runtime.ts, never withJSON.stringifyalone. harness-lifecycle.test.tsruns the suites intests/e2e/harness-fixturesin their own Vitest processes and checks how each run ends.- An interrupted run can leave test fixtures in the vault's
data.json, the 280 px sidebar, and the interrupted test's layout. Reset the vault withnpm run stop:e2e-obsidian && rm -rf .obsidian-e2e-vaults.
Screenshots and recordings come from the same instance. Write them under the
git-ignored .obsidian-e2e-artifacts/, not /tmp:
npm run screenshot:e2e-obsidian -- .obsidian-e2e-artifacts/player.png # the PodNotes view
npm run record:e2e-obsidian -- .obsidian-e2e-artifacts/player.mp4 # 3 s take of the window
npm run record:e2e-obsidian -- .obsidian-e2e-artifacts/flow.mp4 -- ./drive.sh # take while a driver runs
npm run capture:e2e-obsidian -- screenshot .obsidian-e2e-artifacts/modal.png --modalscreenshot and record reveal the PodNotes view first and fail without
capturing when it does not open. capture forwards to obsidian-e2e capture (see its
README), with this instance's CDP port for the verbs that connect. A record
driver that exits non-zero discards the take.
Removing a worktree does not stop its instance. Stop it when you are done:
npm run stop:e2e-obsidian # this worktree's process tree and profile
npm run stop:e2e-obsidian -- --dry-run # show what would be stopped
npm run stop:e2e-obsidian -- --prune # also reap instances whose worktree is gonestop matches only this worktree's --user-data-dir (it carries a
per-worktree hash), including Xvfb on Linux, and never touches the dev vault
or other worktrees. Starting an instance never reaps other instances, so one
worktree's launch never stops a process another session started. Only an
explicit stop --prune reaps instances whose worktree is gone.
scripts/omd.sh drives the real Obsidian Android app through
obsidian-mobile-debug at a
pinned commit. It is Linux-only (KVM, a systemd user unit) and needs uv and jq.
npm run mobile:setup # first run downloads about 8 GB (up to 35 min)
npm run --silent mobile:verify # build, deploy, smoke probe; JSON on stdout, exit 0/2/1
npm run mobile:eval -- 'app.plugins.plugins.podnotes.manifest.version'
npm run mobile:screenshot -- .obsidian-e2e-artifacts/mobile/player.png
npm run mobile:logs -- --seconds 20
npm run --silent mobile:download # downloads the playing episode, reports native memory
npm run mobile:stopThe host runs one emulator. The worktree that boots it owns it until its own
mobile:stop, and every other worktree's mobile commands refuse until then, so
always stop when done. Stop keeps the AVD and every device vault. Each worktree
has its own device vault, seeded from obsidian-e2e.config.mjs defaultData
only on first provision. Run long setup/download commands in an asynchronous
terminal session and allow their documented timeout.
Docs are an Astro Starlight site in docs/, a standalone npm package with its
own package-lock.json. Pages live in docs/src/content/docs/ and the sidebar
in docs/src/site.mjs. Every page sets slug: frontmatter to pin its URL, and
the build fails without it. Update docs with user-facing behavior changes, new
commands, API changes, template syntax, transcript behavior, local-file
behavior, or import/export changes.
Every page follows the standard in docs/STYLE.md: pages grouped by what the
reader is doing, one job per page, UI labels spelled as PodNotes shows them,
and facts that describe the latest published release, not unreleased master.
Screenshots come only from node docs/scripts/screenshots.mjs, which captures
them in light and dark from the seeded vault in docs/screenshots/. Rerun it
after a UI change instead of adding images by hand.
Use npm run docs:build to validate docs locally. It installs the docs
dependencies and runs astro build, which fails on a broken internal link or
anchor, a page missing from the sidebar, or a published heading id that no
longer resolves. Cloudflare Pages builds from docs/ and publishes docs/dist,
configured in docs/wrangler.jsonc. See docs/README.md for the docs project
layout.
Goal: publish each version from a tested, reviewed, and attested commit. Stop
when the GitHub release is public, its tag points at the release commit, and
main.js and manifest.json match their recorded SHA-256 digests.
Three workflows in .github/workflows/ call reusable workflows in
chhoumann/obsidian-plugin-workflows pinned to v4
(ebcf84b80dc48ba15e0eec1f3575d41519a78ed5):
Prepare release(release-prepare.yml) runs after a greenTestpush run onmaster, or byworkflow_dispatchwith an optionaltargetSha. It skips ifmastermoved past the tested commit. It plans the version with semantic-release's commit analyzer from the Conventional Commits since the latest tag.featis minor,fixandperfare patch, and aBREAKING CHANGE:footer is major. The pinnedrelease-policymakesbuild(deps)a patch, so production Dependabot bumps release andbuild(deps-dev)bumps do not. Abuild(deps)commit stays a patch even with aBREAKING CHANGE:footer. The analyzer ignores the!marker, so the PR title check rejects it. Thepodnotes-release-botGitHub App then opens or refreshes one draft PR fromrelease/<version>titledrelease(version): Release <version>, with the generated notes in its body. It changes exactlypackage.json,package-lock.json,manifest.json, andversions.json. Release PRs that a newer plan supersedes are closed.Trigger release(release-trigger.yml) runs onpull_request_targetclosedfor a mergedrelease/*PR intomaster. It validates the bot author, branch, title, exact version-file diff, merger, squash parent, and tree, creates therelease-run/<version>recovery branch at the release commit, and dispatchesrelease.ymlon it.Release(release.yml) validates again, recomputes the version, and runsnpm run lint,npm run format:check,npm run typecheck,npm run test, andnpm run docs:build. It builds, creates the<version>tag, attestsmain.jsandmanifest.json, uploads them to a draft GitHub release, downloads and re-hashes them, publishes, and then deletes the recovery branch. Slack and Discord notifications are optional secrets.
The App, not GITHUB_TOKEN, opens the release PR, so it gets the normal PR
checks. Review the exact diff, wait for the required checks, mark it ready, and
squash-merge it with the generated title unchanged. Only the repository owner
(chhoumann) may merge it, and only directly onto its recorded base. When
master moves, the ruleset marks the PR out of date until the bot refreshes it
after the next green Test push run. Each refresh puts the PR back in draft
and restarts its checks, so once the owner marks it ready, stop merging other
PRs until it merges.
Never update a release PR's branch yourself, not even with
gh pr update-branch. The bot refuses to overwrite a branch head it did not
generate, so release planning fails on every later push. To recover before the
PR merges, close it and delete its release/<version> branch. The next green
Test push run creates both again.
When a feature requires a newer Obsidian API, raise manifest.json
minAppVersion in the feature PR and leave existing versions.json entries
unchanged. The floor can only go up, and the release PR records it under the
new version.
Setup: install the podnotes-release-bot App with the RELEASE_APP_ID
variable and the RELEASE_APP_PRIVATE_KEY secret. The default GITHUB_TOKEN
stays read-only. The pipeline writes only release/* and release-run/*
branches and tags, never master, so the master ruleset needs no bypass actor.
Keep CodeQL default setup enabled: the required CodeQL check comes from it, so
turning it off blocks every PR.
.github/rulesets/protect-master.json is the source of truth for the "Protect
master" ruleset: no deletion, no force push, PRs only, squash only, PRs up to
date with master, and the required checks Test, Docs, Validate PR title,
Dependency Review, and CodeQL. Bring any PR except a release PR up to date
with gh pr update-branch <number>. Each check is pinned to the app that
reports it: GitHub Actions, or GitHub Advanced Security for CodeQL, the code
scanning check that CodeQL default setup reports on every PR. Apply or update
the ruleset with:
gh api -X POST repos/chhoumann/PodNotes/rulesets --input .github/rulesets/protect-master.json
gh api -X PUT repos/chhoumann/PodNotes/rulesets/<id> --input .github/rulesets/protect-master.jsonTo rename a required job, change the JSON in the same PR. For Test, Docs,
and Dependency Review, apply it with PUT just before that PR merges. For
Validate PR title, apply it right after: pull_request_target runs the
workflow from master, so the PR still reports the old name. Prepare release
also matches the Test workflow by its top-level name:, so renaming the
workflow stops release planning without failing any check.
The Test, Documentation, PR Title, Dependency Review, and Platforms
workflows have no concurrency block, so GitHub does not cancel their runs.
Duplicate events on one PR head, such as a reopen or an edit right after a
push, start duplicate runs of the required checks, and every run must finish. A
cancelled run on the head turns the commit's check rollup to FAILURE. Do not
add a concurrency group to these workflows. By default a group cancels a
pending run when a newer run joins it, even with cancel-in-progress: false.
Runs for a superseded head also finish. They cost CI minutes but block nothing.
Recovery is for the owner. Agents never dispatch release workflows. The release stage accepts only the refs below, re-derives and re-verifies the release commit from the PR on each, and re-running a published version verifies it and changes nothing.
# Re-validate from the current master head:
gh workflow run release-trigger.yml --ref master -f pr-number=<merged-pr-number>
# Re-run only the release stage. Use the tag once it exists, or master to pick
# up a reviewed workflow fix:
gh workflow run release.yml --ref release-run/<version> -f releasePr=<merged-pr-number>
gh workflow run release.yml --ref <version> -f releasePr=<merged-pr-number>
gh workflow run release.yml --ref master -f releasePr=<merged-pr-number>Pull requests should include:
- a Conventional Commits title with a type from
.github/workflows/pr-title.yml, because the squash-merge commit takes the PR title and the release planner reads it; record a breaking change with aBREAKING CHANGE:footer in the squash commit body, not with!; - a concise summary of the user-facing change;
- linked issues when relevant;
- screenshots or short recordings for visible UI changes;
- feed URLs, local file details, or transcript setup for podcast-specific fixes;
- exact commands run and whether Obsidian runtime verification was performed;
- release or migration impact, especially for settings, storage, API, or URI behavior.
The sections of .github/PULL_REQUEST_TEMPLATE.md follow this list.
Keep changes scoped to the touched behavior. Do not mix unrelated formatting, dependency churn, docs rewrites, or generated artifact changes into feature and bug-fix commits.