diff --git a/AGENTS.md b/AGENTS.md index be6df464..45ef1f64 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -206,16 +206,25 @@ terminal session and allow their documented timeout. ## Documentation 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/`, images in -`docs/public/resources/`, 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. +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. Cloudflare Pages builds from `docs/` and publishes `docs/dist`, configured in -`docs/wrangler.jsonc`. See `docs/README.md` for the docs project layout. +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. ## Release Workflow Goal: publish each version from a tested, reviewed, and attested commit. Stop diff --git a/docs/README.md b/docs/README.md index b194c5a1..fe4e2d6f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -7,23 +7,65 @@ plugin's dependencies. ## Layout +- `STYLE.md` is the standard every page follows. Read it before you write. - `src/content/docs/` holds the pages as markdown. Each page sets `slug:` frontmatter to its URL path. For example, `slug: commands` serves `/commands/`, and `slug: index` serves `/`. The build fails if a page has no `slug:`, because Astro would otherwise lowercase the generated slug and change URLs such as `/QuickAdd/`. If you change a slug, add a 301 in `public/_redirects`. +- `index.md` is the landing page. It uses Starlight's splash template, and + `src/components/Hero.astro` renders its hero with the hero screenshot. - `astro.config.mjs` holds the Starlight config. The sidebar lives in - `src/site.mjs`, which the LLM files below also read. + `src/site.mjs`, which the LLM files below also read. `src/styles/custom.css` + holds the theme. +- `src/components/MarkdownContent.astro` post-processes every page: + `src/screenshots.mjs` gives each screenshot its size, its dark twin, and its + phone crop, and `src/tables.mjs` labels table cells so wide tables stack on + phones. - `scripts/legacy-fragments.mjs` runs after every build and fails it if a - heading fragment from the old MkDocs site (`legacy-fragments.json`) has no + heading fragment the site has published (`legacy-fragments.json`) has no target. When a heading's new id differs from the old one, start the heading with an empty ``, as `commands.md` does. For an old page-title id, set `legacyTitleId:` in the frontmatter instead. -- `public/` holds static assets served as-is. Images live in - `public/resources/` and are referenced with site-absolute paths such as - `/resources/podcast_grid.png`. The repo-root `README.md` uses the same - images. +- `public/` holds static assets served as-is. Older images live in + `public/resources/` and stay for existing links. + +## Screenshots + +Every screenshot in `public/screenshots/` comes from one script. From the repo +root, after `npm run build`: + +```sh +node docs/scripts/screenshots.mjs +``` + +It builds a vault from `screenshots/` (the notes, a Bases file, the PodNotes +settings in `seed.json`, and the Minimal theme), opens it in a private Obsidian +capture instance at 2x, adds the podcast feeds through Settings, plays the +episodes, creates notes and captures timestamps with PodNotes' own commands, and +writes each shot as `.webp` and `-dark.webp`, with a +`-phone.webp` crop where the desktop shot is unreadable on a phone, plus +the README demo +GIFs and the social preview `og.png`. It needs network access for the feeds, +their artwork, and the theme. The episodes in `seed.json` must still be among +each feed's latest episodes. If one has left, pick a newer one and update its +notes in `screenshots/notes/`. + +To show a screenshot on a page, write +`![What it shows](/screenshots/.webp)`. The site adds the size, lazy +loading, the dark twin, and the phone crop if there is one. + +## Brand + +The PodNotes mark is `public/brand/podnotes-mark.svg`, drawn on a 16 px grid so +the smallest favicon stays sharp. The header logo uses it directly. The +favicons and the Apple touch icon are built from it. After you change the mark, +run from the repo root: + +```sh +node docs/scripts/brand.mjs +``` ## Commands diff --git a/docs/STYLE.md b/docs/STYLE.md new file mode 100644 index 00000000..e44f3864 --- /dev/null +++ b/docs/STYLE.md @@ -0,0 +1,102 @@ +# PodNotes docs standard + +Every page on https://podnotes.obsidian.guide follows this checklist. Review a +page against it before you merge. The sources are listed at the end. + +The reader is an Obsidian user who wants to listen to a podcast and keep notes. +They skim, they often read on a phone, and they arrive from search on a page +other than the home page. + +## The site + +- [ ] The sidebar groups pages by what the reader is doing, in this order: + **Start here**, **Listen**, **Take notes**, **Your library**, **Automate**, + **Reference**, **Help**. `src/site.mjs` holds the sidebar. +- [ ] Every answer is at most two clicks from the home page: the home page or the + sidebar, then the page. +- [ ] Each page has one job, named by its title. A page that does two jobs + becomes two pages. +- [ ] A procedure lives on one page. Other pages link to it instead of repeating + it. Two exceptions: **Getting started** walks through the first install, + podcast, playback, timestamp, and note in one place, and the landing page + and the FAQ may give a one-line fix that links to the page that owns the + procedure. +- [ ] URLs never break. Every page pins `slug:`. A moved page gets a 301 in + `public/_redirects`. A renamed heading keeps its old id with an empty + `` (the build checks `legacy-fragments.json`). +- [ ] Content lives in text, not only in images, so the raw `.md` page and + `llms.txt` carry all of it. + +## The page + +- [ ] The title says what the reader does or looks up: "Capture timestamps", + "Template tags". Sentence case. +- [ ] `description:` frontmatter is one sentence. Search results and link + previews show it. +- [ ] The first sentence says what the page lets the reader do. No "This page", + no "In this guide", no history. +- [ ] The common case comes first. Limits and edge cases come last, under a + heading that names them. +- [ ] Task headings start with a verb ("Add a podcast"). Concept headings are + noun phrases ("Episode identity"). FAQ entries are the reader's questions. + No heading starts with an -ing word, and no level is skipped. +- [ ] A how-to page covers one feature. Each task on it fits in about one + screen at 1440x900, and the page stays under about four screens before any + reference section. Split a page that grows past that. Lookup tables go at + the end of the page under a heading that says so, or on a reference page. +- [ ] A procedure is a numbered list with one action per step. Each step names + the place before the action: "In **Settings → PodNotes**, select + **Add**." +- [ ] When desktop and mobile differ, the page says so where it matters. + +## The sentences + +- [ ] Write to "you", in the present tense and the active voice. +- [ ] UI labels are bold and spelled exactly as PodNotes shows them, case + included: **Search Podcasts**, **Capture timestamp format**. +- [ ] Commands use the palette name: **PodNotes: Capture Timestamp**. +- [ ] Settings paths use an arrow: **Settings → Community plugins**. +- [ ] Use "select" for clicks and taps. Shortcuts look like `Ctrl+P`. +- [ ] Use plain words: "use", not "utilize". No "simply", "just", "easy", + "powerful", or "seamless". No emoji. +- [ ] No em dash and no semicolons. End the sentence instead. +- [ ] Examples use real values: a real feed, a real time such as `00:12:41`. +- [ ] Link text names the destination. Never "here". Link with site-absolute + paths such as `/timestamps/#capture-a-range`. + +## The facts + +- [ ] Describe only the latest published release + (`gh release list --repo chhoumann/PodNotes`). Behavior that exists only + on `master` waits for the release. +- [ ] Check every claim against `src/` at the release tag. +- [ ] State a limit plainly and give the workaround if one exists. + +## Screenshots + +- [ ] A screenshot shows something words cannot. Never screenshot text. +- [ ] `docs/scripts/screenshots.mjs` captures every screenshot from the seed + vault in `docs/screenshots/`. Never add one by hand. Rerun the script + after a UI change. +- [ ] Each screenshot has a light file and a `-dark` file. The site shows the + one that matches the reader's theme. +- [ ] The crop shows the part that matters and enough context to find it. +- [ ] A shot whose text is unreadable at 390 px wide gets a `-phone` crop + captured in Obsidian's phone layout. The site shows it on narrow screens. +- [ ] Alt text says what the screenshot shows in under 150 characters. + +## Before you merge + +- [ ] `npm run docs:build` passes. It checks links, legacy fragments, and that + every page is in the sidebar. +- [ ] You read the page at 390 px and 1440 px wide, in light and dark. + +## Sources + +- Lee Robinson, [Documentation](https://leerob.com/docs) and + [On developer experience](https://leerob.com/dx). +- [Diátaxis](https://diataxis.fr/). +- [Google developer documentation style guide](https://developers.google.com/style). +- [Write the Docs: documentation principles](https://www.writethedocs.org/guide/writing/docs-principles/). +- [Obsidian Help style guide](https://obsidian.md/help/style-guide). +- [Nielsen Norman Group: how users read on the web](https://www.nngroup.com/articles/how-users-read-on-the-web/). diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index 84fb1db7..707d2c67 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -2,7 +2,7 @@ import { defineConfig } from "astro/config"; import starlight from "@astrojs/starlight"; import starlightLinksValidator from "starlight-links-validator"; -import { description, sidebar, site, title } from "./src/site.mjs"; +import { description, repository, sidebar, site, title } from "./src/site.mjs"; // https://astro.build/config export default defineConfig({ @@ -11,28 +11,67 @@ export default defineConfig({ starlight({ title, description, + logo: { src: "./public/brand/podnotes-mark.svg", alt: "" }, plugins: [starlightLinksValidator()], social: [ { icon: "github", label: "GitHub", - href: "https://github.com/chhoumann/PodNotes", + href: repository, }, ], editLink: { baseUrl: "https://github.com/chhoumann/PodNotes/edit/master/docs/", }, + customCss: ["./src/styles/custom.css"], + expressiveCode: { + defaultProps: { wrap: true }, + styleOverrides: { borderRadius: "0.5rem" }, + }, head: [ { - tag: "meta", + tag: "link", + attrs: { + rel: "icon", + type: "image/png", + sizes: "32x32", + href: "/favicon-32.png", + }, + }, + { + tag: "link", + attrs: { + rel: "icon", + type: "image/png", + sizes: "16x16", + href: "/favicon-16.png", + }, + }, + { tag: "link", attrs: { rel: "apple-touch-icon", href: "/apple-touch-icon.png" } }, + { + tag: "link", attrs: { - property: "og:image", - content: `${site}/resources/podcast_grid.png`, + rel: "preload", + href: "/fonts/InterVariable.woff2", + as: "font", + type: "font/woff2", + crossorigin: "", }, }, + { + tag: "meta", + attrs: { property: "og:image", content: `${site}/screenshots/og.png` }, + }, + { tag: "meta", attrs: { property: "og:image:width", content: "1200" } }, + { tag: "meta", attrs: { property: "og:image:height", content: "630" } }, + { tag: "meta", attrs: { name: "twitter:card", content: "summary_large_image" } }, ], sidebar, - components: { PageTitle: "./src/components/PageTitle.astro" }, + components: { + Hero: "./src/components/Hero.astro", + MarkdownContent: "./src/components/MarkdownContent.astro", + PageTitle: "./src/components/PageTitle.astro", + }, }), ], }); diff --git a/docs/legacy-fragments.json b/docs/legacy-fragments.json index b69c9410..07824622 100644 --- a/docs/legacy-fragments.json +++ b/docs/legacy-fragments.json @@ -1,4 +1,21 @@ { + "/": [ + "podnotes", + "features", + "installation", + "installation-with-brat", + "manual-installation", + "screenshots", + "demo", + "podcast-grid", + "episode-list", + "player", + "episode-notes", + "timestamps", + "transcripts-with-speaker-labels", + "browse-your-library-with-bases", + "podcast-search" + ], "/QuickAdd/": ["quickadd", "timestamp-capture", "add-podcast-note"], "/api/": [ "api", @@ -9,7 +26,11 @@ "getpodcasttimeformattedformat-string-linkify-boolean-offsetseconds-number", "getpodcastsegmentformattedformat-string-starttime-number-endtime-number-linkify-boolean", "playbackrate", - "playback-controls" + "playback-controls", + "isplaying", + "length", + "currenttime", + "volume" ], "/commands/": [ "show-player", @@ -27,31 +48,43 @@ "create-episode-note", "create-podcast-feed-note", "copy-universal-episode-link-to-clipboard", - "transcribe-current-episode" + "transcribe-current-episode", + "reorder-queue", + "capture-last-10-seconds--capture-last-20-seconds" + ], + "/faq/": [ + "where-is-the-player", + "why-are-some-commands-missing", + "which-podcasts-can-i-add", + "can-i-add-spotify-exclusives-or-youtube-links", + "can-i-use-a-private-feed", + "do-downloads-sync-with-my-vault", + "what-happens-to-notes-and-links-when-i-remove-a-download", + "can-i-annotate-my-own-audio-or-video", + "does-transcription-cost-money", + "does-playback-progress-sync-between-devices" + ], + "/getting-started/": [ + "install-podnotes", + "add-a-podcast", + "play-an-episode", + "capture-a-timestamp", + "create-an-episode-note" ], "/import_export/": [ "importing", "exporting", "settings-templates", "exporting-settings", - "importing-settings" + "importing-settings", + "settings--templates" ], - "/": [ - "podnotes", - "features", - "installation", - "installation-with-brat", - "manual-installation", - "screenshots", - "demo", - "podcast-grid", - "episode-list", - "player", - "episode-notes", - "timestamps", - "transcripts-with-speaker-labels", - "browse-your-library-with-bases", - "podcast-search" + "/mobile/": [ + "enable-podnotes-and-open-the-player", + "add-a-capture-button-to-the-editor-toolbar", + "capture-with-a-headset", + "download-an-episode", + "known-limits-and-verified-devices" ], "/podcasts/": [ "adding-podcasts", @@ -61,16 +94,34 @@ "context-menu", "reordering-the-queue", "turning-off-the-queue", - "player" + "player", + "when-a-feed-cannot-be-reached" + ], + "/sync/": [ + "plugin-data-and-notes", + "changes-on-two-devices", + "secrets-on-each-device", + "downloads-on-another-device", + "removal-and-existing-links", + "unreadable-plugin-data" ], "/templates/": [ "file-path", "note-template", "linking-an-episode-to-its-podcast-feed-note", "using-the-default-template-with-bases", - "podcast-feed-notes" + "podcast-feed-notes", + "duration-and-chapters", + "transcript-and-timestamp-templates", + "date-formats" + ], + "/timestamps/": [ + "settings", + "capturing-timestamps", + "capturing-segments", + "time-formats", + "podnotes-uri" ], - "/timestamps/": ["settings", "capturing-timestamps", "capturing-segments"], "/transcripts/": [ "transcripts", "setting-up", diff --git a/docs/public/apple-touch-icon.png b/docs/public/apple-touch-icon.png new file mode 100644 index 00000000..d25788d9 Binary files /dev/null and b/docs/public/apple-touch-icon.png differ diff --git a/docs/public/brand/podnotes-mark.svg b/docs/public/brand/podnotes-mark.svg new file mode 100644 index 00000000..4b2826d8 --- /dev/null +++ b/docs/public/brand/podnotes-mark.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/docs/public/favicon-16.png b/docs/public/favicon-16.png new file mode 100644 index 00000000..3a90d509 Binary files /dev/null and b/docs/public/favicon-16.png differ diff --git a/docs/public/favicon-32.png b/docs/public/favicon-32.png new file mode 100644 index 00000000..282e2d38 Binary files /dev/null and b/docs/public/favicon-32.png differ diff --git a/docs/public/favicon.svg b/docs/public/favicon.svg index cba5ac14..4b2826d8 100644 --- a/docs/public/favicon.svg +++ b/docs/public/favicon.svg @@ -1 +1,11 @@ - \ No newline at end of file + + + + + + + + + + + diff --git a/docs/public/fonts/InterVariable-Italic.woff2 b/docs/public/fonts/InterVariable-Italic.woff2 new file mode 100644 index 00000000..70ab12e0 Binary files /dev/null and b/docs/public/fonts/InterVariable-Italic.woff2 differ diff --git a/docs/public/fonts/InterVariable.woff2 b/docs/public/fonts/InterVariable.woff2 new file mode 100644 index 00000000..18688032 Binary files /dev/null and b/docs/public/fonts/InterVariable.woff2 differ diff --git a/docs/public/fonts/LICENSE.txt b/docs/public/fonts/LICENSE.txt new file mode 100644 index 00000000..9b2ca37b --- /dev/null +++ b/docs/public/fonts/LICENSE.txt @@ -0,0 +1,92 @@ +Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION AND CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/docs/public/screenshots/add-podcast-dark.webp b/docs/public/screenshots/add-podcast-dark.webp new file mode 100644 index 00000000..984def11 Binary files /dev/null and b/docs/public/screenshots/add-podcast-dark.webp differ diff --git a/docs/public/screenshots/add-podcast.webp b/docs/public/screenshots/add-podcast.webp new file mode 100644 index 00000000..49d0924e Binary files /dev/null and b/docs/public/screenshots/add-podcast.webp differ diff --git a/docs/public/screenshots/bases-dark.webp b/docs/public/screenshots/bases-dark.webp new file mode 100644 index 00000000..c39a0507 Binary files /dev/null and b/docs/public/screenshots/bases-dark.webp differ diff --git a/docs/public/screenshots/bases-phone-dark.webp b/docs/public/screenshots/bases-phone-dark.webp new file mode 100644 index 00000000..2daf4486 Binary files /dev/null and b/docs/public/screenshots/bases-phone-dark.webp differ diff --git a/docs/public/screenshots/bases-phone.webp b/docs/public/screenshots/bases-phone.webp new file mode 100644 index 00000000..e70ead12 Binary files /dev/null and b/docs/public/screenshots/bases-phone.webp differ diff --git a/docs/public/screenshots/bases.webp b/docs/public/screenshots/bases.webp new file mode 100644 index 00000000..281fe6b6 Binary files /dev/null and b/docs/public/screenshots/bases.webp differ diff --git a/docs/public/screenshots/demo-dark.gif b/docs/public/screenshots/demo-dark.gif new file mode 100644 index 00000000..cd7d9ee5 Binary files /dev/null and b/docs/public/screenshots/demo-dark.gif differ diff --git a/docs/public/screenshots/demo.gif b/docs/public/screenshots/demo.gif new file mode 100644 index 00000000..622bcf2b Binary files /dev/null and b/docs/public/screenshots/demo.gif differ diff --git a/docs/public/screenshots/episode-list-dark.webp b/docs/public/screenshots/episode-list-dark.webp new file mode 100644 index 00000000..61fdbb85 Binary files /dev/null and b/docs/public/screenshots/episode-list-dark.webp differ diff --git a/docs/public/screenshots/episode-list.webp b/docs/public/screenshots/episode-list.webp new file mode 100644 index 00000000..c38fbdae Binary files /dev/null and b/docs/public/screenshots/episode-list.webp differ diff --git a/docs/public/screenshots/episode-note-dark.webp b/docs/public/screenshots/episode-note-dark.webp new file mode 100644 index 00000000..7512c97a Binary files /dev/null and b/docs/public/screenshots/episode-note-dark.webp differ diff --git a/docs/public/screenshots/episode-note-phone-dark.webp b/docs/public/screenshots/episode-note-phone-dark.webp new file mode 100644 index 00000000..8cdc1190 Binary files /dev/null and b/docs/public/screenshots/episode-note-phone-dark.webp differ diff --git a/docs/public/screenshots/episode-note-phone.webp b/docs/public/screenshots/episode-note-phone.webp new file mode 100644 index 00000000..da1b9aab Binary files /dev/null and b/docs/public/screenshots/episode-note-phone.webp differ diff --git a/docs/public/screenshots/episode-note.webp b/docs/public/screenshots/episode-note.webp new file mode 100644 index 00000000..240a7835 Binary files /dev/null and b/docs/public/screenshots/episode-note.webp differ diff --git a/docs/public/screenshots/hero-dark.webp b/docs/public/screenshots/hero-dark.webp new file mode 100644 index 00000000..6b0cbcb0 Binary files /dev/null and b/docs/public/screenshots/hero-dark.webp differ diff --git a/docs/public/screenshots/hero-phone-dark.webp b/docs/public/screenshots/hero-phone-dark.webp new file mode 100644 index 00000000..17107a08 Binary files /dev/null and b/docs/public/screenshots/hero-phone-dark.webp differ diff --git a/docs/public/screenshots/hero-phone.webp b/docs/public/screenshots/hero-phone.webp new file mode 100644 index 00000000..f05b4703 Binary files /dev/null and b/docs/public/screenshots/hero-phone.webp differ diff --git a/docs/public/screenshots/hero.webp b/docs/public/screenshots/hero.webp new file mode 100644 index 00000000..a878b780 Binary files /dev/null and b/docs/public/screenshots/hero.webp differ diff --git a/docs/public/screenshots/mobile-player-dark.webp b/docs/public/screenshots/mobile-player-dark.webp new file mode 100644 index 00000000..34d04803 Binary files /dev/null and b/docs/public/screenshots/mobile-player-dark.webp differ diff --git a/docs/public/screenshots/mobile-player.webp b/docs/public/screenshots/mobile-player.webp new file mode 100644 index 00000000..eb2913f7 Binary files /dev/null and b/docs/public/screenshots/mobile-player.webp differ diff --git a/docs/public/screenshots/mobile-toolbar-dark.webp b/docs/public/screenshots/mobile-toolbar-dark.webp new file mode 100644 index 00000000..ec01e747 Binary files /dev/null and b/docs/public/screenshots/mobile-toolbar-dark.webp differ diff --git a/docs/public/screenshots/mobile-toolbar.webp b/docs/public/screenshots/mobile-toolbar.webp new file mode 100644 index 00000000..c2498f93 Binary files /dev/null and b/docs/public/screenshots/mobile-toolbar.webp differ diff --git a/docs/public/screenshots/og.png b/docs/public/screenshots/og.png new file mode 100644 index 00000000..e0bb71aa Binary files /dev/null and b/docs/public/screenshots/og.png differ diff --git a/docs/public/screenshots/player-dark.webp b/docs/public/screenshots/player-dark.webp new file mode 100644 index 00000000..2bc81c2c Binary files /dev/null and b/docs/public/screenshots/player-dark.webp differ diff --git a/docs/public/screenshots/player.webp b/docs/public/screenshots/player.webp new file mode 100644 index 00000000..1e4ff05c Binary files /dev/null and b/docs/public/screenshots/player.webp differ diff --git a/docs/public/screenshots/podcast-grid-dark.webp b/docs/public/screenshots/podcast-grid-dark.webp new file mode 100644 index 00000000..97c8b69a Binary files /dev/null and b/docs/public/screenshots/podcast-grid-dark.webp differ diff --git a/docs/public/screenshots/podcast-grid.webp b/docs/public/screenshots/podcast-grid.webp new file mode 100644 index 00000000..5c668b11 Binary files /dev/null and b/docs/public/screenshots/podcast-grid.webp differ diff --git a/docs/public/screenshots/timestamps-dark.webp b/docs/public/screenshots/timestamps-dark.webp new file mode 100644 index 00000000..2bfa1ada Binary files /dev/null and b/docs/public/screenshots/timestamps-dark.webp differ diff --git a/docs/public/screenshots/timestamps-phone-dark.webp b/docs/public/screenshots/timestamps-phone-dark.webp new file mode 100644 index 00000000..4cea6cf4 Binary files /dev/null and b/docs/public/screenshots/timestamps-phone-dark.webp differ diff --git a/docs/public/screenshots/timestamps-phone.webp b/docs/public/screenshots/timestamps-phone.webp new file mode 100644 index 00000000..9ca6943b Binary files /dev/null and b/docs/public/screenshots/timestamps-phone.webp differ diff --git a/docs/public/screenshots/timestamps.webp b/docs/public/screenshots/timestamps.webp new file mode 100644 index 00000000..3315976c Binary files /dev/null and b/docs/public/screenshots/timestamps.webp differ diff --git a/docs/screenshots/notes/math-vs-machine.md b/docs/screenshots/notes/math-vs-machine.md new file mode 100644 index 00000000..6b022363 --- /dev/null +++ b/docs/screenshots/notes/math-vs-machine.md @@ -0,0 +1 @@ +Recommended by Maya. Listen before the talk. diff --git a/docs/screenshots/notes/pizza-tracker.md b/docs/screenshots/notes/pizza-tracker.md new file mode 100644 index 00000000..b23b878e --- /dev/null +++ b/docs/screenshots/notes/pizza-tracker.md @@ -0,0 +1,8 @@ +{{at 0:02:30}} 2008. Domino's launches the Pizza Tracker: order, oven, out for delivery. +{{at 0:08:45}} The tracker changes how the wait feels, not how long it is. +{{at 0:14:05}} Now the pattern is everywhere: rides, parcels, food. + +**Follow up** + +- Slide idea for [[Designing for the wait]]: one status page per decade +- Compare with the NeXT years in [[The 12 Years that Changed Steve Jobs]] diff --git a/docs/screenshots/notes/steve-jobs.md b/docs/screenshots/notes/steve-jobs.md new file mode 100644 index 00000000..8ebdb206 --- /dev/null +++ b/docs/screenshots/notes/steve-jobs.md @@ -0,0 +1,7 @@ +{{at 0:00:51}} Why Jobs was pushed out of Apple in 1985. +{{at 0:08:27}} Starting NeXT. Years of losses, and at one point a weekend away from shutting down. +{{at 0:09:34}} The story behind the NeXT logo. + +**Follow up** + +- Look up Paul Rand's brief for the NeXT logo diff --git a/docs/screenshots/seed.json b/docs/screenshots/seed.json new file mode 100644 index 00000000..5b013d54 --- /dev/null +++ b/docs/screenshots/seed.json @@ -0,0 +1,82 @@ +{ + "vaultName": "Notes", + "theme": { + "name": "Minimal", + "repo": "kepano/obsidian-minimal", + "version": "9.1.4" + }, + "feeds": [ + "https://feeds.simplecast.com/BqbsxVfO", + "https://feeds.npr.org/510289/podcast.xml", + "https://feeds.simplecast.com/EmVW7VGp", + "https://feeds.megaphone.fm/FSMI7575968096", + "https://feeds.transistor.fm/acquired", + "https://relay.fm/cortex/feed", + "https://feeds.simplecast.com/Y8lFbOT4", + "https://feeds.simplecast.com/6HKOhNgS", + "https://api.substack.com/feed/podcast/10845.rss" + ], + "search": "the daily", + "settings": { + "episodeListLimit": 25 + }, + "noteTemplate": "---\ntype: podcastEpisode\npodcast: \"{{podcastlink}}\"\ndate: {{date:YYYY-MM-DD}}\nduration: \"{{duration}}\"\ntags:\n - podcastEpisode\nstatus:\nrating:\n---\n![|96]({{artwork}})\n\n[Resume in PodNotes]({{episodelink}})\n\n> [!summary]- Episode description\n{{description:> }}\n\n## Notes\n\n", + "episodes": [ + { + "podcast": "Cortex", + "title": "183: Scouting Indie Games for a Living, with Lucy James", + "status": "next" + }, + { + "podcast": "Planet Money", + "title": "The Domino’s Pizza Tracker Theory of Everything", + "notes": "pizza-tracker.md", + "status": "finished", + "rating": 4 + }, + { + "podcast": "Acquired", + "title": "The Home Depot", + "status": "finished", + "rating": 4 + }, + { + "podcast": "Radiolab", + "title": "Math Vs Machine", + "notes": "math-vs-machine.md", + "status": "next" + }, + { + "podcast": "The Knowledge Project", + "title": "The 12 Years that Changed Steve Jobs", + "notes": "steve-jobs.md", + "status": "listening", + "rating": 5, + "playAt": 640, + "playbackRate": 1.5 + } + ], + "feedNotes": [ + "Planet Money", + "The Knowledge Project", + "Acquired", + "Radiolab", + "Cortex" + ], + "playlist": { + "name": "Commute", + "icon": "headphones", + "episodes": [ + "A 4-Track Mind", + "Doves Type", + "Ferrari", + "Grass Apes", + "The Rocky Statue" + ] + }, + "maxTitleLength": 24, + "demo": { + "at": 698, + "note": "A perfect cube, and why the case took years." + } +} diff --git a/docs/screenshots/vault/Daily/2026-10-09.md b/docs/screenshots/vault/Daily/2026-10-09.md new file mode 100644 index 00000000..c7b12b35 --- /dev/null +++ b/docs/screenshots/vault/Daily/2026-10-09.md @@ -0,0 +1,4 @@ +# Thursday, October 9 + +- Finished the outline for [[Designing for the wait]]. +- Listened to The Knowledge Project on the NeXT years on the walk home. Notes in [[The 12 Years that Changed Steve Jobs]]. diff --git a/docs/screenshots/vault/Daily/2026-10-10.md b/docs/screenshots/vault/Daily/2026-10-10.md new file mode 100644 index 00000000..42de7fb9 --- /dev/null +++ b/docs/screenshots/vault/Daily/2026-10-10.md @@ -0,0 +1,4 @@ +# Friday, October 10 + +- Booked the room for the talk on the 24th. +- Started the Planet Money episode on the pizza tracker. Notes in [[The Domino’s Pizza Tracker Theory of Everything]]. diff --git a/docs/screenshots/vault/Daily/2026-10-11.md b/docs/screenshots/vault/Daily/2026-10-11.md new file mode 100644 index 00000000..f9678279 --- /dev/null +++ b/docs/screenshots/vault/Daily/2026-10-11.md @@ -0,0 +1,5 @@ +# Saturday, October 11 + +- [ ] Finish the pizza tracker notes +- [ ] Pick three examples for the slides +- Coffee with Maya at 10 diff --git a/docs/screenshots/vault/Home.md b/docs/screenshots/vault/Home.md new file mode 100644 index 00000000..58ddee92 --- /dev/null +++ b/docs/screenshots/vault/Home.md @@ -0,0 +1,6 @@ +# Home + +- [[Daily/2026-10-11|Today]] +- [[Projects/Designing for the wait|Designing for the wait]] +- [[PodNotes/Episodes.base|Podcast episodes]] +- [[Reading/The Design of Everyday Things|Currently reading]] diff --git a/docs/screenshots/vault/PodNotes/Episodes.base b/docs/screenshots/vault/PodNotes/Episodes.base new file mode 100644 index 00000000..9443ff0d --- /dev/null +++ b/docs/screenshots/vault/PodNotes/Episodes.base @@ -0,0 +1,22 @@ +filters: + and: + - 'type == "podcastEpisode"' +views: + - type: table + name: Episodes + order: + - file.name + - note.podcast + - note.status + - note.rating + sort: + - property: note.date + direction: DESC + groupBy: + property: note.status + direction: ASC + columnSize: + file.name: 400 + note.podcast: 210 + note.status: 100 + note.rating: 70 diff --git a/docs/screenshots/vault/Projects/Designing for the wait.md b/docs/screenshots/vault/Projects/Designing for the wait.md new file mode 100644 index 00000000..97330921 --- /dev/null +++ b/docs/screenshots/vault/Projects/Designing for the wait.md @@ -0,0 +1,19 @@ +--- +tags: + - project +due: 2026-10-24 +--- +# Designing for the wait + +A 20 minute talk on how products make waiting feel shorter. + +## Examples + +- The pizza tracker. Notes in [[The Domino’s Pizza Tracker Theory of Everything]]. +- Patience as a design skill. See [[The 12 Years that Changed Steve Jobs]]. +- Queued: [[Math Vs Machine]]. + +## Open questions + +- Start with the pizza tracker or end with it? +- How much research before the first example? diff --git a/docs/screenshots/vault/Reading/The Design of Everyday Things.md b/docs/screenshots/vault/Reading/The Design of Everyday Things.md new file mode 100644 index 00000000..5d69ce70 --- /dev/null +++ b/docs/screenshots/vault/Reading/The Design of Everyday Things.md @@ -0,0 +1,10 @@ +--- +author: Don Norman +status: reading +tags: + - book +--- +# The Design of Everyday Things + +- Feedback: show people that something is happening. Compare with the pizza tracker. +- Chapter 4 next. diff --git a/docs/screenshots/vault/Templates/Daily note.md b/docs/screenshots/vault/Templates/Daily note.md new file mode 100644 index 00000000..ba89edbd --- /dev/null +++ b/docs/screenshots/vault/Templates/Daily note.md @@ -0,0 +1,3 @@ +# {{date:dddd, MMMM D}} + +- diff --git a/docs/scripts/brand.mjs b/docs/scripts/brand.mjs new file mode 100644 index 00000000..f714c484 --- /dev/null +++ b/docs/scripts/brand.mjs @@ -0,0 +1,33 @@ +// Builds the site's icons from the one source mark, public/brand/podnotes-mark.svg. +// Run from the repo root after changing the mark: +// +// node docs/scripts/brand.mjs +// +// The mark sits on a 16 px grid (4 units per pixel), so the 16 px favicon stays +// sharp. +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import sharp from "sharp"; + +const publicDir = fileURLToPath(new URL("../public", import.meta.url)); +const source = path.join(publicDir, "brand", "podnotes-mark.svg"); +const svg = await fs.readFile(source); +const tile = svg.toString().match(/]*fill="([^"]+)"/)[1]; + +// Rasterizes the 64-unit mark at `size` pixels, oversampled for clean edges. +const render = (size) => sharp(svg, { density: (72 * size * 4) / 64 }).resize(size, size); + +await fs.copyFile(source, path.join(publicDir, "favicon.svg")); +for (const size of [16, 32]) { + await render(size) + .png() + .toFile(path.join(publicDir, `favicon-${size}.png`)); +} +// iOS rounds the icon itself and fills transparent corners with black, so the +// touch icon is a full square in the tile color. +await render(180) + .flatten({ background: tile }) + .png() + .toFile(path.join(publicDir, "apple-touch-icon.png")); +console.log(`Wrote icons from ${path.relative(process.cwd(), source)}`); diff --git a/docs/scripts/legacy-fragments.mjs b/docs/scripts/legacy-fragments.mjs index 5f3df351..f7b58c63 100644 --- a/docs/scripts/legacy-fragments.mjs +++ b/docs/scripts/legacy-fragments.mjs @@ -1,12 +1,16 @@ -// Keeps every heading fragment of the retired MkDocs site working, so old -// section links such as /commands/#show-player still land on their section. +// Keeps every heading fragment the site has published working, so old section +// links such as /commands/#show-player still land on their section. +// legacy-fragments.json lists the retired MkDocs site's ids plus the Starlight +// ids published before the docs overhaul. Add an id here when a heading that +// users may have linked to changes. // // node scripts/legacy-fragments.mjs check [dist] // Fails if a page in dist lacks an id listed in legacy-fragments.json. // Runs after every `npm run build`. // node scripts/legacy-fragments.mjs extract -// Rewrites legacy-fragments.json from a MkDocs build. It was generated from -// master at f7b9874 (the last MkDocs commit) with: +// Rewrites legacy-fragments.json from a MkDocs build, dropping the ids +// added by hand. The MkDocs ids came from master at f7b9874 (the last +// MkDocs commit) with: // git archive f7b9874 docs | tar -x -C // uvx --from mkdocs==1.6.1 --with mkdocs-material==9.7.7 \ // mkdocs build -f /docs/mkdocs.yml -d site diff --git a/docs/scripts/screenshots.mjs b/docs/scripts/screenshots.mjs new file mode 100644 index 00000000..5f4bc5b9 --- /dev/null +++ b/docs/scripts/screenshots.mjs @@ -0,0 +1,824 @@ +// Captures the docs screenshots from a lived-in vault, so they can be +// regenerated after any UI change. Run from the repo root after `npm run build`: +// +// node docs/scripts/screenshots.mjs +// +// It builds a vault from docs/screenshots (notes, a Bases file, the theme, and +// PodNotes settings), launches a private Obsidian capture instance at 2x (on +// Linux under Xvfb), adds real podcast feeds through Settings, plays episodes, +// creates episode notes and captures timestamps with PodNotes' own commands, +// and writes every shot in light and dark to docs/public/screenshots. It needs +// network access for the feeds, their artwork, and the theme. +import { spawn } from "node:child_process"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + captureLaunchCommand, + captureScreenshot, + connectCapture, + prepareCapture, + prepareCaptureProfile, + typeText, + videoToGif, + withRecording, +} from "obsidian-e2e/capture"; +import sharp from "sharp"; +import e2eConfig from "../../obsidian-e2e.config.mjs"; + +const docsDir = fileURLToPath(new URL("..", import.meta.url)); +const repoDir = path.dirname(docsDir); +const seedDir = path.join(docsDir, "screenshots"); +const outDir = path.join(docsDir, "public", "screenshots"); +const seed = JSON.parse(await fs.readFile(path.join(seedDir, "seed.json"), "utf8")); +const vaultPath = path.join(repoDir, ".obsidian-e2e-vaults", "docs-screenshots", seed.vaultName); +const profileRoot = path.join(os.tmpdir(), "podnotes-docs-capture"); +const cdpPort = 9400 + Math.floor(Math.random() * 400); +const WINDOW = { width: 1440, height: 1000 }; +const DEMO_WINDOW = { width: 1200, height: 900 }; +const PHONE = { width: 390, height: 844 }; +// Wide enough that the artwork, title, and progress lead the player. +const SIDEBAR = 430; +const PLAYER = '.workspace-leaf-content[data-type="podcast_player_view"]'; +const ROOT = ".workspace-split.mod-root"; +// Linux draws scrollbars that macOS hides until you scroll, and window buttons +// that belong to the operating system, not to Obsidian. +const CAPTURE_CSS = "::-webkit-scrollbar, .titlebar-button-container { display: none; }"; +const CROP_CSS = `${CAPTURE_CSS} .status-bar { display: none; }`; + +async function writeJson(file, value) { + await fs.mkdir(path.dirname(file), { recursive: true }); + await fs.writeFile(file, `${JSON.stringify(value, null, "\t")}\n`); +} + +async function buildVault() { + for (const artifact of ["main.js", "manifest.json"]) { + await fs.access(path.join(repoDir, artifact)).catch(() => { + throw new Error(`Missing ${artifact}. Run npm run build first.`); + }); + } + await fs.rm(path.dirname(vaultPath), { recursive: true, force: true }); + await fs.cp(path.join(seedDir, "vault"), vaultPath, { recursive: true }); + const config = path.join(vaultPath, ".obsidian"); + const pluginDir = path.join(config, "plugins", "podnotes"); + await fs.mkdir(pluginDir, { recursive: true }); + for (const artifact of ["main.js", "manifest.json"]) { + await fs.symlink(path.join(repoDir, artifact), path.join(pluginDir, artifact)); + } + await writeJson(path.join(pluginDir, "data.json"), { + ...e2eConfig.defaultData, + ...seed.settings, + note: { ...e2eConfig.defaultData.note, template: seed.noteTemplate }, + }); + await writeJson(path.join(config, "community-plugins.json"), ["podnotes"]); + const on = ["file-explorer", "global-search", "switcher", "graph", "backlink", "outgoing-link"]; + on.push("tag-pane", "properties", "page-preview", "daily-notes", "templates", "note-composer"); + on.push( + "command-palette", + "editor-status", + "bookmarks", + "outline", + "word-count", + "file-recovery", + "bases", + ); + const off = ["canvas", "random-note", "slash-command", "markdown-importer", "zk-prefixer"]; + off.push("audio-recorder", "workspaces", "publish", "sync", "slides", "webviewer"); + await writeJson(path.join(config, "core-plugins.json"), { + ...Object.fromEntries(on.map((id) => [id, true])), + ...Object.fromEntries(off.map((id) => [id, false])), + }); + await writeJson(path.join(config, "app.json"), { + showInlineTitle: true, + readableLineLength: true, + promptDelete: false, + // Settings open in the main window, where the capture can reach them. + settingsPopoutWindow: false, + mobileToolbarCommands: [ + "podnotes:capture-timestamp", + "editor:toggle-bold", + "editor:insert-wikilink", + "editor:toggle-checklist-status", + "editor:undo", + ], + }); + await writeJson(path.join(config, "appearance.json"), { + cssTheme: seed.theme.name, + theme: "moonstone", + interfaceFontFamily: "Inter", + textFontFamily: "Inter", + baseFontSize: 16, + }); + await writeJson(path.join(config, "daily-notes.json"), { + folder: "Daily", + template: "Templates/Daily note", + }); + await writeJson(path.join(config, "templates.json"), { folder: "Templates" }); + const themeDir = path.join(config, "themes", seed.theme.name); + await fs.mkdir(themeDir, { recursive: true }); + for (const file of ["theme.css", "manifest.json"]) { + const url = `https://github.com/${seed.theme.repo}/releases/download/${seed.theme.version}/${file}`; + const response = await fetch(url); + if (!response.ok) throw new Error(`${url} returned ${response.status}`); + await fs.writeFile(path.join(themeDir, file), Buffer.from(await response.arrayBuffer())); + } +} + +async function launch() { + const options = { vaultPath, root: profileRoot, cdpPort, scale: 2 }; + await fs.rm(profileRoot, { recursive: true, force: true }); + const profile = await prepareCaptureProfile(options); + const command = captureLaunchCommand(options, profile); + const child = spawn(command.file, command.args, { + env: command.env, + cwd: command.cwd, + stdio: "ignore", + detached: true, + }); + const stop = () => { + try { + process.kill(-child.pid, "SIGKILL"); + } catch {} + }; + process.on("exit", stop); + return { client: await connectCapture({ port: cdpPort, timeoutMs: 90_000 }), stop }; +} + +async function main() { + await buildVault(); + const instance = await launch(); + const { stop } = instance; + let { client } = instance; + const api = "app.plugins.plugins.podnotes.api"; + /** + * Runs `body` as an async function in Obsidian and returns its value. The + * body is fixed code: every value it needs arrives as the CDP call's + * argument, `args`, so no value is ever written into code. + */ + async function run(body, args = {}) { + const { result } = await client.call("Runtime.evaluate", { expression: "globalThis" }); + const reply = await client.call("Runtime.callFunctionOn", { + objectId: result.objectId, + functionDeclaration: `async function (args) { ${body} }`, + arguments: [{ value: args }], + awaitPromise: true, + returnByValue: true, + }); + if (reply.exceptionDetails) { + const { exception, text } = reply.exceptionDetails; + throw new Error(exception?.description ?? text); + } + return reply.result.value; + } + /** Polls `expression`, which can read `args`, until it is truthy and returns it. */ + const waitFor = (expression, what, args = {}) => + run( + `for (const end = Date.now() + 30000; Date.now() < end; await new Promise((r) => setTimeout(r, 100))) { + const value = ${expression}; + if (value) return value; + } + throw new Error("Timed out waiting for " + args.waitingFor);`, + { ...args, waitingFor: what }, + ); + const click = (selector, text) => + run( + `for (const end = Date.now() + 30000; Date.now() < end; await new Promise((r) => setTimeout(r, 100))) { + const match = [...document.querySelectorAll(args.selector)].find((el) => + el.getClientRects().length > 0 && (args.text === undefined || el.textContent.trim().includes(args.text))); + if (match) { match.click(); return true; } + } + throw new Error("Nothing to click: " + args.selector + " " + (args.text ?? ""));`, + { selector, text }, + ); + const fill = (selector, value) => + run( + `const input = document.querySelector(args.selector); + input.focus(); + input.value = args.value; + input.dispatchEvent(new Event("input", { bubbles: true })); + return true;`, + { selector, value }, + ); + const settle = (ms = 600) => new Promise((resolve) => setTimeout(resolve, ms)); + + /** + * Writes `.webp` in light and `-dark.webp` in dark. A rect target + * is `{ kind: "rect", body, args }`: `body` returns `{ x, y, width, height }` + * and reads its values from `args`. + */ + async function shoot(name, target, { css = CROP_CSS, size = WINDOW, clean = true } = {}) { + await fs.mkdir(outDir, { recursive: true }); + for (const theme of ["light", "dark"]) { + await prepareCapture(client, { + ...size, + scale: 2, + theme, + font: "Inter", + hideSecretWarning: true, + css, + }); + // The Xvfb pointer rests mid-screen and would leave a hover state, so park + // it over empty space: the desktop file explorer, or the phone note's left + // margin, which the open sidebar leaves uncovered. + await client.call("Input.dispatchMouseEvent", { + type: "mouseMoved", + x: size.width === PHONE.width ? 8 : 130, + y: size.width === PHONE.width ? 160 : size.height - 80, + }); + await run( + `document.querySelectorAll(".has-focus").forEach((el) => el.classList.remove("has-focus")); return true;`, + ); + await waitFor( + `[...document.images].every((img) => { const r = img.getBoundingClientRect(); return img.complete || r.width === 0 || r.bottom < 0 || r.top > innerHeight; })`, + "images to load", + ); + await settle(400); + const png = path.join(profileRoot, `${name}-${theme}.png`); + // The capture measures the rect after its own clean-up, so it calls a + // closure that holds the values rather than code built from them. + if (target.kind === "rect") + await run( + `globalThis.captureRect = () => { ${target.body} }; return true;`, + target.args, + ); + const capture = + target.kind === "rect" + ? { kind: "rect", expression: "globalThis.captureRect()" } + : target; + await captureScreenshot(client, png, { target: capture, clean }); + await sharp(png) + .webp({ quality: 86 }) + .toFile(path.join(outDir, `${name}${theme === "dark" ? "-dark" : ""}.webp`)); + } + await prepareCapture(client, { theme: "light", css: CAPTURE_CSS }); + } + + /** + * A crop of `selector` from its top to the bottom of its `count`th visible + * `item`, stopping short of the next item so no sliver of it shows. + */ + const cropTo = (selector, item, count, pad = 16) => ({ + kind: "rect", + body: `const box = document.querySelector(args.selector).getBoundingClientRect(); + const items = [...document.querySelectorAll(args.items)].filter((el) => el.getClientRects().length > 0); + const last = items[args.count - 1].getBoundingClientRect(); + const next = items[args.count]?.getBoundingClientRect().top ?? Infinity; + const bottom = Math.min(last.bottom + args.pad, next - 2, box.bottom); + return { x: box.x, y: box.y, width: box.width, height: bottom - box.y };`, + args: { selector, items: `${selector} ${item}`, count, pad }, + }); + + /** A crop of the episode note's text column, between two of its lines. */ + const noteCrop = (fromLine, toLine) => ({ + kind: "rect", + body: `const leaf = app.workspace.getLeavesOfType("markdown")[0].view; + const view = leaf.contentEl; + const pane = view.getBoundingClientRect(); + const header = leaf.containerEl.querySelector(".view-header").getBoundingClientRect(); + const column = view.querySelector(".cm-sizer").getBoundingClientRect(); + const lines = [...view.querySelectorAll(".inline-title, .metadata-container, .cm-line, .callout")]; + // On a phone the header floats over the note and casts a shadow, and the + // navigation bar sits over its bottom. + const navbar = document.querySelector(".mobile-navbar")?.getBoundingClientRect().top ?? innerHeight; + const top = Math.max(pane.top, header.bottom + 12, (${fromLine})(lines).getBoundingClientRect().top - 16); + const bottom = Math.min(pane.bottom, navbar - 12, (${toLine})(lines).getBoundingClientRect().bottom + 12); + const left = Math.max(pane.left, column.x - 16); + const right = Math.min(pane.right, column.right + 16); + return { x: left, y: top, width: right - left, height: bottom - top };`, + }); + + // Picks episodes from Latest Episodes. On this capture host a selected + // podcast with hundreds of episodes can stall before its list renders, and + // a fresh view can show no episodes until Refresh, which a user would select. + async function showLatest() { + await click('.podcast-view [aria-label="Episode list"]'); + await run(`document.querySelector(".podcast-view .go-back")?.click(); return true;`); + const shown = await run( + `return document.querySelectorAll(".podcast-view .podcast-episode-item").length;`, + ); + if (!shown) await click('.podcast-view [aria-label="Refresh episodes"]'); + await waitFor( + `document.querySelectorAll(".podcast-view .podcast-episode-item").length > 0`, + "the latest episodes", + ); + } + + /** + * Records the README demo: capture a timestamp from the command palette and + * type a note after it. The note text goes in through the editor API, because + * the headless window does not keep keyboard focus in the editor. + */ + async function recordDemo(theme) { + await prepareCapture(client, { + ...DEMO_WINDOW, + scale: 2, + theme, + font: "Inter", + hideSecretWarning: true, + css: CAPTURE_CSS, + }); + // Both takes open the palette on the same list, not on the last take's command. + await run( + `app.internalPlugins.getPluginById("command-palette").instance.recentCommands = []; + app.workspace.leftSplit.collapse(); + app.workspace.rightSplit.setSize(360); + const leaf = app.workspace.getLeavesOfType("markdown")[0]; + app.workspace.setActiveLeaf(leaf, { focus: true }); + const editor = leaf.view.editor; + const lines = [...Array(editor.lineCount()).keys()]; + const last = lines.findLast((i) => /^- \\[\\d/.test(editor.getLine(i))); + editor.setCursor(last, editor.getLine(last).length); + editor.replaceSelection("\\n"); + // The note is too short to scroll its Notes heading to the top, so start at + // the artwork rather than part way through it. + const artwork = lines.find((i) => editor.getLine(i).startsWith("![")); + const view = editor.cm; + view.scrollDOM.scrollTop = view.lineBlockAt(view.state.doc.line(artwork + 1).from).top - 16; + ${api}.currentTime = args.at; + return true;`, + { at: seed.demo.at }, + ); + await client.call("Input.dispatchMouseEvent", { + type: "mouseMoved", + x: 130, + y: DEMO_WINDOW.height - 80, + }); + await settle(800); + const video = path.join(profileRoot, `demo-${theme}.mp4`); + await withRecording( + client, + video, + async () => { + await settle(1200); + await run(`app.commands.executeCommandById("command-palette:open"); return true;`); + await settle(500); + await typeText(client, "capture timestamp", { + delayMs: 70, + selector: ".prompt-input", + }); + await settle(700); + for (const type of ["keyDown", "keyUp"]) { + await client.call("Input.dispatchKeyEvent", { + type, + key: "Enter", + code: "Enter", + windowsVirtualKeyCode: 13, + }); + } + await settle(900); + await run( + `const editor = app.workspace.getLeavesOfType("markdown")[0].view.editor; + for (const ch of args.note) { + editor.replaceSelection(ch); + await new Promise((r) => setTimeout(r, 45)); + } + return true;`, + { note: seed.demo.note }, + ); + await settle(2200); + }, + { fps: 24, backend: "screencast" }, + ); + await videoToGif(video, path.join(outDir, `demo${theme === "dark" ? "-dark" : ""}.gif`), { + width: DEMO_WINDOW.width, + fps: 12, + }); + } + + // Properties first, while the editor and the file agree, then the notes + // through the editor with PodNotes' own capture command, then save. + async function writeNote(episode) { + await run( + `await app.fileManager.processFrontMatter(app.workspace.getActiveFile(), (fm) => { + fm.status = args.status; + if (args.rating !== undefined) fm.rating = args.rating; + }); + return true;`, + { status: episode.status, rating: episode.rating }, + ); + await waitFor( + `app.workspace.activeEditor?.editor?.getValue().includes(args.text)`, + `the properties of ${episode.title}`, + { text: `status: ${episode.status}` }, + ); + if (!episode.notes) return; + const lines = (await fs.readFile(path.join(seedDir, "notes", episode.notes), "utf8")) + .trimEnd() + .split("\n"); + for (const line of lines) { + const at = line.match(/^\{\{at (\d+):(\d\d):(\d\d)\}\} (.*)$/); + await run(`const editor = app.workspace.activeEditor.editor; + editor.focus(); + editor.setCursor(editor.lastLine(), editor.getLine(editor.lastLine()).length); + return true;`); + if (at) { + const seconds = Number(at[1]) * 3600 + Number(at[2]) * 60 + Number(at[3]); + await run( + `${api}.currentTime = args.seconds; + app.commands.executeCommandById("podnotes:capture-timestamp"); + app.workspace.activeEditor.editor.replaceSelection(args.text); + return true;`, + { seconds, text: `${at[4]}\n` }, + ); + } else { + await run( + `app.workspace.activeEditor.editor.replaceSelection(args.text); return true;`, + { + text: `${line}\n`, + }, + ); + } + } + await run(`await app.workspace.activeEditor.save(); return true;`); + } + + try { + await run(`await app.plugins.setEnable(true); + document.querySelectorAll(".modal-container").forEach((m) => m.remove()); + await app.plugins.loadManifests(); + await app.plugins.enablePluginAndSave("podnotes"); + return true;`); + await waitFor(api, "PodNotes to load"); + await prepareCapture(client, { + ...WINDOW, + scale: 2, + theme: "light", + font: "Inter", + hideSecretWarning: true, + css: CAPTURE_CSS, + }); + + // Add the feeds the way a user does: Settings, then Search Podcasts. + await run(`app.setting.open(); app.setting.openTabById("podnotes"); return true;`); + const search = 'input[placeholder="Search or enter feed URL..."]'; + await waitFor(`document.querySelector(args.selector)`, "the podcast search field", { + selector: search, + }); + for (const feed of seed.feeds) { + await fill(search, feed); + const label = await waitFor( + `document.querySelector('.modal.mod-settings [aria-label^="Add "][aria-label$=" podcast"]')?.getAttribute("aria-label")`, + `a result for ${feed}`, + ); + await click(`[aria-label="${label}"]`); + const title = label.slice("Add ".length, -" podcast".length); + await waitFor( + `app.plugins.plugins.podnotes.settings.savedFeeds[args.title]`, + `${title} to save`, + { + title, + }, + ); + } + await fill(search, seed.search); + await waitFor( + `document.querySelectorAll(".podcast-query-results > *").length >= 4`, + "search results", + ); + await settle(2500); + // The search shares its settings card with the playlists and more, so the + // shot hides what follows it and the card ends below the results. + await run(`window.searchSectionRest = []; + const tab = document.querySelector(".modal.mod-settings .vertical-tab-content"); + const card = (${SEARCH_CARD})(tab); + for (let node = tab.querySelector(".podcast-query-container"); node !== card; node = node.parentElement) + for (let next = node.nextElementSibling; next; next = next.nextElementSibling) { + next.style.display = "none"; + searchSectionRest.push(next); + } + return true;`); + await shoot( + "add-podcast", + { kind: "rect", body: SEARCH_SECTION }, + { css: `${CAPTURE_CSS} ${FIRST_FOUR_RESULTS}` }, + ); + await run(`searchSectionRest.forEach((el) => (el.style.display = "")); return true;`); + const { playlist } = seed; + await run( + `const form = document.querySelector(".modal.mod-settings .add-playlist-container"); + const icon = form.querySelector("select"); + icon.value = args.icon; + icon.dispatchEvent(new Event("change", { bubbles: true })); + const name = form.querySelector('input[placeholder="Playlist name"]'); + name.value = args.name; + name.dispatchEvent(new Event("input", { bubbles: true })); + return true;`, + { icon: playlist.icon, name: playlist.name }, + ); + await click('[aria-label="Add playlist"]'); + await waitFor( + `app.plugins.plugins.podnotes.settings.playlists[args.name]`, + "the playlist", + { + name: playlist.name, + }, + ); + await run(`app.setting.close(); return true;`); + + // Play each episode, write its note, and leave the last one playing. + await run(`app.commands.executeCommandById("podnotes:podnotes-show-leaf"); return true;`); + await waitFor( + `document.querySelector(args.selector)?.getClientRects().length`, + "the PodNotes view", + { + selector: PLAYER, + }, + ); + await run(`app.workspace.rightSplit.setSize(args.size); return true;`, { size: SIDEBAR }); + const notePaths = new Map(); + for (const episode of seed.episodes) { + await showLatest(); + await fill('.podcast-view input[placeholder="Search episodes"]', episode.title); + await click(".podcast-view .podcast-episode-item", episode.title); + await waitFor(`${api}.podcast?.title === args.title`, episode.title, { + title: episode.title, + }); + await waitFor(`${api}.length > 0`, `${episode.title} to load`); + const before = await run(`return app.workspace.getActiveFile()?.path ?? null;`); + await run( + `app.commands.executeCommandById("podnotes:create-podcast-note"); return true;`, + ); + const note = await waitFor( + `(() => { const p = app.workspace.getActiveFile()?.path; return p && p !== args.before && p.startsWith("PodNotes/") ? p : null; })()`, + `the note for ${episode.title}`, + { before }, + ); + notePaths.set(episode, note); + await writeNote(episode); + } + for (const podcast of seed.feedNotes) { + await run( + `app.commands.executeCommandById("podnotes:create-podcast-feed-note"); return true;`, + ); + await waitFor(`document.querySelector(".prompt-input")`, "the podcast picker"); + await fill(".prompt-input", podcast); + await click(".suggestion-item", podcast); + await waitFor( + `app.workspace.getActiveFile()?.path === args.path`, + `the feed note for ${podcast}`, + { + path: `PodNotes/Podcasts/${podcast}.md`, + }, + ); + } + + // Fill the playlist, and keep only titles that fit their rows in the + // queue: a longer title runs under the row's menu button. + for (const title of playlist.episodes) { + await showLatest(); + await fill('.podcast-view input[placeholder="Search episodes"]', title); + await click( + `.podcast-view [aria-label=${JSON.stringify(`More options for ${title}`)}]`, + ); + await click(".menu .menu-item", `Add to ${playlist.name}`); + } + await fill('.podcast-view input[placeholder="Search episodes"]', ""); + await click('.podcast-view [aria-label^="Player"]'); + const longTitles = await run( + `return [...document.querySelectorAll(".podcast-view .podcast-episode-row .episode-item-title")] + .map((el) => el.textContent.trim()) + .filter((title) => title.length > args.max);`, + { max: seed.maxTitleLength }, + ); + for (const title of longTitles) { + await click( + `.podcast-view [aria-label=${JSON.stringify(`More options for ${title}`)}]`, + ); + await click(".menu .menu-item", "Remove from Queue"); + } + + const hero = seed.episodes.at(-1); + await run( + `const file = app.vault.getAbstractFileByPath(args.path); + const leaves = []; + app.workspace.iterateRootLeaves((leaf) => leaves.push(leaf)); + await leaves[0].openFile(file); + for (const leaf of leaves.slice(1)) leaf.detach(); + app.workspace.setActiveLeaf(leaves[0], { focus: false }); + const explorer = app.workspace.getLeavesOfType("file-explorer")[0]; + await explorer.loadIfDeferred?.(); + explorer.view.revealInFolder(file); + ${api}.currentTime = args.at; + ${api}.playbackRate = args.rate; + if (!${api}.isPlaying) ${api}.start(); + return true;`, + { path: notePaths.get(hero), at: hero.playAt, rate: hero.playbackRate }, + ); + await waitFor(`app.workspace.getActiveFile()?.path === args.path`, "the hero note", { + path: notePaths.get(hero), + }); + await click('.podcast-view [aria-label^="Player"]'); + await settle(1500); + const scrollNote = (line) => + run( + `const editor = app.workspace.getLeavesOfType("markdown")[0].view.editor; + const view = editor.cm; + const index = [...Array(editor.lineCount()).keys()].find((i) => editor.getLine(i) === args.line) ?? 0; + view.scrollDOM.scrollTop = args.line ? view.lineBlockAt(view.state.doc.line(index + 1).from).top - 40 : 0; + return true;`, + { line }, + ); + const showProperties = (open) => + run( + `const box = app.workspace.getLeavesOfType("markdown")[0].view.contentEl.querySelector(".metadata-container"); + if (box.classList.contains("is-collapsed") === args.open) box.querySelector(".metadata-properties-heading").click(); + await new Promise((r) => setTimeout(r, 300)); + return true;`, + { open }, + ); + const episodeNote = noteCrop( + `(lines) => lines.find((el) => el.matches(".inline-title"))`, + `(lines) => lines.find((el) => el.matches(".callout"))`, + ); + const notesSection = noteCrop( + `(lines) => lines.find((el) => el.textContent.trim() === "Notes")`, + `(lines) => lines.filter((el) => el.matches(".cm-line") && el.textContent.trim()).at(-1)`, + ); + await shoot("episode-note", episodeNote); + // The hero folds the properties away so the captured timestamps sit mid-window. + await showProperties(false); + await shoot("hero", { kind: "viewport" }, { css: CAPTURE_CSS }); + // The social preview: the top of the light hero at 1200x630. + await sharp(path.join(profileRoot, "hero-light.png")) + .resize(1200, 630, { fit: "cover", position: "top" }) + .png({ compressionLevel: 9, palette: true }) + .toFile(path.join(outDir, "og.png")); + await scrollNote("## Notes"); + await shoot("timestamps", notesSection); + await scrollNote(""); + await shoot("player", cropTo(PLAYER, ".controls-container", 1, 20)); + const heroNote = notePaths.get(hero); + const heroText = await run(`return app.vault.adapter.read(args.path);`, { path: heroNote }); + for (const theme of ["light", "dark"]) { + await recordDemo(theme); + await run(`await app.vault.adapter.write(args.path, args.text); return true;`, { + path: heroNote, + text: heroText, + }); + await settle(800); + } + await run( + `app.workspace.leftSplit.expand(); app.workspace.rightSplit.setSize(args.size); return true;`, + { size: SIDEBAR }, + ); + + await click('.podcast-view [aria-label="Podcast grid"]'); + await click(`.podcast-view [aria-label=${JSON.stringify(playlist.name)}]`); + await waitFor( + `document.querySelectorAll(".podcast-view .podcast-episode-item").length >= args.count`, + "the playlist episodes", + { count: playlist.episodes.length }, + ); + await settle(1500); + await shoot( + "episode-list", + cropTo(PLAYER, ".podcast-episode-item", playlist.episodes.length, 12), + ); + await click('.podcast-view [aria-label="Podcast grid"]'); + await settle(1500); + await shoot("podcast-grid", cropTo(PLAYER, ".pn_image_container", 4)); + + await run(`app.workspace.rightSplit.collapse(); + await app.workspace.getLeaf(false).openFile(app.vault.getAbstractFileByPath("PodNotes/Episodes.base")); + return true;`); + await settle(2000); + await shoot("bases", { + kind: "rect", + body: `const box = document.querySelector('.workspace-leaf-content[data-type="bases"]').getBoundingClientRect(); + const rows = [...document.querySelectorAll('.workspace-leaf-content[data-type="bases"] .bases-tr')]; + const top = rows[0].getBoundingClientRect().top - 12; + const last = rows.at(-1).getBoundingClientRect(); + const right = Math.max(...rows.flatMap((row) => [...row.children].map((cell) => cell.getBoundingClientRect().right))); + return { x: box.x, y: top, width: Math.min(box.width, right - box.x + 24), height: last.bottom + 24 - top };`, + }); + // Obsidian's mobile layout at phone size. The switch reloads the window, so + // pause first and reconnect after. + await run(`${api}.stop(); return true;`); + await settle(1000); + await run(`app.emulateMobile(true); return true;`); + await settle(4000); + client.close(); + client = await connectCapture({ port: cdpPort, timeoutMs: 90_000 }); + await prepareCapture(client, { + ...PHONE, + scale: 2, + theme: "light", + font: "Inter", + hideSecretWarning: true, + css: CAPTURE_CSS, + }); + const phone = { css: CAPTURE_CSS, size: PHONE }; + await run(`app.workspace.leftSplit.collapse(); + app.workspace.rightSplit.collapse(); + await app.workspace.getLeaf(false).openFile(app.vault.getAbstractFileByPath("PodNotes/Episodes.base")); + return true;`); + await settle(2000); + await shoot( + "bases-phone", + { + kind: "rect", + body: `const box = document.querySelector('.workspace-leaf-content[data-type="bases"]').getBoundingClientRect(); + const rows = [...document.querySelectorAll('.workspace-leaf-content[data-type="bases"] .bases-tr')]; + const top = rows[0].getBoundingClientRect().top - 12; + const last = rows.at(-1).getBoundingClientRect(); + return { x: box.x, y: top, width: box.width, height: Math.min(innerHeight, last.bottom + 24) - top };`, + }, + phone, + ); + await run( + `await app.workspace.getLeaf(false).openFile(app.vault.getAbstractFileByPath(args.path)); return true;`, + { path: notePaths.get(hero) }, + ); + await settle(1000); + await showProperties(true); + await scrollNote(""); + // The properties, artwork, and callout need a taller phone to clear the + // navigation bar. + await shoot("episode-note-phone", episodeNote, { + ...phone, + size: { ...PHONE, height: 932 }, + }); + await showProperties(false); + await scrollNote("## Notes"); + await shoot("timestamps-phone", notesSection, phone); + // The landing page's hero on phones: the note with its timestamps. + await scrollNote(""); + await shoot( + "hero-phone", + noteCrop( + `(lines) => lines.find((el) => el.matches(".inline-title"))`, + `(lines) => lines.filter((el) => /\\d\\d:\\d\\d:\\d\\d/.test(el.textContent.trim())).at(-1)`, + ), + phone, + ); + await run(`const editor = app.workspace.activeEditor.editor; + const last = [...Array(editor.lineCount()).keys()].findLast((i) => /^- \\[\\d/.test(editor.getLine(i))); + editor.focus(); + editor.setCursor(last, editor.getLine(last).length); + return true;`); + await scrollNote("## Notes"); + await settle(1500); + // Not cleaned: the toolbar shows only while the editor has focus. + await shoot( + "mobile-toolbar", + { + kind: "rect", + body: `const lines = [...app.workspace.getLeavesOfType("markdown")[0].view.contentEl.querySelectorAll(".cm-line")]; + const top = lines.find((el) => el.textContent.trim() === "Notes").getBoundingClientRect().top - 16; + return { x: 0, y: top, width: innerWidth, height: innerHeight - top };`, + }, + { + css: `${CAPTURE_CSS} .cm-cursorLayer { display: none; } .cm-content { caret-color: transparent !important; }`, + // A shorter phone keeps the toolbar close under the note's last line. + size: { ...PHONE, height: 724 }, + clean: false, + }, + ); + await run(`app.commands.executeCommandById("podnotes:podnotes-show-leaf"); return true;`); + await settle(2000); + await click('.podcast-view [aria-label^="Player"]'); + await run(`${api}.currentTime = args.at; ${api}.start(); return true;`, { + at: hero.playAt, + }); + await settle(1500); + await shoot( + "mobile-player", + { + kind: "rect", + body: `const drawer = document.querySelector(".podcast-view").closest(".workspace-drawer, .workspace-split").getBoundingClientRect(); + const top = document.querySelector('.podcast-view [aria-label="Podcast grid"]').getBoundingClientRect().top - 16; + const controls = document.querySelector(".podcast-view .controls-container").getBoundingClientRect(); + return { x: drawer.x, y: top, width: drawer.width, height: controls.bottom + 24 - top };`, + }, + { css: CAPTURE_CSS, size: PHONE }, + ); + console.log(`Wrote screenshots to ${path.relative(repoDir, outDir)}`); + } finally { + client.close(); + stop(); + } +} + +// Shows only the first four search results, so the settings card ends with them. +const FIRST_FOUR_RESULTS = ".podcast-query-results > :nth-child(n + 5) { display: none; }"; + +// The settings card that holds Search Podcasts: the nearest box with a +// background around the search. +const SEARCH_CARD = `(tab) => { + let card = tab.querySelector(".podcast-query-container"); + while (card.parentElement !== tab && getComputedStyle(card).backgroundColor === "rgba(0, 0, 0, 0)") card = card.parentElement; + return card; +}`; + +// That card, with an even margin around it. +const SEARCH_SECTION = `const tab = document.querySelector(".modal.mod-settings .vertical-tab-content"); + const pane = tab.getBoundingClientRect(); + const box = (${SEARCH_CARD})(tab).getBoundingClientRect(); + const top = Math.max(pane.top, box.top - 16); + const bottom = Math.min(pane.bottom, box.bottom + 16); + const left = Math.max(pane.left, box.left - 16); + const right = Math.min(pane.right, box.right + 16); + return { x: left, y: top, width: right - left, height: bottom - top };`; + +await main(); diff --git a/docs/src/components/Hero.astro b/docs/src/components/Hero.astro new file mode 100644 index 00000000..2b81a109 --- /dev/null +++ b/docs/src/components/Hero.astro @@ -0,0 +1,116 @@ +--- +// The landing page hero: the pitch and actions from the page's `hero` +// frontmatter, then the captured hero screenshot in light and dark. Phones get +// the note in Obsidian's mobile layout, which stays readable at that width. +// Both twins load lazily, so the browser fetches only the one the theme shows. +import { LinkButton } from "@astrojs/starlight/components"; +import { darkTwin, screenshotSize } from "../screenshots.mjs"; + +const { data } = Astro.locals.starlightRoute.entry; +const { title = data.title, tagline, actions = [] } = data.hero ?? {}; +const shot = "/screenshots/hero.webp"; +const phoneShot = "/screenshots/hero-phone.webp"; +const size = await screenshotSize(shot); +const phoneSize = await screenshotSize(phoneShot); +const phone = "(max-width: 50rem)"; +const alt = + "An episode note in Obsidian with timestamp links captured while the episode plays in PodNotes"; +--- + +
+
+

