diff --git a/docs/docs/index.md b/docs/docs/index.md index 40987232..209eed32 100644 --- a/docs/docs/index.md +++ b/docs/docs/index.md @@ -5,23 +5,21 @@ 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 - Podcast player built into Obsidian -- Add any publicly available podcast through search +- Add any publicly available podcast through search, or custom feeds by URL - Track played episodes & playback progress -- API that can be used by plugins like [QuickAdd](https://github.com/chhoumann/QuickAdd) or [Templater](https://github.com/silentvoid13/Templater). With it, you can get - - Episode title - - Episode description - - Episode URL - - Episode podcast name - - Episode publish date - - Current playback time, both raw and formatted (with customn formatting). This can link to the episode at a given time, which PodNotes can pick up and play -- API allows controlling the player +- Create podcast notes from templates with metadata about episodes +- Capture timestamps & link directly to the time in the episode +- 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 currently not in the Obsidian plugin repository. Therefore, you'll need to install it manually. + +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. @@ -29,6 +27,7 @@ Add `chhoumann/PodNotes` to BRAT with the `Add a beta plugin for testing` comman 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. @@ -36,21 +35,27 @@ Create a new directory in your Obsidian vaults `.obsidian/plugins/` folder calle 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 + ![Player](resources/player.png) ### Podcast Note Editing + ![Podcast Note Editing](resources/podcast_note.png) ### Podcast search -![Podcast Search](resources/podcast_search.png) +![Podcast Search](resources/podcast_search.png) diff --git a/docs/docs/podcasts.md b/docs/docs/podcasts.md new file mode 100644 index 00000000..07597b45 --- /dev/null +++ b/docs/docs/podcasts.md @@ -0,0 +1,49 @@ +## Adding podcasts +You can add podcasts by searching for them in the podcast search box in settings. +This searches the iTunes podcast repository. + +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. + + +## Playlists +PodNotes can create playlists, which are lists of episodes. +For each playlist, you can specify a name and an icon. + +The playlists are shown in the episode grid, represented by their icons. + +By default, you will have a queue playlist and a favorites playlist. + +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. + +## Episode list +The episode list is a dynamic view, which will change depending on your selection. + +If you have no podcast selected, the episode list will show the latest episodes of all your saved feeds. + +If you have a podcast selected, the episode list will show all episodes of that podcast. + +And lastly, if you select a playlist, the episode list shows all episodes in that playlist. + +## Context menu +You can right-click (desktop) or long-press (mobile) on an episode in the episode list to open the context menu. + +The context menu will let you + +- Play the episode +- Mark the episode as played +- Add / remove the episode to favorites +- Add / remove the episode to queue +- Add / remove the episode to a playlist + +## Player +The player will automatically load and play the current episode. + +Clicking on the epsiode image will toggle episode playback. + +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. + +Episode playback rate can be controlled by using the playback rate slider. +This is, by default, what you have set in the settings. diff --git a/docs/docs/templates.md b/docs/docs/templates.md new file mode 100644 index 00000000..14432f91 --- /dev/null +++ b/docs/docs/templates.md @@ -0,0 +1,25 @@ +PodNotes can create notes from templates. These templates can contain certain syntax, which will be expanded to metadata about the podcast episode you are listening to. + +To use templates, you can use the `Create podcast note` Obsidian command. +This requires you to have defined a template for both the file path and note text. + +## File path +This template will be used to create the file path for the note. You can use the following syntax: + +- `{{title}}`: The title of the podcast episode. +- `{{podcast}}`: The name of the podcast. + +Both syntax items will be formatted such that it is safe to use in a file path. +This means the following characters will be removed: `\ , # % & / { } * < > $ ' " : @ ‣ | ?`. + +## Note template +This template will be used to create the note text. You can use the following syntax: + +- `{{title}}`: The title of the podcast episode. +- `{{description}}`: The description of the podcast episode. + - You can use `{{description:> }}` to prepend each new line with a `>` (to put the entire description in a blockquote). +- `{{podcast}}`: The name of the podcast. +- `{{url}}`: The URL of the podcast episode. +- `{{date}}`: The publish date of the podcast episode. + - You can use `{{date:format}}` to specify a custom [Moment.js](https://momentjs.com) format. E.g. `{{date:YYYY-MM-DD}}`. +- `{{artwork}}`: The URL of the podcast artwork. If no artwork is found, an empty string will be used. diff --git a/docs/docs/timestamps.md b/docs/docs/timestamps.md new file mode 100644 index 00000000..e4f29aaa --- /dev/null +++ b/docs/docs/timestamps.md @@ -0,0 +1,14 @@ +Timestamps can be created with the `Capture Timestamp` Obsidian command. + +This will make PodNotes capture the current playback time to the active note, in the format given in the plugin settings. + +## Settings +For timestamps, you can use the following format strings: + +- `{{time}}`: The current playback time. Default format is `HH:mm:ss`. +- `{{linktime}}`: The current playback time, formatted as a link to the current episode. Default format is `HH:mm:ss`. + +Both of these allow for custom formatting. +By using `{{time:format}}` or `{{linktime:format}}`, you can specify a custom [Moment.js](https://momentjs.com) format. + +For example, you might use `{{time:H\h mm\m ss\s}}` to get the time in the format `0h 20m 37s`. diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index fafcb549..64104635 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -1,7 +1,16 @@ site_name: PodNotes repo_url: https://github.com/chhoumann/podnotes edit_uri: edit/master/docs/docs +nav: + - Home: index.md + - Podcasts: podcasts.md + - 'Notes': + - Timestamps: timestamps.md + - Templates: templates.md + - Advanced: + - API: api.md + - Usage with QuickAdd: QuickAdd.md theme: - name: material - palette: - primary: blue + name: material + palette: + primary: blue diff --git a/package-lock.json b/package-lock.json index 73feefc8..7fd5fca1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8078,7 +8078,8 @@ "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", - "dev": true + "dev": true, + "peer": true }, "node_modules/through": { "version": "2.3.8", @@ -14421,7 +14422,8 @@ "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", - "dev": true + "dev": true, + "peer": true }, "through": { "version": "2.3.8", diff --git a/src/TemplateEngine.ts b/src/TemplateEngine.ts new file mode 100644 index 00000000..5a3ee9fd --- /dev/null +++ b/src/TemplateEngine.ts @@ -0,0 +1,89 @@ +import { htmlToMarkdown, Notice } from "obsidian"; +import { Episode } from "src/types/Episode"; +import Fuse from "fuse.js"; +import { plugin } from "src/store"; +import { get } from "svelte/store"; + +interface Tags { + [tag: string]: string | ((...args: unknown[]) => string); +} + +function TemplateEngine(template: string, tags: Tags) { + return template.replace(/\{\{(.*?)(:\s*?.+?)?\}\}/g, (match: string, tagId: string, params: string) => { + const tagValue = tags[tagId.toLowerCase()]; + if (!tagValue) { + const fuse = new Fuse(Object.keys(tags), { + shouldSort: true, + findAllMatches: false, + threshold: 0.4, + isCaseSensitive: false, + }); + + const similarTag = fuse.search(tagId); + + new Notice(`Tag ${tagId} is invalid.${similarTag.length > 0 ? ` Did you mean ${similarTag[0].item}?` : ""}`); + return match; + } + + if (typeof tagValue === 'function') { + if (params) { + // Remove initial colon with splice. + const splitParams = params.slice(1).split(','); + const args = Array.isArray(splitParams) ? splitParams : [params]; + + return tagValue(...args); + } + + return tagValue(); + } + + return tagValue; + }); +} + +export function NoteTemplateEngine(template: string, episode: Episode) { + return TemplateEngine(template, { + "title": episode.title, + "description": (prependToLines?: string) => { + if (prependToLines) { + return htmlToMarkdown(episode.description) + .split("\n") + .map(prepend(prependToLines)) + .join("\n") + } + + return htmlToMarkdown(episode.description) + }, + "url": episode.url, + "date": (format?: string) => episode.episodeDate ? + window.moment(episode.episodeDate).format(format ?? "YYYY-MM-DD") + : "", + "podcast": episode.podcastName, + "artwork": episode.artworkUrl ?? "", + }); +} + +function prepend(prepend: string) { + return (str: string) => `${prepend}${str}`; +} + +export function TimestampTemplateEngine(template: string) { + return TemplateEngine(template, { + "time": (format?: string) => get(plugin).api.getPodcastTimeFormatted(format ?? "HH:mm:ss"), + "linktime": (format?: string) => get(plugin).api.getPodcastTimeFormatted(format ?? "HH:mm:ss", true), + }); +} + +export function FilePathTemplateEngine(template: string, episode: Episode) { + return TemplateEngine(template, { + "title": replaceIllegalFileNameCharactersInString(episode.title), + "podcast": replaceIllegalFileNameCharactersInString(episode.podcastName), + }); +} + +function replaceIllegalFileNameCharactersInString(string: 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 +} diff --git a/src/constants.ts b/src/constants.ts index fe3c88fb..0eafc9a1 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -36,5 +36,14 @@ export const DEFAULT_SETTINGS: IPodNotesSettings = { skipBackwardLength: 15, skipForwardLength: 15, currentEpisode: undefined, + + timestamp: { + template: "- {{time}} ", + }, + + note: { + path: "", + template: "", + } } diff --git a/src/main.ts b/src/main.ts index 0dd5f1ae..de81a9c2 100644 --- a/src/main.ts +++ b/src/main.ts @@ -22,6 +22,7 @@ import { FavoritesController } from './store_controllers/FavoritesController'; import { Episode } from './types/Episode'; import CurrentEpisodeController from './store_controllers/CurrentEpisodeController'; import { ViewState } from './types/ViewState'; +import { FilePathTemplateEngine, NoteTemplateEngine, TimestampTemplateEngine } from './TemplateEngine'; export default class PodNotes extends Plugin implements IPodNotes { public api: IAPI; @@ -48,7 +49,7 @@ export default class PodNotes extends Plugin implements IPodNotes { favorites.set(this.settings.favorites); if (this.settings.currentEpisode) { currentEpisode.set(this.settings.currentEpisode); - } + } this.playedEpisodeController = new EpisodeStatusController(playedEpisodes, this).on(); this.savedFeedsController = new SavedFeedsController(savedFeeds, this).on(); @@ -113,6 +114,72 @@ export default class PodNotes extends Plugin implements IPodNotes { //@ts-ignore this.app.plugins.disablePlugin(id).then(() => this.app.plugins.enablePlugin(id)) } + }); + + this.addCommand({ + id: 'capture-timestamp', + name: 'Capture Timestamp', + editorCheckCallback: (checking, editor, view) => { + if (checking) { + return !!this.api.podcast && + !!this.settings.timestamp.template; + } + + const cursorPos = editor.getCursor(); + const capture = TimestampTemplateEngine( + this.settings.timestamp.template, + ); + + editor.replaceRange(capture, cursorPos); + editor.setCursor(cursorPos.line, cursorPos.ch + capture.length); + } + }); + + this.addCommand({ + id: 'create-podcast-note', + name: 'Create Podcast Note', + checkCallback: (checking) => { + if (checking) { + return !!this.api.podcast && + !!this.settings.note.path && + !!this.settings.note.template; + } + + (async function createPodcastNote() { + const filePath = FilePathTemplateEngine( + this.settings.note.path, + this.api.podcast + ); + + const filePathDotMd = filePath.endsWith('.md') ? filePath : `${filePath}.md`; + + const content = NoteTemplateEngine( + this.settings.note.template, + this.api.podcast + ); + + const createOrGetFile = async (path: string, content: string) => { + const file = app.vault.getAbstractFileByPath(path); + if (file) { + new Notice(`Note for "${this.api.podcast.title}" already exists`); + return file; + } + + return await this.app.vault.create(path, content); + } + + try { + const file = await createOrGetFile(filePathDotMd, content); + + this.app.workspace + .getLeaf() + .openFile(file) + } catch (error) { + console.error(error); + new Notice(`Failed to create note: "${filePathDotMd}"`); + } + }).bind(this)(); + }, }) this.addSettingTab(new PodNotesSettingsTab(this.app, this)); diff --git a/src/types/IPodNotesSettings.ts b/src/types/IPodNotesSettings.ts index 1eda91a4..7b531490 100644 --- a/src/types/IPodNotesSettings.ts +++ b/src/types/IPodNotesSettings.ts @@ -15,4 +15,13 @@ export interface IPodNotesSettings { queue: Playlist, favorites: Playlist, currentEpisode?: Episode, + + timestamp: { + template: string; + }, + + note: { + path: string; + template: string; + } } diff --git a/src/ui/PodcastView/Loading.svelte b/src/ui/PodcastView/Loading.svelte index 6702c85e..fb378976 100644 --- a/src/ui/PodcastView/Loading.svelte +++ b/src/ui/PodcastView/Loading.svelte @@ -24,9 +24,9 @@ font-size: 0; color: #fff; } -.la-line-scale.la-dark { +/* .la-line-scale.la-dark { color: #333; -} +} */ .la-line-scale > div { display: inline-block; float: none; @@ -79,7 +79,7 @@ -o-animation-delay: -.8s; animation-delay: -.8s; } -.la-line-scale.la-sm { +/* .la-line-scale.la-sm { width: 20px; height: 16px; } @@ -100,7 +100,7 @@ margin: 4px; margin-top: 0; margin-bottom: 0; -} +} .la-line-scale.la-3x { width: 120px; height: 96px; @@ -111,7 +111,7 @@ margin: 6px; margin-top: 0; margin-bottom: 0; -} +}*/ /* * Animation */ diff --git a/src/ui/settings/CaptureSettings.svelte b/src/ui/settings/CaptureSettings.svelte new file mode 100644 index 00000000..ff484824 --- /dev/null +++ b/src/ui/settings/CaptureSettings.svelte @@ -0,0 +1,93 @@ + + +
+
+
+
+
+ + diff --git a/src/ui/settings/PodNotesSettingsTab.ts b/src/ui/settings/PodNotesSettingsTab.ts index 9540d1fc..22437bb9 100644 --- a/src/ui/settings/PodNotesSettingsTab.ts +++ b/src/ui/settings/PodNotesSettingsTab.ts @@ -1,17 +1,26 @@ -import { App, PluginSettingTab, Setting } from 'obsidian'; +import { App, MarkdownRenderer, PluginSettingTab, Setting } from 'obsidian'; import PodNotes from '../../main'; import PodcastQueryGrid from './PodcastQueryGrid.svelte'; import PlaylistManager from './PlaylistManager.svelte'; +import { TimestampTemplateEngine } from '../../TemplateEngine'; +import { FilePathTemplateEngine } from '../../TemplateEngine'; +import { episodeCache } from 'src/store'; +import { Episode } from 'src/types/Episode'; +import { get } from 'svelte/store'; + export class PodNotesSettingsTab extends PluginSettingTab { plugin: PodNotes; - + private podcastQueryGrid: PodcastQueryGrid; private playlistManager: PlaylistManager; + private settingsTab: PodNotesSettingsTab; + constructor(app: App, plugin: PodNotes) { super(app, plugin); this.plugin = plugin; + this.settingsTab = this; } display(): void { @@ -29,7 +38,7 @@ export class PodNotesSettingsTab extends PluginSettingTab { .setName('Search Podcasts') .setHeading() .setDesc('Search for podcasts by name or custom feed URL.'); - + const queryGridContainer = settingsContainer.createDiv(); this.podcastQueryGrid = new PodcastQueryGrid({ target: queryGridContainer @@ -44,9 +53,10 @@ export class PodNotesSettingsTab extends PluginSettingTab { this.playlistManager = new PlaylistManager({ target: playlistManagerContainer }); - + this.addDefaultPlaybackRateSetting(settingsContainer); this.addSkipLengthSettings(settingsContainer); + this.addNoteSettings(settingsContainer); } hide(): void { @@ -95,4 +105,118 @@ export class PodNotesSettingsTab extends PluginSettingTab { .setPlaceholder('seconds'); }); } + + private addNoteSettings(settingsContainer: HTMLDivElement) { + const container = settingsContainer.createDiv(); + + container.createEl('h4', { text: 'Note settings' }); + + const timestampSetting = new Setting(container) + .setName('Capture timestamp format') + .setHeading() + .addTextArea(textArea => { + textArea.setValue(this.plugin.settings.timestamp.template) + textArea.setPlaceholder('- {{linktime}} ') + textArea.onChange(value => { + this.plugin.settings.timestamp.template = value; + this.plugin.saveSettings(); + updateTimestampDemo(value); + }); + textArea.inputEl.style.width = "100%"; + }) + + timestampSetting.settingEl.style.flexDirection = 'column'; + timestampSetting.settingEl.style.alignItems = 'unset'; + timestampSetting.settingEl.style.gap = '10px'; + + const timestampFormatDemoEl = container.createDiv(); + + const updateTimestampDemo = (value: string) => { + const demoVal = TimestampTemplateEngine(value); + timestampFormatDemoEl.empty(); + // @ts-ignore - documentation says component is optional, yet not providing one causes an error + MarkdownRenderer.renderMarkdown(demoVal, timestampFormatDemoEl, "", null); + } + + updateTimestampDemo(this.plugin.settings.timestamp.template); + + const randomEpisode = getRandomEpisode(); + + const noteCreationFilePathSetting = new Setting(container) + .setName('Note creation file path') + .setHeading() + .addText((textComponent) => { + textComponent.setValue(this.plugin.settings.note.path) + textComponent.setPlaceholder('inputs/podcasts/{{podcast}} - {{title}}.md') + textComponent.onChange(value => { + this.plugin.settings.note.path = value; + this.plugin.saveSettings(); + + const demoVal = FilePathTemplateEngine(value, randomEpisode); + noteCreationFilePathDemoEl.empty(); + // @ts-ignore - documentation says component is optional, yet not providing one causes an error + MarkdownRenderer.renderMarkdown(demoVal, noteCreationFilePathDemoEl, "", null); + }); + textComponent.inputEl.style.width = "100%"; + }); + + noteCreationFilePathSetting.settingEl.style.flexDirection = 'column'; + noteCreationFilePathSetting.settingEl.style.alignItems = 'unset'; + noteCreationFilePathSetting.settingEl.style.gap = '10px'; + + const noteCreationFilePathDemoEl = container.createDiv(); + + const noteCreationSetting = new Setting(container) + .setName('Note creation template') + .setHeading() + .addTextArea(textArea => { + textArea.setValue(this.plugin.settings.note.template) + textArea.onChange(value => { + this.plugin.settings.note.template = value; + this.plugin.saveSettings(); + }); + + textArea.inputEl.style.width = "100%"; + textArea.inputEl.style.height = "25vh"; + textArea.setPlaceholder( + "## {{title}}" + + "\n![]({{artwork}})" + + "\n### Metadata" + + "\nPodcast:: {{podcast}}" + + "\nEpisode:: {{title}}" + + "\nPublishDate:: {{date:YYYY-MM-DD}}" + + "\n### Description" + + "\n> {{description}}" + ); + }); + + noteCreationSetting.settingEl.style.flexDirection = 'column'; + noteCreationSetting.settingEl.style.alignItems = 'unset'; + noteCreationSetting.settingEl.style.gap = '10px'; + } +} + +function getRandomEpisode(): Episode { + const fallbackDemoObj = { + description: "demo", + podcastName: "demo", + title: "demo", + url: "demo", + artworkUrl: "demo", + streamUrl: "demo", + episodeDate: new Date(), + feedUrl: "demo", + }; + + const feedEpisodes = Object.values(get(episodeCache)); + if (!feedEpisodes.length) return fallbackDemoObj; + + const randomFeed = + feedEpisodes[Math.floor(Math.random() * feedEpisodes.length)]; + if (!randomFeed.length) return fallbackDemoObj; + + const randomEpisode = + randomFeed[Math.floor(Math.random() * randomFeed.length)]; + + return randomEpisode; }