A podcast player for Obsidian

+

+ {data.legacyTitleId && } + +

+ {tagline &&

} +

+ { + actions.map(({ attrs, icon, link: href, text, variant }) => ( + + {text} + + )) + } +
+
+
+ + + + + + + + +
+
+ + diff --git a/docs/src/components/MarkdownContent.astro b/docs/src/components/MarkdownContent.astro new file mode 100644 index 00000000..d2019646 --- /dev/null +++ b/docs/src/components/MarkdownContent.astro @@ -0,0 +1,23 @@ +--- +import Default from "@astrojs/starlight/components/MarkdownContent.astro"; +import { withScreenshots } from "../screenshots.mjs"; +import { withStackedTables } from "../tables.mjs"; + +// Short inline code, such as `12: Interview`, never splits across two lines. +const withShortCode = (html: string) => + html.replace(/(?=[^<]{1,24}<\/code>)/g, ''); + +// Longer inline code, such as a path or a URL, may break after a slash, so it +// wraps in whole pieces. +const withPathBreaks = (html: string) => + html.replace( + /([^<]*)<\/code>/g, + (code, text: string) => `${text.replace(/\/+/g, "$&")}`, + ); + +const html = withPathBreaks( + withShortCode(withStackedTables(await withScreenshots(await Astro.slots.render("default")))), +); +--- + + diff --git a/docs/src/content/docs/QuickAdd.md b/docs/src/content/docs/QuickAdd.md index 1e273a09..89059c01 100644 --- a/docs/src/content/docs/QuickAdd.md +++ b/docs/src/content/docs/QuickAdd.md @@ -1,123 +1,186 @@ --- -title: QuickAdd +title: Use with QuickAdd slug: QuickAdd legacyTitleId: quickadd +description: Call the PodNotes API from QuickAdd to capture timestamps with comments and create episode notes from your own template. --- -I have made some QuickAdd macros & actions that might be helpful to others. +Build your own capture and note workflows with the [QuickAdd](https://quickadd.obsidian.guide/docs/) plugin and the [PodNotes API](/api/), such as a comment next to each timestamp or a note template that asks you questions. PodNotes alone [captures timestamps](/timestamps/) and [creates episode notes](/notes/). -Both of these are seen in the demo: -![Demo](/resources/demo.gif) +Install and enable QuickAdd from **Settings → Community plugins** first. -## Timestamp capture -This is how I capture timestamps so fast in the demo. -You can make a Capture action like so: -![Capture](/resources/quickadd_timestamp_podcast.png) +## Capture a timestamp with a comment -The capture format is as follows: -```` -\n- **```js quickadd -const pn = this.app.plugins.plugins.podnotes.api; -return pn.getPodcastTimeFormatted("HH:mm:ss", true); -```** | -```` - -This captures a new line with the current time, linked to the current podcast, in the active file. - -## Add podcast note -This is how I add podcast notes with QuickAdd. -First, you will need my template. +A Capture choice, with no macro, writes a timestamp link and your comment on a new line below the cursor: +```md +- [00:12:41](obsidian://podnotes?...) Why tariffs raise prices ``` ---- -tags: in/podcast state/process -aliases: - - "{{VALUE:title}}" -cssclass: null -image: {{VALUE:artworkUrl}} ---- - -# {{VALUE:title}} -### Metadata -Type:: [[%]] -Tags:: -Podcast:: [[{{VALUE:podcastName}}]] -Title:: {{VALUE:title}} -URL:: {{VALUE:url}} -Guest:: -Reference:: -Reviewed Date:: ==UPDATE THIS== -### Description -> {{VALUE:description}} +1. In **Settings → QuickAdd**, select **New choice**. +2. Select **Capture**. +3. Set **Name** to `Podcast timestamp`. +4. Turn on **Capture to active file**. +5. Set **Write position** to **New line below cursor**. +6. In **Capture format**, enter: + + ````md + - ```js quickadd + const podnotes = this.app.plugins.plugins.podnotes.api; + return podnotes.getPodcastTimeFormatted("HH:mm:ss", true); + ``` {{VALUE}} + ```` + +7. Play an episode in PodNotes. +8. Open the note you want to write in, in editing view. +9. Place the cursor where the timestamp goes. +10. Run **QuickAdd: Run**. +11. Select **Podcast timestamp**. +12. Type your comment. + +The `js quickadd` [inline script](https://quickadd.obsidian.guide/docs/InlineScripts/) returns the playback time, and `true` makes it a link. For other formats, use the [time tokens](/timestamps/#time-formats). To subtract reaction time, pass seconds as a third argument. To capture 5 seconds earlier, the script's `return` line becomes: ---- -## Notes - -``` - -Now, you should make a macro with the following script: ```js -module.exports = { - entry: (params, settings) => { - const pn = params.app.plugins.plugins.podnotes.api; - const podcast = pn.podcast; - - if (!podcast) { - new Notice("No podcast loaded.", 5000); - throw new Error(); - } - - params.variables = { - ...podcast, - ...params.variables, - safeTitle: replaceIllegalFileNameCharactersInString(podcast.title), - }; - }, - settings: { - name: 'Podcast Script', - author: 'Christian', - options: { - - } - } -} - -function replaceIllegalFileNameCharactersInString(string) { - return string - .replace(/[\\,#%&\{\}\/*<>$\'\":@\u2023\|\?]*/g, '') // Replace illegal file name characters with empty string - .replace(/\n/, ' ') // replace newlines with spaces - .replace(' ', ' '); // replace multiple spaces with single space to make sure we don't have double spaces in the file name -} +return podnotes.getPodcastTimeFormatted( + "HH:mm:ss", + true, + 5, +); ``` -First, create a new macro in the macro panel. This is accessed by clicking `Manage Macros`. -Add a macro with any name you like. +Without a loaded episode, the call fails with `No podcast loaded`. + +To run the capture from a hotkey: + +1. In **Settings → QuickAdd**, select **Command palette: Podcast timestamp**, the lightning icon next to the choice. +2. In **Settings → Hotkeys**, assign a hotkey to **QuickAdd: Podcast timestamp**. -Now, add the script as the first step and the template as the second step. -Adding these requires that the script and template is saved as files in your Obsidian vault. -Their file names are up to you, but ensure that they end in .js and .md, for the script and template respectively. +QuickAdd's [Capture docs](https://quickadd.obsidian.guide/docs/Choices/CaptureChoice/) describe the other options. -Once you have created the macro in the macro panel, you should add a macro action to the QuickAdd menu. -This is done from the main QuickAdd settings. You should see an input field where you can input the name of the macro choice. -Right next to it is a dropdown menu with the type of choice you'd like to add - select macro here. Then click `Add Choice`. +## Create an episode note from your own template -Once the choice has been added, you should see the macro in the QuickAdd menu in the settings panel. -Click the cogwheel next to the macro choice to edit the macro. You'll want to select the appropriate macro that you created in the macros panel. -Notably, we distinguish between a macro and a macro choice. Selecting a macro in the menu that pops up 'links' it to the macro choice, so the macro choice can execute the macro. +This macro runs a user script that saves the loaded episode's fields as QuickAdd variables. A Template choice then fills your template with them and asks you for the rest. -My settings for the Template action/step in the macro are as follows: -![Template action](/resources/quickadd_create_note_settings.png) +1. QuickAdd cannot read scripts in `.obsidian` or other hidden folders. In a text editor, create `scripts/podnotes-episode.js` in a visible folder of your vault: -Which means that it creates a file with the podcast episode title as the file name in the `inputs/podcasts` folder, and then opens the file after. + ```js + module.exports = async (params) => { + const episode = params.app.plugins.plugins.podnotes?.api?.podcast; + if (!episode) params.abort("Load an episode in PodNotes first."); -Ultimately, activating the macro will lead to notes like this one. -![Example note](/resources/quickadd_example_note.png) + params.variables.title = episode.title; + params.variables.podcast = episode.podcastName; + params.variables.url = episode.url; + params.variables.artwork = episode.artworkUrl ?? ""; + params.variables.fileName = episode.title.replace(/[\\\/:*?"<>|#^[\]]/g, ""); + }; + ``` -If you want more information / guidance on how to use QuickAdd, you can check out the [QuickAdd documentation](https://github.com/chhoumann/quickadd/). You can also check out the [video I made to help you get started with QuickAdd](https://www.youtube.com/watch?v=gYK3VDQsZJo). -There are also similar use cases to this one, which all have similar instructions for installation. These may help: +2. Create `Templates/Podcast episode.md`: -- [How I Import Literature Notes into Obsidian](https://bagerbach.com/blog/importing-source-notes-to-obsidian) -- [Macro: Zettelizer - easily create new notes from headings while keeping the contents in the file](https://github.com/chhoumann/quickadd/blob/master/docs/Examples/Macro_Zettelizer.md) -- [Macro: Toggl Manager - set preset Toggl Track time entries and start them from Obsidian](https://github.com/chhoumann/quickadd/blob/master/docs/Examples/Macro_TogglManager.md) -- [Macro: Fetching movies and TV shows into your vault](https://github.com/chhoumann/quickadd/blob/master/docs/Examples/Macro_MovieAndSeriesScript.md) \ No newline at end of file + ```md + --- + podcast: "{{VALUE:podcast}}" + guest: "{{VALUE:guest}}" + --- + # {{VALUE:title}} + + ![]({{VALUE:artwork}}) + + {{VALUE:url}} + + ## Notes + ``` + +3. In **Settings → QuickAdd**, select **New choice**. +4. Select **Macro**. +5. Set **Name** to `Podcast episode note`. +6. On the **Podcast episode note** page, under **Commands**, next to **User scripts**, select **Browse**. +7. Select `scripts/podnotes-episode.js`. +8. In the command bar, select **Template**. QuickAdd adds **Untitled Template Choice** after the script. +9. Select **Configure Untitled Template Choice**, the gear icon next to it. +10. Set **Template path** to `Templates/Podcast episode.md`. +11. Set **File name** to `{{VALUE:fileName}}`. +12. Load an episode in PodNotes. It can be paused. +13. Run **QuickAdd: Run**. +14. Select **Podcast episode note**. + +QuickAdd asks you for `guest`, which the script does not set. For the other episode fields, see [`podcast`](/api/#podcast). + +PodNotes finds an episode note only at **Note creation file path**, or by the episode title in that folder. **Open Note**, **PodNotes: Create episode note**, and headset capture do not find a note that QuickAdd saves anywhere else. QuickAdd's [Macros](https://quickadd.obsidian.guide/docs/Choices/MacroChoice/) and [User Scripts](https://quickadd.obsidian.guide/docs/UserScripts/) docs describe the macro settings. + +## Send a transcript to an AI prompt + +This recipe has QuickAdd's AI Assistant summarize the current episode and write the summary into the note you have open. It reads the episode's transcript note, so [transcribe the episode](/transcripts/) first. + +### Set up the AI Assistant + +Do this once, as QuickAdd's [AI Assistant docs](https://quickadd.obsidian.guide/docs/AIAssistant/) describe: + +1. In **Settings → QuickAdd**, under **AI & online**, turn off **Disable AI & online features**. +2. Under **AI & online**, select **AI Assistant**. +3. Under **Defaults**, set **Prompt template folder**, for example to `Prompts`. +4. Under **Providers**, select **OpenAI** or **Gemini**. +5. On the provider's page, set **API key**. It links a secret from **Settings → Keychain**. + +For another provider: + +1. Under **Providers**, select **Add provider**. +2. On the provider's card, set **API key**. +3. Select **Connect**. + +### Build the prompt + +1. In a text editor, create `scripts/podnotes-transcript-prompt.js` in a visible folder of your vault: + + ```js + module.exports = async (params) => { + const podnotes = params.app.plugins.plugins.podnotes?.api; + if (!podnotes) throw new Error("PodNotes is not enabled."); + + const transcript = await podnotes.transcript; + if (!transcript) { + throw new Error( + "Generate a transcript for the current PodNotes episode first.", + ); + } + + return [ + "Summarize this podcast transcript in five bullet points.", + "", + "Transcript:", + transcript, + ].join("\n"); + }; + ``` + +2. In **Settings → QuickAdd**, select **New choice**. +3. Select **Macro**. +4. Set **Name** to `Transcript prompt`. +5. On the **Transcript prompt** page, under **Commands**, next to **User scripts**, select **Browse**. +6. Select `scripts/podnotes-transcript-prompt.js`. +7. In your prompt template folder, create a note named `Summarize transcript` that contains `{{MACRO:Transcript prompt}}`. QuickAdd runs the macro and puts its prompt there. + +### Write the summary into your note + +1. In **Settings → QuickAdd**, select **New choice**. +2. Select **Macro**. +3. Set **Name** to `Summarize episode`. +4. On the **Summarize episode** page, select **Add AI Assistant command**, the robot icon in the command bar. +5. Select **Configure AI Assistant**, the gear icon next to it. +6. Turn on **Prompt template**. +7. In the **Prompt template** field, enter `Summarize transcript.md`. +8. Set **Model** to one of your provider's models. +9. Select **Save**. +10. In the command bar, select **Capture**. QuickAdd adds **Untitled Capture Choice** after the AI Assistant. +11. Select **Configure Untitled Capture Choice**, the gear icon next to it. +12. Turn on **Capture to active file**. +13. Set **Write position** to **New line below cursor**. +14. Set **Capture format** to `{{VALUE:output}}`. +15. Load the episode in PodNotes. +16. Open the note for the summary in editing view. +17. Place the cursor where the summary goes. +18. Run **QuickAdd: Run**. +19. Select **Summarize episode**. + +The AI Assistant puts the model's answer in `{{VALUE:output}}`, which only the later commands of the same macro can read. QuickAdd's [AI Assistant docs](https://quickadd.obsidian.guide/docs/AIAssistant/) describe the model and output settings. diff --git a/docs/src/content/docs/api.md b/docs/src/content/docs/api.md index bdb7ee7b..edeaf6b5 100644 --- a/docs/src/content/docs/api.md +++ b/docs/src/content/docs/api.md @@ -1,10 +1,14 @@ --- title: API slug: api -description: Public API members, episode types, playback controls, transcript reads, and timestamp formatting. +description: Read and control the PodNotes player from QuickAdd scripts, Templater, or the console, and read transcripts and timestamps. legacyTitleId: api --- +Read and control the PodNotes player from your own scripts through `app.plugins.plugins.podnotes.api`. +It works wherever a script can reach Obsidian's `app`, such as a QuickAdd script, a Templater template, or the developer console. +The API exists while PodNotes is enabled. For links that open PodNotes, see [PodNotes URI](/timestamps/#podnotes-uri). + ```ts export interface IAPI { readonly podcast: Episode; @@ -38,12 +42,10 @@ export interface IAPI { } ``` -The public API is available at `app.plugins.plugins.podnotes.api` when the plugin is enabled. -It reads and controls the current PodNotes player. [PodNotes URI parameters](/timestamps/#podnotes-uri) are documented separately. - ## `podcast` -This returns the loaded episode, including when playback is paused. -Although `IAPI` declares `Episode`, the value can be `undefined` before an episode is loaded. + +The loaded episode, also while it is paused. +`IAPI` declares `Episode`, but the value is `undefined` until an episode is loaded. ```ts export type EpisodeMediaType = "audio" | "video"; @@ -66,51 +68,56 @@ export interface Episode { } ``` -`streamUrl` is the media source. `url` identifies the episode's page, the feed URL when the item has no link, or a vault link for a local file. -`feedUrl` can contain a private-feed placeholder rather than a fetchable URL. -`duration` is the feed-provided duration in seconds, which can differ from the player's measured `length`. -Local episodes extend this type with `podcastName: "local file"`, empty `description` and `content`, and an optional `filePath`. +- `streamUrl` is the media source. +- `url` is the episode's web page. It is the feed URL when the episode has no link, and a vault link for a local file. +- `feedUrl` can hold a private-feed placeholder instead of a URL you can fetch. +- `duration` is the length in seconds that the feed reports. It can differ from the player's measured `length`. +- A local file has `podcastName: "local file"`, an empty `description` and `content`, and an optional `filePath`. ## `isPlaying` -Returns `true` while the current episode plays, or loads to play. It is `false` for a paused, finished, or freshly restored episode. +`true` when PodNotes is not paused. +It reports the state PodNotes asked for, so it can be `true` before the media starts playing. ## `length` -Returns the player duration in seconds. Before the media loads, it is the saved or feed-provided duration, or zero when neither is known. +The duration of the loaded media in seconds. It is `0` until the media's metadata loads. ## `currentTime` -Gets or sets the player position in seconds. Before the media loads, it is the position playback starts from. -Setting a position clears an active segment's end boundary. A value that is not a finite, non-negative number is ignored. -A position at or past the end stops 0.25 seconds before the end and pauses, so the episode does not finish. +Gets or sets the playback position in seconds. +Setting it cancels the end of an active segment, so playback no longer pauses there. ## `volume` -Gets or sets the volume between `0` and `1`. -Finite writes are clamped to this range. Non-finite writes preserve the current volume. -Volume changes also update the saved **Default Volume** preference. +Gets or sets the volume, from `0` to `1`. +PodNotes clamps finite values to that range and ignores values that are not finite. +Setting the volume also changes the saved **Default Volume** setting. ## `transcript` -This is a convenience getter for `getTranscript()`: + +A shortcut for `getTranscript()` with no argument: ```js const transcript = await app.plugins.plugins.podnotes.api.transcript; ``` -It returns the generated transcript note for the current episode, or `null` if no -episode is loaded or no transcript file exists yet. It does not call a transcription provider or -create a new transcript. **Transcribe current episode** creates transcript notes. +It resolves to the text of the current episode's transcript note. +It resolves to `null` when no episode is loaded or the episode has no transcript note yet. +It never calls a transcription provider. Run **PodNotes: Transcribe current episode** to create the note. -If your transcript template includes metadata such as the title and date, that -metadata is included in the returned text. A **Transcript template** containing only -`{{transcript}}` produces a note containing only the transcript body. +The text is the whole note. +If your **Transcript template** adds a title and date, the text includes them. A template of only `{{transcript}}` returns only the transcript. -## `getTranscript(episode?: Episode)` -This reads the generated transcript note for the provided episode. If no episode -is provided, it reads the current episode's transcript. -The path comes from **Transcript file path** rendered for that episode. -A missing episode or file returns `null`. File read errors reject the promise. +## `getTranscript()` + +```ts +getTranscript(episode?: Episode): Promise +``` + +Reads the transcript note of `episode`, or of the current episode when you omit it. +PodNotes finds the note at the **Transcript file path** rendered for that episode. +It resolves to `null` when there is no episode or no note, and rejects when the note cannot be read. ```js const api = app.plugins.plugins.podnotes.api; @@ -119,58 +126,51 @@ const transcript = await api.getTranscript(); ### QuickAdd AI prompt example -This macro returns a prompt containing the saved transcript note. -A QuickAdd AI action or another workflow can consume that prompt. +[Send a transcript to an AI prompt](/QuickAdd/#send-a-transcript-to-an-ai-prompt) has a QuickAdd user script that returns a prompt with the current episode's transcript. -```js -module.exports = async (params) => { - const podnotes = params.app.plugins.plugins.podnotes?.api; - if (!podnotes) throw new Error("PodNotes is not enabled."); +## `getPodcastTimeFormatted()` - const transcript = await podnotes.transcript; - if (!transcript) { - throw new Error( - "Generate a transcript for the current PodNotes episode first.", - ); - } +```ts +getPodcastTimeFormatted(format: string, linkify?: boolean, offsetSeconds?: number): string +``` + +Returns the current playback time, formatted with the [time tokens](/timestamps/#time-formats). `api.getPodcastTimeFormatted("HH:mm:ss", true)` returns a link such as `[00:12:41](obsidian://podnotes?...)`. - const prompt = `Summarize this podcast transcript in five bullet points. +- `linkify` defaults to `false`. With `true`, the result is a Markdown link that opens PodNotes at that time. Without a feed URL or a local file path to link to, the result stays plain text. +- `offsetSeconds` defaults to `0`. PodNotes subtracts it before formatting and linking, and never goes below zero. -Transcript: -${transcript}`; +Throws `"No podcast loaded"` when no episode is loaded. - return prompt; -}; +## `getPodcastSegmentFormatted()` + +```ts +getPodcastSegmentFormatted( + format: string, + startTime: number, + endTime: number, + linkify?: boolean, +): string ``` -## `getPodcastTimeFormatted(format: string, linkify?: boolean, offsetSeconds?: number)` -Returns the current playback time using the [supported time tokens](/timestamps/#time-formats). -`linkify` defaults to `false`. With `true`, the result is a Markdown timestamp link when a feed URL or registered local file path is available. -Without a target, the result remains plain text. -`offsetSeconds` defaults to `0`. It is subtracted before formatting and linking, with a minimum result of zero. -The method throws `"No podcast loaded"` without a loaded episode. - -## `getPodcastSegmentFormatted(format: string, startTime: number, endTime: number, linkify?: boolean)` -Returns a `start-end` range using the [supported time tokens](/timestamps/#time-formats). -Times are in seconds. `linkify` defaults to `false`. -With `true`, a valid range links to the episode through `time` and `endTime`, so playback pauses at the end. -Negative bounds are normalized to zero. The end must exceed the start after normalization, and both values must be finite. -An invalid range or missing link target returns plain text. -This method has no offset parameter and throws `"No podcast loaded"` without a loaded episode. +Returns a `start-end` range, formatted with the [time tokens](/timestamps/#time-formats). + +- `startTime` and `endTime` are in seconds. Both must be finite. PodNotes raises negative values to zero, and the end must be after the start. +- `linkify` defaults to `false`. With `true`, a valid range becomes a link with `time` and `endTime`, so playback pauses at the end. +- An invalid range, or an episode with nothing to link to, returns plain text. + +There is no offset parameter. Throws `"No podcast loaded"` when no episode is loaded. ## `playbackRate` -Gets or sets the current player speed between `0.5` and `4`, rounded to tenths. -Non-finite writes fall back to **Default Playback Rate**. -Changing the current rate does not change that saved default. + +Gets or sets the current playback speed, from `0.5` to `4`, rounded to tenths. +A value that is not finite sets the speed to **Default Playback Rate**. +Setting the speed does not change **Default Playback Rate**. ## Playback controls -`start()`, `stop()`, and `togglePlayback()` control the current episode, also while the PodNotes view is closed. -`stop()` pauses without resetting the position. `start()` on a finished episode starts it over. -`skipBackward()` and `skipForward()` use **Skip backward length (s)** and **Skip forward length (s)**. -Invalid skip lengths fall back to 15 seconds. Backward skips stop at zero. -With a known duration, a forward skip goes no further than 0.25 seconds before the end and never moves backward. -Playback continues, so a playing episode finishes and the queue advances. - -`increasePlaybackRate()` and `decreasePlaybackRate()` adjust the current playback -speed in `0.1x` steps. `resetPlaybackRate()` returns playback to the default -playback rate configured in settings. + +- `start()` and `stop()` play and pause the current episode. `stop()` keeps the position. +- `togglePlayback()` switches between playing and paused. +- `skipBackward()` and `skipForward()` move by **Skip backward length (s)** and **Skip forward length (s)**. An invalid length falls back to 15 seconds. +- A backward skip stops at the start. When the duration is known, a forward skip stops 0.25 seconds before the end, and never moves backward. +- `increasePlaybackRate()` and `decreasePlaybackRate()` change the current speed by `0.1`, within `0.5` to `4`. +- `resetPlaybackRate()` sets the current speed to **Default Playback Rate**. diff --git a/docs/src/content/docs/commands.md b/docs/src/content/docs/commands.md index 13f68c26..37cba11c 100644 --- a/docs/src/content/docs/commands.md +++ b/docs/src/content/docs/commands.md @@ -1,119 +1,107 @@ --- title: Commands slug: commands -description: Current PodNotes command names, IDs, availability requirements, and effects. +description: Every PodNotes command with its command palette name, its ID, and when it is available. --- -The command palette prefixes these names with **PodNotes:**. -Most playback commands appear only after an episode is loaded. A loaded episode can be paused. -Each command's full ID is listed below for workflow plugins. +Obsidian lists each PodNotes command with the **PodNotes:** prefix, but only while its condition below holds. The same rule applies in the command palette and when you add a command to the mobile toolbar. + +| Command | ID | Available when | +| --- | --- | --- | +| [Show player](#show-player) | `podnotes:podnotes-show-leaf` | Always | +| [Play Podcast](#play-podcast) | `podnotes:start-playing` | An episode is loaded and paused | +| [Stop Podcast](#stop-podcast) | `podnotes:stop-playing` | An episode is playing | +| [Toggle playback](#toggle-playback) | `podnotes:podnotes-toggle-playback` | An episode is loaded | +| [Skip Backward](#skip-backward) | `podnotes:skip-backward` | An episode is loaded | +| [Skip Forward](#skip-forward) | `podnotes:skip-forward` | An episode is loaded | +| [Increase playback rate](#increase-playback-rate) | `podnotes:increase-playback-rate` | An episode is loaded | +| [Decrease playback rate](#decrease-playback-rate) | `podnotes:decrease-playback-rate` | An episode is loaded | +| [Reset playback rate](#reset-playback-rate) | `podnotes:reset-playback-rate` | An episode is loaded | +| [Reorder Queue](#reorder-queue) | `podnotes:reorder-queue` | The queue has two or more episodes | +| [Download Playing Episode](#download-playing-episode) | `podnotes:download-playing-episode` | An episode is loaded | +| [Capture Timestamp](#capture-timestamp) | `podnotes:capture-timestamp` | A note is open in an editor | +| [Capture Last 10 Seconds](#capture-last-10-seconds--capture-last-20-seconds) | `podnotes:capture-segment-10s` | A note is open in an editor, an episode is loaded, and **Capture timestamp format** is set | +| [Capture Last 20 Seconds](#capture-last-10-seconds--capture-last-20-seconds) | `podnotes:capture-segment-20s` | A note is open in an editor, an episode is loaded, and **Capture timestamp format** is set | +| [Create episode note](#create-episode-note) | `podnotes:create-podcast-note` | An episode is loaded, and **Note creation file path** and **Note creation template** are set | +| [Create podcast feed note](#create-podcast-feed-note) | `podnotes:create-podcast-feed-note` | You follow at least one podcast, and **Feed note file path** and **Feed note template** are set | +| [Copy universal episode link to clipboard](#copy-universal-episode-link-to-clipboard) | `podnotes:get-share-link-episode` | An episode is loaded | +| [Transcribe current episode](#transcribe-current-episode) | `podnotes:podnotes-transcribe` | An audio episode is loaded | + +A paused episode counts as loaded. Hotkeys and workflow plugins use the ID. ## Show player -`podnotes:podnotes-show-leaf` opens the PodNotes view or reveals its existing pane. -It is always available. If no pane exists, it creates one in the right sidebar. -The podcast ribbon action **Show PodNotes** opens the same view. +Opens the player in the right sidebar, or reveals it when it is already open. The **Show PodNotes** ribbon action does the same. ## Play Podcast -`podnotes:start-playing` starts playback of a loaded, paused episode. -It is available when an episode is loaded and PodNotes is paused. +Starts the loaded episode. ## Stop Podcast -`podnotes:stop-playing` pauses the loaded episode and keeps its position. -It is available when PodNotes is playing. +Pauses the episode and keeps its position. ## Toggle playback -`podnotes:podnotes-toggle-playback` switches the loaded episode between playing and paused. -It requires a loaded episode. +Plays the loaded episode when it is paused, and pauses it when it is playing. ## Skip Backward -`podnotes:skip-backward` seeks backward by **Skip backward length (s)**, with a minimum position of zero. -It requires a loaded episode and works while paused. The default skip length is 15 seconds. +Moves back by **Skip backward length (s)**, 15 seconds by default. It never moves before the start. ## Skip Forward -`podnotes:skip-forward` seeks forward by **Skip forward length (s)**. -When duration is known, a forward skip goes no further than 0.25 seconds before the end and never rewinds an episode already nearer its end. -Playback continues, so a playing episode finishes and the queue advances. -It requires a loaded episode and works while paused. The default skip length is 15 seconds. +Moves forward by **Skip forward length (s)**, 15 seconds by default. When the duration is known, a forward skip stops 0.25 seconds before the end. -## Download Playing Episode - -`podnotes:download-playing-episode` downloads the loaded episode. Playback does not need to be running. -**Episode download path** controls the vault destination and defaults to `PodNotes/{{podcast}}/{{title}}`. -The media extension is added automatically. - -A path that distinguishes episodes, such as one containing `{{title}}`, avoids file collisions. -A collision with another episode's file causes an error rather than an overwrite. -See [Path templates](/templates/#file-path) and [Downloads and sync](/sync/#downloads-on-another-device). +## Increase playback rate -## Reorder Queue +Raises the speed by 0.1x, up to 4x. -`podnotes:reorder-queue` opens a queue editor with controls to move and remove episodes. -It appears when the queue contains at least two episodes. The saved order is the play order. +## Decrease playback rate -## Capture Timestamp +Lowers the speed by 0.1x, down to 0.5x. -`podnotes:capture-timestamp` inserts the playback time into the active note editor. -It is an editor command that remains available without a loaded episode, so the mobile toolbar can offer it. -Capture itself requires a loaded episode and a non-empty **Capture timestamp format**. -If either is missing, PodNotes shows a notice. +## Reset playback rate -The headset Media Session `previoustrack` action captures a timestamp with an episode-note fallback. -The editor command does not use that fallback. See [Timestamps](/timestamps/) and [Mobile capture](/mobile/). +Sets the speed to **Default Playback Rate**. -## Capture Last 10 Seconds / Capture Last 20 Seconds +## Reorder Queue -`podnotes:capture-segment-10s` and `podnotes:capture-segment-20s` insert a recent playback range into the active editor. -They require a loaded episode and a non-empty timestamp format. -A linked range plays from its start and pauses at its end. See [Segment capture](/timestamps/#capturing-segments). +Opens the **Reorder Queue** window. Each episode has buttons to move it to the top, up, down, or to the bottom, and to remove it from the queue. See [Reorder the queue](/playlists/#reorder-the-queue). -## Increase playback rate +## Download Playing Episode -`podnotes:increase-playback-rate` increases the loaded episode's speed by `0.1x`, up to `4x`. +Downloads the loaded episode to **Episode download path**, `PodNotes/{{podcast}}/{{title}}` by default. PodNotes adds the file extension. +If another episode's file already exists at that path, the download fails. See [Download an episode](/local_files/#download-an-episode). -## Decrease playback rate +## Capture Timestamp -`podnotes:decrease-playback-rate` decreases the loaded episode's speed by `0.1x`, down to `0.5x`. +Inserts a timestamp at the cursor, formatted by **Capture timestamp format**. The default format is `- {{linktime}}` with a space at the end. It makes a list item with a timestamp link. +Obsidian lists the command only while a note is open in an editor. It does not need a loaded episode, so you can add it to the mobile toolbar before you play anything. Without an episode or with an empty format, it shows a notice. See [Capture timestamps](/timestamps/). -## Reset playback rate +## Capture Last 10 Seconds / Capture Last 20 Seconds -`podnotes:reset-playback-rate` resets the loaded episode's speed to **Default Playback Rate**. +Inserts the last 10 or 20 seconds of playback at the cursor as a range, in the style of **Capture timestamp format**. With the default format, the range is a link that plays from the start of the range and pauses at its end. +Obsidian lists these commands only while a note is open in an editor, an episode is loaded, and **Capture timestamp format** is set. To add them to the mobile toolbar, see [Add a capture button to the editor toolbar](/mobile/#add-a-capture-button-to-the-editor-toolbar). +Early in an episode, PodNotes shows a notice to play more first. See [Capture a range](/timestamps/#capture-a-range). ## Create episode note -`podnotes:create-podcast-note` creates or opens a note for the loaded episode. -It requires a loaded episode, **Note creation file path**, and **Note creation template**. -Its ID is unchanged from the older **Create Podcast Note** name, so existing hotkeys remain associated with it. - -See [Templates](/templates/) for the defaults and tag reference. +Creates a note for the loaded episode from **Note creation template** at **Note creation file path**, or opens the note if it exists. +The ID keeps the command's former name, Create Podcast Note. See [Create episode notes](/notes/). ## Create podcast feed note -`podnotes:create-podcast-feed-note` opens a picker of saved podcasts, then creates or opens the selected feed note. -It requires at least one saved feed, **Feed note file path**, and **Feed note template**. -Playback is not required. - -The episode context menu also offers **Create feed note** or **Open feed note**. -See [Feed note templates](/templates/#podcast-feed-notes). +Opens a list of the podcasts you follow, with the playing episode's podcast first. Creates a note for the podcast you select, or opens the note if it exists. +No episode needs to be loaded. See [Create a feed note for a podcast](/notes/#create-a-feed-note-for-a-podcast). ## Copy universal episode link to clipboard -`podnotes:get-share-link-episode` copies a pod.link episode URL for the loaded episode. -It looks up the show in Apple Podcasts, matching its feed URL before trying its name, then matches the episode through pod.link. -A missing show or episode produces a notice. This is a share link for other podcast apps, rather than a PodNotes timestamp link. +Copies a pod.link URL for the loaded episode, to share with people who use other podcast apps. It is not a PodNotes timestamp link. +PodNotes finds the show in the Apple Podcasts directory. If the show or the episode is not there, it shows a notice instead. ## Transcribe current episode -`podnotes:podnotes-transcribe` transcribes the loaded audio episode into a note at **Transcript file path**. -It is available for audio episodes, including local audio, and unavailable for video episodes. -A missing API key does not hide the command. Running it explains which provider needs a key on this device. - -Plain transcription uses OpenAI. With **Speaker diarization** enabled, **Diarization provider** selects OpenAI or Deepgram. -**OpenAI API key** and **Deepgram API key** are under **Transcripts** in PodNotes settings. -An existing transcript file is kept rather than transcribed again. -See [Transcripts](/transcripts/) for provider setup. +Transcribes the loaded episode and saves the transcript at **Transcript file path**. It works for podcast episodes and local audio, but not for video. +If the transcript file already exists, PodNotes keeps it and does not transcribe again. +Without an API key for the selected provider, it shows a notice that names the key to add. See [Transcribe episodes](/transcripts/). diff --git a/docs/src/content/docs/faq.md b/docs/src/content/docs/faq.md index f1863cb9..6f14d33e 100644 --- a/docs/src/content/docs/faq.md +++ b/docs/src/content/docs/faq.md @@ -1,99 +1,98 @@ --- title: FAQ slug: faq -description: Answers about the player, supported podcasts, private feeds, downloads, links, transcription, and devices. +description: Short answers about finding the player, adding podcasts, timestamps, sync, and transcription, with links to the full guides. +next: false --- -## Where is the player? +Find short answers to the questions PodNotes users ask most. Each answer links to the page that covers the topic in full. -Run **PodNotes: Show player** from Obsidian's command palette. The command opens the player or reveals its existing pane. -The podcast ribbon action, **Show PodNotes**, opens the same view. +## Setup and installation -On mobile, the player stays closed at startup until you open it. See [Use PodNotes on mobile](/mobile/). +### Where is the player? -## Why are some commands missing? +Run **PodNotes: Show player** from the command palette, or select the **Show PodNotes** podcast icon in the ribbon. +Both open the player or reveal it if it is already open. +On a phone, the player stays closed until you open it. See [Use PodNotes on your phone](/mobile/). -Most playback commands need a loaded episode. **PodNotes: Create episode note** also needs a non-empty note path and template. -The episode can be paused. **PodNotes: Reorder Queue** appears when the queue contains at least two episodes. +### Why are some commands missing? -**PodNotes: Show player** is always available. **PodNotes: Capture Timestamp** is an editor command, so it needs an active note editor. -The [command reference](/commands/) lists each command's requirements. +Most commands appear only while an episode is loaded. A paused episode counts. +**PodNotes: Capture Timestamp** needs a note open in an editor, and **PodNotes: Reorder Queue** needs at least two episodes in the queue. +**PodNotes: Show player** is always available. [Commands](/commands/) lists what each command needs. -## Which podcasts can I add? +### How do I install a beta version or install manually? -PodNotes plays media published through podcast RSS feeds. Search uses the Apple Podcasts directory and requests up to 20 results. -You can also paste an RSS feed URL or an Apple Podcasts show link into **Search Podcasts** in PodNotes settings. -An Apple link works when the directory provides a feed for that show. +Install a beta version with the BRAT plugin, or copy a release's files into your vault by hand. +[Other ways to install](/getting-started/#other-ways-to-install) has the steps for both. -A missing search result does not mean a podcast is unsupported. Its publisher's RSS URL can work without a directory listing. -An ordinary website or episode page is not an RSS feed. See [Getting started](/getting-started/). +## Podcasts and feeds -## Can I add Spotify exclusives or YouTube links? +### Which podcasts can I add? -PodNotes does not import Spotify or YouTube page links. A show available only inside a service, with no accessible RSS feed, cannot be added. -If the publisher also offers an RSS feed, use that feed. A Spotify subscription does not provide PodNotes with access to Spotify-only media. +Any podcast with an RSS feed. +In **Settings → PodNotes**, under **Search Podcasts**, search the Apple Podcasts directory by name, or paste an RSS feed URL or an Apple Podcasts show link. +A show missing from search can still work if you paste its RSS feed URL. See [Add and play podcasts](/podcasts/). -## Can I use a private feed? +### Can I add Spotify exclusives or YouTube links? -Yes, when your provider gives you a personal RSS URL that PodNotes can fetch. Paste the complete URL into **Search Podcasts**. -PodNotes has no provider sign-in flow for feeds that require a browser login. +No. PodNotes plays RSS feeds and cannot open Spotify or YouTube pages. +A show that exists only inside Spotify or another app has no feed to add. If the publisher also offers an RSS feed, add that feed. -When secret storage succeeds, URLs with embedded credentials or recognized secret query parameters move into Obsidian's device-local secret storage. -The saved subscription and new links then use a placeholder. -On another device, PodNotes asks you to remove the saved feed and add it again with its private URL. -Re-adding creates a new device-local secret reference. Once that reference syncs back, the first device cannot resolve it with its old secret. -Protected private-feed subscriptions do not work across devices through plugin data sync alone. +### Can I use a private feed? -If secret storage fails, the real URL stays in `data.json` and can sync. -A failed migration shows a persistent notice asking you to restart PodNotes to retry. +Yes, if your provider gives you a personal RSS URL. Paste the whole URL into **Search Podcasts**. +PodNotes cannot sign in to providers that require a browser login. +When the URL contains a user name, a password, or a token parameter, PodNotes keeps it on the current device only, so the feed works on one device at a time. See [API keys and private feeds on each device](/sync/#api-keys-and-private-feeds-on-each-device). Importing a settings file also leaves these feeds without their URL, as [Import your settings](/import_export/#import-your-settings) explains. -Tokens embedded in a URL path are not detected. Those URLs remain in `data.json`. -OPML exports include the real subscription URLs. Treat a private feed URL and an export containing it as credentials. -See [Sync and devices](/sync/). +### Can I annotate my own audio or video? -## Do downloads sync with my vault? +Yes. Play an audio or video file from your vault in PodNotes, then capture timestamps and create an episode note as with any episode. +See [Play your own audio and video](/local_files/#play-your-own-audio-and-video) for the steps and the file types PodNotes plays. -Downloads are files inside your vault. Your sync service must include the media files as well as PodNotes' plugin data. -PodNotes records file paths in `data.json`; syncing that file alone does not transfer audio or video. +## Notes and timestamps -File types, size limits, and exclusions depend on your sync service. See [Downloads on another device](/sync/#downloads-on-another-device). +### Why are my timestamps plain text? -## What happens to notes and links when I remove a download? +Your **Capture timestamp format** uses `{{time}}`, which inserts plain text. +Installs set up before PodNotes 2.21.0 keep the old default, which uses `{{time}}`. New installs use `{{linktime}}`, which inserts a link that plays the episode from that moment. +In **Settings → PodNotes**, set **Capture timestamp format** to `- {{linktime}}` with a space at the end. If it already uses `{{linktime}}`, the episode has no feed URL or local file to link to. See [Make timestamps clickable](/timestamps/#make-timestamps-clickable). -Removing a download leaves your notes and captured timestamps unchanged. -Podcast timestamp links address the feed and episode, so they can play online again when the feed and media remain available. -A local-file link needs the media file and its registration in PodNotes on that device. +### Can I take notes on an episode I listen to in another app? -**Remove from Local Files** keeps a manually added file in the vault. -For a downloaded episode, **Remove file** removes its registration and trashes the file. -If PodNotes cannot confirm that it downloaded the file, it asks whether to **Keep file** or **Delete file**. -The [sync page](/sync/#removal-and-existing-links) explains the limits of older links and downloads. -File deletion follows Obsidian's **Deleted files** setting under **Files and links**, including permanent deletion if selected. +Yes. Add the podcast to PodNotes, then create the note from the episode's menu. The episode does not have to play. See [Create a note for another episode](/notes/#create-a-note-for-another-episode). +Timestamps come from the PodNotes player, so to capture them, play the episode in PodNotes. +For notes about a whole show, use a feed note. See [Create a feed note for a podcast](/notes/#create-a-feed-note-for-a-podcast). -## Can I annotate my own audio or video? +### Do headset buttons control PodNotes? -Yes. Open a vault media file's context menu and choose **Play with PodNotes**. -On mobile, long-press the file. You can capture timestamps, create an episode note, and resume playback. -For MP4 and WebM files, choose **Play as audio with PodNotes** or **Play as video with PodNotes**. +One button does. When your headset sends the previous-track action, PodNotes captures a timestamp. If a note is open in an editor, the timestamp goes in at the cursor. If no note is open in an editor, it goes at the end of the episode note. +This works on a phone or a computer, but not every headset sends that action. PodNotes adds no other headset actions, so play, pause, and skip depend on your device. See [Capture with a headset](/mobile/#capture-with-a-headset). -Playback depends on the codecs supported by Obsidian and your device. Transcription accepts audio episodes, including local audio, but not video episodes. -See [Local files](/local_files/). +## Devices and sync -## Does transcription cost money? +### Do downloads sync with my vault? -PodNotes has no transcription subscription. The transcription provider bills your API account. -Plain transcription uses OpenAI Whisper. Optional **Speaker diarization** uses OpenAI or Deepgram, selected through **Diarization provider**. +Downloads are files in your vault, so they sync only if your sync service includes them. +Syncing PodNotes' `data.json` alone does not move the audio or video. File types and size limits depend on your sync service. +See [Downloads on another device](/sync/#downloads-on-another-device). -Configure the matching **OpenAI API key** or **Deepgram API key** in PodNotes settings under **Transcripts**. -Your provider account needs the billing or credit required for API use. PodNotes does not include API credit. -Costs depend on the provider, model, and audio length. Check your provider's current API pricing before a long transcription. +### What happens to notes and links when I remove a download? -PodNotes generates transcript notes from audio. It does not import transcripts shown in Apple Podcasts or automatically read transcripts published in a feed. -See [Transcripts](/transcripts/). +Your notes and timestamps stay. +Timestamp links point at the feed and the episode, so they stream the episode again while the feed still lists it. +Links to your own media files need the file and its entry in **Local Files**. After **Remove from Local Files**, select **Play with PodNotes** on the file again to restore its links. See [Removed downloads and existing links](/sync/#removed-downloads-and-existing-links). -## Does playback progress sync between devices? +### Does playback progress sync between devices? -Saved progress, subscriptions, playlists, templates, and preferences can travel with PodNotes' `data.json` when your sync service includes plugin data. -PodNotes has no separate sync account or live playback handoff. Two devices can overwrite each other's changes. -API key values and protected private-feed URLs remain local to each device. -See [Sync and devices](/sync/) before switching between devices. +Yes, when your sync service syncs plugin data. PodNotes saves progress, podcasts, playlists, and settings in `data.json`. +PodNotes does not merge changes from two devices, so one device can overwrite the other's changes. Use one device at a time. When you switch, let sync finish, then restart Obsidian or turn PodNotes off and on before you use it. See [Use PodNotes on two devices](/sync/#use-podnotes-on-two-devices). +API keys and protected private feeds stay on each device. See [Sync between devices](/sync/). + +## Transcription + +### Does transcription cost money? + +PodNotes is free, but transcription uses your own OpenAI or Deepgram account, and the provider bills you for API use. +Plain transcripts use OpenAI Whisper. **Speaker diarization** uses OpenAI or Deepgram. +PodNotes transcribes the audio itself. It does not import transcripts from Apple Podcasts or from a feed. See [What transcription costs](/transcripts/#what-transcription-costs). diff --git a/docs/src/content/docs/getting-started.md b/docs/src/content/docs/getting-started.md index 1120a08f..98d25d09 100644 --- a/docs/src/content/docs/getting-started.md +++ b/docs/src/content/docs/getting-started.md @@ -1,51 +1,81 @@ --- title: Getting started slug: getting-started -description: Install PodNotes, add a podcast, play an episode, and save your first timestamp and episode note. +description: Install PodNotes, add a podcast, play an episode, and save your first timestamp and episode note in about five minutes. --- -Let's play one podcast episode and save a link to a moment in it. +In about five minutes, you get a podcast playing in Obsidian, a timestamp in a note that jumps back to that moment, and a note for the episode. PodNotes needs Obsidian 1.13 or later. ## Install PodNotes -1. Open Obsidian settings and choose **Community plugins**. -2. Turn off restricted mode if it is enabled. -3. Browse for **PodNotes**, install it, and enable it. -4. Run **PodNotes: Show player** from the command palette. +1. In Obsidian, open **Settings → Community plugins**. +2. If you see **Exit Restricted mode**, select it. +3. Select **Browse**. +4. Search for PodNotes. +5. Select **Install**. +6. Select **Enable**. -The PodNotes view opens. On a phone, use the same command or the **Show PodNotes** ribbon action. +On a computer, the PodNotes view opens in the right sidebar. To open it again later, run **PodNotes: Show player** from the command palette (`Ctrl+P`, or `Command+P` on macOS), or select the **Show PodNotes** podcast icon in the ribbon. On a phone, see [Open the player](/mobile/#open-the-player). ## Add a podcast -1. Open PodNotes settings and find **Search Podcasts**. -2. Enter a show name, its RSS feed URL, or its Apple Podcasts show link. -3. Choose **Add** on the matching result. +1. Open **Settings → PodNotes**. +2. Under **Search Podcasts**, type a show's name. Results appear as you type. +3. Select **Add** on the show you want. -The result changes to **Remove**. Your podcast is now saved. -If a show is missing, try its publisher's RSS URL. See the [supported podcast FAQ](/faq/#which-podcasts-can-i-add) for source limits. +![PodNotes settings with Search Podcasts results for "the daily", each with an Add button](/screenshots/add-podcast.webp) + +The button changes to **Remove**, and the show appears in the PodNotes view. + +You can also paste the show's RSS feed URL or its Apple Podcasts link into the search box. One result then appears, with an **Add** button. If a show does not appear, see [Which podcasts can I add?](/faq/#which-podcasts-can-i-add) ## Play an episode -1. Run **PodNotes: Show player** again. -2. Choose the saved podcast. -3. Select an episode to play it. +1. At the top of the PodNotes view, select **Podcast grid**, the first of the three buttons. +2. Select the show's artwork. +3. Select an episode. -The player loads the episode and its playback time advances. +The episode starts, and the view switches to the player. Select the artwork in the player to pause or play. + +## Create an episode note + +1. With the episode still loaded, run **PodNotes: Create episode note** from the command palette. + +PodNotes creates the note, opens it, and puts it in `PodNotes//` with the episode's date, a link to its podcast, and a link that resumes playback. To change what a new note holds, see [Change what notes contain](/notes/#change-what-notes-contain). ## Capture a timestamp -1. Create or open a Markdown note in edit mode. -2. Place the cursor where you want the timestamp. -3. Run **PodNotes: Capture Timestamp**. +1. In the episode note, in editing view, place the cursor where you want the timestamp. +2. Run **PodNotes: Capture Timestamp** from the command palette. -On a new install, a clickable timestamp appears. Opening that link plays the episode at the captured time. -If your saved format produces plain text, see [Timestamp settings](/timestamps/#settings). -Phone users can add a [mobile toolbar button](/mobile/#add-a-capture-button-to-the-editor-toolbar). +PodNotes inserts the playback time as a link: -## Create an episode note +```md wrap +- [00:12:41](obsidian://podnotes?...) +``` + +Select the link, in Live Preview or reading view, to play the episode from that moment. Capture works the same in any note. + +## Other ways to install + +To try a beta version before it reaches **Community plugins**, install PodNotes with BRAT: + +1. Install the [BRAT](obsidian://show-plugin?id=obsidian42-brat) plugin. +2. In **Settings → Community plugins**, turn on BRAT. +3. Run **BRAT: Plugins: Add a beta plugin for testing (with or without version)**. +4. Enter `chhoumann/PodNotes`. +5. Select **Add plugin**. +6. If PodNotes is not turned on afterwards, turn it on in **Settings → Community plugins**. + +To install a release by hand: + +1. From the [latest release](https://github.com/chhoumann/PodNotes/releases/latest), download `main.js` and `manifest.json`. +2. In your vault, put both files in `.obsidian/plugins/podnotes/`. Create the folder if it does not exist. +3. In **Settings → Community plugins**, select **Reload plugins**. +4. Turn on PodNotes. -1. With the episode still loaded, run **PodNotes: Create episode note**. -2. Write your notes in the file that opens. +## Next steps -PodNotes fills the note with the episode's metadata and a resume link. -The defaults work without a custom template. See [Templates](/templates/) when you want to change the note's path or content. +- [Capture timestamps](/timestamps/) from a hotkey, a phone toolbar, or a headset. +- [Create episode notes](/notes/) with your own template, and browse them with Bases. +- [Use PodNotes on your phone](/mobile/). diff --git a/docs/src/content/docs/import_export.md b/docs/src/content/docs/import_export.md index 93d6828a..07828f61 100644 --- a/docs/src/content/docs/import_export.md +++ b/docs/src/content/docs/import_export.md @@ -1,102 +1,61 @@ --- -title: Import & Export +title: Import and export slug: import_export +description: Import podcasts from OPML, export them to OPML, and back up or move your PodNotes settings. --- -In the settings panel, you will find functionality to import and export podcasts. -This feature lets you import your saved podcasts from other apps, e.g. Pocket Casts. -Similarly, you can also export your podcasts from PodNotes to such apps. - -## Importing - -To import podcasts, follow these steps: - -1. Go to the PodNotes settings in Obsidian. -2. Find the "Import" section. -3. Click the "Import OPML" button. -4. A file selection dialog will open. Choose your OPML file. -5. The selected podcasts will be imported into PodNotes. - -## Exporting - -You can export your saved feeds to `opml` format. -First designate a file path to save to (or use the default), and click _Export_. - -## Settings & templates - -Under the **Settings & templates** heading, you can move your PodNotes -configuration between vaults or back it up. This covers your preferences, -note/timestamp/transcript templates, file paths, saved feeds, and playlists. - -![Settings & templates import/export](/resources/settings_import_export.png) - -Playback progress, downloaded-episode bookkeeping, the currently playing -episode, and the episode-to-note mapping are **not** included, because they are -specific to a single vault. - -PodNotes settings exports use format v2. API key values and the names of the -Obsidian secrets selected in PodNotes are omitted by default. This keeps a -destination vault's existing OpenAI and Deepgram selections unchanged when you -import the file. - -### Exporting settings - -1. Leave **Include API keys** off for a normal settings export. To transfer the - OpenAI and Deepgram keys that are available on this device, enable it - explicitly. -2. Set a file name (or keep the default `PodNotes_Settings.json`). -3. Click **Export**. The settings file is written to your vault. - -When **Include API keys** is enabled, available values are added in plaintext -under a separate top-level `secrets` payload: - -```json -{ - "type": "podnotes-settings", - "version": 2, - "settings": {}, - "secrets": { - "openAI": "your OpenAI API key", - "deepgram": "your Deepgram API key" - } -} -``` - -Only configured keys are included. If a selected secret name exists in PodNotes -settings but its value is unavailable on this device, the export stops instead -of silently creating an incomplete credential backup. Open **Transcript -settings**, select or create the missing secret on this device, and export -again. - -An export containing `secrets` is sensitive. The values are plaintext in a file -inside your vault, where they may sync to other devices or be read by other -plugins. Keep the file private and delete it when it is no longer needed. - -### Importing settings - -1. Click **Import** next to **Import settings** and choose a settings file. - PodNotes accepts current v2 exports, legacy v1 exports, and a raw PodNotes - `data.json`. -2. Confirm the import. Your current preferences, templates, feeds, and playlists - are replaced with the imported values; playback progress and downloads are - kept. - -An import without API key values preserves the OpenAI and Deepgram secret names -already selected in the destination vault. For a legacy v1 export or raw -`data.json`, PodNotes recognizes the old plaintext `openAIApiKey` and -`diarizationApiKey` fields and imports them as secrets instead of putting them -back into plugin settings. - -When an import contains API keys, the confirmation names the affected -providers. PodNotes stores each value under a PodNotes-owned name in Obsidian's -vault-local secret storage, then updates PodNotes to reference it. Existing -Obsidian secrets are never overwritten. If a PodNotes-owned name already holds -a different value, the imported key is stored under a new suffixed name. - -Imported secret values are available only in the current vault on the current -device. Repeat the import or select/create the appropriate secrets separately -on another device. - -A file exported by a newer version of PodNotes is rejected until you update the -plugin. If an episode is already open when you import, a changed default -playback rate applies to the next episode you open. +Move your podcasts between PodNotes and other podcast apps with OPML files, and back up or move your PodNotes setup with a settings file. +All of these controls are in **Settings → PodNotes**. + +## Import podcasts from OPML + +1. In your other podcast app, such as Pocket Casts, export your subscriptions as an OPML file. +2. In **Settings → PodNotes**, under **Import/Export**, select **Import** next to **Import OPML**. +3. Select the `.opml` file. + +PodNotes fetches each feed and adds it to your podcasts, and a notice reports how many it saved and how many failed to load. It skips podcasts you already have, matched by feed URL or by title. + +## Export podcasts to OPML + +1. In **Settings → PodNotes**, under **Import/Export**, enter a file name next to **Export OPML**, or keep `PodNotes_Export.opml`. +2. Select **Export**. + +PodNotes saves the file at the root of your vault. To save it in an existing folder, include the folder in the name, such as `Exports/PodNotes_Export.opml`. If the name is taken, the export fails, so choose another name. + +The OPML file contains the real feed URLs, private ones included. Delete it after you import it into the other app. + +## Back up or move your settings + +A settings file copies your PodNotes setup to another vault, or keeps a backup of it. It holds your preferences, your templates and paths, your saved podcasts, and your queue, favorites, and playlists. It leaves out playback progress, played status, downloads, and the current episode, and it holds your API keys only if you turn on **Include API keys**. + +### Export your settings + +1. In **Settings → PodNotes**, under **Preferences & templates**, enter a file name next to **Export settings**, or keep `PodNotes_Settings.json`. +2. To include your OpenAI and Deepgram API keys, turn on **Include API keys**. Read [API keys in a settings file](#api-keys-in-a-settings-file) first. +3. Next to **Export settings**, select **Export**. + +PodNotes saves the file in your vault and never overwrites an existing file. If the name is taken, choose another name. + +### Import your settings + +After an import, PodNotes cannot fetch your protected private feeds, the ones whose URL contains a user name, a password, or a token parameter such as `auth`. A settings file holds a placeholder instead of their URLs. The same holds when you import a backup or a copy of `data.json` with **Import settings**, even into the same vault. Before you import, keep a copy of their private URLs. + +1. In **Settings → PodNotes**, under **Preferences & templates**, select **Import** next to **Import settings**. +2. Select the settings file. +3. In **Import PodNotes settings?**, select **Import**. +4. Under **Search Podcasts**, with the search box empty, select **Remove** next to each protected private feed. +5. Add each of those feeds again with its private URL, as described in [Add a podcast](/podcasts/#add-a-podcast). + +The import replaces your preferences, templates, saved podcasts, and playlists, and keeps your playback progress and downloads. It also sets the current speed and volume to the imported **Default Playback Rate** and **Default Volume**. + +PodNotes also imports settings files from older versions and a copy of `data.json`. It rejects a file in a newer format than this version supports and asks you to update. + +### API keys in a settings file + +With **Include API keys** on, the file contains your OpenAI and Deepgram API keys in plain text. The file sits in your vault, so it can sync to other devices, and other plugins can read it. Keep the file private, and delete it when you no longer need it. + +- **Include API keys** turns off again each time you open the settings. +- If a selected key is missing on this device, the export stops with a notice. Select or create the key under **Transcripts**, then export again. +- An import saves the file's keys, including plain-text keys from older files, to secret storage on this device only. It never overwrites an existing secret, and an import without keys keeps the keys you selected. + +See [API keys and private feeds on each device](/sync/#api-keys-and-private-feeds-on-each-device). diff --git a/docs/src/content/docs/index.md b/docs/src/content/docs/index.md index 56cb6f29..ad5ef21b 100644 --- a/docs/src/content/docs/index.md +++ b/docs/src/content/docs/index.md @@ -1,96 +1,51 @@ --- title: PodNotes slug: index +description: A podcast player for Obsidian that plays beside your notes, captures timestamp links, and creates a note for every episode. +template: splash +editUrl: false +prev: false +next: false +legacyTitleId: podnotes head: - tag: title - content: PodNotes -legacyTitleId: podnotes + content: PodNotes, a podcast player for Obsidian +hero: + title: Take notes on podcasts without leaving Obsidian + tagline: PodNotes plays podcasts beside your notes. Capture a timestamp that links back to the moment you heard it, and create a note for any episode. + actions: + - text: Get started + link: /getting-started/ + icon: right-arrow + - text: View in the plugin directory + link: https://community.obsidian.md/plugins/podnotes + variant: minimal + icon: external --- -The one goal for PodNotes is to make it easier to write notes on podcasts. - -Here are the features that will help you do that 👇. - -## Features - -- Mobile friendly - works on iOS and Android, including offline playback of local files and downloads -- Podcast player built into Obsidian, for both audio and video episodes -- Add any publicly available podcast through search, or custom feeds by URL -- Track played episodes & playback progress, with continuous resume -- Works with Obsidian Sync and other sync tools: changes made on another device merge in without a restart -- Create podcast notes from templates with rich metadata about episodes -- Bases-friendly default notes you can sort, filter, and group with [Obsidian Bases](https://help.obsidian.md/bases) -- Create a feed note for an entire podcast that every episode note links back to -- Capture timestamps & link directly to the time in the episode -- [Transcribe episodes](/transcripts/), with optional speaker labels (diarization) -- Download episodes for offline playback -- Support for non-podcast local audio and video files -- API that can be used by plugins like [QuickAdd](https://github.com/chhoumann/QuickAdd) or [Templater](https://github.com/silentvoid13/Templater) for custom workflows - -## Installation - -This plugin is in the Obsidian community plugin store. You can find it by searching in the store, or by clicking [here](obsidian://show-plugin?id=podnotes). - -### Installation with BRAT - -BRAT is an Obsidian plugin that helps you test beta plugins and themes. Click [here](obsidian://show-plugin?id=obsidian42-brat) to install it in Obsidian. - -Add `chhoumann/PodNotes` to BRAT with the `Add a beta plugin for testing` command. - -Now follow the appropriate instructions, which most likely will have you go and enable the plugin once it has finished installing. - -### Manual installation - -Go to the [releases](https://github.com/chhoumann/podnotes/releases/latest) page. -Download `main.js` and `manifest.json`. -Create a new directory in your Obsidian vaults `.obsidian/plugins/` folder called `podnotes` and place the downloaded files there. - -Now refresh the plugins in Obsidian and enable PodNotes. - -## Screenshots - -### Demo - -![Demo](/resources/demo.gif) - -### Podcast Grid - -![Podcast Grid](/resources/podcast_grid.png) - -### Episode List - -![Episode List](/resources/episode_list.png) - -### Player - -Play audio and video episodes, adjust the speed, and pick up exactly where you left off. - -![Player](/resources/player.png) - -### Episode notes - -Notes are created from a template with structured, [Bases](https://help.obsidian.md/bases)-friendly frontmatter. - -![Episode note](/resources/podcast_note.png) - -### Timestamps - -Capture the moment you're listening to as a clickable link straight into your notes. - -![Timestamps](/resources/timestamps.png) - -### Transcripts with speaker labels - -Transcribe an episode and, optionally, label who said what. +## What you can do -![Transcript with speaker labels](/resources/transcript_diarization.png) +- [Add and play podcasts](/podcasts/) + Search Apple Podcasts or paste any RSS feed. Play audio and video with speed control, a queue, and playlists. +- [Capture timestamps](/timestamps/) + One command inserts a timestamp link into your note. Select it later to play from that second. +- [Create episode notes](/notes/) + A template fills in the show, date, description, and a link that resumes playback. Browse them as a table with Obsidian Bases. +- [Use PodNotes on your phone](/mobile/) + PodNotes works on iOS and Android. Download episodes for offline listening, or play your own audio and video files. +- [Transcribe episodes](/transcripts/) + Turn an episode into a transcript note with your own API key, and label who speaks if you want. +- [Automate with the API](/api/) + Use the commands, the JavaScript API, and QuickAdd to capture and write notes your way. -### Browse your library with Bases +## Start in three steps -Because episode notes carry structured metadata, you can query them with Obsidian Bases. +1. **Install PodNotes.** Find it under **Browse** in **Settings → Community plugins**, then select **Install** and **Enable**. +2. **Add a podcast.** In **Settings → PodNotes**, search for a show or paste its RSS feed URL, then select **Add**. +3. **Capture a moment.** Run **PodNotes: Show player** and select an episode to play it. In a note, run **PodNotes: Capture Timestamp**. -![Bases view of episode notes](/resources/bases_view.png) +[Getting started](/getting-started/) walks through each step. To install a beta version or install by hand, see [Other ways to install](/getting-started/#other-ways-to-install). -### Podcast search +## Free and open source -![Podcast Search](/resources/podcast_search.png) +PodNotes is MIT licensed, and your notes are Markdown files in your vault. [Read the FAQ](/faq/), [learn what syncs between devices](/sync/), or [report a problem on GitHub](https://github.com/chhoumann/PodNotes/issues). diff --git a/docs/src/content/docs/local_files.md b/docs/src/content/docs/local_files.md index d42df37e..77bc11af 100644 --- a/docs/src/content/docs/local_files.md +++ b/docs/src/content/docs/local_files.md @@ -1,14 +1,56 @@ --- -title: Local files +title: Downloads and local files slug: local_files +description: Download episodes to listen offline, play audio and video files from your vault, and remove them again. --- -PodNotes supports local media files. You can right-click any audio or video file and click `Play with PodNotes`. +Download episodes to listen offline, and play audio and video files from your vault in the PodNotes player. -In the podcast grid, you will see a playlist with a folder icon. This is a playlist of every episode you have available offline: both local media files you have played with `Play with PodNotes` and episodes you have downloaded. Removing a downloaded file also removes it from this playlist. +## Download an episode -`Remove from Local Files` removes a local media file from the playlist and keeps the file in your vault. `Remove file` moves a downloaded episode's file to the trash. If PodNotes cannot confirm that it downloaded the file itself, which is the case for downloads made with an older PodNotes version, it asks first. `Keep file` removes the episode from the playlist and leaves the file where it is. `Remove file` and `Delete file` both follow Obsidian's **Files and links > Deleted files** setting, so the file moves to the system trash or the vault's `.trash` folder, or is deleted permanently. +To download the episode in the player: -PodNotes will attempt to treat all local files as podcast episodes. This means all standard features are available, such as timestamps, note creation, and persisting playback time. Any exceptions will be added here. +1. Run **PodNotes: Download Playing Episode**. -Local files and downloaded episodes are played through Obsidian's native media resource path (the same mechanism Obsidian uses for embedded media), so they work on mobile (iOS and Android) as well as desktop. +To download any other episode: + +1. Right-click the episode, or select its three-dot **More options** button. +2. Select **Download**. + +A notice shows the progress. PodNotes saves the file in your vault at **Episode download path**, `PodNotes/{{podcast}}/{{title}}` by default, and adds the file extension. +The episode then appears in the **Local Files** playlist and plays from the file. + +Keep `{{title}}` in **Episode download path**. Without it, two episodes can resolve to the same file, and the second download fails. [Template tags](/templates/#file-path-tags) lists the tags a path accepts. + +Offline playback needs the file on the device you listen on. If you sync your vault, see [Downloads on another device](/sync/#downloads-on-another-device). + +## Play your own audio and video + +1. In the file explorer, right-click an audio or video file. On a phone, long-press it. +2. Select **Play with PodNotes**. + +MP4 and WebM files can hold audio or video, so their menu offers **Play as audio with PodNotes** and **Play as video with PodNotes** instead. + +PodNotes plays MP3, M4A, AAC, OGG, WAV, FLAC, WMA, AMR, MP4, M4V, MOV, WebM, and OGV files. Whether a file plays also depends on the codecs your device supports. + +## Find your files in Local Files + +The **Local Files** playlist in the podcast grid lists every episode you have offline. It holds your downloads and the files you played with **Play with PodNotes**. + +## Remove a download or a local file + +1. Right-click the episode or select its **More options** button. +2. Select **Remove file** for a download, or **Remove from Local Files** for your own file. + +**Remove from Local Files** keeps your file in the vault. **Remove file** removes the episode from **Local Files** and moves its file to the trash. +Where a trashed file goes depends on Obsidian's **Deleted files** setting in **Settings → Files and links**. If that setting deletes files permanently, so does PodNotes. + +If PodNotes cannot confirm that it downloaded a file, as with downloads from older versions, it asks first. Select **Keep file** to leave the file in your vault, or **Delete file** to move it to the trash. Either way, the episode leaves **Local Files**. + +Your notes and timestamp links stay. Links to a download stream the episode from its feed again while the feed still lists it. Links to your own file stop working until you select **Play with PodNotes** on the file again. See [What happens to notes and links when I remove a download?](/faq/#what-happens-to-notes-and-links-when-i-remove-a-download) + +## What works with local files + +A local file works like a podcast episode. You can capture timestamps, create an episode note, and resume where you stopped. Transcription works for local audio but not for video. +PodNotes uses `local file` as the podcast name, so `{{podcast}}` in a note path resolves to `local file`. +A timestamp link to a local file plays only on a device where the file is in your vault and in **Local Files**. diff --git a/docs/src/content/docs/mobile.md b/docs/src/content/docs/mobile.md index 799b8242..5035408f 100644 --- a/docs/src/content/docs/mobile.md +++ b/docs/src/content/docs/mobile.md @@ -1,71 +1,66 @@ --- -title: Use PodNotes on mobile +title: Use PodNotes on your phone slug: mobile -description: Open the player, capture timestamps from the mobile toolbar or headset, and download episodes on a phone. +description: Open the player, capture timestamps from the editor toolbar or a headset, and listen offline on iOS and Android. --- -## Enable PodNotes and open the player +Listen and capture timestamps on your phone. You open the player yourself, and besides the command palette, you can capture from the editor toolbar or a headset. +To install PodNotes, follow [Install PodNotes](/getting-started/#install-podnotes). The steps are the same on a phone. -1. Open Obsidian settings and go to **Community plugins**. -2. If restricted mode is enabled, turn it off. -3. Browse for **PodNotes**, install it, and enable it. -4. Open the command palette and run **PodNotes: Show player**. +## Open the player -The player appears. The **Show PodNotes** podcast action in the mobile ribbon opens the same view. -On mobile, the player does not open at startup. PodNotes still loads its saved data when Obsidian starts. +On a phone, the player stays closed when Obsidian starts. -To add and play your first podcast, follow [Getting started](/getting-started/#add-a-podcast). -If **Create episode note** is missing, load an episode first. A paused episode is sufficient. +1. Pull down on a note to open the command palette. +2. Run **PodNotes: Show player**. + +You can also select **Show PodNotes** in the menu of the navigation bar at the bottom of the screen. The player opens in the right sidebar. To return to your note, select outside the sidebar. + +![The PodNotes player in Obsidian's right sidebar on a phone, with episode artwork, title, progress, and skip buttons](/screenshots/mobile-player.webp) + +To add your first podcast, see [Add and play podcasts](/podcasts/). Most commands appear only after you load an episode. See [Commands](/commands/). ## Add a capture button to the editor toolbar -1. In Obsidian settings, open **Interface** and choose **Configure mobile toolbar**. -2. Add **PodNotes: Capture Timestamp** to the toolbar. -3. Load an episode in PodNotes. -4. Open a Markdown note in edit mode and place the cursor where you want the timestamp. -5. Tap the capture button in the editor toolbar. +### Set up the button once -The timestamp appears at the cursor. New installs use a clickable episode link. -If your existing format produces plain text, set **Capture timestamp format** to `- {{linktime}} ` in PodNotes settings. -The final space separates the link from the text you type next. +1. Open a note in editing view. Obsidian lists the capture command only while a note is open in an editor. +2. In **Settings → Interface**, select **Configure mobile toolbar**. +3. Select **Add a command...** under **Manage toolbar options**. +4. Find and select **PodNotes: Capture Timestamp**. Its button shows a clock. -For recent playback ranges, use **PodNotes: Capture Last 10 Seconds** or **PodNotes: Capture Last 20 Seconds**. -These commands need a loaded episode and a non-empty timestamp format. -See [Timestamps](/timestamps/) for formats and the **Timestamp offset (s)** setting. +To add a range button, play an episode in PodNotes first. You can pause it. Then repeat steps 1 to 4 with **PodNotes: Capture Last 10 Seconds** or **PodNotes: Capture Last 20 Seconds**. Obsidian lists them only while [the range commands are available](/commands/#capture-last-10-seconds--capture-last-20-seconds). -## Capture with a headset +### Capture from the toolbar -1. Load an episode in PodNotes. -2. Send your headset's previous-track action. +1. In PodNotes, play an episode. +2. Select outside the sidebar to return to your note. +3. In editing view, place the cursor where the timestamp goes. +4. In the editor toolbar, select the clock button. -When the device delivers the Media Session `previoustrack` action, PodNotes captures a timestamp. -It inserts the timestamp into the active editor. Without an active editor, it appends to the current episode note and creates that note if needed. -The fallback requires non-empty **Note creation file path** and **Note creation template** settings. +![A note on a phone with three captured timestamp links and the editor toolbar, whose first button is the clock that captures a timestamp](/screenshots/mobile-toolbar.webp) -Headset gestures vary by device. Ordinary play and pause events do not capture timestamps. -PodNotes registers no dedicated Media Session seek-forward or seek-backward handlers. +PodNotes inserts a timestamp link at the cursor, or shows a notice when no episode is loaded. If your timestamps come out as plain text, see [Make timestamps clickable](/timestamps/#make-timestamps-clickable). -## Download an episode +## Capture with a headset + +While an episode plays, press the headset button that sends the previous-track action, often a triple press. This works on a computer too. -1. Load the episode in PodNotes. -2. Run **PodNotes: Download Playing Episode**. -3. Wait for the download to finish before going offline. +- If a note is open in an editor, the timestamp goes in at the cursor. +- If no note is open in an editor, the timestamp goes at the end of the episode's note. PodNotes creates that note if needed, as long as **Note creation file path** and **Note creation template** are set. + +## Download an episode -The file is saved inside your vault at **Episode download path** and appears in **Local Files**. -You can also tap an episode's **More options** button and choose **Download**. -Offline playback needs the saved file on this device. See [Downloads on another device](/sync/#downloads-on-another-device). +Download episodes while you are online, and wait for the notice that says each download succeeded. On a phone, the episode menu opens from the three-dot **More options** button. See [Download an episode](/local_files/#download-an-episode). -To play your own audio or video, long-press a vault media file and choose **Play with PodNotes**. -For MP4 and WebM files, choose **Play as audio with PodNotes** or **Play as video with PodNotes**. +## Play your own audio and video -## Known limits and verified devices +Long-press a media file in the file explorer, where a computer uses a right-click. See [Play your own audio and video](/local_files/#play-your-own-audio-and-video). -Toolbar capture, local MP3 playback, adding a feed, dormant startup, and a completed download were verified on an Android 14 Pixel 6 emulator. -That check used Obsidian 1.14.4 and PodNotes 2.21.1. -The evidence is recorded on issues [#89](https://github.com/chhoumann/PodNotes/issues/89#issuecomment-6105824251), [#100](https://github.com/chhoumann/PodNotes/issues/100#issuecomment-6105823837), [#109](https://github.com/chhoumann/PodNotes/issues/109#issuecomment-6105824406), [#110](https://github.com/chhoumann/PodNotes/issues/110#issuecomment-6105823950), and [#113](https://github.com/chhoumann/PodNotes/issues/113#issuecomment-6105824122). +## Limits on phones -These checks do not verify iOS, iPadOS, every physical Android phone, or headset delivery. -Media playback also depends on the device's supported codecs. -Values stored in Obsidian's secret storage do not sync through plugin data. Unprotected private-feed URLs can sync. -Plugin data sync does not guarantee that a media file has arrived. -See [Sync and devices](/sync/) for those limits. +- PodNotes does not merge changes from two devices, so use one device at a time. Before you switch, follow [Use PodNotes on two devices](/sync/#use-podnotes-on-two-devices). +- A file that plays on a computer can fail on a phone that lacks its codec. +- Which headset press sends previous track depends on the headset and the phone, and some headsets send none. +- API keys stay on each device, so add them on your phone too. A protected private feed works on one device at a time. See [API keys and private feeds on each device](/sync/#api-keys-and-private-feeds-on-each-device). +- A download made on another device plays only after its media file syncs to this one. See [Downloads on another device](/sync/#downloads-on-another-device). diff --git a/docs/src/content/docs/notes.md b/docs/src/content/docs/notes.md new file mode 100644 index 00000000..b0a05813 --- /dev/null +++ b/docs/src/content/docs/notes.md @@ -0,0 +1,118 @@ +--- +title: Create episode notes +slug: notes +description: Create a note for an episode or a whole podcast, choose where notes go and what they hold, and browse them with Bases. +--- + +Create a note for the episode you are listening to, with the episode's details and a link that resumes playback, and keep your timestamps in it. + +## Create a note for the current episode + +1. Play an episode in PodNotes. You can pause it. +2. Run **PodNotes: Create episode note** from the command palette (`Ctrl+P`, or `Command+P` on macOS). + +PodNotes creates the note and opens it. With the default settings, the note for the Acquired episode "The Home Depot" is `PodNotes/Acquired/The Home Depot.md`. When the note exists, the command opens it instead. To add timestamps while you listen, see [Capture timestamps](/timestamps/). + +## Create a note for another episode + +1. In an episode list, right-click the episode, or select its three-dot **More options** button. +2. Select **Create Note**. Once the note exists, the item reads **Open Note**. + +The [episode menu reference](/podcasts/#episode-menu-reference) lists the other items. + +## Create a feed note for a podcast + +A feed note describes a whole podcast, with its artwork, author, description, website, and feed URL. + +1. Run **PodNotes: Create podcast feed note** from the command palette. +2. Select the podcast. If an episode is loaded, its podcast is first in the list. + +PodNotes creates the note and opens it, or opens the note if it exists. With the default settings, the feed note for Acquired is `PodNotes/Podcasts/Acquired.md`. The episode menu's **Create feed note** does the same. + +The default episode note template links each episode note to its feed note in the `podcast` property, such as `[[PodNotes/Podcasts/Acquired|Acquired]]`, even when you create the feed note later. + +## Change where notes go + +1. Open **Settings → PodNotes**. +2. Under **Episode notes**, edit **Note creation file path**. + +The default is `PodNotes/{{podcast}}/{{title}}.md`. To keep notes in a `Podcasts` folder instead: + +- `Podcasts/{{title}}.md` puts every note in that folder. Episodes with the same title from different podcasts then share one note. +- `Podcasts/{{podcast}}/{{title}}.md` adds a subfolder per podcast. +- `Podcasts/{{date:YYYY}}/{{podcast}} - {{title}}.md` adds a subfolder per publication year. + +After you edit the path, a preview shows the result for one of your episodes. [File path tags](/templates/#file-path-tags) lists every tag a path accepts. + +PodNotes looks for notes at the new path, so move your existing notes to match. Otherwise it can create a second note for an episode. + +Feed notes and downloads have their own paths: **Feed note file path** under **Feed notes**, and **Episode download path** under **Downloads**. + +## Change what notes contain + +**Note creation template** under **Episode notes** sets the content of each new episode note, and PodNotes fills in its `{{tag}}` placeholders. This example replaces the [default template](/templates/#default-episode-note-template). It adds the episode's duration, shows the artwork at 96 pixels wide, folds the description into a callout, and keeps the `status` and `rating` properties for [Bases](#browse-your-notes-with-bases). It leaves out the `# {{title}}` heading, the episode URL, and `favorite`. Obsidian still shows the file name as the note's title: + +```md +--- +type: podcastEpisode +podcast: "{{podcastlink}}" +date: {{date:YYYY-MM-DD}} +duration: "{{duration}}" +tags: + - podcastEpisode +status: +rating: +--- +![|96]({{artwork}}) + +[Resume in PodNotes]({{episodelink}}) + +> [!summary]- Episode description +{{description:> }} + +## Notes +``` + +A note made with this template looks like this: + +![An episode note made with this template, with its properties, artwork, a Resume in PodNotes link, and the description folded into a callout](/screenshots/episode-note.webp) + +**Resume in PodNotes** continues the episode from where you stopped. A changed template applies only to new notes. Feed notes use **Feed note template** under **Feed notes**. [Template tags](/templates/) lists every tag and the [default episode note template](/templates/#default-episode-note-template). + +## Browse your notes with Bases + +[Obsidian Bases](https://obsidian.md/help/bases) shows notes as a table that you can filter, sort, and group. The default episode note template sets `type: podcastEpisode`, `podcast`, `date`, `tags`, and `favorite: false`, and leaves `status` and `rating` for you to fill in. To list your episode notes grouped by status: + +1. In the note where you want the table, insert this code block: + + ````md + ```base + filters: + and: + - 'type == "podcastEpisode"' + views: + - type: table + name: Episodes + order: + - file.name + - note.podcast + - note.status + - note.rating + sort: + - property: note.date + direction: DESC + groupBy: + property: note.status + direction: ASC + ``` + ```` + +![A Bases table of five episode notes, grouped by status into finished, listening, and next](/screenshots/bases.webp) + +The same lines also work as the content of a `.base` file, such as `PodNotes/Episodes.base`. If your template has no `type` property, filter on a property or tag that it sets. + +## Limits of note creation + +- **PodNotes: Create episode note** needs both **Note creation file path** and **Note creation template**. With either one empty, the command is missing and the episode menu shows "Please set a note path and template in the settings." +- PodNotes finds an existing note by its path, or by a matching title in the same folder. After you rename or move a note, it can create a second note for the episode. +- After you change **Feed note file path**, new episode notes link to the new path. Existing episode notes keep their old links. diff --git a/docs/src/content/docs/playlists.md b/docs/src/content/docs/playlists.md new file mode 100644 index 00000000..ed78d357 --- /dev/null +++ b/docs/src/content/docs/playlists.md @@ -0,0 +1,70 @@ +--- +title: Use the queue and playlists +slug: playlists +description: Decide what plays next with the queue, and collect episodes in Favorites and your own playlists. +--- + +The queue decides what plays next. Playlists collect episodes you want to keep together. Both show up in the podcast grid. To add podcasts and play episodes, see [Add and play podcasts](/podcasts/). + +## Manage the queue + +The **Queue** list under the player shows what plays next. With the automatic queue on, the default, the top episode in the queue starts when an episode ends, and the episode you switch away from goes to the top of the queue. To fill it only by hand, [turn off the automatic queue](#turn-off-the-automatic-queue). + +To add an episode to the bottom of the queue: + +1. Right-click the episode, or select its three-dot **More options** button. +2. Select **Add to Queue**. + +### Reorder the queue + +1. In the player's **Queue** list, right-click an episode, or select its **More options** button. +2. Select **Move to top of queue**, **Move up in queue**, **Move down in queue**, or **Move to bottom of queue**. + +For a long queue, run **PodNotes: Reorder Queue**. It opens a list where you move each episode up, down, to the top, or to the bottom, and it needs two or more episodes in the queue. + +### Turn off the automatic queue + +To control the queue only by hand: + +1. In **Settings → PodNotes**, turn off **Keep a queue of episodes you switch away from**. + +PodNotes then stops adding the episode you leave, and when an episode ends, the next one in the queue does not start. With the setting off, the podcast grid hides **Queue** while the queue is empty. + +## Collect episodes in playlists + +The podcast grid also shows three built-in playlists: + +- **Favorites** holds the episodes you add with **Add to Favorites**. +- **Local Files** holds your downloads and your own media files. See [Downloads and local files](/local_files/). +- **Played** lists the episodes you finished or marked as played, including ones that have left their feed. + +To make your own playlist: + +1. Open **Settings → PodNotes**. +2. Under **Playlists**, select an icon in the icon dropdown. +3. In **Playlist name**, type a name, such as `Commute`. +4. Select **Add**. + +The playlist appears in the podcast grid. To add an episode to it: + +1. Right-click the episode, or select its **More options** button. +2. Select **Add to Commute**. + +Select the playlist in the podcast grid to see its episodes: + +![The Commute playlist with five episodes, below the search box and the played and refresh buttons](/screenshots/episode-list.webp) + +## Remove an episode from a list + +1. In the player's **Queue** list, in **Favorites**, or in a playlist, right-click the episode, or select its **More options** button. +2. Select **Remove from Queue**, **Remove from Favorites**, or **Remove from** followed by the playlist's name. + +In the **PodNotes: Reorder Queue** list, **Remove from queue** next to an episode does the same. + +## Delete a playlist + +1. Open **Settings → PodNotes**. +2. Under **Playlists**, select the playlist's **Delete playlist** button, the trash icon. +3. Within two seconds, select **Confirm deletion**, the check icon. + +The episodes stay in their podcasts and in your other lists. diff --git a/docs/src/content/docs/podcasts.md b/docs/src/content/docs/podcasts.md index 6615c9f1..3ee5fef4 100644 --- a/docs/src/content/docs/podcasts.md +++ b/docs/src/content/docs/podcasts.md @@ -1,107 +1,94 @@ --- -title: Podcasts +title: Add and play podcasts slug: podcasts +description: Add podcasts by name, RSS feed URL, or Apple Podcasts link, then browse and play their episodes. --- -## Adding podcasts -You can add podcasts by searching for them in the podcast search box in settings. -This searches the iTunes podcast repository. +Add podcasts to PodNotes, find their episodes, and play them. -It is also possible to add custom podcast feeds by pasting the feed URL in the search box. -PodNotes will try to parse the feed, and if successful, will prompt you to add it to your saved feeds. +## Add a podcast -Each saved podcast gets a tile in the podcast grid, which shows its artwork or, when it has none, its initials. +1. Open **Settings → PodNotes**. +2. Under **Search Podcasts**, type a podcast's name, such as `the daily`. Results appear as you type. +3. Next to the podcast, select **Add**. +Search uses the Apple Podcasts directory. To add a show by its address instead: -## Playlists -PodNotes can create playlists, which are lists of episodes. -For each playlist, you can specify a name and an icon. +1. Copy its RSS feed URL or its Apple Podcasts show link. An RSS feed URL looks like `https://feeds.npr.org/510289/podcast.xml`. +2. Paste it into **Search Podcasts**. +3. Select **Add** on the one result that appears. -The playlists are shown in the episode grid, represented by their icons. +See [Which podcasts can I add?](/faq/#which-podcasts-can-i-add) and [Can I use a private feed?](/faq/#can-i-use-a-private-feed) -By default, you will have a queue playlist and a favorites playlist. +## Remove a podcast -The queue plays from top to bottom: the episode at the top is played next. See [Reordering the queue](#reordering-the-queue) to resequence it. +1. Open **Settings → PodNotes**. +2. Under **Search Podcasts**, clear the search box. It lists the podcasts you follow. +3. Next to the podcast, select **Remove**. -PodNotes also shows a Played playlist in the episode grid. It is a virtual playlist that lists episodes marked as played across all podcasts. Episodes still available in your feeds can be played or managed like normal episodes. Older played-history entries that can no longer be found in current feeds remain visible so you can mark them as unplayed. +PodNotes keeps the podcast's episode notes, downloads, and playlist entries. -You can delete playlists by pressing the trash bin icon next to the playlist name. -The icon will change to a checkmark, which should be pressed within a short duration to confirm the deletion. +## Browse episodes -## Episode list -The episode list is a dynamic view, which will change depending on your selection. +1. Run **PodNotes: Show player**, or select **Show PodNotes** in the ribbon. +2. At the top of the PodNotes view, select **Podcast grid**, the first of the three buttons. +3. Select a podcast's artwork, or select a playlist. -If you have no podcast selected, the episode list will show the latest episodes of all your saved feeds. +![The podcast grid with the Queue, Favorites, Local Files, Played, and Commute playlists beside podcast artwork tiles](/screenshots/podcast-grid.webp) -If you have a podcast selected, the episode list will show all episodes of that podcast. +The view then lists that podcast's or playlist's episodes. **Queue** is the exception: when it holds episodes, it opens the player, which shows the **Queue** list. -And lastly, if you select a playlist, the episode list shows all episodes in that playlist. +To return to the last list you saw, select **Episode list**, the second button at the top of the view. If it shows a podcast or a playlist, select **Latest Episodes** at the top to see the newest episodes of every podcast. -### When a feed cannot be reached - -While **Cache podcast feeds** is on, PodNotes keeps the last copy of each saved podcast's feed on this device. -When a feed cannot be reached, for example offline, the episode list shows that copy, however old it is. -To remove the copies, select **Clear cache** under **Clear cached feeds** in PodNotes settings. - -### How many latest episodes are shown +Above each list: -The "Latest Episodes" list keeps a limited number of the most recent episodes -from each saved podcast. This same set is what the search box filters when no -podcast is selected, so episodes older than the limit will not appear in those -search results. +- **Search episodes** filters the list. +- **Refresh episodes** fetches new episodes in **Latest Episodes** and a podcast's list. In **Played**, it fetches the podcasts that have played episodes. In other playlists, it does nothing. +- **Hide played episodes** hides the episodes that PodNotes strikes through as played, and **Show played episodes** brings them back. The **Played** list always shows them. -You can change this with the **Latest episodes per podcast** setting in the -PodNotes settings tab. It defaults to 10 and can be raised up to 75. Raise it to -surface more of each feed's history in the Latest Episodes list and to search -further back; lower it for a shorter list. Selecting an individual podcast still -shows all of that podcast's episodes regardless of this setting, so the full -back catalogue of any one show always remains searchable from its own view. +The **Queue** list under the player has none of these controls. -## Context menu -You can right-click (desktop) or long-press (mobile) on an episode in the episode list to open the context menu. +**Latest Episodes** shows and searches the 10 newest episodes of each podcast. To show up to 75, raise **Latest episodes per podcast** in **Settings → PodNotes**. -The context menu will let you - -- Play the episode -- Mark the episode as played -- Download / remove the episode -- Add / remove the episode to favorites -- Add / remove the episode to queue -- Add / remove the episode to a playlist +### When a feed cannot be reached -When you open the context menu on an episode in the queue list (shown in the player), it also offers **Move to top / up / down / bottom of queue** so you can resequence without leaving the player. +When PodNotes tries to fetch a podcast's feed as you open the podcast or refresh its list, and the fetch fails, for example offline, PodNotes uses the first of these that has episodes: -## Reordering the queue -The queue plays from the top down, so its order is its play order. There are two ways to change it: +1. The podcast's downloaded episodes. +2. The episodes PodNotes loaded for the podcast since Obsidian started. +3. The cached copy of the feed, if **Cache podcast feeds** is on and the copy is younger than **Cache duration (hours)**. -- **From the player's queue list:** right-click (desktop) or long-press (mobile) a queued episode and choose **Move to top**, **Move up**, **Move down**, or **Move to bottom of queue**. -- **From the command palette:** run **PodNotes: Reorder Queue** to open a dedicated window listing the whole queue, where each episode has move-up / move-down / move-to-top / move-to-bottom buttons (and a remove button). This works the same on desktop and mobile and is the easiest way to resequence a long queue. +If none of them has episodes, the list is empty, and a notice asks you to check your connection and try again. -The new order is saved automatically and restored the next time you open Obsidian. +## Play an episode -Episodes in the queue are kept unique by title, so adding an episode that is already queued will not create a duplicate. +1. In an episode list, select an episode. -## Turning off the queue -By default the queue fills itself: when you switch to a new episode, the one you were listening to is kept at the top of the queue, and playback continues with the next queued episode when the current one ends. +The player opens and resumes where you stopped last time. To return to the player from a list, select **Player**, the third button at the top of the view. -If you would rather manage the queue yourself, turn off **Keep a queue of episodes you switch away from** in PodNotes settings. With it off: +![The player with episode artwork, the episode title, the progress bar, and the skip buttons](/screenshots/player.webp) -- episodes are no longer added to the queue automatically when you switch, and -- playback no longer advances to the next queued episode on its own. +- Select the artwork to play or pause. Select a point on the progress bar to jump there. The time on the left is how far you are, and the time on the right is how much is left. +- The skip buttons jump 15 seconds. **Skip backward length (s)** and **Skip forward length (s)** change that. +- The speed slider, below the volume slider, goes from 0.5x to 4x. Each time Obsidian starts, the speed resets to **Default Playback Rate**. +- **Chapters** lists the episode's chapters when it has them. Select a chapter to jump to it. -You can still add, remove, and reorder episodes in the queue manually from the episode context menu. While the queue is empty in this mode, its tile in the podcast grid and its list in the player are hidden. +## Queue and playlists -## Player -An episode keeps playing while you browse podcasts, change settings, or close the PodNotes view. -When Obsidian starts, the player shows the last episode paused at its saved position. Nothing plays until you press play. -If a sync brings newer progress or another episode before then, the player shows that instead. -A position you already moved that episode to stays. -A video plays in the main Obsidian window. When the PodNotes view is in a separate window, it shows the episode artwork. +The queue decides what plays next, and playlists collect episodes you want to keep together. [Use the queue and playlists](/playlists/) shows how to add and remove episodes, reorder the queue, and make your own playlists. -Clicking on the episode image will toggle episode playback. +## Episode menu reference -The player also features a progress bar, which you can use to seek to a specific time in the episode. -You can also press the buttons on the player to skip backwards or forwards in the episode. +To open an episode's menu, right-click the episode or select its **More options** button. The menu shows these items in this order: -Episode playback rate can be controlled by using the playback rate slider. -This is, by default, what you have set in the settings. +| Item | What it does | +| --- | --- | +| **Play** | Plays the episode. | +| **Mark as Played** or **Mark as Unplayed** | Changes whether the episode counts as played. | +| **Download**, **Remove file**, or **Remove from Local Files** | Downloads the episode. **Remove file** deletes a download to the trash, and **Remove from Local Files** removes your own file from the list and keeps the file. See [Downloads and local files](/local_files/). | +| **Create Note** or **Open Note** | Creates or opens the episode note. See [Create episode notes](/notes/). | +| **Create feed note** or **Open feed note** | Creates or opens the note for the whole podcast. | +| **Add to Favorites** or **Remove from Favorites** | Adds the episode to **Favorites** or removes it. | +| **Add to Queue** or **Remove from Queue** | Adds the episode to the bottom of the queue or removes it. | +| **Move to top of queue**, **Move up in queue**, **Move down in queue**, **Move to bottom of queue** | Moves the episode within the queue. Shown only in the player's **Queue** list when the queue has two or more episodes, and only the directions the episode can move in. | +| **Add to** *playlist* or **Remove from** *playlist* | Adds the episode to that playlist or removes it. One item per playlist you made. | diff --git a/docs/src/content/docs/settings.md b/docs/src/content/docs/settings.md new file mode 100644 index 00000000..c2aba541 --- /dev/null +++ b/docs/src/content/docs/settings.md @@ -0,0 +1,89 @@ +--- +title: Settings +slug: settings +description: Every PodNotes setting, in the order the settings tab shows it, with its default and what it does. +--- + +Look up any PodNotes setting, its default, and what it does. The settings live in **Settings → PodNotes**, and the sections below follow the tab from top to bottom. + +## Search Podcasts + +The search box at the top finds podcasts by name, RSS feed URL, or Apple Podcasts link. With the box empty, it lists the podcasts you follow, each with a **Remove** button. See [Add a podcast](/podcasts/#add-a-podcast). + +## Playlists + +The section lists **Queue**, **Favorites**, and your own playlists with their episode counts. Each playlist you made has a **Delete playlist** button, and the controls below the list add a playlist. See [Collect episodes in playlists](/playlists/#collect-episodes-in-playlists). + +## Playback + +These settings sit below **Playlists** in the tab, with no heading of their own. + +| Setting | Default | What it does | +| --- | --- | --- | +| **Keep a queue of episodes you switch away from** | On | Puts the episode you switch away from at the top of the queue, and starts the next queued episode when one ends. See [Turn off the automatic queue](/playlists/#turn-off-the-automatic-queue). | +| **Latest episodes per podcast** | 10 | Sets how many of each podcast's newest episodes **Latest Episodes** shows and searches, from 1 to 75. | +| **Default Playback Rate** | 1x | Sets the speed the player uses when Obsidian starts, from 0.5x to 4x. **PodNotes: Reset playback rate** returns to it. | +| **Default Volume** | 1 (100%) | Sets the volume when PodNotes starts, from 0 to 1. Changing this setting does not change the current volume. The player's volume slider updates this saved default too. | +| **Skip backward length (s)** | 15 | Sets how many seconds the skip backward button and command move back. | +| **Skip forward length (s)** | 15 | Sets how many seconds the skip forward button and command move forward. | + +## Episode notes + +| Setting | Default | What it does | +| --- | --- | --- | +| **Capture timestamp format** | `- {{linktime}}` with a space at the end | Sets the text each capture inserts. A preview below the field shows the result for the loaded episode. See [Capture timestamps](/timestamps/). | +| **Timestamp offset (s)** | 0 | Subtracts this many seconds from each capture, to make up for reaction time. | +| **Note creation file path** | `PodNotes/{{podcast}}/{{title}}.md` | Sets where PodNotes creates episode notes. See [Create episode notes](/notes/). | +| **Note creation template** | [The default episode note template](/templates/#default-episode-note-template) | Sets the content of a new episode note. | + +## Feed notes + +[Feed note tags](/templates/#feed-note-tags) lists the tags a feed note path and template accept. + +| Setting | Default | What it does | +| --- | --- | --- | +| **Feed note file path** | `PodNotes/Podcasts/{{podcast}}.md` | Sets where **PodNotes: Create podcast feed note** creates the note for a podcast. See [Create episode notes](/notes/). | +| **Feed note template** | [The default feed note template](/templates/#feed-note-tags) | Sets the content of a new feed note. | + +## Downloads + +| Setting | Default | What it does | +| --- | --- | --- | +| **Episode download path** | `PodNotes/{{podcast}}/{{title}}` | Sets where downloaded episodes go. PodNotes adds the file extension. A warning appears when the path has no `{{title}}`, because episodes would share one file. See [Download an episode](/local_files/#download-an-episode). | + +## Performance + +| Setting | Default | What it does | +| --- | --- | --- | +| **Cache podcast feeds** | On | Keeps recently fetched feeds on this device, outside your vault, so PodNotes starts faster. | +| **Cache duration (hours)** | 6 | Sets how long PodNotes reuses a cached feed before it fetches the feed again, from 1 to 24. | +| **Clear cached feeds** | None | **Clear cache** deletes the cached feeds. PodNotes fetches them again as needed. | + +## Import/Export + +| Setting | Default | What it does | +| --- | --- | --- | +| **Import OPML** | None | **Import** opens a file picker. PodNotes adds the podcasts in the `.opml` file you choose. See [Import podcasts from OPML](/import_export/#import-podcasts-from-opml). | +| **Export OPML** | `PodNotes_Export.opml` | **Export** saves the podcasts you follow to an OPML file with this name in your vault. See [Export podcasts to OPML](/import_export/#export-podcasts-to-opml). | + +## Preferences & templates + +| Setting | Default | What it does | +| --- | --- | --- | +| **Import settings** | None | **Import** opens a file picker. After you confirm, PodNotes applies the preferences, templates, feeds, and playlists in the file. Playback progress and downloads stay as they are. Protected private feeds need their private URLs again. See [Import your settings](/import_export/#import-your-settings). | +| **Include API keys** | Off | Adds the OpenAI and Deepgram keys on this device to the next export, in plain text. The export file sits in your vault, where sync and other plugins can read it. The toggle turns off again each time you open the tab. | +| **Export settings** | `PodNotes_Settings.json` | **Export** saves your preferences, templates, feeds, and playlists to a file with this name in your vault. It does not overwrite an existing file. See [Export your settings](/import_export/#export-your-settings). | + +## Transcripts + +The last three settings appear only when **Speaker diarization** is on. **Deepgram API key** also needs **Diarization provider** set to **Deepgram**. + +| Setting | Default | What it does | +| --- | --- | --- | +| **OpenAI API key** | None | Selects the Obsidian secret that holds your OpenAI API key, or creates one. See [Set up an OpenAI key](/transcripts/#set-up-an-openai-key). | +| **Transcript file path** | `transcripts/{{podcast}}/{{title}}.md` | Sets where PodNotes saves transcripts. Keep `.md` at the end, because PodNotes does not add it. | +| **Transcript template** | [The default transcript template](/templates/#transcript-and-timestamp-tags) | Sets the content of a new transcript note. | +| **Speaker diarization** | Off | Labels transcript segments by speaker. See [Add speaker labels](/transcripts/#add-speaker-labels). | +| **Diarization provider** | **OpenAI (gpt-4o-transcribe-diarize)** | Chooses OpenAI, which uses your OpenAI API key, or **Deepgram**, which needs its own key and keeps speaker labels consistent across the episode. | +| **Deepgram API key** | None | Selects the Obsidian secret that holds your Deepgram API key, or creates one. | +| **Speaker label format** | `**{{speaker}}:**` with a space at the end | Sets the text before each speaker's turn. `{{speaker}}` becomes the speaker label. See [Change the speaker label format](/transcripts/#change-the-speaker-label-format). | diff --git a/docs/src/content/docs/sync.md b/docs/src/content/docs/sync.md index 5fb8df06..534c559a 100644 --- a/docs/src/content/docs/sync.md +++ b/docs/src/content/docs/sync.md @@ -1,108 +1,129 @@ --- -title: Sync and devices +title: Sync between devices slug: sync -description: What PodNotes saves, what vault sync can transfer, and the limits of downloads and changes on several devices. +description: What PodNotes saves in your vault, how to use it on two devices, and what vault sync does not carry. --- -PodNotes saves its state in your vault. It does not run a sync service. -Using the same library on several devices depends on the files your vault sync service transfers. +Keep one PodNotes library on several devices by syncing your vault. +PodNotes has no sync service of its own, so your vault sync service, such as Obsidian Sync, decides which files reach each device. -## Plugin data and notes +## What syncs -PodNotes stores preferences and library state in `.obsidian/plugins/podnotes/data.json`. -If you use a different Obsidian configuration folder, the path starts with that folder instead of `.obsidian`. +| What | How it syncs | +| --- | --- | +| Your podcasts, progress, queue, playlists, templates, and settings | In `data.json`, if your sync service includes plugin data | +| Episode notes, feed notes, and transcripts | As Markdown files | +| Downloads | As media files, if your sync service includes them | +| API keys and protected private-feed URLs | Not at all. They stay on each device. | +| The current playback speed | Not at all. PodNotes does not save it. Only **Default Playback Rate** syncs. | -The file contains the following data: +For your library to sync, your sync service must include PodNotes' plugin folder, `.obsidian/plugins/podnotes/`. +With Obsidian Sync, turn on **Active community plugin list** and **Installed community plugins** under **Vault configuration sync** in **Settings → Sync**. The second one syncs each plugin's files and settings, which hold your PodNotes library. Obsidian keeps these settings per device, so change them on each device. [Sync settings in Obsidian Help](https://obsidian.md/help/sync/settings) describes them. -- Saved podcast subscriptions. -- Played status and saved playback positions. -- The current episode, queue, favorites, and custom playlists. -- Registrations and vault paths for downloaded episodes and local files. -- Note, timestamp, transcript, and download templates. -- Playback preferences, skip lengths, and feed cache preferences. -- References to API keys and protected private-feed URLs, without their secret values. +## Use PodNotes on two devices -Episode notes, feed notes, and transcript notes are separate Markdown files. -Audio and video downloads are separate media files. A sync service can transfer these files independently of `data.json`. +Use one device at a time, and let PodNotes load the synced library before you use it: -The current episode's temporary playback speed and paused state are not saved as a live session. -The **Default Playback Rate** preference is saved. Saved progress provides a resume position, not control of another device's player. +1. On the device you are leaving, pause playback. +2. Wait until your sync service finishes uploading. +3. On the device you are switching to, open Obsidian and wait until sync finishes. +4. Restart Obsidian, or turn PodNotes off and on again in **Settings → Community plugins**. + +Step 4 matters because PodNotes reads `data.json` only when it loads, which is usually as Obsidian starts and before sync finishes. +Then use PodNotes on this device until you switch back. ## Changes on two devices -PodNotes saves a snapshot of its local state. It does not merge subscriptions, playlists, preferences, or progress changed on another device. -An open instance does not automatically reload a synced `data.json`. +PodNotes saves its whole library each time something changes. +It does not merge changes made on another device, and a running PodNotes does not reload a `data.json` that sync replaces. + +For example, you add a podcast on your laptop, then listen on your phone before the phone has loaded that change. +When the phone saves your progress, its copy of the library replaces the laptop's copy, and the new podcast is gone. +Which copy survives depends on sync timing and on how your sync service handles conflicts. + +The same loss can happen when Obsidian starts before sync finishes, including with sync services that run inside Obsidian. +To avoid it, follow [Use PodNotes on two devices](#use-podnotes-on-two-devices). + +## What PodNotes saves + +PodNotes keeps your library in `.obsidian/plugins/podnotes/data.json`. +If your vault uses a different configuration folder, the path starts with that folder instead of `.obsidian`. -For example, one device can add a podcast while another updates its playback progress. -The second device's next save can replace the first device's subscription change with its older library snapshot. -The outcome depends on sync timing and your sync service's conflict handling. +`data.json` holds: -Using one device at a time reduces this risk. Allow sync to finish before loading PodNotes on the second device. -If sync is still running when Obsidian starts, PodNotes can load an older snapshot before the synced file arrives. -This includes sync services that run inside Obsidian. On mobile, data loads even while the player stays closed. -Keep PodNotes disabled on the second device while sync finishes, then enable it to load the transferred data. -Syncing a file into an already open session does not update that session's in-memory library. +- Your saved podcasts. +- Played status and the saved position in each episode. +- The current episode, the queue, favorites, and your playlists. +- The list of downloaded episodes and local files, with their paths in the vault. +- Your note, timestamp, transcript, and download templates. +- Your playback, skip, and feed cache preferences. +- References to your API keys and protected private-feed URLs, but not the keys or URLs themselves. -## Secrets on each device +Episode notes, feed notes, transcripts, and downloads are separate files in your vault. Your sync service transfers them separately from `data.json`. -OpenAI and Deepgram API keys live in Obsidian's device-local secret storage. -The selected secret reference can sync in `data.json`, but the key value does not. -Each device needs its own available key for the chosen transcription provider. +PodNotes saves where you stopped, not a live session. +Another device resumes from the saved position, but it cannot follow or control the player on this device. -Private feed URLs with embedded credentials or recognized secret query parameters also use device-local secret storage when storage succeeds. -The synced subscription then contains a placeholder and a secret reference. -Without the corresponding secret, the device cannot fetch that feed or resolve its private-feed timestamp links. -PodNotes' notice asks you to remove the feed and add it again with its private URL on that device. -Re-adding creates a new secret reference. When that reference reaches another device, the other device cannot resolve the feed with its old secret. -Protected private-feed subscriptions therefore do not work across devices through plugin data sync alone. +## API keys and private feeds on each device -If secret storage fails, the real private URL stays in `data.json` and can sync. -A failed migration shows a persistent notice asking you to restart PodNotes to retry. +PodNotes stores your OpenAI and Deepgram API keys in Obsidian's secret storage on each device. +Only a reference to the key syncs in `data.json`. +Add the key on every device where you transcribe. See [Transcribe episodes](/transcripts/). -Private tokens embedded in a URL path are not detected and remain in `data.json`. -OPML exports contain real feed URLs, including private URLs. +PodNotes also moves a private feed URL into secret storage when the URL contains a user name, a password, or a query parameter such as `auth`, `token`, or `key`. +The synced subscription then holds a placeholder. +On another device, PodNotes cannot fetch that feed or open its timestamp links. When it tries to fetch the feed, it asks you to remove the feed and add it again with its private URL. +Adding it again stores a new reference. When that reference syncs back, the first device loses access instead. +A protected private feed works on one device at a time. + +Limits: + +- If PodNotes cannot save the URL to secret storage, the URL stays in `data.json` and syncs. When this happens as PodNotes moves an existing URL at startup, a notice asks you to restart PodNotes to try again. When it happens as you add a feed, no notice appears. +- PodNotes does not detect a token in the URL path, such as `https://example.com/feeds/a1b2c3/rss`. That URL stays in `data.json`, syncs, and works on every device. +- OPML exports contain the real feed URLs, private ones included. ## Downloads on another device -**Episode download path** determines where PodNotes writes media inside the vault. -The default is `PodNotes/{{podcast}}/{{title}}`, with the media extension added automatically. +PodNotes saves downloads at **Episode download path** in your vault. +The default is `PodNotes/{{podcast}}/{{title}}`, and PodNotes adds the file extension. -Offline playback needs both the media file at its registered vault path and the episode's registration in `data.json`. -A synced registration can appear before the file arrives. PodNotes does not transfer the file itself. -A matching download registration takes priority over streaming. -If its file has not arrived, the episode does not play on that device. PodNotes does not stream it instead. +To play a download offline on another device, that device needs both the file and the download's entry in `data.json`. +PodNotes does not transfer the file. Your sync service does. -The **Local Files** playlist includes manually added media and downloaded episodes. -Copying a local file to another device does not register it there unless its PodNotes data also arrives. -**Play with PodNotes** can register a local file on that device. +The entry can arrive before the file. +While the entry exists, PodNotes plays the downloaded file instead of streaming. +If the file has not arrived, the episode does not play on that device, and PodNotes does not stream it instead. Wait until the file arrives. -Sync exclusions, attachment size limits, available storage, and file type support still apply. +**Local Files** lists your downloads and the media files you added yourself. +A media file copied to another device does not appear there until its PodNotes entry also arrives. +To add it on that device, select **Play with PodNotes** in the file's menu. See [Downloads and local files](/local_files/). -## Removal and existing links +Your sync service's file type support, size limits, and exclusions still apply, and so does the free storage on each device. -**Remove from Local Files** removes a manually added file's registration and keeps the file. -**Remove file** on a downloaded episode removes the registration and trashes a file that PodNotes knows it downloaded. +## Removed downloads and existing links -For an older download or another file without that ownership record, PodNotes asks for confirmation. -**Keep file** leaves the file in place but removes the registration. **Delete file** trashes the file. -The trash action follows Obsidian's **Files and links** setting for **Deleted files**, including permanent deletion if you selected it. -Your sync service determines whether that deletion reaches other devices. +To remove a download or a media file you added yourself, see [Remove a download or a local file](/local_files/#remove-a-download-or-a-local-file). +Your sync service decides whether the removal reaches other devices. -Removal does not delete episode notes or timestamp text. -Podcast links use the feed URL and episode title rather than the downloaded file path, so they can still resolve to online media. -They need an available feed and a matching episode title. Word reordering can still match when the normalized word set is identical and unique. -An episode removed from its feed or retitled with different words may no longer resolve. +Removing a download never deletes episode notes or timestamps. +Timestamp links point at the feed URL and the episode title, not at the file. +They play the episode online again as long as the feed still lists an episode with that title. +A title with the same words in a different order still matches, if no other episode has those words. +A link stops working when the episode leaves the feed or its title changes words. -Local-file links need a usable local registration and an existing media file. -Protected private-feed links also need the saved feed's secret on the current device. -Older links can contain the original private URL and are not rewritten by the settings migration. +A link to a local file needs the file and its PodNotes entry on the current device. +A link to a protected private feed needs that feed's URL in secret storage on the current device. +Links captured before PodNotes protected a private feed contain the original private URL, and PodNotes does not rewrite them. ## Unreadable plugin data -If `data.json` exists but Obsidian cannot read or parse it, PodNotes leaves the file unchanged. -It starts with default settings and shows a persistent notice that nothing is saved. -The notice includes the actual file path. +If `data.json` exists but PodNotes cannot read it, PodNotes leaves the file unchanged and starts with default settings. +PodNotes saves nothing until you fix the file, and a notice that stays on screen names the file's path. Changes you make in the meantime are lost. + +To recover: + +1. Replace `data.json` with a readable backup to keep your library, or delete it to start a new library. +2. Restart Obsidian. -Saving remains disabled until you restore or delete that file and restart Obsidian. -Restoring a readable backup preserves the previous library. Deleting the file starts a new library. -Changes made while saving is disabled do not become a replacement for the unreadable file. +If `data.json` uses a data format that this version of PodNotes does not support, PodNotes does not start, and its notice asks you to update. +Update PodNotes on every device that shares the vault. diff --git a/docs/src/content/docs/templates.md b/docs/src/content/docs/templates.md index a2561464..5c85b137 100644 --- a/docs/src/content/docs/templates.md +++ b/docs/src/content/docs/templates.md @@ -1,124 +1,112 @@ --- -title: Templates +title: Template tags slug: templates -description: Template tags, formats, defaults, and escaping rules for episode notes, feed notes, downloads, and transcripts. +description: Every template tag PodNotes fills in for note paths, episode notes, feed notes, downloads, and transcripts, with the default templates and date formats. --- -PodNotes expands `{{tag}}` placeholders into episode or feed metadata. -**Note creation file path** and **Note creation template** control episode notes. -**Feed note file path** and **Feed note template** control feed notes. -These settings are under **Episode notes** and **Feed notes** in PodNotes settings. +Look up every `{{tag}}` that PodNotes fills in when it creates a note, a download, or a transcript. To change where notes go and what they hold, see [Create episode notes](/notes/). The tags for captured timestamps are under [Timestamp tags](/timestamps/#timestamp-tags). -Tag names are case-insensitive. `{{currentDate}}` and `{{currentdate}}` are equivalent. -An unknown tag remains unchanged and produces a notice, with a suggested tag when one is found. -An empty argument, such as `{{date:}}`, uses the tag's default. -Everything after the first colon is one argument, including commas. +## Tag rules -## File path +These rules apply to every template except **Speaker label format**: -Episode note paths, **Transcript file path**, and **Episode download path** support the following tags: +- Tag names ignore case. `{{currentDate}}` and `{{currentdate}}` are the same tag. +- An unknown tag stays in the output as written, and PodNotes shows a notice that suggests a similar tag when it finds one. +- Some tags take an argument after a colon, as in `{{date:YYYY}}`. Everything after the first colon is one argument, commas included. +- An empty argument, as in `{{date:}}`, gives the tag's default. -| Tag | Value | Optional argument | +**Speaker label format** replaces only `{{speaker}}`, in any case and with spaces allowed inside the braces. It shows no notice for other tags. Without `{{speaker}}`, PodNotes puts the label, a colon, and a space before your text. + +## File path tags + +**Note creation file path**, **Transcript file path**, and **Episode download path** accept these tags: + +| Tag | Value | Argument | | --- | --- | --- | -| `{{title}}` | Episode title, sanitized for a file name. | Replaces whitespace, as in `{{title:_}}`. | -| `{{podcast}}` | Podcast name, sanitized for a file name. | Replaces whitespace, as in `{{podcast:_}}`. | -| `{{date}}` | Episode publication date, or empty when unavailable. | [Date format](#date-formats), default `YYYY-MM-DD`. | -| `{{currentDate}}` | Date when the template is expanded. | [Date format](#date-formats), default `YYYY-MM-DD`. | -| `{{episodeNumber}}` | Feed episode number, or a number inferred from the title. | All-zero width, as in `{{episodeNumber:000}}` for `042`. | - -The default episode note path is `PodNotes/{{podcast}}/{{title}}.md`. -PodNotes adds `.md` if needed. Each folder and file name is capped at 255 units. -The limit uses UTF-16 code units on Windows, macOS, and iOS, and UTF-8 bytes on Linux and Android. -Long names are truncated, preserving the file extension. The complete path has no separate length cap. -The default download path is `PodNotes/{{podcast}}/{{title}}`. -Downloads add the detected media extension automatically and remove a trailing extension from the template. -A download path needs to distinguish episodes. `{{title}}` supplies that distinction in the default. -An existing file belonging to another episode causes a download error rather than an overwrite. - -The `title` and `podcast` path tags remove these characters: - -```text +| `{{title}}` | Episode title, cleaned for a file name. | Replaces whitespace, as in `{{title:_}}`. | +| `{{podcast}}` | Podcast name, cleaned for a file name. | Replaces whitespace, as in `{{podcast:_}}`. | +| `{{date}}` | Episode publication date, or empty when the feed has none. | [Date format](#date-formats). Default `YYYY-MM-DD`. | +| `{{currentDate}}` | Date when PodNotes fills in the path. | [Date format](#date-formats). Default `YYYY-MM-DD`. | +| `{{episodeNumber}}` | Episode number from the feed or the title, or empty. | Zero padding, as in `{{episodeNumber:000}}` for `042`. | + +`{{duration}}` is not available in paths. + +| Setting | Default | +| --- | --- | +| **Note creation file path** | `PodNotes/{{podcast}}/{{title}}.md` | +| **Transcript file path** | `transcripts/{{podcast}}/{{title}}.md` | +| **Episode download path** | `PodNotes/{{podcast}}/{{title}}` | + +PodNotes adds `.md` to a note path when it is missing. A transcript path keeps the extension you type, so end it with `.md`. A download path gets the media file's extension, and PodNotes removes any extension you type at its end. + +A download path must tell episodes apart. Without `{{title}}`, the setting shows a warning. When a file for another episode already exists at the path, the download fails and does not overwrite it. + +### Characters removed from names + +The `{{title}}` and `{{podcast}}` path tags remove these characters: + +```text wrap \ , # % & / { } * < > $ ' " : @ ‣ | ? [ ] ``` -They replace control characters with spaces, collapse whitespace, trim surrounding whitespace, and remove leading and trailing dots. -Dots inside a name remain. Date values are not sanitized for paths, so a format containing `:` can produce an invalid file name. -`{{duration}}` is not registered in path templates, including download paths. +They also turn control characters into spaces, collapse runs of whitespace, trim the ends, and remove leading and trailing dots. Dots inside a name stay. PodNotes does not clean date values, so a date format that contains `:` can produce a file name that the system rejects. -Existing episode note lookup checks the current path, the older sanitizer that removed dots, and a unique title match in the expected folder. -That fallback does not guarantee a match after a title change, a note move, or a path-template change. +PodNotes shortens a long folder or file name to fit file system limits, and keeps the extension. -## Note template +## Episode note tags -Episode note templates support the following tags: +**Note creation template** accepts these tags: | Tag | Value | | --- | --- | -| `{{title}}` | Episode title as escaped Markdown text on one line. | -| `{{safeTitle}}` | Episode title sanitized as a file name. | -| `{{podcast}}` | Podcast name sanitized as a file name. | -| `{{description}}` | Episode description converted from HTML to Markdown. | +| `{{title}}` | Episode title as Markdown text on one line, with Markdown characters escaped. | +| `{{safeTitle}}` | Episode title cleaned for a file name. | +| `{{podcast}}` | Podcast name cleaned for a file name. | +| `{{description}}` | Episode description, converted from HTML to Markdown. | | `{{content}}` | Episode content from ``, converted to Markdown. | -| `{{url}}` | Episode URL, or the vault link for a local file. | -| `{{stream}}` | Public HTTP or HTTPS media URL without embedded credentials. Empty for a local resource URL or a refused target. | -| `{{date}}` | Episode publication date, or empty when unavailable. | -| `{{currentDate}}` | Date when the note template is expanded. | +| `{{url}}` | Episode web page from the item's ``, or the feed URL when the item has none. For a local file, the vault link. | +| `{{stream}}` | Public HTTP or HTTPS media URL. Empty for a device-only URL or a blocked address. | +| `{{date}}` | Episode publication date, or empty when the feed has none. | +| `{{currentDate}}` | Date when PodNotes creates the note. | | `{{episodeNumber}}` | Episode number, or empty when unknown. | -| `{{duration}}` | Episode duration from ``, or empty when unavailable. | -| `{{chapters}}` | Markdown chapter list, or empty when no visible chapters are available. | -| `{{artwork}}` | Episode artwork URL, or empty when unavailable or refused. | -| `{{episodelink}}` | Bare `obsidian://podnotes` URL that resumes this episode. Empty without a feed URL or local file path. | +| `{{duration}}` | Episode length from ``, or empty when the feed has none. | +| `{{chapters}}` | Chapter list in Markdown, or empty when the episode has no visible chapters. | +| `{{artwork}}` | Episode artwork URL, or empty when there is none or the address is blocked. | +| `{{episodelink}}` | `obsidian://podnotes` link that resumes this episode. Empty without a feed URL or local file. | -`{{date}}` and `{{currentDate}}` accept [date formats](#date-formats). -`{{episodeNumber:000}}` pads to three digits. Any argument other than an all-zero width returns the unpadded number. +`{{date}}` and `{{currentDate}}` accept a [date format](#date-formats). `{{episodeNumber:000}}` pads to three digits. Any other argument gives the number without padding. -Episode numbers come from `` when valid. Otherwise PodNotes tries a leading title marker such as `#12`, `Ep. 12`, or `Episode 12`. -A leading number followed by a separator, such as `12: Interview`, can also match. -This inference can mistake a year such as `2024: A year in review` for an episode number. +The episode number comes from `` when the feed has a valid one. Otherwise PodNotes reads a number at the start of the title, such as `#12`, `Ep. 12`, `Episode 12`, or `12: Interview`. PodNotes can mistake a year at the start of a title, as in `2024: A year in review`, for episode number 2024. -Description, content, and chapter tags accept a line prefix, as in `{{description:> }}`. -Descriptions and content keep ordinary Markdown formatting and links. -Media elements are removed, Markdown embeds are escaped, and executable Dataview fences become plain text fences. -Image alt text remains. +`{{description}}`, `{{content}}`, and `{{chapters}}` accept a prefix for every line, as in `{{description:> }}`. Descriptions and content keep ordinary Markdown formatting, links, and image alt text. PodNotes removes media elements, escapes Markdown embeds, and turns `dataview` and `dataviewjs` code blocks into `text` code blocks. -Feed-controlled URLs percent-encode whitespace, control characters, quotes, backticks, brackets, parentheses, angle brackets, and backslashes. -A genuine local-file `{{url}}` remains a vault link without that encoding. -Artwork and stream tags accept public HTTP or HTTPS targets without embedded credentials. -Device-local resource URLs and literal private-network targets produce an empty value. +PodNotes percent-encodes URLs from a feed where they contain whitespace, control characters, quotes, backticks, brackets, parentheses, angle brackets, or backslashes. A local file's `{{url}}` stays a vault link. `{{artwork}}` and `{{stream}}` accept only public HTTP or HTTPS addresses without a user name or password. A device-only address, or a private or local IP address, gives an empty value. ### Duration and chapters -With no argument, `{{duration}}` renders `4:05` below an hour or `1:02:03` at or above an hour. -`{{duration:minutes}}` returns total whole minutes. `{{duration:seconds}}` returns total seconds. -Other arguments use the [time tokens](/timestamps/#time-formats), such as `{{duration:HH:mm:ss}}`. - -A restored episode from an older version can lack duration until it is opened from its feed again. +With no argument, `{{duration}}` gives `4:05` for an episode under an hour and `1:02:03` for longer ones. `{{duration:minutes}}` gives whole minutes, and `{{duration:seconds}}` gives seconds. Any other argument is a [time format](/timestamps/#time-formats), as in `{{duration:HH:mm:ss}}`. An episode restored from an older version can lack a duration until you open it from its feed again. -`{{chapters}}` uses the feed's Podcasting 2.0 `` URL. -Each visible chapter renders as `- 0:00 Chapter title`, sorted by start time, with the title escaped as plain text. -Hidden chapters with `toc: false` are omitted. A failed request or empty chapter file produces an empty value. +`{{chapters}}` reads the chapters file from the feed's Podcasting 2.0 `` tag. Each visible chapter becomes a line such as `- 0:00 Chapter title`, sorted by start time, with the title as plain text. PodNotes leaves out chapters marked `toc: false`. A failed request or an empty chapters file gives an empty value. -### Linking an episode to its podcast (feed) note +### Feed link tags -The following episode note tags refer to the episode or its parent feed explicitly: +These episode note tags point to the episode or to its podcast: | Tag | Value | | --- | --- | -| `{{episodeurl}}` | Alias of `{{url}}`. | -| `{{episodeartwork}}` | Alias of `{{artwork}}`. | -| `{{feedurl}}` | Feed URL, or a placeholder for a protected private feed. Empty when no feed URL is stored. | -| `{{feedartwork}}` | Saved feed artwork, with episode artwork as the fallback when absent. | -| `{{podcastlink}}` | Wikilink derived from **Feed note file path**. | +| `{{episodeurl}}` | Same as `{{url}}`. | +| `{{episodeartwork}}` | Same as `{{artwork}}`. | +| `{{feedurl}}` | Podcast feed URL, or the placeholder for a private feed. Empty when no feed URL is stored. | +| `{{feedartwork}}` | Saved podcast artwork, or the episode artwork when the podcast has none. | +| `{{podcastlink}}` | Wikilink to the feed note, built from **Feed note file path**. | -With the default feed path, `{{podcastlink}}` produces `[[PodNotes/Podcasts/My Show|My Show]]`. -A path without a folder produces `[[My Show]]`. The link uses the same name-length limits as the feed note. -A date in the feed path can make this link depend on the expansion date rather than the date the feed note was created. +With the default **Feed note file path**, `{{podcastlink}}` gives `[[PodNotes/Podcasts/Acquired|Acquired]]`. A path without a folder gives `[[Acquired]]`. The link follows the same name length limit as the feed note. A date tag in the feed note path makes the link use the date when the episode note is created, which can differ from the date in the feed note's name. -### Using the default template with Bases +## Default episode note template -The default episode note has properties that [Obsidian Bases](https://help.obsidian.md/bases) can filter, sort, and group: +**Note creation template** starts as this template: -```markdown +```md --- type: podcastEpisode podcast: "{{podcastlink}}" @@ -140,91 +128,83 @@ favorite: false {{description}} ``` -The title and URLs remain in the body. An escaped title can contain backslashes, and a vault link can contain quotes. -Either can break a quoted YAML value. -The quoted podcast link assumes an ordinary feed-note path without literal quotes or backslashes. -`status`, `rating`, and `favorite` are user-editable properties. - -A `.base` file with this configuration lists episode notes by listening status: - -```yaml -filters: - and: - - 'type == "podcastEpisode"' -views: - - type: table - name: Episodes - order: - - file.name - - note.podcast - - note.date - - note.status - - note.rating - - note.favorite - groupBy: - property: note.status - direction: ASC -``` - -![Bases view of episode notes, grouped by listening status](/resources/bases_view.png) +Its properties work with Obsidian Bases. You fill in `status` and `rating`, and change `favorite` to `true` for the episodes you like. To build a table of episode notes, see [Browse your notes with Bases](/notes/#browse-your-notes-with-bases). -## Podcast feed notes +The title and the URL are in the body, not the properties. An escaped title can contain backslashes, and a vault link can contain quotes, so either value can break a quoted property. The quoted `{{podcastlink}}` is safe as long as **Feed note file path** has no quotes or backslashes. -A feed note represents a whole podcast. -**PodNotes: Create podcast feed note** opens a picker of saved podcasts without requiring playback. -The episode context menu also has **Create feed note** or **Open feed note**. +## Feed note tags -The default path is `PodNotes/Podcasts/{{podcast}}.md`. -Feed paths support `{{title}}`, `{{podcast}}`, and `{{date}}`. -Both name tags are sanitized and accept a whitespace replacement argument. -Here, `{{date}}` is the current date, with a default format of `YYYY-MM-DD`. +**Feed note file path** accepts `{{title}}` and `{{podcast}}`, which both give the podcast name cleaned for a file name and accept a whitespace replacement. It also accepts `{{date}}`, the current date, with the default format `YYYY-MM-DD`. The default path is `PodNotes/Podcasts/{{podcast}}.md`. -Feed note content supports these tags: +**Feed note template** accepts these tags: | Tag | Value | | --- | --- | -| `{{title}}` | Feed title as escaped Markdown text on one line. | -| `{{safeTitle}}` | Feed title sanitized as a file name. | -| `{{podcast}}` | Feed title sanitized as a file name. | -| `{{url}}` | Feed website from ``, or empty when absent. | -| `{{feedurl}}` | Feed RSS URL, or a protected private-feed placeholder. | -| `{{artwork}}` | Feed artwork URL. Empty when absent or refused. | -| `{{feedartwork}}` | Alias of `{{artwork}}`. | -| `{{author}}` | Feed author from `` as escaped Markdown text. | -| `{{description}}` | Feed description converted to Markdown. Accepts a line prefix. | +| `{{title}}` | Podcast name as Markdown text on one line, with Markdown characters escaped. | +| `{{safeTitle}}` | Podcast name cleaned for a file name. | +| `{{podcast}}` | Podcast name cleaned for a file name. | +| `{{url}}` | Podcast website from the feed's ``, or empty. | +| `{{feedurl}}` | Podcast feed URL, or the placeholder for a private feed. | +| `{{artwork}}` | Podcast artwork URL, or empty when there is none or the address is blocked. | +| `{{feedartwork}}` | Same as `{{artwork}}`. | +| `{{author}}` | Author from ``, with Markdown characters escaped. | +| `{{description}}` | Podcast description, converted to Markdown. Accepts a line prefix. | | `{{date}}` | Current date. Accepts a [date format](#date-formats). | -`{{currentDate}}` is not registered for feed notes or feed paths. -The title, author, description, URL, and artwork rules match their episode-note counterparts. +`{{currentDate}}` is not available in feed notes or feed note paths. The title, author, description, URL, and artwork tags follow the same rules as their episode note versions. -![A podcast feed note](/resources/feed_note.png) +**Feed note template** starts as this template: -## Transcript and timestamp templates +```md +--- +type: podcast +podcast: "{{podcast}}" +image: "{{artwork}}" +url: "{{url}}" +feedUrl: "{{feedurl}}" +tags: + - podcast +--- +# {{title}} +{{author}} + +![]({{artwork}}) + +{{description}} +``` + +## Transcript and timestamp tags + +**Transcript template** accepts `{{title}}`, `{{podcast}}`, `{{date}}`, `{{currentDate}}`, `{{episodeNumber}}`, `{{duration}}`, `{{transcript}}`, `{{description}}`, `{{url}}`, and `{{artwork}}`. `{{transcript}}` is the transcript text. `{{title}}` and `{{podcast}}` give names cleaned for a file name, as in paths, and accept a whitespace replacement. The other tags follow the episode note rules. `{{stream}}`, `{{chapters}}`, and `{{episodelink}}` are not available in transcripts. + +**Transcript template** starts as this template: + +```md +# {{title}} + +Podcast: {{podcast}} +Date: {{date}} + +{{transcript}} +``` -Transcript note content supports `{{title}}`, `{{podcast}}`, `{{date}}`, `{{currentDate}}`, `{{episodeNumber}}`, `{{duration}}`, `{{transcript}}`, `{{description}}`, `{{url}}`, and `{{artwork}}`. -Its title and podcast tags use the file-name sanitizer and accept a whitespace replacement argument. -The other shared tags use the formats and URL rules described above. `{{transcript}}` contains the generated transcript text. -Episode-only tags such as `{{stream}}`, `{{chapters}}`, and `{{episodelink}}` are not registered in transcript content. +**Speaker label format** accepts `{{speaker}}`, the label of the speaker for each turn. See [Transcribe episodes](/transcripts/) for speaker labels. -**Speaker label format** supports `{{speaker}}` for each diarized turn. -The [transcript guide](/transcripts/) covers provider setup. -The [timestamp reference](/timestamps/) lists the four timestamp tags. +**Capture timestamp format** accepts `{{time}}`, `{{linktime}}`, `{{segment}}`, and `{{linksegment}}`. See [Timestamp tags](/timestamps/#timestamp-tags). If your captures come out as plain text, see [Make timestamps clickable](/timestamps/#make-timestamps-clickable). ## Date formats -Date tags use this subset of Moment-style tokens: +Date tags accept these Moment-style tokens: | Part | Tokens | | --- | --- | -| Year | `YYYY`, `YY` | -| Month | `MMMM`, `MMM`, `MM`, `Mo`, `M` | -| Day of month | `Do`, `DD`, `D` | -| Weekday | `dddd`, `ddd` | -| Hour | `HH`, `H`, `hh`, `h` | -| Minute | `mm`, `m` | -| Second | `ss`, `s` | -| AM or PM | `A`, `a` | - -Names and ordinals use English. Dates use the device's local time zone. -Square brackets preserve literal text, as in `{{currentDate:[Captured] YYYY-MM-DD}}`. -Other Moment tokens are not supported. +| Year | `YYYY`, `YY` | +| Month | `MMMM`, `MMM`, `MM`, `Mo`, `M` | +| Day of month | `Do`, `DD`, `D` | +| Weekday | `dddd`, `ddd` | +| Hour | `HH`, `H`, `hh`, `h` | +| Minute | `mm`, `m` | +| Second | `ss`, `s` | +| AM or PM | `A`, `a` | + +Month and weekday names and ordinals are in English. Dates use the device's time zone. Square brackets keep text as it is, as in `{{currentDate:[Captured] YYYY-MM-DD}}`. PodNotes does not support other Moment tokens. diff --git a/docs/src/content/docs/timestamps.md b/docs/src/content/docs/timestamps.md index b22523ad..61d887ee 100644 --- a/docs/src/content/docs/timestamps.md +++ b/docs/src/content/docs/timestamps.md @@ -1,111 +1,141 @@ --- -title: Timestamps +title: Capture timestamps slug: timestamps -description: Timestamp and segment tags, supported time formats, capture behavior, and PodNotes URI parameters. +description: Insert the current playback time into a note as a link that plays the episode from that moment. --- -**PodNotes: Capture Timestamp** inserts the current episode's playback time into an active Markdown editor. -New installs use `- {{linktime}} `, including a final space, so captures are clickable links. -Existing saved formats remain unchanged. +Capture a timestamp while you listen, and PodNotes inserts the playback time at your cursor as a link back to that moment in the episode. -![Notes with clickable timestamp links](/resources/timestamps.png) +![The Notes section of an episode note with three captured timestamp links, each followed by a note](/screenshots/timestamps.webp) -## Settings +## Capture a timestamp -**Capture timestamp format** is the template used by timestamp and segment capture. -**Timestamp offset (s)** subtracts a non-negative whole number of seconds from the playback time, with a minimum result of zero. -Its default is `0`. Blank or invalid setting input becomes `0`. -The offset also shifts the end of a recent segment capture. +1. Play an episode in PodNotes. You can pause it before you capture. +2. Open a note in editing view. +3. Place the cursor where you want the timestamp. +4. Run **PodNotes: Capture Timestamp** from the command palette (`Ctrl+P`, or `Command+P` on macOS). -The timestamp template supports four tags: +PodNotes inserts a list item with a link such as `00:12:41`, followed by a space for your note. A capture replaces any selected text. Select the link, in Live Preview or reading view, to play the episode from that moment. To keep your timestamps in one note per episode, see [Create episode notes](/notes/). -| Tag | Value | -| --- | --- | -| `{{time}}` | Playback time as plain text. | -| `{{linktime}}` | Playback time as a Markdown episode link. | -| `{{segment}}` | Captured start and end times as plain text. | -| `{{linksegment}}` | Captured start and end times as a Markdown episode link. | +## Capture from a hotkey, the toolbar, or a headset -Each tag defaults to `HH:mm:ss` and accepts a custom format after a colon, such as `{{linktime:m:ss}}`. -During an ordinary timestamp capture, the segment tags produce a single time rather than a range. -A missing link target produces plain text instead of a link. +To capture from a hotkey: -### Time formats +1. Open **Settings → Hotkeys**. +2. Search for **PodNotes: Capture Timestamp**. +3. Assign a shortcut to it. -Time tags use the following tokens, rather than the full Moment.js format language: +On a phone, [add a capture button to the editor toolbar](/mobile/#add-a-capture-button-to-the-editor-toolbar). -| Part | Tokens | Meaning | -| --- | --- | --- | -| Hours | `H`, `HH` | Total elapsed hours, unpadded or padded to at least two digits. | -| Twelve-hour clock | `h`, `hh` | Hour on a twelve-hour clock, unpadded or padded. | -| Minutes | `m`, `mm` | Minute within the hour, unpadded or padded. | -| Seconds | `s`, `ss` | Second within the minute, unpadded or padded. | -| AM or PM | `A`, `a` | Uppercase or lowercase clock label. | +A headset button that sends the previous-track action also captures. If a note is open in an editor, the timestamp goes in at the cursor. If no note is open in an editor, PodNotes adds it to the end of the episode note. See [Capture with a headset](/mobile/#capture-with-a-headset). + +## Capture a range + +To mark a passage instead of a moment: + +1. Play an episode in PodNotes. +2. In a note in editing view, place the cursor where the range goes. +3. Run **PodNotes: Capture Last 10 Seconds** or **PodNotes: Capture Last 20 Seconds**. + +PodNotes inserts the range that ends at the current playback time, such as `00:12:31-00:12:41`. It does not cut an audio clip. With the default **Capture timestamp format**, the range is a link that plays that passage and pauses at its end. With a format that uses `{{time}}`, the range is plain text. + +The range commands insert a range in the style of your **Capture timestamp format**, as [Timestamp tags](#timestamp-tags) describes. [Commands](/commands/#capture-last-10-seconds--capture-last-20-seconds) lists when they are available. -A backslash escapes a literal character. `{{time:H\h mm\m ss\s}}` can render `0h 20m 37s`. -Square-bracket literal escaping is not supported for time tags. -[Date tags](/templates/#date-formats) use a separate formatter. +## Make timestamps clickable -## Capturing timestamps +If your captures come out as plain text with no link, your format most likely uses `{{time}}`, the default before PodNotes 2.21.0. -Capture needs a loaded episode, a non-empty **Capture timestamp format**, and an active editor. -The episode can be paused. A missing episode or format produces this notice: +1. Open **Settings → PodNotes**. +2. Under **Episode notes**, set **Capture timestamp format** to `- {{linktime}}` with a space at the end. -> Play an episode and set a Capture timestamp format in settings to capture a timestamp. +New captures are links. Timestamps that you captured before stay plain text. If the format already uses `{{linktime}}`, the episode has no feed URL or local file to link to. -The command inserts at the cursor and replaces selected text. -In a Markdown table, pipes are escaped and newlines become spaces so the capture stays inside the cell. +## Compensate for reaction time -Obsidian's **Hotkeys** settings can assign a shortcut to **PodNotes: Capture Timestamp**. -The mobile editor toolbar can include the same command. See [Add a capture button](/mobile/#add-a-capture-button-to-the-editor-toolbar). +To capture a few seconds before you react: -The headset Media Session `previoustrack` action also captures a timestamp. -It uses the active editor, or appends to the episode note when no editor is active. -See [Headset capture](/mobile/#capture-with-a-headset) for its requirements and device limits. +1. Open **Settings → PodNotes**. +2. Under **Episode notes**, set **Timestamp offset (s)** to that whole number of seconds. -## Capturing segments +With an offset of `5`, a capture at `00:12:46` inserts `00:12:41`, and ranges move back by the same amount. A capture never goes below `00:00:00`. -**PodNotes: Capture Last 10 Seconds** and **PodNotes: Capture Last 20 Seconds** insert a recent playback range. -The range ends at the current playback time minus **Timestamp offset (s)**. -Its start is 10 or 20 seconds earlier, with both ends bounded at zero. -A zero-length range produces the notice "Play more of the episode before capturing a segment". +## Change the format -Segment commands use **Capture timestamp format**. -A template containing `{{segment}}` or `{{linksegment}}` is used as a range template. -Otherwise, `{{time}}` becomes `{{segment}}` and `{{linktime}}` becomes `{{linksegment}}`. -A template without either kind of tag falls back to `- {{linksegment}}`. -The default template therefore produces a linked range such as `00:01:55-00:02:05`. +**Capture timestamp format** under **Episode notes** decides what a capture inserts. It can hold text, Markdown, and line breaks around the [timestamp tags](#timestamp-tags), and it previews the result while an episode is loaded. See [Example formats](#example-formats). + +## Limits of capture and links + +Captures and links behave differently in these cases: + +- Without a loaded episode or with an empty **Capture timestamp format**, **PodNotes: Capture Timestamp** shows "Play an episode and set a Capture timestamp format in settings to capture a timestamp." +- `{{linktime}}` and `{{linksegment}}` insert plain text when the episode has no feed URL or local file to link to. +- At the very start of an episode, a range command shows "Play more of the episode before capturing a segment". +- In a table cell, PodNotes escapes pipe characters and turns line breaks into spaces. +- A link finds its episode by title, despite changes in case, punctuation, or word order. If the podcast renames the episode with different words, the link shows "Episode not found". +- A link to a private feed works only on a device where you saved that feed. +- A link to a local file needs the file in **Local Files**. See [Downloads and local files](/local_files/). + +## Reference + +### Timestamp tags + +**Capture timestamp format** accepts these four tags: + +| Tag | Inserts | +| --- | --- | +| `{{time}}` | The playback time as plain text. | +| `{{linktime}}` | The playback time as a link to that moment. | +| `{{segment}}` | A range as plain text. In a single capture, the playback time. | +| `{{linksegment}}` | A range as a link. In a single capture, a link to the playback time. | -Opening a segment link seeks to its start, plays the episode, and pauses at its end. -Segment capture does not extract an audio clip or create a separate media file. +Each tag uses `HH:mm:ss` unless you add a [time format](#time-formats) after a colon, as in `{{linktime:H:mm:ss}}`. -## PodNotes URI +The range commands adapt **Capture timestamp format** as follows. PodNotes uses a format that contains `{{segment}}` or `{{linksegment}}` unchanged. Otherwise `{{time}}` becomes `{{segment}}` and `{{linktime}}` becomes `{{linksegment}}`. A format with no time tag at all becomes `- {{linksegment}}`. + +### Example formats + +| Format | Inserts | +| --- | --- | +| `- {{linktime}} ` | A list item with a link such as `00:12:41`. This is the default. | +| `{{linktime:H:mm:ss}} ` | A link such as `0:12:41`, with no list marker. | +| `> {{linktime}} ` | A blockquote line that starts with a link. | +| `- {{time}} ` | A list item with the plain text `00:12:41` and no link. | + +### Time formats + +Time tags accept these tokens: + +| Part | Tokens | Meaning | +| --- | --- | --- | +| Hours | `H`, `HH` | Total elapsed hours, unpadded or padded to at least two digits. | +| Twelve-hour clock | `h`, `hh` | Hour on a twelve-hour clock, unpadded or padded. | +| Minutes | `m`, `mm` | Minute within the hour, unpadded or padded. | +| Seconds | `s`, `ss` | Second within the minute, unpadded or padded. | +| AM or PM | `A`, `a` | Uppercase or lowercase clock label. | + +A backslash makes the next character literal. `{{time:H\h mm\m ss\s}}` inserts `0h 12m 41s`. Square brackets do not mark literal text in time tags. [Date tags](/templates/#date-formats) use a different set of tokens. + +### PodNotes URI + +Timestamp links and resume links open `obsidian://podnotes`. A captured link looks like this: + +```text wrap +obsidian://podnotes?episodeName=The%20Home%20Depot&url=https%3A%2F%2Ffeeds.transistor.fm%2Facquired&time=761 +``` -Timestamp and resume links use the `obsidian://podnotes` handler. The handler accepts these query parameters: | Parameter | Required | Meaning | | --- | --- | --- | -| `url` | Yes | Podcast feed URL, vault media path, or protected private-feed placeholder. | +| `url` | Yes | Podcast feed URL, vault path of a local file, or the placeholder that stands for a private feed. | | `episodeName` | Yes | Episode title. | -| `time` | No | Start time in seconds. The whole value must be a non-negative finite number, so `12abc` is refused. | -| `endTime` | No | Segment end in seconds, greater than `time`. The whole value must be a finite number. Requires a start time. | -| `end` | No | Accepted alias of `endTime`. | -| `to` | No | Accepted alias of `endTime`, after `end` in precedence. | - -An omitted or empty `time` resumes from saved progress. -An unplayed or finished episode starts at zero. -A `time` at or past the episode's end stops 0.25 seconds before the end and pauses. -`endTime` takes precedence over `end`, which takes precedence over `to`. - -Query values are percent-encoded. Current links encode spaces as `%20` and literal plus signs as `%2B`. -Older links using `+` for spaces are also tried during title and local-path lookup. - -Feed targets must use a public HTTP or HTTPS URL. A protected private-feed placeholder resolves only through a saved subscription and its device-local secret. -Local targets need a registered episode and a media file. See [Sync and devices](/sync/#removal-and-existing-links). -When the vault path in `url` exists, that file plays, even if a same-named file in another folder took over its registration. - -A link plays the loaded episode without a feed lookup only when its `url` names that episode's feed, or its file for a local file. -Episode lookup first tries the title, ignoring case and surrounding whitespace. -Its fallback requires a unique match with the same normalized word set, ignoring punctuation, word order, and leading zeros in numeric words. -Different words or episode numbers can prevent a match. Timestamp links do not guarantee recovery after every feed title change. +| `time` | No | Start time in seconds, zero or greater. It can have decimals. | +| `endTime` | No | End of a range in seconds, greater than `time`. Needs `time`. | +| `end` | No | Alias of `endTime`. | +| `to` | No | Alias of `endTime`. | + +When a link has no `time`, or an empty one, the episode resumes from its saved progress. An unplayed or finished episode starts at zero. When a link has more than one end parameter, `endTime` wins over `end`, and `end` wins over `to`. + +A feed `url` must be a public HTTP or HTTPS address. A private-feed placeholder resolves only through a saved podcast and the feed address stored on that device. A local `url` needs a registered episode and its media file. + +PodNotes percent-encodes query values, and also reads links from older versions that encode a space as `+`. The handler matches `episodeName` to an episode title regardless of case, and otherwise to the one title with the same words in any order and with any punctuation. diff --git a/docs/src/content/docs/transcripts.md b/docs/src/content/docs/transcripts.md index 4fcd1d97..835cdd4f 100644 --- a/docs/src/content/docs/transcripts.md +++ b/docs/src/content/docs/transcripts.md @@ -1,129 +1,73 @@ --- -title: Transcripts +title: Transcribe episodes slug: transcripts legacyTitleId: transcripts +description: Transcribe an episode into a note with your own API key, add speaker labels, and see what it costs. --- -PodNotes can create transcript notes from podcast episodes. Plain transcription -uses OpenAI's Whisper model, while optional speaker diarization can use OpenAI -or Deepgram. +Turn the audio of an episode into a transcript note with your own OpenAI or Deepgram account. Without speaker labels, PodNotes uses OpenAI. With speaker labels, you choose OpenAI or Deepgram, and Deepgram needs no OpenAI key. -## Setting Up +## Set up an OpenAI key -Before you can use transcription, set up the following: +Without speaker labels, PodNotes sends the audio to OpenAI with your API key. -1. **OpenAI API key**: Create a key at [OpenAI's website](https://openai.com/). - In **Settings -> PodNotes -> Transcript settings**, use **OpenAI API key** to - select an existing Obsidian secret or create one. Plain Whisper transcription - and OpenAI diarization both use this secret. +1. Create a key on the [OpenAI API keys page](https://platform.openai.com/api-keys). +2. In Obsidian, open **Settings → PodNotes**. +3. Under **Transcripts**, select the secret picker next to **OpenAI API key**. +4. Create a secret that holds your key, or select one that you saved before. -2. **Transcript file path**: Choose where transcript files are saved. You can - use placeholders such as `{{podcast}}` and `{{title}}` in the path. +On each other device, select or create the secret again. See [API keys and private feeds on each device](/sync/#api-keys-and-private-feeds-on-each-device). -3. **Transcript template**: Customize how the transcript note is formatted. +## Transcribe an episode -### How API keys are stored +1. Play an audio episode in PodNotes. You can pause it. +2. Run **PodNotes: Transcribe current episode** from the command palette (`Ctrl+P`, or `Command+P` on macOS). -PodNotes uses Obsidian's native secret picker. The API key value is kept in -Obsidian's vault-local secret storage; PodNotes stores only a provider-specific, -PodNotes-owned secret name in its `data.json`, through the `openAISecretId` and -`deepgramSecretId` references. If you explicitly select a shared Obsidian -secret, PodNotes copies its value into a collision-safe name owned by the chosen -provider. Persisted OpenAI references therefore cannot read Deepgram or foreign -secrets, and persisted Deepgram references cannot read OpenAI or foreign -secrets. +A notice shows the progress, and you can keep listening. When the transcript is ready, PodNotes saves it as a note and opens it. With the default settings, the transcript of the Acquired episode "The Home Depot" is `transcripts/Acquired/The Home Depot.md`. -PodNotes requires Obsidian 1.11.5 or newer because that release encrypts secret -storage on disk using encryption provided by the operating system. Secret -storage keeps keys out of PodNotes files and normal settings exports, but it is -shared infrastructure rather than plugin isolation: other installed plugins can -access the same vault-local secret collection. +## Add speaker labels -Secret values are local to the current vault on the current device. A selected -secret name can sync with the rest of the vault configuration while its value -does not. If PodNotes says that a selected secret is unavailable on this device, -open **Transcript settings** on that device and select or create the secret -again. +1. Open **Settings → PodNotes**. +2. Under **Transcripts**, turn on **Speaker diarization**. +3. In **Diarization provider**, select **OpenAI (gpt-4o-transcribe-diarize)** or **Deepgram**. +4. If you selected **Deepgram**, create a key in the [Deepgram console](https://console.deepgram.com/). +5. If you selected **Deepgram**, under **Deepgram API key**, select or create a secret that holds the Deepgram key. With OpenAI, PodNotes uses your OpenAI key. -When upgrading from a version that stored API keys directly in `data.json`, -PodNotes moves existing OpenAI and Deepgram keys into Obsidian's secret storage -before rewriting the settings file. The plaintext fields are then removed. If -that migration cannot be completed and verified, PodNotes leaves the existing -settings file unchanged and shows a persistent notice so you can restart the -plugin and retry. +The providers differ in these ways: -The migration cannot erase copies that already exist in backups, Obsidian Sync -version history, filesystem snapshots, or old plaintext settings exports. If a -key may have reached an untrusted device or backup, rotate it with the provider -and remove any old export files you no longer need. +| Provider | Key | Labels | Long episodes | +| --- | --- | --- | --- | +| OpenAI | Your OpenAI key | `A`, `B`, `C` | Sent in parts of about 20 MB. `A` in one part may be `B` in the next. | +| Deepgram | A Deepgram key. No OpenAI key needed. | `1`, `2`, `3` | Sent in one request. Labels stay the same for the whole episode. | -## Creating a Transcript +### Change the speaker label format -To create a transcript: +1. In **Settings → PodNotes**, under **Transcripts**, turn on **Speaker diarization**. **Speaker label format** appears below it. +2. Set **Speaker label format**, for example to `**Speaker {{speaker}}:**` with a space at the end. -1. Start playing the audio episode you want to transcribe. -2. Run **PodNotes: Transcribe current episode** from the command palette. -3. PodNotes fetches the currently playing episode's audio, reusing an existing - download when available. Plain Whisper and OpenAI diarization split large - audio into chunks; Deepgram diarization sends the episode in one request. -4. When transcription finishes, PodNotes creates a note at the configured - transcript path. +The format sets the text before each turn. The default, `**{{speaker}}:**` with a space at the end, starts a turn with **A:** in bold. With `**Speaker {{speaker}}:**`, a turn starts with **Speaker A:**. With Deepgram, the labels are numbers, as in **Speaker 1:**. The labeled text fills `{{transcript}}`, so **Transcript template** needs no change. -The command always uses the currently playing episode's own audio, regardless -of your episode download path setting. It remains available when a required -secret is missing so it can tell you which provider must be configured on the -current device. +## Change where transcripts go and what they contain -Generated transcript notes are also available to workflow plugins through the -[PodNotes API](/api/#transcript), so tools such as QuickAdd or Templater can -read the text and send it to the AI provider configured in your own macro. +**Transcript file path** under **Transcripts** sets where the note goes, `transcripts/{{podcast}}/{{title}}.md` by default. Keep `.md` at the end, because PodNotes does not add it. **Transcript template** sets the content, and `{{transcript}}` is the transcript text. [Transcript and timestamp tags](/templates/#transcript-and-timestamp-tags) lists every tag and the default template. -## Transcript Template +## What transcription costs -The transcript template works similarly to the -[note template](/templates/#note-template), with the additional -`{{transcript}}` placeholder. +PodNotes charges nothing. Your provider bills your account per minute of audio: -## Speaker Diarization +- [OpenAI API pricing](https://openai.com/api/pricing/) lists the rate for plain transcription (`whisper-1`) and for speaker labels (`gpt-4o-transcribe-diarize`). +- [Deepgram pricing](https://deepgram.com/pricing) lists the rate for pre-recorded audio with speaker labels. -By default, transcription uses OpenAI's Whisper model, which produces plain -text with **no speaker labels**. Speaker diarization is an opt-in setting that -labels each transcript segment by speaker: +## Limits and error messages -![Transcript with speaker labels](/resources/transcript_diarization.png) +These cases stop a transcription or change its result: -### Enabling it +- PodNotes transcribes audio only. For a video episode, the command is missing. +- When the provider's key is not on this device, a notice asks you to select or create it in the transcript settings. +- When a transcript note already exists at the path, a notice gives its path and PodNotes does nothing else. To transcribe again, delete the note first. +- PodNotes sends long audio to OpenAI in parts of about 20 MB. In a transcript without speaker labels, a part that fails three times leaves the marker `[Error transcribing chunk N]`. To retry, delete the note and run the command again. +- Transcripts have no timestamps, so a line does not link to its moment in the episode. -In the **Transcript settings** section, turn on **Speaker diarization** and choose a provider: +## API key storage -![Speaker diarization settings](/resources/diarization_settings.png) - -- **OpenAI** (`gpt-4o-transcribe-diarize`) reuses the OpenAI secret selected - above. Because each request is capped at about 20 MB, a conservative margin - under OpenAI's 25 MB request cap, a long episode is diarized in independent - chunks. Speaker labels can therefore reset across chunk boundaries. A typical - episode fits in one request and keeps consistent labels. -- **Deepgram** sends the whole episode in one request, so speaker labels remain - consistent throughout the episode. Create a separate key at - [deepgram.com](https://deepgram.com), then select an existing Obsidian secret - or create one under **Deepgram API key**. The Deepgram secret is used only for - Deepgram diarization and is local to the current vault and device. - -Diarization is off by default, so existing transcripts and the plain-Whisper workflow are unchanged unless you enable it. - -### Speaker label format - -The **Speaker label format** setting controls the prefix added before each speaker's turn. Use the `{{speaker}}` placeholder for the speaker's label: - -- OpenAI labels speakers `A`, `B`, `C`, … -- Deepgram labels speakers `1`, `2`, `3`, … - -The default is `**{{speaker}}:** `, which renders as an `**A:**`-style bold -prefix. To spell out the word "Speaker", use `**Speaker {{speaker}}:** `. To put -each turn in a blockquote, use `> {{speaker}}: `. - -The labelled transcript replaces the usual `{{transcript}}` value in your [transcript template](#transcript-template), so you don't need to change your template to use diarization. - -### Cost - -Diarization providers bill per minute/hour of audio (separately from any plain-Whisper usage). As of mid-2026, OpenAI's diarize model is roughly $0.006 per minute, and Deepgram's diarized pre-recorded transcription is roughly $0.0068 per minute. Check each provider's current pricing before transcribing long back-catalogues. +PodNotes keeps your API keys in Obsidian's secret storage on each device, and only the name of each key syncs. On the first start after an upgrade from a version that kept plain-text keys in `data.json`, PodNotes moves them to secret storage and does not finish loading until the move succeeds. If it fails, restart PodNotes. The move cannot erase old copies in backups or settings exports, so replace a key at the provider if it may have leaked. See [API keys and private feeds on each device](/sync/#api-keys-and-private-feeds-on-each-device). diff --git a/docs/src/markdown.ts b/docs/src/markdown.ts index 1b9d39ad..15b2b602 100644 --- a/docs/src/markdown.ts +++ b/docs/src/markdown.ts @@ -3,14 +3,14 @@ import { sidebar } from "./site.mjs"; type Doc = CollectionEntry<"docs">; -/** Empty `` anchors that keep old MkDocs fragments working (see scripts/legacy-fragments.mjs). */ +/** Empty `` anchors that keep old fragments working (see scripts/legacy-fragments.mjs). */ const legacyAnchor = /<\/span>/g; export function pageMarkdown(entry: Doc): string { return `# ${entry.data.title}\n\n${(entry.body ?? "").replace(legacyAnchor, "")}`; } -/** Docs pages grouped and ordered as in the sidebar. Ungrouped pages come first, under "Docs". */ +/** Docs pages grouped and ordered as in the sidebar. External links are left out. */ export async function sidebarSections(): Promise<{ label: string; pages: Doc[] }[]> { const unplaced = new Map((await getCollection("docs")).map((entry) => [entry.id, entry])); const page = (slug: string): Doc => { @@ -19,18 +19,10 @@ export async function sidebarSections(): Promise<{ label: string; pages: Doc[] } unplaced.delete(slug); return entry; }; - const ungrouped = { label: "Docs", pages: [] as Doc[] }; - const sections = [ungrouped]; - for (const item of sidebar) { - if (item.items) { - sections.push({ - label: item.label, - pages: item.items.map((child) => page(child.slug)), - }); - } else { - ungrouped.pages.push(page(item.slug)); - } - } + const sections = sidebar.map((group) => ({ + label: group.label, + pages: group.items.flatMap((item) => ("slug" in item ? [page(item.slug)] : [])), + })); if (unplaced.size > 0) { throw new Error(`Docs pages missing from the sidebar: ${[...unplaced.keys()].join(", ")}`); } diff --git a/docs/src/screenshots.mjs b/docs/src/screenshots.mjs new file mode 100644 index 00000000..77c6f824 --- /dev/null +++ b/docs/src/screenshots.mjs @@ -0,0 +1,51 @@ +// Renders the captured screenshots in public/screenshots. Each +// `![alt](/screenshots/.webp)` gets its size, lazy loading, and a +// `-dark.webp` twin, and the reader's theme decides which one shows. +// A `-phone.webp` crop, if one exists, replaces it on narrow screens. +import { existsSync } from "node:fs"; +import path from "node:path"; +import sharp from "sharp"; + +// Astro bundles this module, so resolve public/ from the docs package root. +const publicDir = path.resolve("public"); + +export const darkTwin = (src) => src.replace(/\.webp$/, "-dark.webp"); + +const phoneTwin = (src) => src.replace(/\.webp$/, "-phone.webp"); + +/** The screenshots are captured at 2x, so they display at half their pixel size. */ +export async function screenshotSize(src) { + const { width, height } = await sharp(publicDir + src).metadata(); + return { width: width / 2, height: height / 2 }; +} + +const screenshotImg = /]*\bsrc="(\/screenshots\/[^"]+\.webp)"[^>]*>/g; + +/** Rewrites every screenshot `` in rendered page HTML. */ +export async function withScreenshots(html) { + const files = [...html.matchAll(screenshotImg)].flatMap(([, src]) => [src, phoneTwin(src)]); + const sizes = new Map( + await Promise.all( + [...new Set(files)] + .filter((src) => existsSync(publicDir + src)) + .map(async (src) => [src, await screenshotSize(src)]), + ), + ); + return html.replace(screenshotImg, (tag, src) => { + const alt = tag.match(/\balt="([^"]*)"/)?.[1] ?? ""; + const { width, height } = sizes.get(src); + // Sidebar-width shots display smaller, so they do not tower over the text. + const kind = width <= 480 ? "screenshot narrow" : "screenshot"; + const phone = sizes.get(phoneTwin(src)); + const picture = (twin, hidden) => { + const img = `${alt}`; + const source = phone + ? `` + : ""; + return `${source}${img}`; + }; + const same = (file) => file; + if (!existsSync(publicDir + darkTwin(src))) return picture(same, ""); + return picture(same, "dark:sl-hidden") + picture(darkTwin, "light:sl-hidden"); + }); +} diff --git a/docs/src/site.mjs b/docs/src/site.mjs index bde86471..60e1eee5 100644 --- a/docs/src/site.mjs +++ b/docs/src/site.mjs @@ -8,29 +8,61 @@ export const site = "https://podnotes.obsidian.guide"; export const { description } = manifest; +export const repository = "https://github.com/chhoumann/PodNotes"; + export const sidebar = [ - { label: "Home", slug: "index" }, - { label: "Getting started", slug: "getting-started" }, - { label: "Mobile", slug: "mobile" }, - { label: "FAQ", slug: "faq" }, - { label: "Sync and devices", slug: "sync" }, - { label: "Commands", slug: "commands" }, - { label: "Podcasts", slug: "podcasts" }, - { label: "Local files", slug: "local_files" }, - { label: "Import & Export", slug: "import_export" }, - { label: "Transcripts", slug: "transcripts" }, { - label: "Notes", + label: "Start here", + items: [ + { label: "Overview", slug: "index" }, + { label: "Getting started", slug: "getting-started" }, + { label: "Use PodNotes on your phone", slug: "mobile" }, + ], + }, + { + label: "Listen", items: [ - { label: "Timestamps", slug: "timestamps" }, - { label: "Templates", slug: "templates" }, + { label: "Add and play podcasts", slug: "podcasts" }, + { label: "Use the queue and playlists", slug: "playlists" }, + { label: "Downloads and local files", slug: "local_files" }, + { label: "Transcribe episodes", slug: "transcripts" }, ], }, { - label: "Advanced", + label: "Take notes", items: [ + { label: "Capture timestamps", slug: "timestamps" }, + { label: "Create episode notes", slug: "notes" }, + ], + }, + { + label: "Your library", + items: [ + { label: "Sync between devices", slug: "sync" }, + { label: "Import and export", slug: "import_export" }, + ], + }, + { + label: "Automate", + items: [ + { label: "Use with QuickAdd", slug: "QuickAdd" }, { label: "API", slug: "api" }, - { label: "Usage with QuickAdd", slug: "QuickAdd" }, + ], + }, + { + label: "Reference", + items: [ + { label: "Commands", slug: "commands" }, + { label: "Settings", slug: "settings" }, + { label: "Template tags", slug: "templates" }, + ], + }, + { + label: "Help", + items: [ + { label: "FAQ", slug: "faq" }, + { label: "Report a problem", link: `${repository}/issues` }, + { label: "Release notes", link: `${repository}/releases` }, ], }, ]; diff --git a/docs/src/styles/custom.css b/docs/src/styles/custom.css new file mode 100644 index 00000000..523d3ff4 --- /dev/null +++ b/docs/src/styles/custom.css @@ -0,0 +1,428 @@ +/* PodNotes theme for Starlight, shared with the QuickAdd docs. */ + +/* Inter, self-hosted and subset to Latin (see public/fonts/LICENSE.txt). */ +@font-face { + font-family: InterVariable; + font-style: normal; + font-weight: 100 900; + font-display: swap; + src: url('/fonts/InterVariable.woff2') format('woff2'); +} +@font-face { + font-family: InterVariable; + font-style: italic; + font-weight: 100 900; + font-display: swap; + src: url('/fonts/InterVariable-Italic.woff2') format('woff2'); +} + +:root { + --sl-font: + 'InterVariable', system-ui, -apple-system, 'Segoe UI', Roboto, + Ubuntu, Cantarell, 'Noto Sans', sans-serif, 'Apple Color Emoji', + 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; + --sl-font-mono: + 'JetBrains Mono', 'SF Mono', Monaco, Consolas, 'Liberation Mono', + 'Courier New', monospace; +} + +/* Obsidian-ish purple-blue accent (dark mode). */ +:root { + --sl-color-accent-low: #2a2360; + --sl-color-accent: #6b5cef; + --sl-color-accent-high: #c9c1fb; +} + +/* Light mode accent - darker shade for AA contrast. */ +:root[data-theme='light'] { + --sl-color-accent-low: #e6e3fd; + --sl-color-accent: #5a4aec; + --sl-color-accent-high: #38288a; +} + +/* --------------------------------------------------------------------------- + * Typography rhythm + * ------------------------------------------------------------------------ */ + +/* Tighter tracking on large headings; Inter wants it at display sizes. */ +.sl-markdown-content :is(h1, h2):not(:where(.not-content *)), +.content-panel h1 { + letter-spacing: -0.02em; +} +.sl-markdown-content :is(h3, h4):not(:where(.not-content *)) { + letter-spacing: -0.01em; +} + +/* Generous whitespace between top-level sections keeps long reference pages + * scannable. */ +.sl-markdown-content h2:not(:where(.not-content *)) { + margin-top: 2em; +} + +/* --------------------------------------------------------------------------- + * Inline code + * ------------------------------------------------------------------------ */ + +/* Quieter chips: softer fill, hairline border, rounded. The default solid + * gray-5 block is loud enough to shred reading flow on token-dense pages. + * Scoped to inline code only, so Starlight's pre>code reset stays untouched. */ +.sl-markdown-content :not(pre) > code:not(:where(.not-content *)) { + background-color: color-mix(in srgb, var(--sl-color-gray-5) 55%, var(--sl-color-bg)); + border: 1px solid color-mix(in srgb, var(--sl-color-gray-5) 80%, var(--sl-color-bg)); + border-radius: 0.375rem; + padding: 0.125rem 0.3125rem; + /* A chip that wraps keeps its border and rounding on both lines. */ + -webkit-box-decoration-break: clone; + box-decoration-break: clone; +} +:root[data-theme='light'] .sl-markdown-content :not(pre) > code:not(:where(.not-content *)) { + background-color: color-mix(in srgb, var(--sl-color-gray-6) 70%, var(--sl-color-bg)); + border-color: var(--sl-color-gray-5); +} + +/* Short chips (marked by MarkdownContent.astro) stay on one line. */ +.sl-markdown-content code.short { + white-space: nowrap; +} + +/* Code inside headings at full heading size reads as shouting (and giant + * mono chips dominated the old token pages). Scale it down and let the + * human-language part of the heading lead. */ +.sl-markdown-content :is(h1, h2, h3, h4, h5, h6) code { + font-size: 0.8em; + font-weight: 500; + padding: 0.1em 0.4em; + vertical-align: baseline; +} + +/* Code blocks: match the chip radius. */ +.sl-markdown-content pre:not(:where(.not-content *)) { + border-radius: 0.5rem; +} + +/* --------------------------------------------------------------------------- + * Tables + * ------------------------------------------------------------------------ */ + +/* Calmer header row + slightly muted rules so token tables read as rows of + * facts, not grids of boxes. */ +.sl-markdown-content th:not(:where(.not-content *)) { + color: var(--sl-color-gray-2); + font-size: var(--sl-text-sm); + border-bottom: 1px solid var(--sl-color-gray-4); +} +.sl-markdown-content td:not(:where(.not-content *)) { + border-bottom: 1px solid color-mix(in srgb, var(--sl-color-gray-5) 60%, var(--sl-color-bg)); +} +.sl-markdown-content :is(th, td):not(:where(.not-content *)) { + padding-block: 0.625rem; +} + +/* Every table spans the column on wider screens, where they all fit. */ +@media (min-width: 50rem) { + .sl-markdown-content table { + display: table; + width: 100%; + } + .sl-markdown-content table.settings { + table-layout: fixed; + } + .sl-markdown-content table.settings th:nth-child(1) { + width: 28%; + } + .sl-markdown-content table.settings th:nth-child(2) { + width: 31%; + } + .sl-markdown-content table.settings td code { + white-space: normal; + overflow-wrap: anywhere; + } +} + +/* --------------------------------------------------------------------------- + * Headings + * ------------------------------------------------------------------------ */ + +/* A group heading directly above its first question gets a rule, so the two + * do not read as a repeated heading. */ +.sl-markdown-content .sl-heading-wrapper.level-h2:has(+ .level-h3) { + padding-bottom: 0.5rem; + border-bottom: 1px solid var(--sl-color-hairline); +} + +.sl-anchor-link { + margin-inline-start: 0.25em; +} +.sl-anchor-link:focus-visible { + outline-offset: 2px; +} + +/* Touch screens cannot hover, so the link icons would show on every heading. */ +@media (hover: none) { + .sl-markdown-content .sl-anchor-link { + display: none; + } +} + +/* --------------------------------------------------------------------------- + * Header and sidebar + * ------------------------------------------------------------------------ */ + +/* Links that leave the site say so. */ +.sidebar-content ul a[href^="http"]::after { + content: "↗"; + margin-inline-start: 0.25em; + color: var(--sl-color-gray-3); +} + +/* Starlight's phone menu button is a white disc, which glares in dark mode. */ +:root[data-theme="dark"] .sl-menu-button:not(:has(~ :popover-open)) { + background-color: var(--sl-color-gray-5); + color: var(--sl-color-white); +} + +/* --------------------------------------------------------------------------- + * Right-hand "On this page" list + * ------------------------------------------------------------------------ */ + +/* Keep long task-style entries readable: a touch smaller with room to + * breathe. */ +starlight-toc a { + line-height: 1.4; +} + +/* --------------------------------------------------------------------------- + * Screenshots + * ------------------------------------------------------------------------ */ + +/* Captured screenshots (see src/screenshots.mjs) sit in a quiet frame and + * center when they are narrower than the column. Never set `display` here: + * the light/dark twins rely on Starlight's sl-hidden utilities. */ +.sl-markdown-content p:has(img.screenshot) { + text-align: center; +} +.sl-markdown-content img.screenshot { + margin-inline: auto; + border: 1px solid var(--sl-color-hairline); + border-radius: 0.75rem; + box-shadow: 0 1px 2px rgb(0 0 0 / 0.06), 0 8px 24px -12px rgb(0 0 0 / 0.25); +} +.sl-markdown-content img.screenshot.narrow { + max-width: min(100%, 21rem); +} + +/* --------------------------------------------------------------------------- + * Landing page (the only page with a hero) + * ------------------------------------------------------------------------ */ + +[data-has-hero] .sl-markdown-content { + max-width: 72rem; + margin-inline: auto; +} +[data-has-hero] .sl-markdown-content h2 { + margin-top: 3.5rem; +} + +/* "What you can do": each list item is a card, and its link covers the card. */ +[data-has-hero] .sl-markdown-content ul:first-of-type { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(17rem, 1fr)); + gap: 1rem; + padding: 0; + margin-top: 1.5rem; + list-style: none; +} +[data-has-hero] .sl-markdown-content ul:first-of-type > li { + position: relative; + margin: 0; + padding: 1.25rem 1.375rem; + border: 1px solid var(--sl-color-hairline); + border-radius: 0.75rem; + background: var(--sl-color-bg-nav); + color: var(--sl-color-gray-2); + line-height: 1.55; + transition: border-color 0.15s ease; +} +[data-has-hero] .sl-markdown-content ul:first-of-type > li:hover { + border-color: var(--sl-color-accent); +} +[data-has-hero] .sl-markdown-content ul:first-of-type > li > a { + display: block; + margin-bottom: 0.375rem; + font-size: var(--sl-text-lg); + font-weight: 600; + /* The accent color marks the title as a link, also on touch screens. */ + color: var(--sl-color-text-accent); + text-decoration: none; +} +[data-has-hero] .sl-markdown-content ul:first-of-type > li > a::after { + content: ""; + position: absolute; + inset: 0; + border-radius: inherit; +} +[data-has-hero] .sl-markdown-content ul:first-of-type > li > a:focus-visible { + outline: none; +} +[data-has-hero] .sl-markdown-content ul:first-of-type > li:has(> a:focus-visible) { + outline: 2px solid var(--sl-color-accent); + outline-offset: 2px; +} + +/* "Start in three steps": a numbered strip. */ +[data-has-hero] .sl-markdown-content ol { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr)); + gap: 1.5rem; + padding: 0; + margin-top: 1.5rem; + list-style: none; + counter-reset: step; +} +[data-has-hero] .sl-markdown-content ol > li { + counter-increment: step; + margin: 0; + color: var(--sl-color-gray-2); + line-height: 1.55; +} +[data-has-hero] .sl-markdown-content ol > li::before { + content: counter(step); + display: grid; + place-items: center; + width: 2rem; + height: 2rem; + margin-bottom: 0.75rem; + border-radius: 999px; + background: var(--sl-color-accent-low); + color: var(--sl-color-text-accent); + font-weight: 600; +} +[data-has-hero] .sl-markdown-content ol > li strong:first-child { + display: block; + color: var(--sl-color-white); +} + +/* The first section keeps the same gap below the hero as the others. */ +[data-has-hero] .sl-markdown-content > :first-child { + margin-top: 3.5rem; +} + +/* Headings on the landing page are not deep-link targets worth an icon. */ +[data-has-hero] .sl-markdown-content .sl-anchor-link { + display: none; +} + +/* The landing page has no sidebar menu, so phones show the theme switcher in + * the header instead. */ +@media (max-width: 49.99rem) { + html:not([data-has-sidebar]) .header .right-group { + display: flex; + } + html:not([data-has-sidebar]) .header .social-icons { + display: none; + } + html:not([data-has-sidebar]) .header > div:has(> site-search) { + margin-inline-start: auto; + } +} + +/* --------------------------------------------------------------------------- + * Reference tables on phones + * ------------------------------------------------------------------------ */ + +/* Tables with three or more columns (labeled by src/tables.mjs) become one + * stacked entry per row, so no column hides off the edge. */ +@media (max-width: 40rem) { + .sl-markdown-content table.stack, + .sl-markdown-content table.stack tbody, + .sl-markdown-content table.stack tr, + .sl-markdown-content table.stack td { + display: block; + width: auto; + } + .sl-markdown-content table.stack thead { + display: none; + } + .sl-markdown-content table.stack tr { + padding-block: 0.75rem; + border-bottom: 1px solid var(--sl-color-hairline); + } + .sl-markdown-content table.stack td { + border: 0; + padding: 0.125rem 0; + overflow-wrap: anywhere; + } + .sl-markdown-content table.stack td + td { + margin-top: 0.25rem; + } + .sl-markdown-content table.stack tbody tr:first-child { + padding-top: 0; + } + .sl-markdown-content table.stack td:first-child { + font-weight: 600; + color: var(--sl-color-white); + } + .sl-markdown-content table.stack td:not(:first-child)::before { + content: attr(data-label) ": "; + color: var(--sl-color-gray-3); + } + + /* Two-column tables keep their rows full width, each label beside the + * first line of its value, and paths wrap inside one box instead of running + * off the edge. */ + .sl-markdown-content table:not(.stack) { + display: table; + width: 100%; + } + .sl-markdown-content td { + vertical-align: top; + } + .sl-markdown-content td code:not(.short) { + display: inline-block; + max-width: 100%; + overflow-wrap: anywhere; + } +} + +/* --------------------------------------------------------------------------- + * Line breaks + * ------------------------------------------------------------------------ */ + +/* Headings break into even lines instead of leaving one word behind. Section + * headings sit inline in Starlight's wrapper, which owns their lines. */ +.sl-markdown-content .sl-heading-wrapper, +h1#_top { + text-wrap: balance; +} + +/* Code in headings shrinks on narrow screens so a method name fits one line. */ +@media (max-width: 50rem) { + .sl-markdown-content :is(h2, h3) code { + font-size: 0.65em; + } +} + +/* On wide screens, command IDs and label lists stay on one line. */ +@media (min-width: 50rem) { + .sl-markdown-content td[data-label="ID"] code, + .sl-markdown-content td[data-label="Labels"] { + white-space: nowrap; + } +} + +/* The landing page's prose keeps a readable line length under the wide hero. */ +[data-has-hero] .sl-markdown-content > p { + max-width: 45rem; +} + +/* --------------------------------------------------------------------------- + * Brand + * ------------------------------------------------------------------------ */ + +/* The mark and the name read as one lockup. */ +.site-title { + gap: 0.625rem; +} +.site-title img { + height: 2rem; +} diff --git a/docs/src/tables.mjs b/docs/src/tables.mjs new file mode 100644 index 00000000..2cc75012 --- /dev/null +++ b/docs/src/tables.mjs @@ -0,0 +1,41 @@ +// Labels each cell of a table with three or more columns with its column +// header, so phones can show each row as a stacked entry (see custom.css). +// Settings tables also get fixed columns, so each one lines up with the next. +const escapeAttribute = (text) => + text + .replaceAll("&", "&") + .replaceAll('"', """) + .replaceAll("<", "<") + .replaceAll(">", ">"); + +/** The text of an HTML fragment: everything outside its tags. */ +const textOf = (html) => { + let text = ""; + let inTag = false; + for (const char of html) { + if (char === "<") inTag = true; + else if (char === ">" && inTag) inTag = false; + else if (!inTag) text += char; + } + return text.trim(); +}; + +export function withStackedTables(html) { + return html.replace(/]*)>([\s\S]*?)<\/table>/g, (table, attributes, inner) => { + const headers = [...inner.matchAll(/]*>([\s\S]*?)<\/th>/g)].map((match) => + textOf(match[1]), + ); + if (headers.length < 3) return table; + const body = inner.replace(/([\s\S]*?)<\/tr>/g, (row, rowCells) => { + let column = 0; + const labeled = rowCells.replace( + /]*)>/g, + (cell, cellAttributes) => + ``, + ); + return `${labeled}`; + }); + const settings = headers.join("|") === "Setting|Default|What it does" ? " settings" : ""; + return `${body}`; + }); +} diff --git a/docs/src/tables.test.mjs b/docs/src/tables.test.mjs new file mode 100644 index 00000000..dc981e9b --- /dev/null +++ b/docs/src/tables.test.mjs @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { withStackedTables } from "./tables.mjs"; + +const table = (headers, cells) => + `${headers.map((h) => ``).join("")}` + + `${cells.map((c) => ``).join("")}
${h}
${c}
`; + +const labels = (html) => [...html.matchAll(/data-label="([^"]*)"/g)].map((match) => match[1]); + +describe("withStackedTables", () => { + it("labels each cell with its column header", () => { + const html = withStackedTables( + table(["Setting", "Default", "What it does"], ["A", "B", "C"]), + ); + + expect(labels(html)).toEqual(["Setting", "Default", "What it does"]); + expect(html).toContain('class="stack settings"'); + }); + + it("keeps no markup from a header with a nested tag in the label", () => { + const html = withStackedTables( + table(["ipt>x", "Default", "Notes"], ["A", "B", "C"]), + ); + + const [first] = labels(html); + expect(first).not.toMatch(/[<>]/); + expect(first).toBe("ipt>x"); + }); + + it("leaves tables with fewer than three columns unchanged", () => { + const input = table(["Tag", "Value"], ["A", "B"]); + + expect(withStackedTables(input)).toBe(input); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 2cba7ed6..7c617e73 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -30,6 +30,7 @@ export default defineConfig({ include: [ "src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}", "scripts/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}", + "docs/src/**/*.{test,spec}.{js,mjs}", ], globals: true, // Files that need a DOM or load Svelte code opt in with