Repository: DominikPieper/obsidian-ReadItLater Branch: master Commit: f7777f9b4416 Files: 63 Total size: 242.0 KB Directory structure: gitextract_eagbvdgs/ ├── .claude/ │ └── settings.local.json ├── .editorconfig ├── .eslintrc.js ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ ├── bug-report.yaml │ │ ├── config.yml │ │ └── feature-request.yaml │ ├── pull_request_template.md │ └── workflows/ │ └── release.yml ├── .gitignore ├── .prettierignore ├── .prettierrc.js ├── CLAUDE.md ├── LICENSE ├── README.md ├── esbuild.config.mjs ├── manifest.json ├── package.json ├── src/ │ ├── NoteService.ts │ ├── ReadtItLaterApi.ts │ ├── constants/ │ │ └── urlProtocols.ts │ ├── enums/ │ │ ├── delimiter.ts │ │ ├── enum.ts │ │ └── fileExistsStrategy.ts │ ├── error/ │ │ ├── FileExists.ts │ │ └── FileNotFound.ts │ ├── helpers/ │ │ ├── date.ts │ │ ├── domUtils.ts │ │ ├── error.ts │ │ ├── fileutils.ts │ │ ├── networkUtils.ts │ │ ├── numberUtils.ts │ │ ├── replaceImages.ts │ │ ├── setting.ts │ │ └── stringUtils.ts │ ├── main.ts │ ├── modal/ │ │ └── FileExistsAsk.ts │ ├── parsers/ │ │ ├── BilibiliParser.ts │ │ ├── BlueskyParser.ts │ │ ├── GithubParser.ts │ │ ├── MastodonParser.ts │ │ ├── Note.ts │ │ ├── Parser.ts │ │ ├── ParserCreator.ts │ │ ├── PinterestParser.ts │ │ ├── StackExchangeParser.ts │ │ ├── TextSnippetParser.ts │ │ ├── TikTokParser.ts │ │ ├── TwitterParser.ts │ │ ├── VimeoParser.ts │ │ ├── WebsiteParser.ts │ │ ├── WikipediaParser.ts │ │ ├── YoutubeChannelParser.ts │ │ ├── YoutubeParser.ts │ │ └── parsehtml.ts │ ├── repository/ │ │ ├── DefaultVaultRepository.ts │ │ └── VaultRepository.ts │ ├── settings.ts │ ├── template/ │ │ └── TemplateEngine.ts │ ├── turndown-plugin-gfm.d.ts │ └── views/ │ └── settings-tab.ts ├── styles.css ├── tsconfig.json └── versions.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .claude/settings.local.json ================================================ { "permissions": { "allow": [ "Bash(npm run:*)" ] } } ================================================ FILE: .editorconfig ================================================ root = true [*] charset = utf-8 indent_style = space indent_size = 4 insert_final_newline = true trim_trailing_whitespace = true [*.xml] indent_size = 2 [*.json] indent_size = 2 # Stop IDEs adding newlines to end of Obsidian .json config files: insert_final_newline = false [*.{yml,yaml}] indent_size = 2 [*.{md,mdx}] trim_trailing_whitespace = true [*.{htm,html,js,jsm,ts,tsx,mjs}] indent_size = 4 [*.{cmd,bat,ps1}] end_of_line = crlf [*.sh] end_of_line = lf ================================================ FILE: .eslintrc.js ================================================ module.exports = { env: { es2016: true, node: true, browser: true, }, extends: [ 'plugin:prettier/recommended', 'eslint:recommended', 'plugin:@typescript-eslint/eslint-recommended', 'plugin:import/typescript', ], globals: { Atomics: 'readonly', SharedArrayBuffer: 'readonly', }, parser: '@typescript-eslint/parser', parserOptions: { ecmaVersion: 7, sourceType: 'module', ecmaFeatures: { modules: true, }, }, plugins: ['@typescript-eslint', 'import'], rules: { 'linebreak-style': ['error', 'unix'], quotes: ['error', 'single', { avoidEscape: true }], '@typescript-eslint/no-unused-vars': 0, // Configured in tsconfig instead. 'no-unused-vars': 0, // Configured in tsconfig instead. 'prettier/prettier': [ 'error', { trailingComma: 'all', printWidth: 120, tabWidth: 4, useTabs: false, singleQuote: true, bracketSpacing: true, }, ], semi: ['error', 'always'], 'import/order': 'error', 'sort-imports': [ 'error', { ignoreDeclarationSort: true, }, ], 'no-useless-escape': 0 }, }; ================================================ FILE: .github/ISSUE_TEMPLATE/bug-report.yaml ================================================ name: Bug Report description: File a bug report labels: ["type: bug"] body: - type: markdown attributes: value: | ## Before you start Please **make sure you are on the latest version of plugin.** If you encountered the issue after you installed, updated, or reloaded the plugin, **please try restarting obsidian before reporting the bug**. - type: checkboxes id: no-duplicate-issues attributes: label: "Please check that this issue hasn't been reported before." description: "The **Label filters** may help make your search more focussed." options: - label: "I searched previous [Bug Reports](https://github.com/DominikPieper/obsidian-ReadItLater/labels/type%3A%20bug) didn't find any similar reports." required: true - type: textarea id: expected attributes: label: Expected Behavior description: Tell us what **should** happen. validations: required: true - type: textarea id: what-happened attributes: label: Current behaviour description: | Tell us what happens instead of the expected behavior. Adding of screenshots really helps. validations: required: true - type: textarea id: reproduce attributes: label: Steps to reproduce description: | Which exact steps can a developer take to reproduce the issue? The more detail you provide, the easier it will be to narrow down and fix the bug. Please paste in any relevant text such as URL of page you want to save. validations: required: true - type: checkboxes id: operating-systems attributes: label: Which Operating Systems are you using? description: You may select more than one. options: - label: Android - label: iPhone/iPad - label: Linux - label: macOS - label: Windows validations: required: true - type: input id: obsidian-version attributes: label: Obsidian Version description: Which Obsidian version are you using? placeholder: 0.15.9 validations: required: true - type: input id: plugin-version attributes: label: Plugin Version description: Which plugin version are you using? placeholder: 0.0.18 validations: required: true - type: checkboxes id: other-plugins-disabled attributes: label: Checks description: Please confirm options: - label: I have tried it with all other plugins disabled and the error still occurs required: false - type: textarea id: possible-solution attributes: label: Possible solution description: | Not obligatory, but please suggest a fix or reason for the bug, if you have an idea. ================================================ FILE: .github/ISSUE_TEMPLATE/config.yml ================================================ blank_issues_enabled: false contact_links: - name: Ask a question url: https://github.com/DominikPieper/obsidian-ReadItLater/discussions/categories/q-a about: Ask for help with using the plugin. In case your question has been asked before, for quicker results you can search existing Discussion Q&As. ================================================ FILE: .github/ISSUE_TEMPLATE/feature-request.yaml ================================================ name: Feature Request description: Request a new feature labels: ["type: enhancement"] body: - type: checkboxes id: no-duplicate-issues attributes: label: "⚠️ Please check that this feature request hasn't been suggested before." description: "There are two locations for previous feature requests. Please search in both. Thank you. The **Label filters** may help make your search more focussed." options: - label: "I searched previous [Ideas in Discussions](https://github.com/DominikPieper/obsidian-ReadItLater/discussions/categories/ideas) didn't find any similar feature requests." required: true - label: "I searched previous [Issues](https://github.com/DominikPieper/obsidian-ReadItLater/labels/type%3A%20enhancement) didn't find any similar feature requests." required: true - type: textarea id: feature-description validations: required: true attributes: label: "🔖 Feature description" description: "A clear and concise description of what the feature request is." placeholder: "You should add ..." - type: textarea id: solution validations: required: true attributes: label: "✔️ Solution" description: "A clear and concise description of what you want to happen, and why." placeholder: "In my use-case, ..." - type: textarea id: alternatives validations: required: false attributes: label: "❓ Alternatives" description: "A clear and concise description of any alternative solutions or features you've considered." placeholder: "I have considered ..." - type: textarea id: additional-context validations: required: false attributes: label: "📝 Additional Context" description: "Add any other context or screenshots about the feature request here." placeholder: "..." ================================================ FILE: .github/pull_request_template.md ================================================ # Description ## Motivation and Context ## How has this been tested? ## Screenshots (if appropriate) ## Types of changes Changes visible to users: - [ ] **Bug fix** (prefix: `fix` - non-breaking change which fixes an issue) - [ ] **New feature** (prefix: `feat` - non-breaking change which adds functionality) - [ ] **Breaking change** (prefix: `feat!!` or `fix!!` - fix or feature that would cause existing functionality to not work as expected) - [ ] **Documentation** (prefix: `docs` - improvements to any documentation content) Internal changes: - [ ] **Refactor** (prefix: `refactor` - non-breaking change which only improves the design or structure of existing code, and making no changes to its external behaviour) - [ ] **Tests** (prefix: `test` - additions and improvements to unit tests and the smoke tests) - [ ] **Infrastructure** (prefix: `chore` - examples include GitHub Actions, issue templates) ## Checklist - [ ] My code follows the code style of this project and passes `npm run lint`. - [ ] My change requires an update of README.md - [ ] I have updated the README.md ================================================ FILE: .github/workflows/release.yml ================================================ name: Release Obsidian Plugin on: push: # Sequence of patterns matched against refs/tags tags: - '*' # Push events to matching any tag format, i.e. 1.0, 20.15.10 jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 with: fetch-depth: 0 # otherwise, you will failed to push refs to dest repo - name: Use Node.js uses: actions/setup-node@v3 with: node-version: '16.x' # You might need to adjust this value to your own version # Get the version number and put it in a variable - name: Get Version id: version run: | echo "::set-output name=tag::$(git describe --abbrev=0 --tags)" # Build the plugin - name: Build id: build run: | npm install npm run build --if-present # Package the required files into a zip - name: Package run: | mkdir ${{ github.event.repository.name }} cp main.js manifest.json README.md ${{ github.event.repository.name }} zip -r ${{ github.event.repository.name }}.zip ${{ github.event.repository.name }} # Create the release on github - name: Create Release id: create_release uses: actions/create-release@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} VERSION: ${{ github.ref }} with: tag_name: ${{ github.ref }} release_name: ${{ github.ref }} draft: true prerelease: false # Upload the packaged release file - name: Upload zip file id: upload-zip uses: actions/upload-release-asset@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: ${{ steps.create_release.outputs.upload_url }} asset_path: ./${{ github.event.repository.name }}.zip asset_name: ${{ github.event.repository.name }}-${{ steps.version.outputs.tag }}.zip asset_content_type: application/zip # Upload the main.js - name: Upload main.js id: upload-main uses: actions/upload-release-asset@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: ${{ steps.create_release.outputs.upload_url }} asset_path: ./main.js asset_name: main.js asset_content_type: text/javascript # Upload the manifest.json - name: Upload manifest.json id: upload-manifest uses: actions/upload-release-asset@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: ${{ steps.create_release.outputs.upload_url }} asset_path: ./manifest.json asset_name: manifest.json asset_content_type: application/json ================================================ FILE: .gitignore ================================================ # Intellij *.iml .idea # npm node_modules # build main.js *.js.map # obsidian data.json ================================================ FILE: .prettierignore ================================================ rollup.config.js ================================================ FILE: .prettierrc.js ================================================ module.exports = { semi: true, trailingComma: "all", singleQuote: true, printWidth: 120, tabWidth: 4 }; ================================================ FILE: CLAUDE.md ================================================ # CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## Commands ```bash npm run dev # Development build with watch mode npm run build # Production build (minified) npm run lint # ESLint with auto-fix (src/ only) ``` There are no automated tests. The build output is a single `main.js` file bundled by esbuild. ## Architecture **ReadItLater** is an Obsidian plugin that saves web content as markdown notes. It supports 14 content types via a parser chain pattern. ### Data Flow 1. User triggers action (ribbon icon, command, or context menu) → clipboard or provided content 2. `ReadItLaterApi.processContent()` → `NoteService.createNote()` 3. `ParserCreator` iterates registered parsers calling `parser.test(content)` until one matches 4. Matched parser's `prepareNote()` fetches metadata and returns a `Note` object 5. `TemplateEngine` renders note title and body using per-content-type templates from settings 6. `DefaultVaultRepository` writes the file to the Obsidian vault ### Key Components - **`src/main.ts`** — Plugin entry point; registers parsers in priority order, sets up commands/ribbon/settings - **`src/NoteService.ts`** — Core business logic: note creation, batch processing, file conflict handling (Ask/Append/Nothing strategies), cursor insertion - **`src/parsers/Parser.ts`** — Abstract base: `test(content): boolean` and `prepareNote(content): Promise` - **`src/parsers/ParserCreator.ts`** — Factory that selects the right parser; parsers are registered in priority order (specific parsers before `WebsiteParser`, with `TextSnippetParser` as catch-all) - **`src/parsers/Note.ts`** — Data model returned by parsers (fileName, content, contentType, metadata) - **`src/template/TemplateEngine.ts`** — Custom template engine: `{{ variable }}`, `{{ value | filter }}`, `{% for item in array %}...{% endfor %}` - **`src/repository/DefaultVaultRepository.ts`** — Vault I/O: creates directories (supports path templating with `date`/`fileName`/`contentType` variables), handles file existence, appends content - **`src/settings.ts`** — 80+ settings; per-content-type title template, note template, and content slug; also defines available template filters ### Adding a New Parser 1. Create `src/parsers/MyParser.ts` extending `Parser` 2. Implement `test()` to identify matching URLs/content 3. Implement `prepareNote()` to fetch metadata and return a `Note` 4. Register in `src/main.ts` before `WebsiteParser` in the parser list 5. Add corresponding settings and defaults in `src/settings.ts` ### Template Engine Templates use `{{ variableName }}` syntax. Filters chain with `|`: - `blockquote`, `capitalize`, `upper`, `lower` - `replace(search, replacement)` - `join(separator)` - `striptags` - `map(key)` (for arrays of objects) - `numberLexify` (formats numbers) Loops: `{% for item in array %}...{% endfor %}` ### Code Style - TypeScript strict mode (`noImplicitAny`) - Single quotes, 4-space indent, 120-char line width, trailing commas - Target: ES2022, ESNext modules - Platform: Desktop + Mobile (no Node.js APIs; use Obsidian's `requestUrl` instead of `fetch`) ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2021 Dominik Pieper Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # ReadItLater Plugin for Obsidian ## Table of contents - [Introduction](#introduction) - [Content Types](#content-types) - [Website Article](#website-article) - [Youtube](#youtube) - [Youtube Channel](#youtube-channel) - [Twitter](#twitter) - [Bluesky](#bluesky) - [Stack Exchange](#stack-exchange) - [Pinterest](#pinterest) - [Mastodon](#mastodon) - [Vimeo](#vimeo) - [Bilibili](#bilibili) - [TikTok](#tiktok) - [Text Snippet](#text-snippet) - [API](#api) ## Introduction Save the web with ReadItLater plugin for Obsidian. Archive web pages for reading later, referencing in your second brain or for other flexible use case Obsidian provides. ReadItLater can do a lot more than converting web pages to markdown. For every content type there is specific template with carefully selected variables to ease up your archiving process. ### What makes ReadItLater plugin great? - Simple, but powerful template engine - Carefully selected predefined template variables to straightforward archiving process - Compatibility with Obsidian iOS and Android apps - Downloading images from articles to your Vault - Batch processing of URLs list ### How to use ReadItLater plugin? To create single note you can either click on the plugin ribbon icon, select `ReadItLater: Create from clipboard` from command palette or click on `ReadItLater` shortcut in context or share menu. You can also create multiple notes from batch of URLs, delimited by selected delimiter in plugin settings using `ReadItLater: Create from batch in clipboard` command. If you want just add content to existing note, you can use `ReadItLater: Insert at the cursor position` command to insert content after the current cursor position. ## Template engine ReadItLater provides for every content type dedicated template that can be edited in plugin settings. ### Variables Variables are rendered in template using familiar syntax `{{ content }}`. Nested data types can be accessed using dot notation `{{ author.name }}`. ### Filters Variables output can be modified using filters. Filters are separated by `|` symbol. Filters can be chained, so output of the previous is passed to the next.
blockquote Adds quote prefix to each line of value.
capitalize Modifies first character to uppercase and others to lowercase. ``` {{ 'hello world'|capitalize}} outputs: Hello world ```
numberLexify Converts number to lexified format. ``` {{ 12682|numberLexify}} outputs: 12.6K ```
lower Converts value to lowercase. ``` {{ 'Hello World'|lower}} outputs: hello world ```
replace Replaces all occurrences in input value. ``` {{ 'Hello world'|replace('o') }} outputs: Hell wrld ```
upper Converts value to uppercase. ``` {{ 'Hello World'|upper}} outputs: HELLO WORLD ```
## Inbox and Assets directories You can use template variables in `Inbox dir` and `Assets dir` settings to better distribute content in your Vault. | Directory template variable | Description | | --------------------------- | -------------------------------------------------- | | date | Current date in format from plguin settins | | fileName | Filename of new note | | contentType | Slug of detected content type from plugin settings | ## Content Types Structure of note content is determined by URL. Currenty plugin supports saving content of websites and embedding content from multiple services. Each content type has title and note template with replacable variables, which can be edited in plugin settings. Available content types are ordered by URL detection priority. ### Website Article Will be parsed to readable form using [Mozilla Readability](https://github.com/mozilla/readability) and then converted to markdown. In case website content is marked by [Readbility](https://github.com/mozilla/readability) as not readable, empty note with URL will be created. If enabled, images will be downloaded to folder (default is `ReadItLater Inbox/assets`) configured in plugin settings. (Supported only on desktop for now) | Title template variable | Description | | ------------------------| ---------------------------------------- | | title | Article title from `` HTML tag | | date | Current date in format from plugin settings | | Content template variable | Description | |---------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | articleTitle | Article title from `<title>` HTML tag | | articleURL | Article URL | | articleReadingTime | Estimated reading time in minutes by Readbility.js | | articleContent | Article content | | date | Current date in format from plugin settings | | previewURL | Aritlce preview image URL parsed from [OpenGraph](https://ogp.me/) or [Twitter](https://developer.twitter.com/en/docs/twitter-for-websites/cards/overview/markup) image `<meta>` property | | publishedTime | Article publish time parsed from [OpenGraph](https://ogp.me/) `<meta>` property or [Schema.org](https://schema.org/) JSON and formatted in content format from plugin settings | ### Youtube | Title template variable | Description | | ----------------------- | ------------------------------------------- | | title | Video title | | date | Current date in format from plugin settings | | Content template variable | Description | | ------------------------- | -------------------------------------------------------------------- | | videoTitle | Video title | | date | Current date in format from plugin settings | | videoDescription | Video description | | videoURL | Video URL on Youtube.com | | videoId | Video ID | | videoPlayer | Embeded player generated by plugin | | channelId | Channel ID | | channelName | Channel name | | channelURL | Channel URL on Youtube.com | | videoThumbnail | Video thumbnail image URL | | videoChapters | List of video chapters with linked timestamps | | videoPublishDate | Video plublish date formatted in content format from plugin settings | | videoViewsCount | Video views count | | Chapter template variable | Description | | ------------------------- | ----------------------------- | | chapterTitle | Chapter title | | chapterTimestamp | Chapter start time in mm:ss | | chapterSeconds | Chapter start time in seconds | | chapterUrl | Url to chapter start | Parsing of HTML DOM has its limitations thus additional data can be fetched only from [Google API](https://developers.google.com/youtube/v3/getting-started). Retrieved API key can be set in plugin settings and then plugin will use the Google API for fetching data. | Content template variable | Description | | ------------------------- | -------------------------------------------------------------------- | | videoDuration | Video duration in seconds | | videoDurationFormatted | Formatted video duration (1h 25m 23s) | | videoTags | Formatted list of tags delimited by space | ### Youtube Channel | Title template variable | Description | | ----------------------- | -------------------------------------------- | | title | Channel title. | | date | Current date in format from plugin settings. | | Content template variable | Description | | ------------------------- | ---------------------------------------------------- | | date | Current date in format from plugin settings. | | channelId | Channel ID. | | channelTitle | Channel title. | | channelDescription | Channel description. | | channelURL | Channel URL on Youtube.com. | | channelAvatar | URL of channel's avatar (thumbnail) image. | | channelBanner | URL of channel's banner image. | | channelSubscribersCount | The number of subscribers that the channel has. | | channelVideosCount | The number of public videos uploaded to the channel. | | channelVideosURL | URL to channel's videos on Youtube.com | | channelShortsURL | URL to channel's shorts on Youtube.com | ### X.com (Twitter) Parser use [X Publish API](https://publish.twitter.com/) to fetch data. | Title template variable | Description | | ----------------------- | ------------------------------------------- | | tweetAuthorName | Post author name | | date | Current date in format from plugin settings | | Content template variable | Description | | ------------------------- | ------------------------------------------------------------------ | | tweetAuthorName | Post author name | | date | Current date in format from plugin settings | | tweetURL | Post URL on X.com | | tweetContent | Post content | | tweetPublishDate | Post publish date formatted in content format from plugin settings | ### Bluesky Parser fetch the content from Bluesky API. | Title template variable | Description | | ----------------------- | ------------------------------------------- | | authorHandle | Post author handle | | authorName | Post author name | | date | Current date in format from plugin settings | | Content template variable | Description | | ------------------------- | ------------------------------------------------------------------------------- | | date | Current date in format from plugin settings | | content | Formatted post content with embedded content. If enabled, replies are appended. | | postURL | Post URL | | authorHandle | Post author handle | | authorName | Post author name | | likeCount | Post like count | | replyCount | Post reply count | | repostCount | Post repost count | | quouteCount | Post quote count | | publishedAt | Post publish time | If enabled in plugin settings, you can fetch also replies. ***Reply template variable*** | Content template variable | Description | | ------------------------- | --------------------------------------------- | | date | Current date in format from plugin settings | | content | Formatted post content with embedded content. | | postURL | Reply URL | | authorHandle | Reply author handle | | authorName | Reply author name | | likeCount | Reply like count | | replyCount | Reply reply count | | repostCount | Reply repost count | | quouteCount | Reply quote count | | publishedAt | Reply publish time | ### Stack Exchange | Title template variable | Description | | ----------------------- | ------------------------------------------- | | title | Question title | | date | Current date in format from plugin settings | ***Note template variables*** | Content template variable | Description | | ------------------------- | ------------------------------------------- | | date | Current date in format from plugin settings | | questionTitle | Question title | | questionURL | Question URL on selected StackExchange site | | questionContent | Question content | | authorName | Question author name | | authorProfileURL | Question author profile URL | | topAnswer | Formatted first answer | | answers | Formatted other answers | ***Answer template variables*** | Content template variable | Description | | ------------------------- | ------------------------------------------- | | date | Current date in format from plugin settings | | answerContent | Answer content | | authorName | Answer author name | | authorProfileURL | Answer author profile URL | ### Pinterest | Title template variable | Description | | ----------------------- | -------------------------------------------- | | authorName | Pin author name | | date | Current date in format from plugin settings. | | Content template variable | Description | | ------------------------- | ---------------------------------------------------- | | date | Current date in format from plugin settings. | | pinId | Pin ID. | | pinURL | URL of pin. | | title | Pin title. | | link | Pin link. | | image | URL of pin image. | | description | Pin description. | | likeCount | Pin like count. | | authorName | Pin author name. | | authorProfileURL | URL of Pin author page. | ### Mastodon | Title template variable | Description | | ----------------------- | ------------------------------------------- | | tootAuthorName | Status author name | | date | Current date in format from plugin settings | | Content template variable | Description | | ------------------------- | ------------------------------------------- | | tootAuthorName | Status author name | | date | Current date in format from plugin settings | | tootURL | Status URL on selected Mastodon instance | | tootContent | Status content | If enabled in plugin settings, you can fetch also replies. ***Reply template variable*** | Content template variable | Description | | ------------------------- | ------------------------------------------- | | tootAuthorName | Reply author name | | tootURL | Reply URL on selected Mastodon instance | | tootContent | Reply content | ### Vimeo | Title template variable | Description | | ----------------------- | ------------------------------------------- | | title | Video title | | date | Current date in format from plugin settings | | Content template variable | Description | | ------------------------- | ------------------------------------------- | | videoTitle | Video title | | date | Current date in format from plugin settings | | videoURL | Video URL on Vimeo.com | | videoId | Video ID | | videoPlayer | Embeded player generated by plugin | | channelName | Channel name | | channelURL | Channel URL on Vimeo.com | ### Bilibili | Title template variable | Description | | ----------------------- | ------------------------------------------- | | title | Video title | | date | Current date in format from plugin settings | | Content template variable | Description | | ------------------------- | ------------------------------------------- | | videoTitle | Video title | | date | Current date in format from plugin settings | | videoURL | Video URL on Bilibili.com | | videoId | Video ID | | videoPlayer | Embeded player generated by plugin | ### TikTok | Title template variable | Description | | ----------------------- | ------------------------------------------- | | authorName | Video author name | | date | Current date in format from plugin settings | | Content template variable | Description | | ------------------------- | ------------------------------------------- | | videoDescription | Video description | | date | Current date in format from plugin settings | | videoURL | Video URL on TikTok.com | | videoId | Video ID | | videoPlayer | Embeded player generated by plugin | | authorName | Author name | | authorURL | Author profile URL on TikTok.com | ### Text Snippet If your clipboard content is not recognized by any of above parsers plugin will create note with unformatted clipboard content. | Title template variable | Description | | ------------------------| ------------------------------------------- | | date | Current date in format from plugin settings | | Content template variable | Description | | ------------------------- | ------------------------------------------- | | content | Clipboard content | | date | Current date in format from plugin settings | ## API To invoke functionality from other plugins we provide an API. You can access it via `this.app.plugins.plugins['obsidian-read-it-later'].api` which is an instance of `ReadItLaterAPI` class defined in [src/ReadItLaterApi.ts](https://github.com/DominikPieper/obsidian-ReadItLater/blob/master/src/ReadItLaterApi.ts). ================================================ FILE: esbuild.config.mjs ================================================ import esbuild from "esbuild"; import process from "process"; import builtins from 'builtin-modules' const banner = `/* THIS IS A GENERATED/BUNDLED FILE BY ESBUILD if you want to view the source, please visit the github repository of this plugin */ `; const prod = (process.argv[2] === 'production'); const context = await esbuild.context({ banner: { js: banner, }, entryPoints: ["src/main.ts"], bundle: true, external: [ "obsidian", "electron", "@codemirror/autocomplete", "@codemirror/collab", "@codemirror/commands", "@codemirror/language", "@codemirror/lint", "@codemirror/search", "@codemirror/state", "@codemirror/view", "@lezer/common", "@lezer/highlight", "@lezer/lr", ...builtins], format: "cjs", target: "es2018", logLevel: "info", sourcemap: prod ? false : "inline", minify: prod ? true : false, treeShaking: true, outfile: "main.js", }); if (prod) { await context.rebuild(); process.exit(0); } else { await context.watch(); } ================================================ FILE: manifest.json ================================================ { "id": "obsidian-read-it-later", "name": "ReadItLater", "version": "0.11.4", "minAppVersion": "1.7.7", "description": "Save online content to your Vault, utilize embedded template engine and organize your reading list to your needs. Preserve the web with ReadItLater.", "author": "Dominik Pieper", "authorUrl": "https://github.com/DominikPieper", "isDesktopOnly": false } ================================================ FILE: package.json ================================================ { "name": "obsidian-ReadItLater", "version": "0.11.4", "description": "Save online content to your Vault, utilize embedded template engine and organize your reading list to your needs. Preserve the web with ReadItLater.", "main": "main.js", "scripts": { "dev": "node esbuild.config.mjs development", "build": "node esbuild.config.mjs production", "lint": "eslint ./src --fix" }, "keywords": [], "author": "Dominik Pieper", "license": "MIT", "devDependencies": { "@types/node": "^16.18.11", "@types/spark-md5": "^3.0.4", "@types/turndown": "^5.0.4", "@typescript-eslint/eslint-plugin": "^5.49.0", "@typescript-eslint/parser": "^5.49.0", "builtin-modules": "^3.3.0", "esbuild": "0.21.5", "eslint": "^8.33.0", "eslint-config-prettier": "^8.6.0", "eslint-plugin-import": "^2.29.1", "eslint-plugin-prettier": "^4.2.1", "isomorphic-dompurify": "^0.20.0", "obsidian": "^1.5.7", "prettier": "^2.8.3", "tslib": "2.4.0", "typescript": "5.1.6" }, "dependencies": { "@guyplusplus/turndown-plugin-gfm": "^1.0.7", "@mozilla/readability": "^0.5.0", "@types/gapi.youtube": "^3.0.40", "@types/mime-types": "^2.1.4", "iso8601-duration": "^2.1.2", "mime": "^4.0.4", "turndown": "^7.2.0" } } ================================================ FILE: src/NoteService.ts ================================================ import { Editor, Notice } from 'obsidian'; import ParserCreator from './parsers/ParserCreator'; import { VaultRepository } from './repository/VaultRepository'; import { Note } from './parsers/Note'; import FileExistsError from './error/FileExists'; import FileExistsAsk from './modal/FileExistsAsk'; import { FileExistsStrategy } from './enums/fileExistsStrategy'; import ReadItLaterPlugin from './main'; import { getAndCheckUrls } from './helpers/stringUtils'; export class NoteService { constructor( private parserCreator: ParserCreator, private plugin: ReadItLaterPlugin, private repository: VaultRepository, ) {} public async createNote(content: string): Promise<void> { const note = await this.makeNote(content); try { await this.repository.saveNote(note); } catch (error) { if (error instanceof FileExistsError) { this.handleFileExistsError([note]); } } } public async createNotesFromBatch(contentBatch: string): Promise<void> { const urlCheckResult = getAndCheckUrls(contentBatch, this.plugin.settings.batchProcessDelimiter); const existingNotes: Note[] = []; for (const contentSegment of urlCheckResult.everyLineIsURL ? urlCheckResult.urls : [contentBatch]) { const note = await this.makeNote(contentSegment); try { await this.repository.saveNote(note); } catch (error) { if (error instanceof FileExistsError) { existingNotes.push(note); } } } if (existingNotes.length > 0) { this.handleFileExistsError(existingNotes); } } public async insertContentAtEditorCursorPosition(content: string, editor: Editor): Promise<void> { const note = await this.makeNote(content); editor.replaceRange(note.content, editor.getCursor()); } private async makeNote(content: string): Promise<Note> { try { const parser = await this.parserCreator.createParser(content); return await parser.prepareNote(content); } catch (error) { new Notice(`ReadItLater: Failed to process content. ${error.message}`); throw error; } } private openNote(note: Note): void { if (this.plugin.settings.openNewNote || this.plugin.settings.openNewNoteInNewTab) { try { const file = this.repository.getFileByPath(note.filePath); this.plugin.app.workspace .getLeaf(this.plugin.settings.openNewNoteInNewTab ? 'tab' : false) .openFile(file); } catch (error) { console.error(error); new Notice(`Unable to open ${note.getFullFilename()}`); } } } private async handleFileExistsError(notes: Note[]): Promise<void> { switch (this.plugin.settings.fileExistsStrategy) { case FileExistsStrategy.Ask: new FileExistsAsk(this.plugin.app, notes, (strategy, doNotAskAgain) => { this.handleFileAskModalResponse(strategy, doNotAskAgain, notes); }).open(); break; case FileExistsStrategy.Nothing: this.handleFileExistsStrategyNothing(notes); break; case FileExistsStrategy.AppendToExisting: this.handleFileExistsStrategyAppend(notes); break; } } private async handleFileAskModalResponse( strategy: FileExistsStrategy, doNotAskAgain: boolean, notes: Note[], ): Promise<void> { switch (strategy) { case FileExistsStrategy.Nothing: this.handleFileExistsStrategyNothing(notes); break; case FileExistsStrategy.AppendToExisting: this.handleFileExistsStrategyAppend(notes); break; } if (doNotAskAgain) { this.plugin.saveSetting('fileExistsStrategy', strategy as FileExistsStrategy); } if (notes.length === 1) { this.openNote(notes.shift()); } } private async handleFileExistsStrategyAppend(notes: Note[]): Promise<void> { for (const note of notes) { try { await this.repository.appendToExistingNote(note); new Notice(`${note.getFullFilename()} was updated.`); } catch (error) { console.error(error); new Notice(`${note.getFullFilename()} was not updated!`, 0); } } } private handleFileExistsStrategyNothing(notes: Note[]): void { for (const note of notes) { new Notice(`${note.getFullFilename()} already exists.`); } } } ================================================ FILE: src/ReadtItLaterApi.ts ================================================ import { Editor } from 'obsidian'; import { NoteService } from './NoteService'; export class ReadItLaterApi { constructor(private noteService: NoteService) {} /** * Create single note from provided input. */ public async processContent(content: string): Promise<void> { this.noteService.createNote(content); } /** * Create multiple notes from provided input delimited by delimiter defined in plugin settings. */ public async processContentBatch(contentBatch: string): Promise<void> { this.noteService.createNotesFromBatch(contentBatch); } /** * Insert processed content from input at current position in editor. */ public async insertContentAtEditorCursorPosition(content: string, editor: Editor): Promise<void> { this.noteService.insertContentAtEditorCursorPosition(content, editor); } } ================================================ FILE: src/constants/urlProtocols.ts ================================================ // represents protocols of URL objects (https://developer.mozilla.org/en-US/docs/Web/API/URL) export const HTTP_PROTOCOL: string = 'http:'; export const HTTPS_PROTOCOL: string = 'https:'; ================================================ FILE: src/enums/delimiter.ts ================================================ import { DropdownEnumOption } from './enum'; export enum Delimiter { NewLine = 'newLine', Comma = 'comma', Period = 'period', Semicolon = 'semicolon', } export function getDelimiterOptions(): DropdownEnumOption[] { return [ { label: 'New Line', option: Delimiter.NewLine }, { label: 'Comma', option: Delimiter.Comma }, { label: 'Period', option: Delimiter.Period }, { label: 'Semicolon', option: Delimiter.Semicolon }, ]; } export function getDelimiterValue(type: Delimiter): string { switch (type) { case Delimiter.NewLine: return '\n'; case Delimiter.Comma: return ','; case Delimiter.Period: return '.'; case Delimiter.Semicolon: return ';'; } } ================================================ FILE: src/enums/enum.ts ================================================ export interface DropdownEnumOption { label: string; option: string; } ================================================ FILE: src/enums/fileExistsStrategy.ts ================================================ import { DropdownEnumOption } from './enum'; export enum FileExistsStrategy { AppendToExisting = 'appendToExisting', Ask = 'ask', Nothing = 'nothing', } export function getFileExistStrategyOptions(): DropdownEnumOption[] { return [ { label: 'Ask', option: FileExistsStrategy.Ask }, { label: 'Nothing', option: FileExistsStrategy.Nothing }, { label: 'Append to the existing note', option: FileExistsStrategy.AppendToExisting }, ]; } ================================================ FILE: src/error/FileExists.ts ================================================ export default class FileExistsError extends Error { constructor(message: string) { super(message); this.name = 'FileExistsError'; } } ================================================ FILE: src/error/FileNotFound.ts ================================================ export default class FileNotFoundError extends Error { constructor(message: string) { super(message); this.name = 'FileNotFoundError'; } } ================================================ FILE: src/helpers/date.ts ================================================ import { moment } from 'obsidian'; export function formatCurrentDate(format: string): string { return formatDate(new Date(), format); } export function formatDate(date: Date | string, format: string): string { return moment(date).format(format); } ================================================ FILE: src/helpers/domUtils.ts ================================================ export interface JavascriptDeclaration { type: string; name: string; value: string; } const DECLARATION_REGEX = /(const|let|var)\s+(\w+)\s*=\s*(.+|\n+?)\s*(?=(?:^|\s+)(const|let|var)\s+|$)/g; export function getJavascriptDeclarationByName( name: string, elements: Element[] | NodeList, ): JavascriptDeclaration | undefined { return getJavascriptDeclarationsFromElement(elements).find((declaration: JavascriptDeclaration) => { return declaration.name === name; }); } function getJavascriptDeclarationsFromElement(elements: Element[] | NodeList): JavascriptDeclaration[] { const declarations: JavascriptDeclaration[] = []; let match; elements.forEach((element) => { while ((match = DECLARATION_REGEX.exec(element.textContent)) !== null) { declarations.push({ type: match[1].trim(), name: match[2].trim(), value: match[3].trim().replace(/;+\s*$/, ''), }); } }); return declarations; } ================================================ FILE: src/helpers/error.ts ================================================ import { Notice } from 'obsidian'; export function handleError(error: Error, noticeMessage: string) { new Notice(`${noticeMessage} Check the console output.`); throw error; } ================================================ FILE: src/helpers/fileutils.ts ================================================ import { CapacitorAdapter, FileSystemAdapter, Platform, normalizePath } from 'obsidian'; import { ReadItLaterSettings } from 'src/settings'; import mime from 'mime'; export interface FilesystemLimits { path: number; fileName: number; } export function isValidUrl(url: string, allowedProtocols: string[] = []): boolean { let urlObject; try { urlObject = new URL(url); } catch (e) { return false; } if (allowedProtocols.length === 0) { return true; } return allowedProtocols.includes(urlObject.protocol); } export function getBaseUrl(url: string, origin: string): string { const baseURL = new URL(url, origin); return baseURL.href; } export function normalizeFilename(fileName: string, preserveUnicode: boolean = true): string { if (preserveUnicode) { return fileName.replace(/[:#/\\|?*<>"]/g, ''); } return fileName.replace( /[:#/\\()|?*<>"[\u{1F300}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}\u{1F000}-\u{1F02F}\u{1F0A0}-\u{1F0FF}\u{1F100}-\u{1F64F}\u{1F680}-\u{1F6FF}]/gu, '', ); } export function getOsOptimizedPath( path: string, fileName: string, dataAdapter: CapacitorAdapter | FileSystemAdapter, filesystemLimits: FilesystemLimits, ): string { const fileExtension = `.${getFileExtension(fileName)}`; let optimizedFileName = fileName; if (optimizedFileName.length > filesystemLimits.fileName) { optimizedFileName = optimizedFileName.substring(0, filesystemLimits.fileName - fileExtension.length) + `${fileExtension}`; } let optimizedFilePath = normalizePath(`${path}/${optimizedFileName}`); const fullPath = dataAdapter.getFullPath(optimizedFilePath); if (fullPath.length > filesystemLimits.path) { optimizedFilePath = optimizedFilePath.substring( 0, optimizedFilePath.length - (fullPath.length - filesystemLimits.path) - fileExtension.length, ) + `${fileExtension}`; } return optimizedFilePath; } export function getFileSystemLimits(platform: typeof Platform, settings: ReadItLaterSettings): FilesystemLimits { const defaultFilesystenLimits = getDefaultFilesystenLimits(platform); return { path: settings.filesystemLimitPath ?? defaultFilesystenLimits.path, fileName: settings.filesystemLimitFileName ?? defaultFilesystenLimits.fileName, }; } export function getDefaultFilesystenLimits(platform: typeof Platform): FilesystemLimits { if (platform.isLinux) { return createFilesystemLimits(4096, 255); } if (platform.isMacOS || platform.isIosApp || platform.isAndroidApp || platform.isMobile) { return createFilesystemLimits(1024, 255); } return createFilesystemLimits(256, 256); } export function getFileExtension(fileName: string): string { if (!fileName.includes('.')) { return ''; } return fileName.split('.').pop(); } export function getFileExtensionFromMimeType(mimeType: string): string { return mime.getExtension(mimeType) ?? ''; } function createFilesystemLimits(path: number, fileName: number): FilesystemLimits { return { path: path, fileName: fileName }; } ================================================ FILE: src/helpers/networkUtils.ts ================================================ export const desktopBrowserUserAgent = { 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36', }; ================================================ FILE: src/helpers/numberUtils.ts ================================================ export function lexify(number: number): string { if (number < 1000) { return String(number); } if (number < 1000000) { return `${toFixedWithoutZeros(Number(number / 1000), 1)}K`; } if (number < 1000000000) { return `${toFixedWithoutZeros(Number(number / 1000000), 1)}M`; } return String(number); } export function toFixedWithoutZeros(number: number, precision: number): string { return number.toFixed(precision).replace(/\.0+$/, ''); } ================================================ FILE: src/helpers/replaceImages.ts ================================================ import { CapacitorAdapter, DataAdapter, FileSystemAdapter, normalizePath, requestUrl } from 'obsidian'; import { HTTPS_PROTOCOL, HTTP_PROTOCOL } from 'src/constants/urlProtocols'; import ReadItLaterPlugin from 'src/main'; import { FilesystemLimits, getFileExtensionFromMimeType, getOsOptimizedPath, isValidUrl } from './fileutils'; import { createRandomString } from './stringUtils'; type Replacer = { (match: string, anchor: string, link: string): Promise<string>; }; const EXTERNAL_MEDIA_LINK_PATTERN = /!\[(?<anchor>.*?)\]\((?<link>.+?)\)/g; const CREATE_FILENAME_ATTEMPTS = 5; const MAX_FILENAME_INDEX = 1000; export async function replaceImages( plugin: ReadItLaterPlugin, noteFileName: string, content: string, assetsDir: string, ): Promise<string> { return await replaceAsync(content, EXTERNAL_MEDIA_LINK_PATTERN, imageTagProcessor(plugin, noteFileName, assetsDir)); } async function replaceAsync(content: string, searchValue: string | RegExp, replacer: Replacer) { try { if (typeof replacer === 'function') { // 1. Run fake pass of `replace`, collect values from `replacer` calls // 2. Resolve them with `Promise.all` // 3. Run `replace` with resolved values const values: Promise<string>[] = []; String.prototype.replace.call(content, searchValue, function (match: string, anchor: string, link: string) { values.push(replacer(match, anchor, link)); return ''; }); return Promise.all(values).then(function (resolvedValues) { return String.prototype.replace.call(content, searchValue, function () { return resolvedValues.shift(); }); }); } else { return Promise.resolve(String.prototype.replace.call(content, searchValue, replacer)); } } catch (error) { console.error(); return Promise.reject(error); } } function imageTagProcessor(plugin: ReadItLaterPlugin, noteFileName: string, assetsDir: string): Replacer { return async function processImageTag(match: string, anchor: string, link: string): Promise<string> { if (!isValidUrl(link, [HTTP_PROTOCOL, HTTPS_PROTOCOL])) { return match; } const url = new URL(link); await plugin.getVaultRepository().createDirectory(assetsDir); try { const { fileContent, fileExtension } = await downloadImage(url); let attempt = 0; while (attempt < CREATE_FILENAME_ATTEMPTS) { try { const { fileName, needWrite } = await chooseFileName( plugin.app.vault.adapter, plugin.getFileSystemLimits(), assetsDir, noteFileName, fileExtension, ); if (needWrite && fileName !== '') { await plugin.app.vault.createBinary(fileName, fileContent); const maskedFilename = fileName.replace(/\s/g, '%20'); return `![${anchor}](${maskedFilename})`; } else { return match; } } catch (error) { console.warn(error); attempt++; } } return match; } catch (error) { console.warn(error); return match; } }; } async function chooseFileName( dataAdapter: DataAdapter, filesystemLimits: FilesystemLimits, assetsDir: string, noteFileName: string, fileExtension: string, ): Promise<{ fileName: string; needWrite: boolean }> { if (fileExtension === '') { return { fileName: '', needWrite: false }; } let needWrite = false; let fileName = ''; let index = 0; while (fileName === '' && index < MAX_FILENAME_INDEX) { let suggestedName; if (dataAdapter instanceof CapacitorAdapter || dataAdapter instanceof FileSystemAdapter) { suggestedName = getOsOptimizedPath( assetsDir, `${noteFileName}-${createRandomString(10)}.${fileExtension}`, dataAdapter, filesystemLimits, ); } else { suggestedName = `${assetsDir}/${noteFileName}-${createRandomString(10)}.${fileExtension}`; } if (!(await dataAdapter.exists(normalizePath(suggestedName), false))) { fileName = suggestedName; needWrite = true; } index++; } return { fileName, needWrite }; } async function downloadImage(url: URL): Promise<{ fileContent: ArrayBuffer; fileExtension: string }> { const res = await requestUrl({ url: url.href, method: 'get', }); return { fileContent: res.arrayBuffer, fileExtension: getFileExtensionFromMimeType(res.headers['content-type'] || '') ?? '', }; } ================================================ FILE: src/helpers/setting.ts ================================================ export function createHTMLDiv(html: string): DocumentFragment { return createFragment((documentFragment) => (documentFragment.createDiv().innerHTML = html)); } ================================================ FILE: src/helpers/stringUtils.ts ================================================ import { Delimiter, getDelimiterValue } from 'src/enums/delimiter'; import { HTTPS_PROTOCOL, HTTP_PROTOCOL } from 'src/constants/urlProtocols'; import { isValidUrl } from './fileutils'; export function createRandomString(length: number) { const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; let result = ''; for (let i = 0; i < length; i++) { result += chars.charAt(Math.floor(Math.random() * chars.length)); } return result; } export interface UrlCheckResult { urls: string[]; everyLineIsURL: boolean; } export function getAndCheckUrls(content: string, delimiter: Delimiter): UrlCheckResult { const delimitedContent = content .trim() .split(getDelimiterValue(delimiter)) .filter((line) => line.trim().length > 0); const everyLineIsURL = delimitedContent.reduce((status: boolean, url: string): boolean => { return status && isValidUrl(url, [HTTP_PROTOCOL, HTTPS_PROTOCOL]); }, true); return { urls: delimitedContent, everyLineIsURL: everyLineIsURL, }; } ================================================ FILE: src/main.ts ================================================ import { Editor, Menu, MenuItem, Platform, Plugin, addIcon } from 'obsidian'; import { DEFAULT_SETTINGS, ReadItLaterSettingValue, ReadItLaterSettings } from './settings'; import { ReadItLaterSettingsTab } from './views/settings-tab'; import YoutubeParser from './parsers/YoutubeParser'; import VimeoParser from './parsers/VimeoParser'; import BilibiliParser from './parsers/BilibiliParser'; import TwitterParser from './parsers/TwitterParser'; import StackExchangeParser from './parsers/StackExchangeParser'; import WebsiteParser from './parsers/WebsiteParser'; import TextSnippetParser from './parsers/TextSnippetParser'; import MastodonParser from './parsers/MastodonParser'; import TikTokParser from './parsers/TikTokParser'; import ParserCreator from './parsers/ParserCreator'; import { HTTPS_PROTOCOL, HTTP_PROTOCOL } from './constants/urlProtocols'; import GithubParser from './parsers/GithubParser'; import WikipediaParser from './parsers/WikipediaParser'; import TemplateEngine from './template/TemplateEngine'; import { FilesystemLimits, getFileSystemLimits, isValidUrl } from './helpers/fileutils'; import YoutubeChannelParser from './parsers/YoutubeChannelParser'; import { VaultRepository } from './repository/VaultRepository'; import DefaultVaultRepository from './repository/DefaultVaultRepository'; import { NoteService } from './NoteService'; import { ReadItLaterApi } from './ReadtItLaterApi'; import { BlueskyParser } from './parsers/BlueskyParser'; import { PinterestParser } from './parsers/PinterestParser'; export default class ReadItLaterPlugin extends Plugin { public api: ReadItLaterApi; public settings: ReadItLaterSettings; private fileSystemLimits: FilesystemLimits; private noteService: NoteService; private parserCreator: ParserCreator; private templateEngine: TemplateEngine; private vaultRepository: VaultRepository; getFileSystemLimits(): FilesystemLimits { return this.fileSystemLimits; } getVaultRepository(): VaultRepository { return this.vaultRepository; } async onload(): Promise<void> { await this.loadSettings(); this.fileSystemLimits = getFileSystemLimits(Platform, this.settings); this.templateEngine = new TemplateEngine(); this.parserCreator = new ParserCreator([ new YoutubeParser(this.app, this, this.templateEngine), new YoutubeChannelParser(this.app, this, this.templateEngine), new VimeoParser(this.app, this, this.templateEngine), new BilibiliParser(this.app, this, this.templateEngine), new TwitterParser(this.app, this, this.templateEngine), new StackExchangeParser(this.app, this, this.templateEngine), new TikTokParser(this.app, this, this.templateEngine), new GithubParser(this.app, this, this.templateEngine), new WikipediaParser(this.app, this, this.templateEngine), new BlueskyParser(this.app, this, this.templateEngine), new PinterestParser(this.app, this, this.templateEngine), new MastodonParser(this.app, this, this.templateEngine), new WebsiteParser(this.app, this, this.templateEngine), new TextSnippetParser(this.app, this, this.templateEngine), ]); this.vaultRepository = new DefaultVaultRepository(this, this.templateEngine); this.noteService = new NoteService(this.parserCreator, this, this.vaultRepository); this.api = new ReadItLaterApi(this.noteService); addIcon('read-it-later', clipboardIcon); this.addRibbonIcon('read-it-later', 'ReadItLater: Create from clipboard', async () => { await this.api.processContent(await this.getTextClipboardContent()); }); this.addCommand({ id: 'save-clipboard-to-notice', name: 'Create from clipboard', callback: async () => { await this.api.processContent(await this.getTextClipboardContent()); }, }); this.addCommand({ id: 'create-from-clipboard-batch', name: 'Create from batch in clipboard', callback: async () => { await this.api.processContentBatch(await this.getTextClipboardContent()); }, }); this.addCommand({ id: 'insert-at-cursor', name: 'Insert at the cursor position', editorCallback: async (editor: Editor) => { await this.api.insertContentAtEditorCursorPosition(await this.getTextClipboardContent(), editor); }, }); this.addSettingTab(new ReadItLaterSettingsTab(this.app, this)); if (this.settings.extendShareMenu) { this.registerEvent( //eslint-disable-next-line @typescript-eslint/ban-ts-comment //@ts-ignore this.app.workspace.on('receive-text-menu', (menu: Menu, shareText: string) => { menu.addItem((item: MenuItem) => { item.setTitle('ReadItLater'); item.setIcon('read-it-later'); item.onClick(() => this.api.processContent(shareText)); }); }), ); } this.registerEvent( this.app.workspace.on('url-menu', (menu: Menu, url: string) => { if (isValidUrl(url, [HTTP_PROTOCOL, HTTPS_PROTOCOL])) { menu.addItem((item: MenuItem) => { item.setTitle('ReadItLater'); item.setIcon('read-it-later'); item.onClick(() => this.api.processContent(url)); }); } }), ); } async loadSettings(): Promise<void> { this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); } async saveSetting(setting: string, value: ReadItLaterSettingValue): Promise<void> { this.settings[setting] = value; await this.saveSettings(); } async saveSettings(): Promise<void> { await this.saveData(this.settings); } async getTextClipboardContent(): Promise<string> { return await navigator.clipboard.readText(); } } const clipboardIcon = ` <svg fill="currentColor" stroke="currentColor" version="1.1" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg"> <g> <path d="m365.9,144.9c-12.3,0-24.2,1.8-35.4,5.2v-114.7h-96.9l7.3-35.4h-150.2l6.8,35.4h-97.5v454.6h330.5v-102.1c11.2,3.4 23.1,5.2 35.4,5.2 68.8-0.1 124.1-56.4 124.1-124.1 0-67.8-55.3-124.1-124.1-124.1zm-150.1-124l-10.4,50h-79.2l-9.4-50h99zm93.8,448.2h-288.7v-412.8h80.7l6.8,35.4h113.6l7.3-35.4h80.3v102.2c-27.3,14-48.8,37.9-59.7,66.7h-200.9v20.8h195c-1.4,7.4-2.2,15.1-2.2,22.9 0,13.4 2.2,26.4 6.2,38.6h-199v20.9h208.1c12,21.8 30.3,39.7 52.5,51.1v89.6zm56.3-98c-57.3,0-103.2-46.9-103.2-103.2s46.9-103.2 103.2-103.2c57.3,0 103.2,46.9 103.2,103.2s-45.8,103.2-103.2,103.2z"/> <polygon points="426.4,223.1 346.1,303.4 313.8,271.1 299.2,285.7 346.1,332.6 441,237.7 "/> <rect width="233.5" x="49" y="143.9" height="20.9"/> <rect width="233.5" x="49" y="388.9" height="20.9"/> </g> </svg>`; ================================================ FILE: src/modal/FileExistsAsk.ts ================================================ import { App, Modal, Setting } from 'obsidian'; import { FileExistsStrategy } from 'src/enums/fileExistsStrategy'; import { createHTMLDiv } from 'src/helpers/setting'; import { Note } from 'src/parsers/Note'; export default class FileExistsAsk extends Modal { constructor(app: App, notes: Note[], onSubmit: (strategy: FileExistsStrategy, doNotAskAgain: boolean) => void) { super(app); this.setTitle('Duplicate notes detected'); const fileNames = notes.map((note) => `<li>${note.fileName}</li>`).join(''); this.setContent(createHTMLDiv(`<ul>${fileNames}</ul>`)); let doNotAskAgain = false; new Setting(this.contentEl).setName('Do not ask again').addToggle((toggle) => { toggle.setValue(false).onChange(() => (doNotAskAgain = true)); }); new Setting(this.contentEl) .addButton((btn) => btn .setButtonText('Append to existing') .setCta() .onClick(() => { this.close(); onSubmit(FileExistsStrategy.AppendToExisting, doNotAskAgain); }), ) .addButton((btn) => btn.setButtonText('Nothing').onClick(() => { this.close(); onSubmit(FileExistsStrategy.Nothing, doNotAskAgain); }), ); } } ================================================ FILE: src/parsers/BilibiliParser.ts ================================================ import { requestUrl } from 'obsidian'; import { handleError } from 'src/helpers/error'; import { Note } from './Note'; import { Parser } from './Parser'; interface BilibiliNoteData { date: string; videoId: string; videoTitle: string; videoURL: string; videoPlayer: string; } class BilibiliParser extends Parser { private PATTERN = /(bilibili.com)\/(video)?\/([a-z0-9]+)?/i; test(url: string): boolean { return this.isValidUrl(url) && this.PATTERN.test(url); } async prepareNote(url: string): Promise<Note> { const createdAt = new Date(); let data: BilibiliNoteData; try { data = await this.getNoteData(url, createdAt); } catch (error) { handleError(error, 'Unable to parse Bilibili page.'); } const content = this.templateEngine.render(this.plugin.settings.bilibiliNote, data); const fileNameTemplate = this.templateEngine.render(this.plugin.settings.bilibiliNoteTitle, { title: data.videoTitle, date: this.getFormattedDateForFilename(createdAt), }); return new Note(fileNameTemplate, 'md', content, this.plugin.settings.bilibiliContentTypeSlug, createdAt); } private async getNoteData(url: string, createdAt: Date): Promise<BilibiliNoteData> { const response = await requestUrl({ method: 'GET', url, headers: { 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.36', }, }); if (response.status === 429) { throw new Error('Rate limited (HTTP 429). Try again later.'); } if (response.status >= 400) { throw new Error(`HTTP ${response.status} error fetching ${url}`); } const html = new TextDecoder().decode(response.arrayBuffer); const videoHTML = new DOMParser().parseFromString(html, 'text/html'); const videoId = this.PATTERN.exec(url)[3] ?? ''; return { date: this.getFormattedDateForContent(createdAt), videoId: videoId, videoTitle: videoHTML.querySelector("[property~='og:title']")?.getAttribute('content') ?? '', videoURL: url, videoPlayer: `<iframe width="${this.plugin.settings.bilibiliEmbedWidth}" height="${this.plugin.settings.bilibiliEmbedHeight}" src="https://player.bilibili.com/player.html?autoplay=0&bvid=${videoId}" scrolling="no" border="0" frameborder="no" framespacing="0" allowfullscreen="true"></iframe>`, }; } } export default BilibiliParser; ================================================ FILE: src/parsers/BlueskyParser.ts ================================================ import { Notice, moment, request } from 'obsidian'; import { normalizeFilename } from 'src/helpers/fileutils'; import { replaceImages } from 'src/helpers/replaceImages'; import { handleError } from 'src/helpers/error'; import { Note } from './Note'; import { Parser } from './Parser'; enum FacetTypeApi { Mention = 'app.bsky.richtext.facet#mention', Link = 'app.bsky.richtext.facet#link', Tag = 'app.bsky.richtext.facet#tag', } enum EmbedTypeApi { Image = 'app.bsky.embed.images#view', Video = 'app.bsky.embed.video#view', External = 'app.bsky.embed.external#view', Record = 'app.bsky.embed.record#view', RecordView = 'app.bsky.embed.record#viewRecord', RecordWithMedia = 'app.bsky.embed.recordWithMedia#view', } interface BaseFacet { byteStart: number; byteEnd: number; type: FacetType; } interface MentionFacet extends BaseFacet { type: FacetType.Mention; did: string; } interface LinkFacet extends BaseFacet { type: FacetType.Link; uri: string; } interface TagFacet extends BaseFacet { type: FacetType.Tag; tag: string; } type Facet = MentionFacet | LinkFacet | TagFacet; enum FacetType { Mention = 'mention', Link = 'link', Tag = 'tag', } interface PostId { handle: string; id: string; } enum EmbedType { Image = 'image', Video = 'video', External = 'external', Record = 'record', } interface Embed { type: EmbedType; url: string; thumbnail: string; title: string; description: string; } interface Author { did: string; displayName: string; handle: string; avatar: string; } interface PostData { url: string; content: string; author: Author; embeds: Embed[]; likeCount: number; replyCount: number; repostCount: number; quoteCount: number; publishedAt: Date; facets: Facet[]; } interface Post extends PostData { replies: PostReply[]; } interface PostReply extends PostData {} interface BlueskyNoteData { date: string; content: string; postURL: string; authorHandle: string; authorName: string; likeCount: number; replyCount: number; repostCount: number; quoteCount: number; publishedAt: string; extra: { post: PostData; }; } export class BlueskyParser extends Parser { private PATTERN = /^https:\/\/bsky\.app\/profile\/(?<handle>[a-zA-Z0-9-.:]+)\/post\/(?<postId>[a-zA-Z0-9]+)/; private AT_URI_PATTERN = /^at:\/\/(?<handle>(?:did:plc:[a-zA-Z0-9]+|[a-zA-Z0-9.-]+(?:\.[a-zA-Z0-9.-]+)*?))\/(?<collection>[a-zA-Z.]+)\/(?<rkey>[a-zA-Z0-9]+)$/; public test(clipboardContent: string): boolean { return this.isValidUrl(clipboardContent) && this.PATTERN.test(clipboardContent); } public async prepareNote(clipboardContent: string): Promise<Note> { const createdAt = new Date(); const post = await this.loadPost(clipboardContent); let formattedPostContent = this.formatPostContent(post, createdAt, this.plugin.settings.blueskyNote); if (this.plugin.settings.saveBlueskyPostReplies) { post.replies.forEach((reply) => { formattedPostContent = formattedPostContent.concat( '\n\n***\n\n', this.formatPostContent(reply, createdAt, this.plugin.settings.blueskyPostReply), ); }); } const fileName = this.templateEngine.render(this.plugin.settings.blueskyNoteTitle, { date: this.getFormattedDateForFilename(createdAt), authorHandle: post.author.handle, authorName: normalizeFilename(post.author.displayName, false), }); if (this.plugin.settings.downloadBlueskyEmbeds) { let assetsDir; if (this.plugin.settings.downloadBlueskyEmbedsInDir) { assetsDir = this.templateEngine.render(this.plugin.settings.assetsDir, { date: '', fileName: '', contentType: '', }); assetsDir = `${assetsDir}/${normalizeFilename(fileName)}`; } else { assetsDir = this.templateEngine.render(this.plugin.settings.assetsDir, { date: this.getFormattedDateForFilename(createdAt), fileName: normalizeFilename(fileName), contentType: this.plugin.settings.mastodonContentTypeSlug, }); } formattedPostContent = await replaceImages( this.plugin, normalizeFilename(fileName), formattedPostContent, assetsDir, ); } return new Note(fileName, 'md', formattedPostContent, this.plugin.settings.blueskyContentTypeSlug, createdAt); } private async loadPost(postUrl: string): Promise<Post> { try { const postUri = this.getPostUri(this.getPostIdFromUrl(postUrl)); const response: any = JSON.parse( await request({ method: 'GET', contentType: 'application/json', url: `https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread?uri=${postUri}`, }), ); const replies: PostReply[] = []; response.thread.replies.forEach((reply: any) => { const replyPostUrl = this.getPostUrl(this.getPostIdFromAtUri(reply.post.uri)); replies.push({ url: replyPostUrl, content: reply.post.record.text, author: { ...reply.post.author }, embeds: Object.prototype.hasOwnProperty.call(reply.post, 'embed') ? this.makeEmbeds(reply.post.embed, replyPostUrl) : [], likeCount: reply.post.likeCount, replyCount: reply.post.replyCount, repostCount: reply.post.repostCount, quoteCount: reply.post.replyCount, publishedAt: moment(reply.post.record.createdAt).toDate(), facets: reply.post.record?.facets?.map((facet: any) => { return this.makeFacet(facet); }) ?? [], }); }); return { url: postUrl, content: response.thread.post.record.text, author: { ...response.thread.post.author }, embeds: Object.prototype.hasOwnProperty.call(response.thread.post, 'embed') ? this.makeEmbeds(response.thread.post.embed, postUrl) : [], likeCount: response.thread.post.likeCount, replyCount: response.thread.post.replyCount, repostCount: response.thread.post.repostCount, quoteCount: response.thread.post.replyCount, publishedAt: moment(response.thread.post.record.createdAt).toDate(), facets: response.thread.post.record?.facets?.map((facet: any) => { return this.makeFacet(facet); }) ?? [], replies: replies, }; } catch (error) { handleError(error, 'Unable to parse Bluesky API response.'); } } private makeEmbeds(responseEmbed: any, postUrl: string): Embed[] { const embeds: Embed[] = []; const embedTypeApi = responseEmbed.$type as EmbedTypeApi; switch (embedTypeApi) { case EmbedTypeApi.Image: responseEmbed.images.forEach((image: any) => { embeds.push({ type: EmbedType.Image, url: image.fullsize, thumbnail: image.thumb, title: '', description: image.alt, }); }); break; case EmbedTypeApi.Video: embeds.push({ type: EmbedType.Video, url: postUrl, thumbnail: responseEmbed.thumbnail, title: '', description: '', }); break; case EmbedTypeApi.External: embeds.push({ type: EmbedType.External, url: responseEmbed.external.uri, thumbnail: responseEmbed.external.thumb, title: responseEmbed.external?.title || '', description: responseEmbed.external.description || '', }); break; case EmbedTypeApi.Record: embeds.push({ type: EmbedType.Record, url: this.getPostUrl(this.getPostIdFromAtUri(responseEmbed.record.uri)), thumbnail: '', title: `Post from ${ responseEmbed.record.author?.displayName || responseEmbed.record.author.handle }`, description: responseEmbed.record.value.text, }); break; case EmbedTypeApi.RecordView: embeds.push({ type: EmbedType.Record, url: this.getPostUrl(this.getPostIdFromAtUri(responseEmbed.uri)), thumbnail: '', title: `Post from ${responseEmbed.author?.displayName || responseEmbed.author.handle}`, description: responseEmbed.value.text, }); break; case EmbedTypeApi.RecordWithMedia: if (Object.prototype.hasOwnProperty.call(responseEmbed, 'media')) { const mediaEmbeds = this.makeEmbeds(responseEmbed.media, postUrl); if (mediaEmbeds) { embeds.push(...mediaEmbeds); } } if (Object.prototype.hasOwnProperty.call(responseEmbed, 'record')) { const recordEmbeds = this.makeEmbeds(responseEmbed.record.record, postUrl); if (recordEmbeds) { embeds.push(...recordEmbeds); } } break; } return embeds; } private makeFacet(facetResponse: any): Facet { switch (facetResponse.features?.[0].$type) { case FacetTypeApi.Mention: return { type: FacetType.Mention, did: facetResponse.features?.[0].did, byteStart: facetResponse.index.byteStart, byteEnd: facetResponse.index.byteEnd, }; case FacetTypeApi.Link: return { type: FacetType.Link, uri: facetResponse.features?.[0].uri, byteStart: facetResponse.index.byteStart, byteEnd: facetResponse.index.byteEnd, }; case FacetTypeApi.Tag: return { type: FacetType.Tag, tag: facetResponse.features?.[0].tag, byteStart: facetResponse.index.byteStart, byteEnd: facetResponse.index.byteEnd, }; } throw new Error(`Unrecognized facet type ${facetResponse}`); } private formatPostContent(post: PostData, createdAt: Date, template: string): string { let formattedPostEmbeds = ''; post.embeds.forEach((attachment) => { let formattedEmbed; switch (attachment.type) { case EmbedType.Video: formattedEmbed = `[Watch video on Bluesky](${attachment.url})`; break; case EmbedType.External: case EmbedType.Record: if (attachment.url.startsWith('https://media.tenor.com')) { formattedEmbed = `![${attachment.title}](${attachment.url})`; } else { if (attachment.thumbnail) { formattedEmbed = `![${attachment.title}](${attachment.thumbnail})\n${attachment.description}\n${attachment.url}`; } else { formattedEmbed = `[${attachment.title}](${attachment.url})\n${attachment.description}`; } } break; default: formattedEmbed = `![](${attachment.thumbnail})`; } formattedPostEmbeds = formattedPostEmbeds.concat(formattedEmbed, '\n\n'); }); const content = this.replaceFacets(post) + '\n\n' + formattedPostEmbeds; return this.renderPost(template, { date: this.getFormattedDateForContent(createdAt), content: content.trim(), postURL: post.url, authorHandle: post.author.handle, authorName: post.author.displayName || post.author.handle, likeCount: post.likeCount, replyCount: post.replyCount, repostCount: post.repostCount, quoteCount: post.quoteCount, publishedAt: this.getFormattedDateForContent(post.publishedAt), extra: { post: post, }, }); } private replaceFacets(post: PostData): string { if (post.facets.length === 0) { return post.content; } const encoder = new TextEncoder(); const decoder = new TextDecoder(); const bytes = encoder.encode(post.content); // Sort facets by position const sortedFacets = [...post.facets].sort((a, b) => a.byteStart - b.byteStart); let result = ''; let lastPos = 0; for (const facet of sortedFacets) { // Add text before the facet result += decoder.decode(bytes.slice(lastPos, facet.byteStart)); // Extract facet text const facetText = decoder.decode(bytes.slice(facet.byteStart, facet.byteEnd)); // Format based on facet type switch (facet.type) { case FacetType.Mention: result += `[${facetText}](https://bsky.app/profile/${facet.did})`; break; case FacetType.Link: result += `[${facetText}](${facet.uri})`; break; case FacetType.Tag: result += `[${facetText}](https://bsky.app/search?q=${encodeURIComponent(facet.tag)})`; break; } lastPos = facet.byteEnd; } // Add remaining text if (lastPos < bytes.length) { result += decoder.decode(bytes.slice(lastPos)); } return result; } private renderPost(template: string, noteData: BlueskyNoteData): string { return this.templateEngine.render(template, noteData); } private getPostUrl(postId: PostId): string { return `https://bsky.app/profile/${postId.handle}/post/${postId.id}`; } private getPostUri(postId: PostId): string { return `at://${postId.handle}/app.bsky.feed.post/${postId.id}`; } private getPostIdFromUrl(url: string): PostId { const match = url.match(this.PATTERN); if (!match) { const errorMessage = `Unable to determine handle and id from provided url ${url}`; new Notice(errorMessage); throw new Error(errorMessage); } return { handle: match.groups.handle, id: match.groups.postId, }; } private getPostIdFromAtUri(atUri: string): PostId { const match = atUri.match(this.AT_URI_PATTERN); if (!match) { const errorMessage = `Unable to determine handle and id from provided AT uri ${atUri}`; new Notice(errorMessage); throw new Error(errorMessage); } return { handle: match.groups.handle, id: match.groups.rkey, }; } } ================================================ FILE: src/parsers/GithubParser.ts ================================================ import { Note } from './Note'; import WebsiteParser from './WebsiteParser'; export default class GithubParser extends WebsiteParser { private PATTERN = /^https?:\/\/(?:www\.)?github\.com\/([a-zA-Z0-9_-]+)\/([a-zA-Z0-9_-]+)\/?/i; test(url: string): boolean { return this.isValidUrl(url) && this.PATTERN.test(url); } async prepareNote(url: string): Promise<Note> { const originUrl = new URL(url); const document = await this.getDocument(originUrl); // Extract readme content (may be null on issue/PR pages, profile pages, etc.) const readme = document.querySelector('article.markdown-body'); if (readme) { // Remove anchor elements causing to show empty links readme.querySelectorAll('[aria-label^="Permalink:"]').forEach((anchorElement) => anchorElement.remove()); document.querySelector('body').innerHTML = readme.outerHTML; } return this.makeNote(document, originUrl); } } ================================================ FILE: src/parsers/MastodonParser.ts ================================================ import { Platform, request } from 'obsidian'; import { isValidUrl, normalizeFilename } from 'src/helpers/fileutils'; import { replaceImages } from 'src/helpers/replaceImages'; import { handleError } from 'src/helpers/error'; import { Parser } from './Parser'; import { Note } from './Note'; import { parseHtmlContent } from './parsehtml'; const MASTODON_API = { INSTANCE: '/api/v2/instance', OEMBED: '/api/oembed', STATUS: '/api/v1/statuses', CONTEXT: '/api/v1/statuses/%id%/context', }; interface MediaAttachment { id: string; type: string; url: string; preview_url: string; remote_url: string | null; meta: object; description: string | null; blurhash: string | null; } interface Account { id: string; display_name: string; } interface Status { url: string; content: string; account: Account; media_attachments: MediaAttachment[]; } interface MastodonStatusNoteData { date: string; tootContent: string; tootURL: string; tootAuthorName: string; extra: { status: Status; replies: Status[]; }; } class MastodonParser extends Parser { async test(url: string): Promise<boolean> { return isValidUrl(url) && (await this.testIsMastodon(url)); } async prepareNote(url: string): Promise<Note> { const createdAt = new Date(); const mastodonUrl = new URL(url); const statusId = mastodonUrl.pathname.split('/')[2]; const status = await this.loadStatus(mastodonUrl.hostname, statusId); let replies: Status[] = []; if (this.plugin.settings.saveMastodonReplies) { replies = await this.loadReplies(mastodonUrl.hostname, statusId); } const fileNameTemplate = this.templateEngine.render(this.plugin.settings.mastodonNoteTitle, { tootAuthorName: status.account.display_name, date: this.getFormattedDateForFilename(createdAt), }); let assetsDir; if (this.plugin.settings.downloadMastodonMediaAttachmentsInDir) { assetsDir = this.templateEngine.render(this.plugin.settings.assetsDir, { date: '', fileName: '', contentType: '', }); assetsDir = `${assetsDir}/${normalizeFilename(fileNameTemplate)}`; } else { assetsDir = this.templateEngine.render(this.plugin.settings.assetsDir, { date: this.getFormattedDateForFilename(createdAt), fileName: normalizeFilename(fileNameTemplate), contentType: this.plugin.settings.mastodonContentTypeSlug, }); } const data = await this.getNoteData(status, replies, assetsDir, normalizeFilename(fileNameTemplate), createdAt); const content = this.templateEngine.render(this.plugin.settings.mastodonNote, data); return new Note(fileNameTemplate, 'md', content, this.plugin.settings.mastodonContentTypeSlug, createdAt); } private async getNoteData( status: Status, replies: Status[], fileName: string, assetsDir: string, createdAt: Date, ): Promise<MastodonStatusNoteData> { let parsedStatusContent = await this.parseStatus(status, fileName, assetsDir); if (replies.length > 0) { for (let i = 0; i < replies.length; i++) { const parsedReply = await this.parseStatus(replies[i], fileName, assetsDir); const processedReply = this.templateEngine.render(this.plugin.settings.mastodonReply, { tootAuthorName: replies[i].account.display_name, tootURL: replies[i].url, tootContent: parsedReply, }); parsedStatusContent = parsedStatusContent.concat('\n\n***\n\n', processedReply); } } return { date: this.getFormattedDateForContent(createdAt), tootAuthorName: status.account.display_name, tootURL: status.url, tootContent: parsedStatusContent, extra: { status: status, replies: replies, }, }; } private async loadStatus(hostname: string, statusId: string): Promise<Status> { try { const response = JSON.parse( await request({ method: 'GET', contentType: 'application/json', url: `https://${hostname}${MASTODON_API.STATUS}/${statusId}`, }), ); return response; } catch (error) { handleError(error, 'Unable to load Mastodon status.'); } } private async loadReplies(hostname: string, statusId: string): Promise<Status[]> { const url = String.prototype.concat.call( 'https://', hostname, String.prototype.replace.call(MASTODON_API.CONTEXT, '%id%', statusId), ); try { const response = JSON.parse( await request({ method: 'GET', contentType: 'application/json', url: url, }), ); return response.descendants; } catch (error) { console.warn('Unable to load Mastodon replies:', error); return []; } } private async parseStatus(status: Status, fileName: string, assetsDir: string): Promise<string> { const parsedStatusContent = await parseHtmlContent(status.content); const mediaAttachments = this.plugin.settings.downloadMastodonMediaAttachments && Platform.isDesktop ? await replaceImages(this.plugin, fileName, this.prepareMedia(status.media_attachments), assetsDir) : this.prepareMedia(status.media_attachments); return parsedStatusContent.concat(mediaAttachments); } private prepareMedia(media: MediaAttachment[]): string { return media.reduce((prev: string, { url, description }: { url: string; description: string }): string => { const processedDescription = description ? `\n> *${description}*` : ''; return `${prev}\n\n![](${url})${processedDescription}`; }, ''); } private async testIsMastodon(url: string): Promise<boolean> { if (!url) return false; const urlDomain = new URL(url).hostname; try { const response = JSON.parse( await request({ method: 'GET', contentType: 'application/json', url: `https://${urlDomain}${MASTODON_API.INSTANCE}`, }), ); return response?.domain === urlDomain; } catch (e) { return false; } } } export default MastodonParser; ================================================ FILE: src/parsers/Note.ts ================================================ import { normalizeFilename } from 'src/helpers/fileutils'; export class Note { private _filePath: string | null = null; constructor( public readonly fileName: string, public readonly fileExtension: string, public readonly content: string, public readonly contentType: string, public readonly createdAt: Date, ) { this.fileName = normalizeFilename(this.fileName); } public getFullFilename(): string { return `${this.fileName}.${this.fileExtension}`; } public get filePath() { return this._filePath; } public set filePath(filePath: string) { this._filePath = filePath; } } ================================================ FILE: src/parsers/Parser.ts ================================================ import { App } from 'obsidian'; import TemplateEngine from 'src/template/TemplateEngine'; import ReadItLaterPlugin from 'src/main'; import { isValidUrl } from 'src/helpers/fileutils'; import { formatDate } from 'src/helpers/date'; import { Note } from './Note'; export abstract class Parser { protected app: App; protected plugin: ReadItLaterPlugin; protected templateEngine: TemplateEngine; constructor(app: App, plugin: ReadItLaterPlugin, templateEngine: TemplateEngine) { this.app = app; this.plugin = plugin; this.templateEngine = templateEngine; } abstract test(clipboardContent: string): boolean | Promise<boolean>; abstract prepareNote(clipboardContent: string): Promise<Note>; protected isValidUrl(url: string): boolean { return isValidUrl(url); } protected getFormattedDateForFilename(date: Date | string): string { return formatDate(date, this.plugin.settings.dateTitleFmt); } protected getFormattedDateForContent(date: Date | string): string { return formatDate(date, this.plugin.settings.dateContentFmt); } } ================================================ FILE: src/parsers/ParserCreator.ts ================================================ import { Parser } from './Parser'; export default class ParserCreator { private parsers: Parser[]; constructor(parsers: Parser[]) { this.parsers = parsers; } public async createParser(content: string): Promise<Parser> { for (const parser of this.parsers) { if (await parser.test(content)) { return parser; } } throw new Error('No parser found for content.'); } } ================================================ FILE: src/parsers/PinterestParser.ts ================================================ import { request } from 'obsidian'; import { desktopBrowserUserAgent } from 'src/helpers/networkUtils'; import { normalizeFilename } from 'src/helpers/fileutils'; import { replaceImages } from 'src/helpers/replaceImages'; import { handleError } from 'src/helpers/error'; import { Note } from './Note'; import { Parser } from './Parser'; interface PinAuthor { fullName: string; username: string; profileURL: string; } interface Pin { id: string; url: string; title: string | null; description: string; link: string; image: string; author: PinAuthor; likeCount: number; } interface PinterestNoteData { date: string; pinId: string; pinURL: string; title: string; link: string; image: string; description: string; likeCount: number; authorName: string; authorProfileURL: string; } export class PinterestParser extends Parser { private PATTERN = /^https?:\/\/(?:[a-z]{2}\.|www\.)?pinterest\.(?:com|ca|co\.uk|fr|de|es|it)\/pin\/(\d+)\/?/i; public test(clipboardContent: string): boolean { return this.isValidUrl(clipboardContent) && this.PATTERN.test(clipboardContent); } public async prepareNote(clipboardContent: string): Promise<Note> { const createdAt = new Date(); let pin: Pin; try { pin = await this.parseHtml(clipboardContent); } catch (e) { handleError(e, 'Unable to parse Pinterest note data.'); } const fileName = this.templateEngine.render(this.plugin.settings.pinterestNoteTitle, { date: this.getFormattedDateForFilename(createdAt), authorName: pin.author.fullName || pin.author.username, }); let content = this.renderContent({ date: this.getFormattedDateForContent(createdAt), pinId: pin.id, pinURL: pin.url, title: pin.title, link: pin.link, image: pin.image, description: pin.description, likeCount: pin.likeCount, authorName: pin.author.fullName || pin.author.username, authorProfileURL: pin.author.profileURL, }); if (this.plugin.settings.downloadPinterestImage) { const assetsDir = this.templateEngine.render(this.plugin.settings.assetsDir, { date: this.getFormattedDateForFilename(createdAt), fileName: normalizeFilename(fileName), contentType: this.plugin.settings.pinterestContentTypeSlug, }); content = await replaceImages(this.plugin, normalizeFilename(fileName), content, assetsDir); } return new Note(fileName, 'md', content, this.plugin.settings.pinterestContentTypeSlug, createdAt); } private renderContent(data: PinterestNoteData): string { return this.templateEngine.render(this.plugin.settings.pinterestNote, data); } private async parseHtml(url: string): Promise<Pin> { const response = await request({ method: 'GET', url: url, headers: { ...desktopBrowserUserAgent }, }); const document = new DOMParser().parseFromString(response, 'text/html'); const relayResponseElements = document.querySelectorAll("[data-relay-response='true']"); let desktopRelayResponse; relayResponseElements.forEach((el) => { const jsonData = JSON.parse(el.textContent); if (jsonData?.variables?.isDesktop === true) { desktopRelayResponse = jsonData; } }); if (desktopRelayResponse === undefined) { desktopRelayResponse = JSON.parse(relayResponseElements?.[0].textContent) ?? {}; } const pinJsonData = desktopRelayResponse?.response?.data?.v3GetPinQuery?.data; if (pinJsonData === undefined) { throw new Error('pinJsonData is undefined'); } const pinner = pinJsonData?.originPinner ?? pinJsonData?.pinner ?? {}; return { id: url.match(this.PATTERN)[1], url: url, title: pinJsonData?.title ?? document.querySelector('h1')?.textContent ?? '', description: pinJsonData?.description ?? document.querySelector("[data-test-id='truncated-description'] div div")?.textContent ?? '', link: pinJsonData?.link ?? document.querySelector("meta[property='pinterestapp:source']")?.getAttribute('content') ?? document.querySelector("meta[property='og:see_also']")?.getAttribute('content'), image: pinJsonData?.imageSpec_orig?.url ?? document.querySelector("[data-test-id='pin-closeup-image'] img")?.getAttribute('src') ?? '', author: { fullName: pinner?.fullName ?? '', username: pinner?.username ?? '', profileURL: `https://www.pinterest.com/${pinner?.username ?? ''}`, }, likeCount: pinJsonData?.reactionCountsData?.find((countData: any) => countData?.reactionType === 1) ?.reactionCount ?? 0, }; } } ================================================ FILE: src/parsers/StackExchangeParser.ts ================================================ import { Platform, requestUrl } from 'obsidian'; import * as DOMPurify from 'isomorphic-dompurify'; import { normalizeFilename } from 'src/helpers/fileutils'; import { replaceImages } from 'src/helpers/replaceImages'; import { desktopBrowserUserAgent } from 'src/helpers/networkUtils'; import { Parser } from './Parser'; import { Note } from './Note'; import { parseHtmlContent } from './parsehtml'; interface StackExchangeQuestion { title: string; content: string; url: string; topAnswer: StackExchangeAnswer | null; answers: Array<StackExchangeAnswer>; author: StackExchangeUser; } interface StackExchangeAnswer { content: string; author: StackExchangeUser; } interface StackExchangeUser { name: string; profile: string; } interface StackExchangeNoteData { date: string; questionTitle: string; questionURL: string; questionContent: string; authorName: string; authorProfileURL: string; topAnswer: string; answers: string; extra: { question: StackExchangeQuestion; }; } class StackExchangeParser extends Parser { private PATTERN = /(https:\/\/|http:\/\/)(stackoverflow\.com|serverfault\.com|superuser\.com|askubuntu\.com|stackapps\.com|.*\.stackexchange\.com)\/(q|a|questions)\/(\d+)/; test(clipboardContent: string): boolean { return this.isValidUrl(clipboardContent) && this.PATTERN.test(clipboardContent); } async prepareNote(clipboardContent: string): Promise<Note> { const createdAt = new Date(); const response = await requestUrl({ method: 'GET', url: clipboardContent, headers: { ...desktopBrowserUserAgent }, }); if (response.status === 429) { throw new Error('Rate limited (HTTP 429). Try again later.'); } if (response.status >= 400) { throw new Error(`HTTP ${response.status} error fetching ${clipboardContent}`); } const html = new TextDecoder().decode(response.arrayBuffer); const document = new DOMParser().parseFromString(html, 'text/html'); const question = await this.parseDocument(document); const fileNameTemplate = this.templateEngine.render(this.plugin.settings.stackExchangeNoteTitle, { title: question.title, date: this.getFormattedDateForFilename(createdAt), }); let assetsDir; if (this.plugin.settings.downloadStackExchangeAssetsInDir) { assetsDir = this.templateEngine.render(this.plugin.settings.assetsDir, { date: '', fileName: '', contentType: '', }); assetsDir = `${assetsDir}/${normalizeFilename(fileNameTemplate)}`; } else { assetsDir = this.templateEngine.render(this.plugin.settings.assetsDir, { date: this.getFormattedDateForFilename(createdAt), fileName: normalizeFilename(fileNameTemplate), contentType: this.plugin.settings.stackExchangeContentType, }); } let content = this.templateEngine.render( this.plugin.settings.stackExchangeNote, this.getNoteData(question, createdAt), ); if (this.plugin.settings.downloadStackExchangeAssets && Platform.isDesktop) { content = await replaceImages(this.plugin, normalizeFilename(fileNameTemplate), content, assetsDir); } return new Note(fileNameTemplate, 'md', content, this.plugin.settings.stackExchangeContentType, createdAt); } private getNoteData(question: StackExchangeQuestion, createdAt: Date): StackExchangeNoteData { const topAnswer = question.topAnswer ? this.templateEngine.render(this.plugin.settings.stackExchangeAnswer, { date: this.getFormattedDateForContent(createdAt), answerContent: question.topAnswer.content, authorName: question.topAnswer.author.name, authorProfileURL: question.topAnswer.author.profile, }) : ''; let answers = ''; for (let i = 0; i < question.answers.length; i++) { answers = answers.concat( '\n\n***\n\n', this.templateEngine.render(this.plugin.settings.stackExchangeAnswer, { date: this.getFormattedDateForContent(createdAt), answerContent: question.answers[i].content, authorName: question.answers[i].author.name, authorProfileURL: question.answers[i].author.profile, }), ); } return { date: this.getFormattedDateForContent(createdAt), questionTitle: question.title, questionURL: question.url, questionContent: question.content, authorName: question.author.name, authorProfileURL: question.author.profile, topAnswer: topAnswer, answers: answers.trim(), extra: { question: question, }, }; } private async parseDocument(document: Document): Promise<StackExchangeQuestion> { let questionURL; try { questionURL = new URL( document.querySelector('link[rel="canonical"]')?.getAttribute('href') ?? document.querySelector('meta[property="og:url"]')?.getAttribute('content'), ); } catch (e) { questionURL = null; } const author = document.querySelector('#question [itemprop="author"]'); const answers: Array<StackExchangeAnswer> = []; for (const el of document.querySelectorAll('.answer')) { const answerAuthor = el.querySelector('[itemprop="author"]'); answers.push({ content: await parseHtmlContent(DOMPurify.sanitize(el.querySelector('[itemprop="text"]') ?? '')), author: { name: answerAuthor?.querySelector('[itemprop="name"]')?.textContent ?? '', profile: answerAuthor instanceof Element && questionURL instanceof URL ? String.prototype.concat( questionURL.origin, answerAuthor.querySelector('a')?.getAttribute('href') ?? '', ) : '', }, }); } return { title: document.querySelector('#question-header [itemprop="name"]')?.textContent ?? '', content: await parseHtmlContent( DOMPurify.sanitize(document.querySelector('#question [itemprop="text"]') ?? ''), ), url: questionURL?.href ?? '', topAnswer: answers.slice(0, 1).shift(), answers: answers.slice(1), author: { name: author?.querySelector('[itemprop="name"]')?.textContent ?? '', profile: author instanceof Element && questionURL instanceof URL ? String.prototype.concat( questionURL.origin, author.querySelector('a')?.getAttribute('href') ?? '', ) : '', }, }; } } export default StackExchangeParser; ================================================ FILE: src/parsers/TextSnippetParser.ts ================================================ import { Parser } from './Parser'; import { Note } from './Note'; class TextSnippetParser extends Parser { test(): boolean { return true; } async prepareNote(text: string): Promise<Note> { const createdAt = new Date(); const fileNameTemplate = this.templateEngine.render(this.plugin.settings.textSnippetNoteTitle, { date: this.getFormattedDateForFilename(createdAt), }); const content = this.templateEngine.render(this.plugin.settings.textSnippetNote, { content: text, date: this.getFormattedDateForContent(createdAt), }); return new Note(fileNameTemplate, 'md', content, this.plugin.settings.textSnippetContentType, createdAt); } } export default TextSnippetParser; ================================================ FILE: src/parsers/TikTokParser.ts ================================================ import { requestUrl } from 'obsidian'; import { handleError } from 'src/helpers/error'; import { Note } from './Note'; import { Parser } from './Parser'; interface TiktokNoteData { date: string; videoId: string; videoURL: string; videoDescription: string; videoPlayer: string; authorName: string; authorURL: string; } class TikTokParser extends Parser { private PATTERN = /(tiktok.com)\/(\S+)\/(video)\/(\d+)/; test(clipboardContent: string): boolean | Promise<boolean> { return this.isValidUrl(clipboardContent) && this.PATTERN.test(clipboardContent); } async prepareNote(clipboardContent: string): Promise<Note> { const createdAt = new Date(); let data: TiktokNoteData; try { data = await this.parseHtml(clipboardContent, createdAt); } catch (error) { handleError(error, 'Unable to parse TikTok page.'); } const content = this.templateEngine.render(this.plugin.settings.tikTokNote, data); const fileNameTemplate = this.templateEngine.render(this.plugin.settings.tikTokNoteTitle, { authorName: data.authorName, date: this.getFormattedDateForFilename(createdAt), }); return new Note(fileNameTemplate, 'md', content, this.plugin.settings.tikTokContentTypeSlug, createdAt); } private async parseHtml(url: string, createdAt: Date): Promise<TiktokNoteData> { const response = await requestUrl({ method: 'GET', url, headers: { 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36', }, }); if (response.status === 429) { throw new Error('Rate limited (HTTP 429). Try again later.'); } if (response.status >= 400) { throw new Error(`HTTP ${response.status} error fetching ${url}`); } const html = new TextDecoder().decode(response.arrayBuffer); const videoHTML = new DOMParser().parseFromString(html, 'text/html'); const videoRegexExec = this.PATTERN.exec(url); return { date: this.getFormattedDateForContent(createdAt), videoId: videoRegexExec[4], videoURL: videoHTML.querySelector('meta[property="og:url"]')?.getAttribute('content') ?? url, videoDescription: videoHTML.querySelector('meta[property="og:description"]')?.getAttribute('content') ?? '', videoPlayer: `<iframe width="${this.plugin.settings.tikTokEmbedWidth}" height="${this.plugin.settings.tikTokEmbedHeight}" src="https://www.tiktok.com/embed/v2/${videoRegexExec[4]}"></iframe>`, authorName: videoRegexExec[2], authorURL: `https://www.tiktok.com/${videoRegexExec[2]}`, }; } } export default TikTokParser; ================================================ FILE: src/parsers/TwitterParser.ts ================================================ import { moment, request } from 'obsidian'; import { Parser } from './Parser'; import { Note } from './Note'; import { parseHtmlContent } from './parsehtml'; interface TweetNoteData { date: string; tweetAuthorName: string; tweetURL: string; tweetContent: string; tweetPublishDate: string; } class TwitterParser extends Parser { private PATTERN = /(https:\/\/(twitter|x).com\/([a-zA-Z0-9_]+\/)([a-zA-Z0-9_]+\/[a-zA-Z0-9_]+))/; test(url: string): boolean { return this.isValidUrl(url) && this.PATTERN.test(url); } async prepareNote(url: string): Promise<Note> { const createdAt = new Date(); const twitterUrl = new URL(url); if (twitterUrl.hostname === 'x.com') { twitterUrl.hostname = 'twitter.com'; } const data = await this.getTweetNoteData(twitterUrl, createdAt); const content = this.templateEngine.render(this.plugin.settings.twitterNote, data); const fileNameTemplate = this.templateEngine.render(this.plugin.settings.twitterNoteTitle, { tweetAuthorName: data.tweetAuthorName, date: this.getFormattedDateForFilename(createdAt), }); return new Note(fileNameTemplate, 'md', content, this.plugin.settings.twitterContentTypeSlug, createdAt); } private async getTweetNoteData(url: URL, createdAt: Date): Promise<TweetNoteData> { const response = JSON.parse( await request({ method: 'GET', contentType: 'application/json', url: `https://publish.twitter.com/oembed?url=${url.href}`, }), ); const content = await parseHtmlContent(response.html); return { date: this.getFormattedDateForContent(createdAt), tweetAuthorName: response.author_name, tweetURL: response.url, tweetContent: content, tweetPublishDate: this.getPublishedDateFromDOM(response.html), }; } private getPublishedDateFromDOM(html: string): string { const dom = new DOMParser().parseFromString(html, 'text/html'); const dateElement = dom.querySelector('blockquote > a'); const date = moment(dateElement?.textContent ?? ''); return date.isValid() ? date.format(this.plugin.settings.dateContentFmt) : ''; } } export default TwitterParser; ================================================ FILE: src/parsers/VimeoParser.ts ================================================ import { requestUrl } from 'obsidian'; import { handleError } from 'src/helpers/error'; import { Note } from './Note'; import { Parser } from './Parser'; interface Schema { '@type': string; } interface Person extends Schema { name?: string; url?: string; } interface VideoObject extends Schema { author?: Person; embedUrl?: string; name?: string; url?: string; } interface VimeoNoteData { date: string; videoId: string; videoTitle: string; videoURL: string; videoPlayer: string; channelName: string; channelURL: string; } class VimeoParser extends Parser { private PATTERN = /(vimeo.com)\/(\d+)?/; test(clipboardContent: string): boolean | Promise<boolean> { return this.isValidUrl(clipboardContent) && this.PATTERN.test(clipboardContent); } async prepareNote(clipboardContent: string): Promise<Note> { const createdAt = new Date(); const data = await this.parseSchema(clipboardContent, createdAt); const content = this.templateEngine.render(this.plugin.settings.vimeoNote, data); const fileNameTemplate = this.templateEngine.render(this.plugin.settings.vimeoNoteTitle, { title: data.videoTitle, date: this.getFormattedDateForFilename(createdAt), }); return new Note(fileNameTemplate, 'md', content, this.plugin.settings.vimeoContentTypeSlug, createdAt); } private async parseSchema(url: string, createdAt: Date): Promise<VimeoNoteData> { try { const response = await requestUrl({ method: 'GET', url, headers: { 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36', }, }); if (response.status === 429) { throw new Error('Rate limited (HTTP 429). Try again later.'); } if (response.status >= 400) { throw new Error(`HTTP ${response.status} error fetching ${url}`); } const html = new TextDecoder().decode(response.arrayBuffer); const videoHTML = new DOMParser().parseFromString(html, 'text/html'); const schemaElement = videoHTML.querySelector('script[type="application/ld+json"]'); if (!schemaElement) { throw new Error('Vimeo ld+json schema element not found'); } const schema: [VideoObject, Schema] = JSON.parse(schemaElement.textContent); const videoSchema = schema[0]; const videoIdRegexExec = this.PATTERN.exec(url); return { date: this.getFormattedDateForContent(createdAt), videoId: videoIdRegexExec.length === 3 ? videoIdRegexExec[2] : '', videoURL: videoSchema?.url ?? '', videoTitle: videoSchema?.name ?? '', videoPlayer: `<iframe width="${this.plugin.settings.vimeoEmbedWidth}" height="${this.plugin.settings.vimeoEmbedHeight}" src="${videoSchema?.embedUrl}" title="Vimeo video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>`, channelName: videoSchema?.author?.name ?? '', channelURL: videoSchema?.author?.url ?? '', }; } catch (error) { handleError(error, 'Unable to parse Vimeo page.'); } } } export default VimeoParser; ================================================ FILE: src/parsers/WebsiteParser.ts ================================================ import { Notice, Platform, RequestUrlResponse, requestUrl } from 'obsidian'; import { Readability, isProbablyReaderable } from '@mozilla/readability'; import * as DOMPurify from 'isomorphic-dompurify'; import { getBaseUrl, normalizeFilename } from 'src/helpers/fileutils'; import { replaceImages } from 'src/helpers/replaceImages'; import { desktopBrowserUserAgent } from 'src/helpers/networkUtils'; import { Note } from './Note'; import { Parser } from './Parser'; import { parseHtmlContent } from './parsehtml'; interface ReadabilityArticle { title: string; content: string; textContent: string; length: number; excerpt: string; byline: string; dir: string; siteName: string; lang: string; publishedTime: string; } interface WebsiteNoteData { date: string; articleTitle: string; articleURL: string; articleReadingTime: number; articleContent: string; siteName: string; author: string; previewURL: string; publishedTime: string; readabilityArticle: ReadabilityArticle; articleOgDescription: string; articleMetaAuthor: string; articlePublishedTime: string; } class WebsiteParser extends Parser { test(url: string): boolean { return this.isValidUrl(url); } async prepareNote(url: string): Promise<Note> { const originUrl = new URL(url); const document = await this.getDocument(originUrl); return this.makeNote(document, originUrl); } protected async makeNote(document: Document, originUrl: URL): Promise<Note> { if (!isProbablyReaderable(document)) { new Notice('@mozilla/readability considers this document to unlikely be readerable.'); } const createdAt = new Date(); const previewUrl = this.extractPreviewUrl(document); // Extract OG/meta fields for template variables and thin-content fallback const ogTitle = document.querySelector('meta[property="og:title"]')?.getAttribute('content') ?? ''; const ogDescription = document.querySelector('meta[property="og:description"]')?.getAttribute('content') ?? ''; const ogSiteName = document.querySelector('meta[property="og:site_name"]')?.getAttribute('content') ?? ''; const metaAuthor = document.querySelector('meta[name="author"]')?.getAttribute('content') ?? document.querySelector('meta[property="article:author"]')?.getAttribute('content') ?? ''; const rawPublishedTime = document.querySelector('meta[property="article:published_time"]')?.getAttribute('content') ?? ''; const readableDocument = new Readability(document).parse(); if (readableDocument === null || !Object.prototype.hasOwnProperty.call(readableDocument, 'content')) { return this.notParsableArticle(originUrl.href, previewUrl, createdAt, { title: ogTitle, description: ogDescription, siteName: ogSiteName, }); } // Fall back to not-parsable path for thin content (JS-rendered pages, paywalls, etc.) if (readableDocument.textContent.trim().length < 100) { return this.notParsableArticle(originUrl.href, previewUrl, createdAt, { title: ogTitle, description: ogDescription, siteName: ogSiteName, }); } const content = await parseHtmlContent(readableDocument.content); return this.parsableArticle( { date: this.getFormattedDateForContent(createdAt), articleTitle: readableDocument.title || ogTitle || 'No title', articleURL: originUrl.href, articleReadingTime: this.getEstimatedReadingTime(readableDocument), articleContent: content, siteName: readableDocument.siteName || ogSiteName || '', author: readableDocument.byline || metaAuthor || '', previewURL: previewUrl || '', publishedTime: readableDocument.publishedTime !== null ? this.getFormattedDateForContent(readableDocument.publishedTime) : '', readabilityArticle: readableDocument, articleOgDescription: ogDescription, articleMetaAuthor: metaAuthor, articlePublishedTime: rawPublishedTime ? this.getFormattedDateForContent(rawPublishedTime) : '', }, createdAt, ); } protected async getDocument(url: URL): Promise<Document> { const document = await this.parseHtmlDom(url); //check for existing base element const originBasElements = document.getElementsByTagName('base'); let originBaseUrl = null; if (originBasElements.length > 0) { originBaseUrl = originBasElements.item(0).getAttribute('href'); Array.from(originBasElements).forEach((originBasEl) => { originBasEl.remove(); }); } // Set base to allow Readability to resolve relative path's const baseEl = document.createElement('base'); baseEl.setAttribute('href', getBaseUrl(originBaseUrl ?? url.href, url.origin)); document.head.append(baseEl); const cleanDocumentBody = DOMPurify.sanitize(document.body.innerHTML); document.body.innerHTML = cleanDocumentBody; /* DOM optimizations from MarkDownload. Distributed under an Apache License 2.0: https://github.com/deathau/markdownload/blob/main/LICENSE */ document.body.querySelectorAll('pre br')?.forEach((br) => { // we need to keep <br> tags because they are removed by Readability.js br.outerHTML = '<br-keep></br-keep>'; }); document.body.querySelectorAll('h1, h2, h3, h4, h5, h6')?.forEach((header) => { // Readability.js will strip out headings from the dom if certain words appear in their className // See: https://github.com/mozilla/readability/issues/807 header.className = ''; }); document.body.querySelectorAll('[class*=highlight-text],[class*=highlight-source]')?.forEach((codeSource) => { const language = codeSource.className.match(/highlight-(?:text|source)-([a-z0-9]+)/)?.[1]; if (codeSource.firstElementChild.nodeName == 'PRE') { codeSource.removeAttribute('data-snippet-clipboard-copy-content'); codeSource.firstElementChild.id = `code-lang-${language}`; } }); document.body.querySelectorAll('[class*=language-]')?.forEach((codeSource) => { const language = codeSource.className.match(/language-([a-z0-9]+)/)?.[1]; codeSource.id = `code-lang-${language}`; }); document.body.querySelectorAll('.codehilite > pre')?.forEach((codeSource) => { if (codeSource.firstChild.nodeName !== 'CODE' && !codeSource.className.includes('language')) { codeSource.id = 'code-lang-text'; } }); //fix for substack images document.body.querySelectorAll('.captioned-image-container figure')?.forEach((figure) => { const imgEl = figure.querySelector('img'); if (!imgEl) { return; } figure.querySelector('.image-link').remove(); figure.prepend(imgEl); }); //fix for Readability removing <figure> elements in <div> document.body.querySelectorAll('div > figure')?.forEach((figure) => { const figureEl = figure; figure.parentElement.before(figureEl); }); //fix for WeChat site (https://github.com/DominikPieper/obsidian-ReadItLater/issues/187) document.body.querySelector('.rich_media_content.js_underline_content')?.removeAttribute('style'); document.body.querySelectorAll('.rich_media_content.js_underline_content img[data-src]')?.forEach((imgEl) => { if (imgEl.getAttribute('src') === null) { imgEl.setAttribute('src', imgEl.getAttribute('data-src')); } }); return document; } protected async parsableArticle(data: WebsiteNoteData, createdAt: Date): Promise<Note> { const fileNameTemplate = this.templateEngine.render(this.plugin.settings.parseableArticleNoteTitle, { title: data.articleTitle, date: this.getFormattedDateForFilename(createdAt), }); let processedContent = this.templateEngine.render(this.plugin.settings.parsableArticleNote, data); if (this.plugin.settings.downloadImages && Platform.isDesktop) { processedContent = await replaceImages( this.plugin, normalizeFilename(fileNameTemplate), processedContent, this.getAssetsDir(fileNameTemplate, createdAt), ); } return new Note( fileNameTemplate, 'md', processedContent, this.plugin.settings.parseableArticleContentType, createdAt, ); } protected async notParsableArticle( url: string, previewUrl: string | null, createdAt: Date, ogData?: { title?: string; description?: string; siteName?: string }, ): Promise<Note> { console.error('Website not parseable'); let content = this.templateEngine.render(this.plugin.settings.notParsableArticleNote, { articleURL: url, previewURL: previewUrl ?? '', articleTitle: ogData?.title ?? '', articleOgDescription: ogData?.description ?? '', siteName: ogData?.siteName ?? '', }); const fileNameTemplate = this.templateEngine.render(this.plugin.settings.notParseableArticleNoteTitle, { date: this.getFormattedDateForFilename(createdAt), }); if (this.plugin.settings.downloadImages && Platform.isDesktop) { content = await replaceImages( this.plugin, normalizeFilename(fileNameTemplate), content, this.getAssetsDir(fileNameTemplate, createdAt), ); } return new Note( fileNameTemplate, 'md', content, this.plugin.settings.notParseableArticleContentType, createdAt, ); } /** * Extracts a preview URL from the document. * Searches for OpenGraph `og:image` and Twitter `twitter:image` meta tags. * @param document The document to extract preview URL from */ protected extractPreviewUrl(document: Document): string | null { let previewMetaElement = document.querySelector('meta[property="og:image"]'); if (previewMetaElement == null) { previewMetaElement = document.querySelector('meta[name="twitter:image"]'); } return previewMetaElement?.getAttribute('content'); } protected getAssetsDir(fileName: string, createdAt: Date): string { if (this.plugin.settings.downloadImagesInArticleDir) { const assetsDir = this.templateEngine.render(this.plugin.settings.assetsDir, { date: '', fileName: '', contentType: '', }); return `${assetsDir}/${normalizeFilename(fileName)}`; } return this.templateEngine.render(this.plugin.settings.assetsDir, { date: this.getFormattedDateForFilename(createdAt), fileName: normalizeFilename(fileName), contentType: this.plugin.settings.parseableArticleContentType, }); } /** * Returns estimated reading time of article in minutes */ private getEstimatedReadingTime(article: ReadabilityArticle): number { const readingSpeed = this.getReadingSpeed(article.lang || 'en'); const words = article.textContent.trim().split(/\s+/).length; return Math.ceil(words / readingSpeed); } /** * Reading speed in words per minute. Data are gathered from this study https://irisreading.com/average-reading-speed-in-various-languages/ */ private getReadingSpeed(lang: string): number { const readingSpeed = new Map([ ['en', 228], ['ar', 138], ['de', 179], ['es', 218], ['fi', 161], ['fr', 195], ['he', 187], ['it', 188], ['ja', 193], ['nl', 202], ['pl', 166], ['pt', 181], ['ru', 184], ['sk', 190], ['sl', 180], ['sv', 199], ['tr', 166], ['zh', 158], ]); return readingSpeed.get(lang) || readingSpeed.get('en'); } private async parseHtmlDom(url: URL, charsetOverride: string | null = null): Promise<Document> { const response = await requestUrl({ method: 'GET', url: url.href, headers: { ...desktopBrowserUserAgent }, }); const charset: string = charsetOverride ?? this.getCharsetFromResponseHeader(response); const buffer = response.arrayBuffer; const decoder = new TextDecoder(charset); const text = decoder.decode(buffer); const parser = new DOMParser(); const document = parser.parseFromString(text, 'text/html'); // Double-check meta tags for charset const metaCharset = document.querySelector('meta[charset], meta[http-equiv="Content-Type"]'); if (metaCharset) { const docCharset = metaCharset.getAttribute('charset') || metaCharset.getAttribute('content')?.match(/charset=([^;]+)/i)?.[1]; if (docCharset && docCharset !== charset) { // If different charset found in meta, re-decode return this.parseHtmlDom(url, docCharset); } } return document; } private getCharsetFromResponseHeader(response: RequestUrlResponse): string { const contentType = response.headers?.['content-type']; let charset = 'UTF-8'; // Try to extract charset from content-type header const charsetMatch = contentType?.match(/charset=([^;]+)/i); if (charsetMatch) { charset = charsetMatch[1]; } return charset; } } export default WebsiteParser; ================================================ FILE: src/parsers/WikipediaParser.ts ================================================ import { Note } from './Note'; import WebsiteParser from './WebsiteParser'; export default class WikipediaParser extends WebsiteParser { private PATTERN = /^(?:https?:\/\/)?(?:[a-z]{2,3}(?:-[a-z]{2,3})?)(?:\.m)?\.wikipedia\.org\/wiki\/([^\/]+)/i; test(url: string): boolean { return this.isValidUrl(url) && this.PATTERN.test(url); } async prepareNote(url: string): Promise<Note> { const originUrl = new URL(url); const document = await this.getDocument(originUrl); // remove cite square brackets to enhance readability document.querySelectorAll('.reference .cite-bracket').forEach((element) => { element.remove(); }); // add non-breaking whitespace before cite reference document.querySelectorAll('.reference a').forEach((element) => { element.textContent = '\u00A0' + element.textContent; }); // fix for https://github.com/DominikPieper/obsidian-ReadItLater/issues/176 document.querySelectorAll('.mw-cite-backlink').forEach((element) => { element.remove(); }); // fix for https://github.com/DominikPieper/obsidian-ReadItLater/issues/174 document.querySelectorAll('.infobox caption').forEach((element) => { const newParagraph = document.createElement('p'); newParagraph.innerHTML = element.innerHTML; element.parentElement.insertBefore(newParagraph, element); element.remove(); }); document.querySelectorAll('.wikitable caption').forEach((element) => { const newParagraph = document.createElement('p'); newParagraph.innerHTML = element.innerHTML; element.parentElement.insertBefore(newParagraph, element); element.remove(); }); return this.makeNote(document, originUrl); } } ================================================ FILE: src/parsers/YoutubeChannelParser.ts ================================================ import { request } from 'obsidian'; import { getJavascriptDeclarationByName } from 'src/helpers/domUtils'; import { handleError } from 'src/helpers/error'; import { desktopBrowserUserAgent } from 'src/helpers/networkUtils'; import { Note } from './Note'; import { Parser } from './Parser'; interface YoutubeChannelNoteData { date: string; channelId: string; channelTitle: string; channelDescription: string; channelURL: string; channelAvatar: string; channelBanner: string; channelSubscribersCount: number; channelVideosCount: number; channelVideosURL: string; channelShortsURL: string; } export default class YoutubeChannelParser extends Parser { private PATTERN = /^(https?:\/\/(?:(?:www|m)\.)?youtube\.com\/(?:channel\/(UC[\w-]{22})|c\/([^\s\/]+)|@([\w-]+)))(?:\/.*)?$/u; test(url: string): boolean { return this.isValidUrl(url) && this.PATTERN.test(url); } async prepareNote(url: string): Promise<Note> { const createdAt = new Date(); const data = this.plugin.settings.youtubeApiKey === '' ? await this.parseSchema(url, createdAt) : await this.parseApiResponse(url, createdAt); const content = this.templateEngine.render(this.plugin.settings.youtubeChannelNote, data); const fileNameTemplate = this.templateEngine.render(this.plugin.settings.youtubeChannelNoteTitle, { title: data.channelTitle, date: this.getFormattedDateForFilename(createdAt), }); return new Note(fileNameTemplate, 'md', content, this.plugin.settings.youtubeChannelContentTypeSlug, createdAt); } private async parseSchema(url: string, createdAt: Date): Promise<YoutubeChannelNoteData> { try { const response = await request({ method: 'GET', url, headers: { ...desktopBrowserUserAgent }, }); const [, channelURL] = this.PATTERN.exec(url); const channelHTML = new DOMParser().parseFromString(response, 'text/html'); const declaration = getJavascriptDeclarationByName('ytInitialData', channelHTML.querySelectorAll('script')); const jsonData = typeof declaration !== 'undefined' ? JSON.parse(declaration.value) : {}; const jsonDataSubscribersCount = jsonData?.header?.pageHeaderRenderer?.content?.pageHeaderViewModel?.metadata?.contentMetadataViewModel ?.metadataRows?.[1]?.metadataParts?.[0]?.text.content; if (jsonDataSubscribersCount === null) { console.warn('Unable to parse subscribers count.'); } const jsonDataVideosCount = jsonData?.header?.pageHeaderRenderer?.content?.pageHeaderViewModel?.metadata?.contentMetadataViewModel ?.metadataRows?.[1]?.metadataParts?.[1]?.text.content; if (jsonDataVideosCount === null) { console.warn('Unable to parse subscribers count.'); } return { date: this.getFormattedDateForContent(createdAt), channelId: jsonData?.metadata?.channelMetadataRenderer?.externalId ?? '', channelTitle: jsonData?.metadata?.channelMetadataRenderer?.title ?? '', channelDescription: jsonData?.metadata?.channelMetadataRenderer?.description ?? '', channelURL: channelURL, channelAvatar: jsonData?.metadata?.channelMetadataRenderer?.avatar?.thumbnails?.[0]?.url ?? '', channelBanner: jsonData?.header?.pageHeaderRenderer?.content?.pageHeaderViewModel?.banner?.imageBannerViewModel ?.image?.sources?.[0]?.url ?? '', channelSubscribersCount: this.parseNumberValue(jsonDataSubscribersCount ?? ''), channelVideosCount: this.parseNumberValue(jsonDataVideosCount ?? ''), channelVideosURL: `${channelURL}/videos`, channelShortsURL: `${channelURL}/shorts`, }; } catch (error) { handleError(error, 'Unable to parse Youtube channel schema from DOM.'); } } private async parseApiResponse(url: string, createdAt: Date): Promise<YoutubeChannelNoteData> { const apiURL = new URL( 'https://youtube.googleapis.com/youtube/v3/channels?part=snippet,contentDetails,statistics,brandingSettings', ); const [, channelURL, channelId, legacyUsername, handle] = this.PATTERN.exec(url); if (channelId) { apiURL.searchParams.append('id', channelId); } else if (handle) { apiURL.searchParams.append('forHandle', handle); } else if (legacyUsername) { // if channel name contains special international characters, API does not return valid response const channelNoteDOMData = await this.parseSchema(url, createdAt); apiURL.searchParams.append('id', channelNoteDOMData.channelId); } else { throw new Error('Unable to compose Youtube API URL'); } apiURL.searchParams.append('key', this.plugin.settings.youtubeApiKey); try { const channelApiResponse = await request({ method: 'GET', url: apiURL.toString(), headers: { Accept: 'application/json', }, }); const channelJsonResponse = JSON.parse(channelApiResponse); if (channelJsonResponse.items.length === 0) { throw new Error(`Channel (${url}) cannot be fetched from API`); } const channel: GoogleApiYouTubeChannelResource = channelJsonResponse.items[0]; return { date: this.getFormattedDateForContent(createdAt), channelId: channel.id, channelTitle: channel.snippet.title, channelDescription: channel.snippet.description, channelURL: channelURL, channelAvatar: channel.snippet.thumbnails?.high.url ?? channel.snippet.thumbnails.default.url, channelBanner: channel.brandingSettings?.image?.bannerExternalUrl ?? '', channelSubscribersCount: channel.statistics.subscriberCount, channelVideosCount: channel.statistics.videoCount, channelVideosURL: `${channelURL}/videos`, channelShortsURL: `${channelURL}/shorts`, }; } catch (error) { handleError(error, 'Unable to parse Youtube channel API response.'); } } private parseNumberValue(numberValue: string): number { const numberValueRegex = /(\d+(?:\.\d+)?)(K|M|B)?/; const match = numberValue.match(numberValueRegex); if (!match) { return 0; } const [, number, notation] = match; if (typeof notation === 'undefined') { return Number(number); } switch (notation) { case 'K': return Number(number) * 1000; case 'M': return Number(number) * 1000000; case 'B': return Number(number) * 1000000000; } } } ================================================ FILE: src/parsers/YoutubeParser.ts ================================================ import { moment, request } from 'obsidian'; import { Duration, parse, toSeconds } from 'iso8601-duration'; import { handleError } from 'src/helpers/error'; import { getJavascriptDeclarationByName } from 'src/helpers/domUtils'; import { desktopBrowserUserAgent } from 'src/helpers/networkUtils'; import { Note } from './Note'; import { Parser } from './Parser'; interface YoutubeNoteData { date: string; videoId: string; videoTitle: string; videoDescription: string; videoThumbnail: string; videoDuration: Number; videoDurationFormatted: string; videoPublishDate: string; videoViewsCount: Number; videoURL: string; videoTags: string; videoPlayer: string; videoChapters: string; channelId: string; channelName: string; channelURL: string; extra: YoutubeVideo; } interface YoutubeVideo { thumbnails: GoogleApiYouTubeThumbnailResource; publishedAt: Date; tags: string[]; channel: YoutubeChannel; chapters: YoutubeVideoChapter[]; } interface YoutubeVideoChapter { timestamp: string; title: string; seconds: number; } interface YoutubeChannel { thumbnails: GoogleApiYouTubeThumbnailResource; } class YoutubeParser extends Parser { private PATTERN = /^(?:https?:\/\/)?(?:www\.)?(?:youtube\.com\/(?:watch\?v=|shorts\/)|youtu\.be\/)([a-zA-Z0-9_-]+)(?:\?([^&\s]+(?:&[^&\s]+)*))?/; test(url: string): boolean { return this.isValidUrl(url) && this.PATTERN.test(url); } async prepareNote(url: string): Promise<Note> { const createdAt = new Date(); const data = this.plugin.settings.youtubeApiKey === '' ? await this.parseSchema(url, createdAt) : await this.parseApiResponse(url, createdAt); const content = this.templateEngine.render(this.plugin.settings.youtubeNote, data); const fileNameTemplate = this.templateEngine.render(this.plugin.settings.youtubeNoteTitle, { title: data.videoTitle, date: this.getFormattedDateForFilename(createdAt), }); return new Note(fileNameTemplate, 'md', content, this.plugin.settings.youtubeContentTypeSlug, createdAt); } private async parseApiResponse(url: string, createdAt: Date): Promise<YoutubeNoteData> { const videoId = this.PATTERN.exec(url)[1]; try { const videoApiResponse = await request({ method: 'GET', url: `https://www.googleapis.com/youtube/v3/videos?part=contentDetails,snippet,statistics,status,topicDetails&id=${videoId}&key=${this.plugin.settings.youtubeApiKey}`, headers: { Accept: 'application/json', }, }); const videoJsonResponse = JSON.parse(videoApiResponse); if (videoJsonResponse.items.length === 0) { throw new Error(`Video (${url}) cannot be fetched from API`); } const video: GoogleApiYouTubeVideoResource = videoJsonResponse.items[0]; const channelApiResponse = await request({ method: 'GET', url: `https://www.googleapis.com/youtube/v3/channels?part=snippet,contentDetails,statistics&id=${video.snippet.channelId}&key=${this.plugin.settings.youtubeApiKey}`, headers: { Accept: 'application/json', }, }); const channelJsonResponse = JSON.parse(channelApiResponse); if (channelJsonResponse.items.length === 0) { throw new Error(`Channel (${video.snippet.channelId}) cannot be fetched from API`); } const channel: GoogleApiYouTubeChannelResource = channelJsonResponse.items[0]; const duration = parse(video.contentDetails.duration); const tags: string[] = video.snippet?.tags?.map((tag) => tag.replace(/[\s:\-_.]/g, '').replace(/^/, '#')) ?? []; const chapters = this.getVideoChapters(video.snippet.description); return { date: this.getFormattedDateForContent(createdAt), videoId: video.id, videoURL: url, videoTitle: video.snippet.title, videoDescription: video.snippet.description, videoThumbnail: video.snippet.thumbnails?.maxres?.url ?? video.snippet.thumbnails?.medium?.url ?? video.snippet.thumbnails?.default?.url ?? '', videoPlayer: this.getEmbedPlayer(video.id), videoDuration: toSeconds(duration), videoDurationFormatted: this.formatDuration(duration), videoPublishDate: moment(video.snippet.publishedAt).format(this.plugin.settings.dateContentFmt), videoViewsCount: video.statistics.viewCount, videoTags: tags.join(' '), videoChapters: this.formatVideoChapters(video.id, chapters), channelId: channel.id, channelURL: `https://www.youtube.com/channel/${channel.id}`, channelName: channel.snippet.title ?? '', extra: { thumbnails: video.snippet.thumbnails, publishedAt: moment(video.snippet.publishedAt).toDate(), tags: tags, channel: { thumbnails: channel.snippet.thumbnails, }, chapters: chapters, }, }; } catch (error) { handleError(error, 'Unable to parse Youtube API response.'); } } private async parseSchema(url: string, createdAt: Date): Promise<YoutubeNoteData> { try { const response = await request({ method: 'GET', url, headers: { ...desktopBrowserUserAgent }, }); const videoHTML = new DOMParser().parseFromString(response, 'text/html'); const declaration = getJavascriptDeclarationByName('ytInitialData', videoHTML.querySelectorAll('script')); const jsonData = typeof declaration !== 'undefined' ? JSON.parse(declaration.value) : {}; const videoSchemaElement = videoHTML.querySelector('[itemtype*="http://schema.org/VideoObject"]'); if (videoSchemaElement === null) { throw new Error('Unable to find Schema.org element in HTML.'); } const videoId = videoSchemaElement?.querySelector('[itemprop="identifier"]')?.getAttribute('content') ?? ''; const personSchemaElement = videoSchemaElement.querySelector('[itemtype="http://schema.org/Person"]'); const description = jsonData?.contents?.twoColumnWatchNextResults?.results?.results?.contents?.[1] ?.videoSecondaryInfoRenderer?.attributedDescription?.content ?? videoSchemaElement?.querySelector('[itemprop="description"]')?.getAttribute('content') ?? ''; const chapters = this.getVideoChapters(description); const publishedAt = jsonData?.engagementPanels?.[5]?.engagementPanelSectionListRenderer?.content ?.structuredDescriptionContentRenderer?.items?.[0]?.videoDescriptionHeaderRenderer?.publishDate ?.simpleText ?? ''; const videoViewsCount = jsonData?.contents?.twoColumnWatchNextResults?.results?.results?.contents?.[0]?.videoPrimaryInfoRenderer ?.viewCount?.videoViewCountRenderer?.originalViewCount ?? 0; const channelId = jsonData?.contents?.twoColumnWatchNextResults?.results?.results?.contents?.[1] ?.videoSecondaryInfoRenderer?.subscribeButton?.subscribeButtonRenderer?.channelId ?? videoSchemaElement?.querySelector('[itemprop="channelId"]')?.getAttribute('content') ?? ''; return { date: this.getFormattedDateForContent(createdAt), videoId: videoId, videoURL: url, videoTitle: videoSchemaElement?.querySelector('[itemprop="name"]')?.getAttribute('content') ?? '', videoDescription: description, videoThumbnail: videoHTML.querySelector('meta[property="og:image"]')?.getAttribute('content') ?? '', videoPlayer: this.getEmbedPlayer(videoId), videoDuration: 0, videoDurationFormatted: '', videoPublishDate: publishedAt !== '' ? moment(publishedAt).format(this.plugin.settings.dateContentFmt) : '', videoViewsCount: videoViewsCount, videoTags: '', videoChapters: this.formatVideoChapters(videoId, chapters), channelId: channelId, channelURL: personSchemaElement?.querySelector('[itemprop="url"]')?.getAttribute('href') ?? '', channelName: personSchemaElement?.querySelector('[itemprop="name"]')?.getAttribute('content') ?? '', extra: null, }; } catch (error) { handleError(error, 'Unable to parse Youtube schema from DOM.'); } } private formatDuration(duration: Duration): string { let formatted: string = ''; if (duration.years > 0) { formatted = formatted.concat(' ', `${duration.years}y`); } if (duration.months > 0) { formatted = formatted.concat(' ', `${duration.months}m`); } if (duration.weeks > 0) { formatted = formatted.concat(' ', `${duration.weeks}w`); } if (duration.days > 0) { formatted = formatted.concat(' ', `${duration.days}d`); } if (duration.hours > 0) { formatted = formatted.concat(' ', `${duration.hours}h`); } if (duration.minutes > 0) { formatted = formatted.concat(' ', `${duration.minutes}m`); } if (duration.seconds > 0) { formatted = formatted.concat(' ', `${duration.seconds}s`); } return formatted.trim(); } private formatVideoChapters(videoId: string, chapters: YoutubeVideoChapter[]): string { return chapters .map((chapter) => { return this.templateEngine.render(this.plugin.settings.youtubeChapter, { chapterTimestamp: chapter.timestamp, chapterTitle: chapter.title, chapterSeconds: chapter.seconds, chapterUrl: `https://www.youtube.com/watch?v=${videoId}&t=${chapter.seconds}`, }); }, this) .join('\n'); } private getVideoChapters(description: string): YoutubeVideoChapter[] { const chapterRegex = /^((?:\d{1,2}:)?(?:\d{1,2}):(?:\d{1,2}))\s+(.+)$/gm; const chapters = []; let match; while ((match = chapterRegex.exec(description)) !== null) { const timestamp = match[1].trim(); // First capture group - timestamp only const title = match[2].trim(); // Second capture group - title only // Convert timestamp to seconds const timestampSegments = timestamp.split(':'); let hours = 0, minutes, seconds; if (timestampSegments.length === 3) { [hours, minutes, seconds] = timestampSegments.map(Number); } else { [minutes, seconds] = timestampSegments.map(Number); } const totalSeconds = hours * 3600 + minutes * 60 + seconds; chapters.push({ timestamp, title, seconds: totalSeconds, }); } return chapters; } private getEmbedPlayer(videoId: string): string { const domain = this.plugin.settings.youtubeUsePrivacyEnhancedEmbed ? 'youtube-nocookie.com' : 'youtube.com'; return `<iframe width="${this.plugin.settings.youtubeEmbedWidth}" height="${this.plugin.settings.youtubeEmbedHeight}" src="https://www.${domain}/embed/${videoId}" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>`; } } export default YoutubeParser; ================================================ FILE: src/parsers/parsehtml.ts ================================================ import TurndownService from 'turndown'; import * as turndownPluginGfm from '@guyplusplus/turndown-plugin-gfm'; export async function parseHtmlContent(content: string) { const gfm = turndownPluginGfm.gfm; const turndownService = new TurndownService({ headingStyle: 'atx', hr: '---', bulletListMarker: '-', codeBlockStyle: 'fenced', emDelimiter: '*', }); turndownService.use(gfm); turndownService.addRule('torchlightCodeBlock', { filter: (node) => { return ( node.nodeName === 'PRE' && node.firstChild.nodeName === 'CODE' && node.firstChild.firstChild.nodeName === 'P' ); }, replacement: function (_content, node, options) { node.querySelectorAll('p').forEach((codeLine) => { codeLine.innerHTML = codeLine.innerHTML + '\n'; }); return ( //eslint-disable-next-line @typescript-eslint/ban-ts-comment //@ts-ignore '\n\n' + options.fence + //eslint-disable-next-line @typescript-eslint/ban-ts-comment //@ts-ignore node.firstChild.getAttribute('data-lang') + '\n' + node.firstChild.textContent + '\n' + options.fence + '\n\n' ); }, }); turndownService.addRule('fencedCodeLangBlock', { filter: (node) => { return ( node.nodeName == 'PRE' && (!node.firstChild || node.firstChild.nodeName != 'CODE') && !node.querySelector('img') ); }, replacement: function (_content, node, options) { //eslint-disable-next-line @typescript-eslint/ban-ts-comment //@ts-ignore node.innerHTML = node.innerHTML.replaceAll('<br-keep></br-keep>', '<br>'); //eslint-disable-next-line @typescript-eslint/ban-ts-comment //@ts-ignore const langMatch = node.id?.match(/code-lang-(.+)/); const language = langMatch?.length > 0 ? langMatch[1] : ''; const code = node.textContent; const fenceChar = options.fence.charAt(0); let fenceSize = 3; const fenceInCodeRegex = new RegExp('^' + fenceChar + '{3,}', 'gm'); let match; while ((match = fenceInCodeRegex.exec(code))) { if (match[0].length >= fenceSize) { fenceSize = match[0].length + 1; } } const fence = Array(fenceSize + 1).join(fenceChar); return '\n\n' + fence + language + '\n' + code.replace(/\n$/, '') + '\n' + fence + '\n\n'; }, }); const articleContent = turndownService.turndown(content); return articleContent; } ================================================ FILE: src/repository/DefaultVaultRepository.ts ================================================ import { Note } from 'src/parsers/Note'; import { CapacitorAdapter, FileSystemAdapter, Notice, TFile, TFolder, normalizePath } from 'obsidian'; import ReadItLaterPlugin from 'src/main'; import { getOsOptimizedPath } from 'src/helpers/fileutils'; import TemplateEngine from 'src/template/TemplateEngine'; import { formatDate } from 'src/helpers/date'; import FileNotFoundError from 'src/error/FileNotFound'; import FileExistsError from '../error/FileExists'; import { VaultRepository } from './VaultRepository'; export default class DefaultVaultRepository implements VaultRepository { private plugin: ReadItLaterPlugin; private templateEngine: TemplateEngine; constructor(plugin: ReadItLaterPlugin, templateEngine: TemplateEngine) { this.plugin = plugin; this.templateEngine = templateEngine; } public async saveNote(note: Note): Promise<void> { let filePath; if ( this.plugin.app.vault.adapter instanceof CapacitorAdapter || this.plugin.app.vault.adapter instanceof FileSystemAdapter ) { filePath = getOsOptimizedPath( '/', note.getFullFilename(), this.plugin.app.vault.adapter, this.plugin.getFileSystemLimits(), ); } else { filePath = normalizePath(`/${note.getFullFilename()}`); } if (this.plugin.settings.inboxDir) { const inboxDir = this.templateEngine.render(this.plugin.settings.inboxDir, { date: formatDate(note.createdAt, this.plugin.settings.dateTitleFmt), fileName: note.fileName, contentType: note.contentType, }); await this.createDirectory(inboxDir); if ( this.plugin.app.vault.adapter instanceof CapacitorAdapter || this.plugin.app.vault.adapter instanceof FileSystemAdapter ) { filePath = getOsOptimizedPath( inboxDir, note.getFullFilename(), this.plugin.app.vault.adapter, this.plugin.getFileSystemLimits(), ); } else { filePath = normalizePath(`${inboxDir}/${note.getFullFilename()}`); } } note.filePath = filePath; if (await this.exists(note.filePath)) { throw new FileExistsError(`${note.getFullFilename()} already exists!`); } else { const newFile = await this.plugin.app.vault.create(note.filePath, note.content); if (this.plugin.settings.openNewNote || this.plugin.settings.openNewNoteInNewTab) { this.plugin.app.workspace .getLeaf(this.plugin.settings.openNewNoteInNewTab ? 'tab' : false) .openFile(newFile); } new Notice(`${note.getFullFilename()} created successfully`); } } public async createDirectory(directoryPath: string): Promise<void> { const normalizedPath = normalizePath(directoryPath); const directory = this.plugin.app.vault.getAbstractFileByPath(normalizedPath); if (directory && directory instanceof TFolder) { return; } await this.plugin.app.vault.createFolder(normalizedPath); } public async exists(filePath: string): Promise<boolean> { return await this.plugin.app.vault.adapter.exists(filePath); } public getFileByPath(filePath: string): TFile { const file = this.plugin.app.vault.getFileByPath(filePath); if (file === null) { throw new FileNotFoundError(`File not found: ${filePath}`); } return file; } public async appendToExistingNote(note: Note): Promise<void> { let file; try { file = this.getFileByPath(note.filePath); } catch (error) { if (error instanceof FileNotFoundError) { new Notice(`Unable to edit ${note.getFullFilename()}`); } else { throw error; } } await this.plugin.app.vault.process(file, (data) => { return `${data}\n\n${note.content}`; }); } } ================================================ FILE: src/repository/VaultRepository.ts ================================================ import { TFile } from 'obsidian'; import { Note } from 'src/parsers/Note'; export interface VaultRepository { saveNote(note: Note): Promise<void>; createDirectory(directoryPath: string): Promise<void>; exists(filePath: string): Promise<boolean>; getFileByPath(filePath: string): TFile; appendToExistingNote(note: Note): Promise<void>; } ================================================ FILE: src/settings.ts ================================================ import { Delimiter } from './enums/delimiter'; import { FileExistsStrategy } from './enums/fileExistsStrategy'; export type ReadItLaterSettingValue = string | number | boolean | Delimiter | FileExistsStrategy | null; export interface ReadItLaterSettings { [key: string]: ReadItLaterSettingValue; inboxDir: string; assetsDir: string; openNewNote: boolean; batchProcess: boolean; // deprecated batchProcessDelimiter: Delimiter; openNewNoteInNewTab: boolean; youtubeContentTypeSlug: string; youtubeNoteTitle: string; youtubeNote: string; youtubeChapter: string; youtubeEmbedWidth: string; youtubeEmbedHeight: string; youtubeUsePrivacyEnhancedEmbed: boolean; vimeoContentTypeSlug: string; vimeoNoteTitle: string; vimeoNote: string; vimeoEmbedWidth: string; vimeoEmbedHeight: string; bilibiliContentTypeSlug: string; bilibiliNoteTitle: string; bilibiliNote: string; bilibiliEmbedWidth: string; bilibiliEmbedHeight: string; twitterContentTypeSlug: string; twitterNoteTitle: string; twitterNote: string; parseableArticleContentType: string; parseableArticleNoteTitle: string; parsableArticleNote: string; notParseableArticleContentType: string; notParseableArticleNoteTitle: string; notParsableArticleNote: string; textSnippetContentType: string; textSnippetNoteTitle: string; textSnippetNote: string; mastodonContentTypeSlug: string; mastodonNoteTitle: string; mastodonNote: string; downloadImages: boolean; downloadImagesInArticleDir: boolean; dateTitleFmt: string; dateContentFmt: string; downloadMastodonMediaAttachments: boolean; downloadMastodonMediaAttachmentsInDir: boolean; saveMastodonReplies: boolean; mastodonReply: string; stackExchangeContentType: string; stackExchangeNoteTitle: string; stackExchangeNote: string; stackExchangeAnswer: string; downloadStackExchangeAssets: boolean; downloadStackExchangeAssetsInDir: boolean; youtubeApiKey: string; tikTokContentTypeSlug: string; tikTokNoteTitle: string; tikTokNote: string; tikTokEmbedWidth: string; tikTokEmbedHeight: string; extendShareMenu: boolean; filesystemLimitPath: number | null; filesystemLimitFileName: number | null; youtubeChannelContentTypeSlug: string; youtubeChannelNoteTitle: string; youtubeChannelNote: string; fileExistsStrategy: FileExistsStrategy; blueskyContentTypeSlug: string; blueskyNoteTitle: string; blueskyNote: string; downloadBlueskyEmbeds: boolean; downloadBlueskyEmbedsInDir: boolean; saveBlueskyPostReplies: boolean; blueskyPostReply: string; pinterestContentTypeSlug: string; pinterestNoteTitle: string; pinterestNote: string; downloadPinterestImage: boolean; } export const DEFAULT_SETTINGS: ReadItLaterSettings = { inboxDir: 'ReadItLater Inbox', assetsDir: 'ReadItLater Inbox/assets', openNewNote: false, batchProcess: false, // deperecated batchProcessDelimiter: Delimiter.NewLine, openNewNoteInNewTab: false, youtubeContentTypeSlug: 'youtube', youtubeNoteTitle: 'Youtube - {{ title }}', youtubeNote: '[[ReadItLater]] [[Youtube]]\n\n# [{{ videoTitle }}]({{ videoURL }})\n\n{{ videoPlayer }}\n\n{{ videoChapters }}', youtubeChapter: '- [{{ chapterTimestamp }}]({{ chapterUrl }}) {{ chapterTitle }}', youtubeEmbedWidth: '560', youtubeEmbedHeight: '315', youtubeUsePrivacyEnhancedEmbed: true, vimeoContentTypeSlug: 'vimeo', vimeoNoteTitle: 'Vimeo - {{ title }}', vimeoNote: '[[ReadItLater]] [[Vimeo]]\n\n# [{{ videoTitle }}]({{ videoURL }})\n\n{{ videoPlayer }}\n\n{{ videoChapters }}', vimeoEmbedWidth: '560', vimeoEmbedHeight: '315', bilibiliContentTypeSlug: 'bilibili', bilibiliNoteTitle: 'Bilibili - {{ title }}', bilibiliNote: '[[ReadItLater]] [[Bilibili]]\n\n# [{{ videoTitle }}]({{ videoURL }})\n\n{{ videoPlayer }}', bilibiliEmbedWidth: '560', bilibiliEmbedHeight: '315', twitterContentTypeSlug: 'xcom', twitterNoteTitle: 'Tweet from {{ tweetAuthorName }} ({{ date }})', twitterNote: '[[ReadItLater]] [[Tweet]]\n\n# [{{ tweetAuthorName }}]({{ tweetURL }})\n\n{{ tweetContent }}', parseableArticleContentType: 'article', parseableArticleNoteTitle: '{{ title }}', parsableArticleNote: '[[ReadItLater]] [[Article]]\n\n# [{{ articleTitle }}]({{ articleURL }})\n\n{{ articleContent }}', notParseableArticleContentType: 'article', notParseableArticleNoteTitle: 'Article {{ date }}', notParsableArticleNote: '[[ReadItLater]] [[Article]]\n\n[{{ articleURL }}]({{ articleURL }})', textSnippetContentType: 'textsnippet', textSnippetNoteTitle: 'Note {{ date }}', textSnippetNote: '[[ReadItLater]] [[Textsnippet]]\n\n{{ content }}', mastodonContentTypeSlug: 'mastodon', mastodonNoteTitle: 'Toot from {{ tootAuthorName }} ({{ date }})', mastodonNote: '[[ReadItLater]] [[Toot]]\n\n# [{{ tootAuthorName }}]({{ tootURL }})\n\n> {{ tootContent }}', downloadImages: true, downloadImagesInArticleDir: false, dateTitleFmt: 'YYYY-MM-DD HH-mm-ss', dateContentFmt: 'YYYY-MM-DD', downloadMastodonMediaAttachments: true, downloadMastodonMediaAttachmentsInDir: false, saveMastodonReplies: false, mastodonReply: '[{{ tootAuthorName }}]({{ tootURL }})\n\n> {{ tootContent }}', stackExchangeContentType: 'stackexchange', stackExchangeNoteTitle: '{{ title }}', stackExchangeNote: '[[ReadItLater]] [[StackExchange]]\n\n# [{{ questionTitle }}]({{ questionURL }})\n\nAuthor: [{{ authorName }}]({{ authorProfileURL }})\n\n{{ questionContent }}\n\n***\n\n{{ topAnswer }}\n\n{{ answers }}', stackExchangeAnswer: 'Answered by: [{{ authorName }}]({{ authorProfileURL }})\n\n{{ answerContent }}', downloadStackExchangeAssets: true, downloadStackExchangeAssetsInDir: false, youtubeApiKey: '', tikTokContentTypeSlug: 'tiktok', tikTokNoteTitle: 'TikTok from {{ authorName }} ({{ date }})', tikTokNote: '[[ReadItLater]] [[TikTok]]\n\n{{ videoDescription }}\n\n[{{ videoURL }}]({{ videoURL }})\n\n{{ videoPlayer }}', tikTokEmbedWidth: '325', tikTokEmbedHeight: '760', extendShareMenu: true, filesystemLimitPath: null, filesystemLimitFileName: null, youtubeChannelContentTypeSlug: 'youtube-channel', youtubeChannelNoteTitle: '{{ title }}', youtubeChannelNote: '[[ReadItLater]] [[YoutubeChannel]]\n\n# [{{ channelTitle }}]({{ channelURL }})\n\n![{{ channelTitle }}|300]({{ channelAvatar }})\n\n[Videos]({{ channelVideosURL }})\n\n{{ channelSubscribersCount|numberLexify }} subscribers', fileExistsStrategy: FileExistsStrategy.Ask, blueskyContentTypeSlug: 'bluesky', blueskyNoteTitle: 'Status from {{ authorName }} ({{ date }})', blueskyNote: '[[ReadItLater]] [[Bluesky]]\n\n# [{{ authorName }}]({{ postURL }})\n\n{{ content|blockquote }}', downloadBlueskyEmbeds: true, downloadBlueskyEmbedsInDir: false, saveBlueskyPostReplies: false, blueskyPostReply: '[{{ authorName }}]({{ postURL }})\n\n{{ content|blockquote }}', pinterestContentTypeSlug: 'pinterest', pinterestNoteTitle: 'Pin from {{ authorName }} ({{ date }})', pinterestNote: '[[ReadItLater]] [[Pinterest]]\n\n# [{{ authorName }}]({{ pinURL }})\n\n![]({{ image }})\n{{ description }}', downloadPinterestImage: true, }; ================================================ FILE: src/template/TemplateEngine.ts ================================================ import { lexify } from 'src/helpers/numberUtils'; interface TemplateData { [key: string]: any; } type ModifierFunction = (value: any, ...args: any[]) => any; interface Modifiers { [key: string]: ModifierFunction; } export const stringableTypes: string[] = ['string', 'number', 'bigint', 'symbol']; export const variableRegex = /{{(.*?)}}/g; export default class TemplateEngine { private modifiers: Modifiers; constructor() { this.modifiers = { blockquote: (value: string) => { if (!this.validateFilterValueType(value, 'blockquote', stringableTypes)) { return value; } return value .split('\n') .map((line) => `> ${line}`) .join('\n'); }, capitalize: (value: string) => { if (!this.validateFilterValueType(value, 'capitalize', stringableTypes)) { return value; } const str = String(value); return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase(); }, join: (value: any[], separator: string = ',') => { if (!this.validateFilterValueType(value, 'join', ['array'])) { return value; } return value.join(separator); }, numberLexify: (value: number) => { return lexify(value); }, lower: (value: string) => { if (!this.validateFilterValueType(value, 'lower', stringableTypes)) { return value; } String(value).toLowerCase(); }, map: (value: any[], transform: (item: any) => any) => { if (!this.validateFilterValueType(value, 'map', ['array'])) { return value; } try { return value.map(transform); } catch (e) { console.warn('Error in map modifier:', e); return value; } }, replace: (value: string, search: string, replacement: string = '') => { if (!this.validateFilterValueType(value, 'replace', stringableTypes)) { return value; } return value.replaceAll(search, replacement); }, striptags: (value: string, allowedTags: string = '') => { if (!this.validateFilterValueType(value, 'striptags', stringableTypes)) { return value; } const regex = new RegExp( `<(?!/?(${allowedTags.replace(/[<>]/g, '').split(',').join('|')})s*/?)[^>]+>`, 'gi', ); return value.replace(regex, ''); }, upper: (value: string) => { if (!this.validateFilterValueType(value, 'upper', stringableTypes)) { return value; } String(value).toUpperCase(); }, }; } public render(template: string, data: TemplateData): string { try { // First process any loops in the template let result = this.processLoops(template, data); // Then process variables with modifiers result = this.processVariables(result, data); // Finally process simple pattern substitutions result = this.processSimplePattern(result, data); return result; } catch (e) { console.error('Error rendering template:', e); return template; // Return original template on error } } private processSimplePattern(template: string, data: TemplateData): string { const simplePatternRegex = /%(\w+(?:\.\w+)*)%/g; return template.replace(simplePatternRegex, (match: string, path: string) => { try { const value = this.resolveValue(path, data); if (value === undefined) { console.warn(`Unable to resolve ${path}`); return match; } return String(value); } catch (e) { console.warn(`Error processing simple pattern "${match}":`, e); return match; } }); } private processVariables(template: string, data: TemplateData): string { return template.replace(variableRegex, (match: string, content: string) => { try { const [key, ...modifiers] = content.split('|').map((item) => item.trim()); // check if value is raw string const rawStringRegex = /(['"])((?:[^\\]|\\.)*?)\1/; const rawStringMatch = rawStringRegex.exec(key); let value; // if value is raw string don't resolve value from template data if (rawStringMatch !== null) { value = rawStringMatch[2]; } else { value = this.resolveValue(key, data); } if (value === undefined) { console.warn(`Unable to resolve ${key}`); return match; } let processedValue = value; for (const modifier of modifiers) { processedValue = this.applyModifier(processedValue, modifier); } return String(processedValue); } catch (e) { console.warn(`Error processing variable "${match}":`, e); return match; } }); } private processLoops(template: string, data: TemplateData): string { const loopRegex = /{%\s*for\s+(\w+)\s+in\s+(\w+(?:\.\w+)*)\s*%}([\s\S]*?){%\s*endfor\s*%}/g; return template.replace(loopRegex, (match: string, itemName: string, arrayPath: string, content: string) => { try { const arrayValue = this.resolveValue(arrayPath, data); if (!Array.isArray(arrayValue)) { console.warn(`Value at "${arrayPath}" is not an array`); return ''; } return arrayValue .map((item: any) => { const loopContext = { ...data, [itemName]: item }; return this.render(content, loopContext); }) .join(''); } catch (e) { console.warn(`Error processing loop "${match}":`, e); return ''; } }); } private resolveValue(path: string, data: TemplateData): any { const parts = path.trim().split('.'); let value = data; for (const part of parts) { if (value === undefined || value === null) return undefined; value = value[part]; } return value; } public addModifier(name: string, func: ModifierFunction): void { if (typeof func !== 'function') { throw new Error('Modifier must be a function'); } this.modifiers[name] = func; } private parseModifier(modifierString: string): { name: string; args: any[] } { const match = modifierString.match(/(\w+)(?:\((.*?)\))?/); if (!match) return { name: modifierString, args: [] }; const [, name, argsString] = match; const args = argsString ? this.parseArguments(argsString) : []; return { name, args }; } private parseArguments(argsString: string): any[] { const args: any[] = []; let current = ''; let inQuotes = false; let quoteChar = ''; let inArrowFunction = false; let bracketCount = 0; let escapeNext = false; const pushArg = () => { const trimmed = current.trim(); if (trimmed || inQuotes) { // Consider empty strings when in quotes args.push(this.evaluateArgument(current.trim())); } current = ''; }; for (let i = 0; i < argsString.length; i++) { const char = argsString[i]; if (escapeNext) { current += char; escapeNext = false; continue; } switch (char) { case '\\': escapeNext = true; break; case '"': case "'": if (!inArrowFunction) { if (inQuotes && char === quoteChar) { inQuotes = false; } else if (!inQuotes) { inQuotes = true; quoteChar = char; } } current += char; break; case '(': bracketCount++; current += char; break; case ')': bracketCount--; current += char; break; case '=': if (argsString[i + 1] === '>') { inArrowFunction = true; current += '=>'; i++; // Skip next character } else { current += char; } break; case ',': if (!inQuotes && !inArrowFunction && bracketCount === 0) { pushArg(); } else { current += char; } break; default: current += char; } } if (current || inQuotes) { // Consider empty strings when in quotes pushArg(); } return args; } private evaluateArgument(arg: string): any { try { // Handle arrow functions if (arg.includes('=>')) { const arrowFunc = Function(`return ${arg}`)(); return typeof arrowFunc === 'function' ? arrowFunc : arg; } // Handle quoted strings if ((arg.startsWith('"') && arg.endsWith('"')) || (arg.startsWith("'") && arg.endsWith("'"))) { return arg.slice(1, -1); } // Handle empty strings (when quotes were present but removed) if (arg === '') { return ''; } // Handle numbers if (!isNaN(Number(arg))) { return Number(arg); } // Handle arrays if (arg.startsWith('[') && arg.endsWith(']')) { try { return JSON.parse(arg); } catch (e) { return arg; } } return arg; } catch (e) { console.warn('Error evaluating argument:', arg, e); return arg; } } private applyModifier(value: any, modifierString: string): any { try { const { name, args } = this.parseModifier(modifierString); if (this.modifiers[name]) { return this.modifiers[name](value, ...args); } console.warn(`Modifier "${name}" not found`); return value; } catch (e) { console.warn(`Error applying modifier "${modifierString}":`, e); return value; } } private validateFilterValueType(value: any, filter: string, supportedTypes: string[]): boolean { const valueType = typeof value; if (supportedTypes.includes(valueType)) { return true; } if (supportedTypes.includes('array')) { return Array.isArray(value); } console.warn( `Filter ${filter} supports following types ${supportedTypes.join(', ')}, but ${valueType} was provided.`, ); return false; } } ================================================ FILE: src/turndown-plugin-gfm.d.ts ================================================ declare module '@guyplusplus/turndown-plugin-gfm'; ================================================ FILE: src/views/settings-tab.ts ================================================ import { App, Notice, Platform, PluginSettingTab, Setting } from 'obsidian'; import { Delimiter, getDelimiterOptions } from 'src/enums/delimiter'; import { FileExistsStrategy, getFileExistStrategyOptions } from 'src/enums/fileExistsStrategy'; import { getDefaultFilesystenLimits } from 'src/helpers/fileutils'; import { createHTMLDiv } from 'src/helpers/setting'; import ReadItLaterPlugin from 'src/main'; import { DEFAULT_SETTINGS } from 'src/settings'; enum DetailsItem { ReadableArticle = 'readableArticle', Youtube = 'youtube', YoutubeChannel = 'youtubeChannel', X = 'x', Bluesky = 'bluesky', StackExchange = 'stackExchange', Pinterest = 'pinterest', Mastodon = 'mastodon', Vimeo = 'vimeo', Bilibili = 'bilibili', TikTok = 'tikTok', NonReadableArticle = 'nonReadableArticle', TextSnippet = 'textSnippet', } export class ReadItLaterSettingsTab extends PluginSettingTab { plugin: ReadItLaterPlugin; private activeDetatils: string[] = []; constructor(app: App, plugin: ReadItLaterPlugin) { super(app, plugin); this.plugin = plugin; } display(): void { const { containerEl } = this; containerEl.empty(); containerEl.createEl('h2', { text: 'General' }); new Setting(containerEl) .setName('Inbox directory') .setDesc( 'Enter valid directory name. For nested directory use this format: Directory A/Directory B. If no directory is entered, new note will be created in vault root.', ) .addText((text) => text .setPlaceholder('Defaults to vault root directory') .setValue( typeof this.plugin.settings.inboxDir === 'undefined' ? DEFAULT_SETTINGS.inboxDir : this.plugin.settings.inboxDir, ) .onChange(async (value) => { this.plugin.settings.inboxDir = value; await this.plugin.saveSettings(); }), ); new Setting(containerEl) .setName('Assets directory') .setDesc( 'Enter valid directory name. For nested directory use this format: Directory A/Directory B. If no directory is entered, new note will be created in Vault root.', ) .addText((text) => text .setPlaceholder('Defaults to vault root directory') .setValue( typeof this.plugin.settings.assetsDir === 'undefined' ? DEFAULT_SETTINGS.inboxDir + '/assets' : this.plugin.settings.assetsDir, ) .onChange(async (value) => { this.plugin.settings.assetsDir = value; await this.plugin.saveSettings(); }), ); new Setting(containerEl) .setName('Open new note in current workspace') .setDesc('If enabled, new note will open in current workspace') .addToggle((toggle) => toggle .setValue(this.plugin.settings.openNewNote || DEFAULT_SETTINGS.openNewNote) .onChange(async (value) => { this.plugin.settings.openNewNote = value; if (value === true) { this.plugin.settings.openNewNoteInNewTab = false; } await this.plugin.saveSettings(); this.display(); }), ); new Setting(containerEl) .setName('Open new note in new tab') .setDesc('If enabled, new note will open in new tab') .addToggle((toggle) => toggle .setValue(this.plugin.settings.openNewNoteInNewTab || DEFAULT_SETTINGS.openNewNoteInNewTab) .onChange(async (value) => { this.plugin.settings.openNewNoteInNewTab = value; if (value === true) { this.plugin.settings.openNewNote = false; } await this.plugin.saveSettings(); this.display(); }), ); new Setting(containerEl) .setName('Duplicate note filename behavior') .setDesc('Applied when note with the same filename already exists') .addDropdown((dropdown) => { getFileExistStrategyOptions().forEach((fileExistsStrategyOption) => dropdown.addOption(fileExistsStrategyOption.option, fileExistsStrategyOption.label), ); dropdown.setValue(this.plugin.settings.fileExistsStrategy || DEFAULT_SETTINGS.fileExistsStrategy); dropdown.onChange(async (value) => { this.plugin.settings.fileExistsStrategy = value as FileExistsStrategy; await this.plugin.saveSettings(); }); }); new Setting(containerEl) .setName('Batch note creation delimiter') .setDesc('Delimiter for batch list of notes') .addDropdown((dropdown) => { getDelimiterOptions().forEach((delimiterOption) => dropdown.addOption(delimiterOption.option, delimiterOption.label), ); dropdown.setValue(this.plugin.settings.batchProcessDelimiter || DEFAULT_SETTINGS.batchProcessDelimiter); dropdown.onChange(async (value) => { this.plugin.settings.batchProcessDelimiter = value as Delimiter; await this.plugin.saveSettings(); }); }); new Setting(containerEl) .setName('Date format string') .setDesc('Format of the %date% variable. NOTE: do not use symbols forbidden in file names.') .addText((text) => text .setPlaceholder(`Defaults to ${DEFAULT_SETTINGS.dateTitleFmt}`) .setValue( typeof this.plugin.settings.dateTitleFmt === 'undefined' ? DEFAULT_SETTINGS.dateTitleFmt : this.plugin.settings.dateTitleFmt, ) .onChange(async (value) => { this.plugin.settings.dateTitleFmt = value; await this.plugin.saveSettings(); }), ); new Setting(containerEl) .setName('Date format string in content') .setDesc('Format of the %date% variable for content') .addText((text) => text .setPlaceholder(`Defaults to ${DEFAULT_SETTINGS.dateContentFmt}`) .setValue( typeof this.plugin.settings.dateContentFmt === 'undefined' ? DEFAULT_SETTINGS.dateContentFmt : this.plugin.settings.dateContentFmt, ) .onChange(async (value) => { this.plugin.settings.dateContentFmt = value; await this.plugin.saveSettings(); }), ); new Setting(containerEl) .setName('Extend share menu') .setDesc( 'If enabled, share menu will be extended with shortcut to create note directly from it. Requires plugin reload or Obsidian restart to apply change.', ) .addToggle((toggle) => toggle .setValue( Object.prototype.hasOwnProperty.call(this.plugin.settings, 'extendShareMenu') ? this.plugin.settings.extendShareMenu : DEFAULT_SETTINGS.extendShareMenu, ) .onChange(async (value) => { this.plugin.settings.extendShareMenu = value; await this.plugin.saveSettings(); }), ); new Setting(containerEl) .setName('Youtube Data API v3 key') .setDesc('If entered, Youtube related content types will use Youtube API to fetc the data.') .addText((text) => text .setPlaceholder('') .setValue(this.plugin.settings.youtubeApiKey || DEFAULT_SETTINGS.youtubeApiKey) .onChange(async (value) => { this.plugin.settings.youtubeApiKey = value; await this.plugin.saveSettings(); }), ); containerEl.createEl('h1', { text: 'Content Types' }); containerEl.createDiv({ text: 'Settings for each content. Click on caret to expand.' }); let detailsEl: HTMLElement; containerEl.createEl('hr', { cls: 'readitlater-setting-hr' }); detailsEl = this.createDetailsElement(containerEl, DetailsItem.ReadableArticle); detailsEl.createEl('summary', { text: 'Readable Article', cls: 'readitlater-setting-h3', }); new Setting(detailsEl) .setName('Readable content type slug') .setDesc(this.createTemplateVariableReferenceDiv()) .addText((text) => text .setPlaceholder(DEFAULT_SETTINGS.parseableArticleContentType) .setValue( typeof this.plugin.settings.parseableArticleContentType === 'undefined' ? DEFAULT_SETTINGS.parseableArticleContentType : this.plugin.settings.parseableArticleContentType, ) .onChange(async (value) => { this.plugin.settings.parseableArticleContentType = value; await this.plugin.saveSettings(); }), ); new Setting(detailsEl) .setName('Readable article note template title') .setDesc(this.createTemplateVariableReferenceDiv()) .addText((text) => text .setPlaceholder(DEFAULT_SETTINGS.parseableArticleNoteTitle) .setValue( this.plugin.settings.parseableArticleNoteTitle || DEFAULT_SETTINGS.parseableArticleNoteTitle, ) .onChange(async (value) => { this.plugin.settings.parseableArticleNoteTitle = value; await this.plugin.saveSettings(); }), ); new Setting(detailsEl) .setName('Readable article note template') .setDesc(this.createTemplateVariableReferenceDiv()) .addTextArea((textarea) => { textarea .setValue(this.plugin.settings.parsableArticleNote || DEFAULT_SETTINGS.parsableArticleNote) .onChange(async (value) => { this.plugin.settings.parsableArticleNote = value; await this.plugin.saveSettings(); }); textarea.inputEl.rows = 10; textarea.inputEl.cols = 25; }); new Setting(detailsEl) .setName('Download images') .setDesc( 'Images from article will be downloaded to the assets directory (Desktop App feature only). To dynamically change destination directory you can use variables. Check variables reference to learn more.', ) .addToggle((toggle) => toggle .setValue( Object.prototype.hasOwnProperty.call(this.plugin.settings, 'downloadImages') ? this.plugin.settings.downloadImages : DEFAULT_SETTINGS.downloadImages, ) .onChange(async (value) => { this.plugin.settings.downloadImages = value; if (value === false) { this.plugin.settings.downloadImagesInArticleDir = false; } await this.plugin.saveSettings(); this.display(); }), ); new Setting(detailsEl) .setName('Download images to note directory') .setDesc( 'Images from article will be downloaded to the dedicated note assets directory (Desktop App feature only). Overrides assets directory template.', ) .addToggle((toggle) => toggle .setValue( Object.prototype.hasOwnProperty.call(this.plugin.settings, 'downloadImagesInArticleDir') ? this.plugin.settings.downloadImagesInArticleDir : DEFAULT_SETTINGS.downloadImagesInArticleDir, ) .onChange(async (value) => { this.plugin.settings.downloadImagesInArticleDir = value; if (value === true) { this.plugin.settings.downloadImages = true; } await this.plugin.saveSettings(); this.display(); }), ); containerEl.createEl('hr', { cls: 'readitlater-setting-hr' }); detailsEl = this.createDetailsElement(containerEl, DetailsItem.Youtube); detailsEl.createEl('summary', { text: 'YouTube', cls: 'readitlater-setting-h3', }); new Setting(detailsEl) .setName('Youtube content type slug') .setDesc(this.createTemplateVariableReferenceDiv()) .addText((text) => text .setPlaceholder(DEFAULT_SETTINGS.youtubeContentTypeSlug) .setValue( typeof this.plugin.settings.youtubeContentTypeSlug === 'undefined' ? DEFAULT_SETTINGS.youtubeContentTypeSlug : this.plugin.settings.youtubeContentTypeSlug, ) .onChange(async (value) => { this.plugin.settings.youtubeContentTypeSlug = value; await this.plugin.saveSettings(); }), ); new Setting(detailsEl) .setName('Youtube note template title') .setDesc(this.createTemplateVariableReferenceDiv()) .addText((text) => text .setPlaceholder(DEFAULT_SETTINGS.youtubeNoteTitle) .setValue(this.plugin.settings.youtubeNoteTitle || DEFAULT_SETTINGS.youtubeNoteTitle) .onChange(async (value) => { this.plugin.settings.youtubeNoteTitle = value; await this.plugin.saveSettings(); }), ); new Setting(detailsEl) .setName('Youtube note template') .setDesc(this.createTemplateVariableReferenceDiv()) .addTextArea((textarea) => { textarea .setValue(this.plugin.settings.youtubeNote || DEFAULT_SETTINGS.youtubeNote) .onChange(async (value) => { this.plugin.settings.youtubeNote = value; await this.plugin.saveSettings(); }); textarea.inputEl.rows = 10; textarea.inputEl.cols = 25; }); new Setting(detailsEl) .setName('Youtube chapter template') .setDesc(this.createTemplateVariableReferenceDiv()) .addTextArea((textarea) => { textarea .setValue(this.plugin.settings.youtubeChapter || DEFAULT_SETTINGS.youtubeChapter) .onChange(async (value) => { this.plugin.settings.youtubeChapter = value; await this.plugin.saveSettings(); }); textarea.inputEl.rows = 10; textarea.inputEl.cols = 25; }); new Setting(detailsEl).setName('Youtube embed player width').addText((text) => text .setPlaceholder(DEFAULT_SETTINGS.youtubeEmbedWidth) .setValue(this.plugin.settings.youtubeEmbedWidth || DEFAULT_SETTINGS.youtubeEmbedWidth) .onChange(async (value) => { this.plugin.settings.youtubeEmbedWidth = value; await this.plugin.saveSettings(); }), ); new Setting(detailsEl).setName('Youtube embed player height').addText((text) => text .setPlaceholder(DEFAULT_SETTINGS.youtubeEmbedHeight) .setValue(this.plugin.settings.youtubeEmbedHeight || DEFAULT_SETTINGS.youtubeEmbedHeight) .onChange(async (value) => { this.plugin.settings.youtubeEmbedHeight = value; await this.plugin.saveSettings(); }), ); new Setting(detailsEl) .setName('Embed in privacy enhanced mode') .setDesc( 'If enabled, content will be embeded in privacy enhanced mode, which prevents the use of views of it from influencing the viewer’s browsing experience on YouTube.', ) .addToggle((toggle) => toggle .setValue( Object.prototype.hasOwnProperty.call(this.plugin.settings, 'youtubeUsePrivacyEnhancedEmbed') ? this.plugin.settings.youtubeUsePrivacyEnhancedEmbed : DEFAULT_SETTINGS.youtubeUsePrivacyEnhancedEmbed, ) .onChange(async (value) => { this.plugin.settings.youtubeUsePrivacyEnhancedEmbed = value; await this.plugin.saveSettings(); }), ); containerEl.createEl('hr', { cls: 'readitlater-setting-hr' }); detailsEl = this.createDetailsElement(containerEl, DetailsItem.YoutubeChannel); detailsEl.createEl('summary', { text: 'YouTube channel', cls: 'readitlater-setting-h3', }); new Setting(detailsEl) .setName('Youtube channel content type slug') .setDesc(this.createTemplateVariableReferenceDiv()) .addText((text) => text .setPlaceholder(DEFAULT_SETTINGS.youtubeChannelContentTypeSlug) .setValue( typeof this.plugin.settings.youtubeChannelContentTypeSlug === 'undefined' ? DEFAULT_SETTINGS.youtubeChannelContentTypeSlug : this.plugin.settings.youtubeChannelContentTypeSlug, ) .onChange(async (value) => { this.plugin.settings.youtubeChannelContentTypeSlug = value; await this.plugin.saveSettings(); }), ); new Setting(detailsEl) .setName('Youtube channel note template title') .setDesc(this.createTemplateVariableReferenceDiv()) .addText((text) => text .setPlaceholder(DEFAULT_SETTINGS.youtubeChannelNoteTitle) .setValue(this.plugin.settings.youtubeChannelNoteTitle || DEFAULT_SETTINGS.youtubeChannelNoteTitle) .onChange(async (value) => { this.plugin.settings.youtubeChannelNoteTitle = value; await this.plugin.saveSettings(); }), ); new Setting(detailsEl) .setName('Youtube channel note template') .setDesc(this.createTemplateVariableReferenceDiv()) .addTextArea((textarea) => { textarea .setValue(this.plugin.settings.youtubeChannelNote || DEFAULT_SETTINGS.youtubeChannelNote) .onChange(async (value) => { this.plugin.settings.youtubeChannelNote = value; await this.plugin.saveSettings(); }); textarea.inputEl.rows = 10; textarea.inputEl.cols = 25; }); containerEl.createEl('hr', { cls: 'readitlater-setting-hr' }); detailsEl = this.createDetailsElement(containerEl, DetailsItem.X); detailsEl.createEl('summary', { text: 'X.com', cls: 'readitlater-setting-h3', }); new Setting(detailsEl) .setName('X.com content type slug') .setDesc(this.createTemplateVariableReferenceDiv()) .addText((text) => text .setPlaceholder(DEFAULT_SETTINGS.twitterContentTypeSlug) .setValue( typeof this.plugin.settings.twitterContentTypeSlug === 'undefined' ? DEFAULT_SETTINGS.twitterContentTypeSlug : this.plugin.settings.twitterContentTypeSlug, ) .onChange(async (value) => { this.plugin.settings.twitterContentTypeSlug = value; await this.plugin.saveSettings(); }), ); new Setting(detailsEl) .setName('X.com note template title') .setDesc(this.createTemplateVariableReferenceDiv()) .addText((text) => text .setPlaceholder(DEFAULT_SETTINGS.twitterNoteTitle) .setValue(this.plugin.settings.twitterNoteTitle || DEFAULT_SETTINGS.twitterNoteTitle) .onChange(async (value) => { this.plugin.settings.twitterNoteTitle = value; await this.plugin.saveSettings(); }), ); new Setting(detailsEl) .setName('X.com note template') .setDesc(this.createTemplateVariableReferenceDiv()) .addTextArea((textarea) => { textarea .setValue(this.plugin.settings.twitterNote || DEFAULT_SETTINGS.twitterNote) .onChange(async (value) => { this.plugin.settings.twitterNote = value; await this.plugin.saveSettings(); }); textarea.inputEl.rows = 10; textarea.inputEl.cols = 25; }); containerEl.createEl('hr', { cls: 'readitlater-setting-hr' }); detailsEl = this.createDetailsElement(containerEl, DetailsItem.Bluesky); detailsEl.createEl('summary', { text: 'Bluesky', cls: 'readitlater-setting-h3', }); new Setting(detailsEl) .setName('Bluesky content type slug') .setDesc(this.createTemplateVariableReferenceDiv()) .addText((text) => text .setPlaceholder(DEFAULT_SETTINGS.blueskyContentTypeSlug) .setValue( typeof this.plugin.settings.blueskyContentTypeSlug === 'undefined' ? DEFAULT_SETTINGS.blueskyContentTypeSlug : this.plugin.settings.blueskyContentTypeSlug, ) .onChange(async (value) => { this.plugin.settings.blueskyContentTypeSlug = value; await this.plugin.saveSettings(); }), ); new Setting(detailsEl) .setName('Bluesky note template title') .setDesc(this.createTemplateVariableReferenceDiv()) .addText((text) => text .setPlaceholder(DEFAULT_SETTINGS.blueskyNoteTitle) .setValue(this.plugin.settings.blueskyNoteTitle || DEFAULT_SETTINGS.blueskyNoteTitle) .onChange(async (value) => { this.plugin.settings.blueskyNoteTitle = value; await this.plugin.saveSettings(); }), ); new Setting(detailsEl) .setName('Bluesky note template') .setDesc(this.createTemplateVariableReferenceDiv()) .addTextArea((textarea) => { textarea .setValue(this.plugin.settings.blueskyNote || DEFAULT_SETTINGS.blueskyNote) .onChange(async (value) => { this.plugin.settings.blueskyNote = value; await this.plugin.saveSettings(); }); textarea.inputEl.rows = 10; textarea.inputEl.cols = 25; }); new Setting(detailsEl) .setName('Download embedded content') .setDesc( 'Embedded content will be downloaded to the assets directory (Desktop App feature only). To dynamically change destination directory you can use variables. Check variables reference to learn more.', ) .addToggle((toggle) => toggle .setValue( Object.prototype.hasOwnProperty.call(this.plugin.settings, 'downloadBlueskyEmbeds') ? this.plugin.settings.downloadBlueskyEmbeds : DEFAULT_SETTINGS.downloadBlueskyEmbeds, ) .onChange(async (value) => { this.plugin.settings.downloadBlueskyEmbeds = value; if (value === false) { this.plugin.settings.downloadBlueskyEmbedsInDir = false; } await this.plugin.saveSettings(); this.display(); }), ); new Setting(detailsEl) .setName('Download embedded content to note directory') .setDesc( 'Embedded content will be downloaded to the dedicated note assets directory (Desktop App feature only). Overrides assets directory template.', ) .addToggle((toggle) => toggle .setValue( Object.prototype.hasOwnProperty.call(this.plugin.settings, 'downloadBlueskyEmbedsInDir') ? this.plugin.settings.downloadBlueskyEmbedsInDir : DEFAULT_SETTINGS.downloadBlueskyEmbedsInDir, ) .onChange(async (value) => { this.plugin.settings.downloadBlueskyEmbedsInDir = value; if (value === true) { this.plugin.settings.downloadBlueskyEmbeds = true; } await this.plugin.saveSettings(); this.display(); }), ); new Setting(detailsEl) .setName('Save replies') .setDesc('If enabled, post replies will be saved.') .addToggle((toggle) => toggle .setValue( Object.prototype.hasOwnProperty.call(this.plugin.settings, 'saveBlueskyPostReplies') ? this.plugin.settings.saveBlueskyPostReplies : DEFAULT_SETTINGS.saveBlueskyPostReplies, ) .onChange(async (value) => { this.plugin.settings.saveBlueskyPostReplies = value; await this.plugin.saveSettings(); }), ); new Setting(detailsEl).setName('Bluesky post reply template').addTextArea((textarea) => { textarea .setValue(this.plugin.settings.blueskyPostReply || DEFAULT_SETTINGS.blueskyPostReply) .onChange(async (value) => { this.plugin.settings.blueskyPostReply = value; await this.plugin.saveSettings(); }); textarea.inputEl.rows = 10; textarea.inputEl.cols = 25; }); containerEl.createEl('hr', { cls: 'readitlater-setting-hr' }); detailsEl = this.createDetailsElement(containerEl, DetailsItem.StackExchange); detailsEl.createEl('summary', { text: 'Stack Exchange', cls: 'readitlater-setting-h3', }); new Setting(detailsEl) .setName('Stack Exchange content type slug') .setDesc(this.createTemplateVariableReferenceDiv()) .addText((text) => text .setPlaceholder(DEFAULT_SETTINGS.stackExchangeContentType) .setValue( typeof this.plugin.settings.stackExchangeContentType === 'undefined' ? DEFAULT_SETTINGS.stackExchangeContentType : this.plugin.settings.stackExchangeContentType, ) .onChange(async (value) => { this.plugin.settings.stackExchangeContentType = value; await this.plugin.saveSettings(); }), ); new Setting(detailsEl).setName('Stack Exchange note title template').addText((text) => text .setPlaceholder(DEFAULT_SETTINGS.stackExchangeNoteTitle) .setValue(this.plugin.settings.stackExchangeNoteTitle || DEFAULT_SETTINGS.stackExchangeNoteTitle) .onChange(async (value) => { this.plugin.settings.stackExchangeNoteTitle = value; await this.plugin.saveSettings(); }), ); new Setting(detailsEl) .setName('Stack Exchange question note template') .setDesc(this.createTemplateVariableReferenceDiv()) .addTextArea((textarea) => { textarea .setValue(this.plugin.settings.stackExchangeNote || DEFAULT_SETTINGS.stackExchangeNote) .onChange(async (value) => { this.plugin.settings.stackExchangeNote = value; await this.plugin.saveSettings(); }); textarea.inputEl.rows = 10; textarea.inputEl.cols = 25; }); new Setting(detailsEl) .setName('Stack Exchange answer template') .setDesc(this.createTemplateVariableReferenceDiv()) .addTextArea((textarea) => { textarea .setValue(this.plugin.settings.stackExchangeAnswer || DEFAULT_SETTINGS.stackExchangeAnswer) .onChange(async (value) => { this.plugin.settings.stackExchangeAnswer = value; await this.plugin.saveSettings(); }); textarea.inputEl.rows = 10; textarea.inputEl.cols = 25; }); new Setting(detailsEl) .setName('Download media attachments') .setDesc( 'Media attachments will be downloaded to the assets directory (Desktop App feature only). To dynamically change destination directory you can use variables. Check variables reference to learn more.', ) .addToggle((toggle) => toggle .setValue( Object.prototype.hasOwnProperty.call(this.plugin.settings, 'downloadStackExchangeAssets') ? this.plugin.settings.downloadStackExchangeAssets : DEFAULT_SETTINGS.downloadStackExchangeAssets, ) .onChange(async (value) => { this.plugin.settings.downloadStackExchangeAssets = value; if (value === false) { this.plugin.settings.downloadStackExchangeAssetsInDir = false; } await this.plugin.saveSettings(); this.display(); }), ); new Setting(detailsEl) .setName('Download media attachments to note directory') .setDesc( 'Media attachments will be downloaded to the dedicated note assets directory (Desktop App feature only). Overrides assets directory template.', ) .addToggle((toggle) => toggle .setValue( Object.prototype.hasOwnProperty.call(this.plugin.settings, 'downloadStackExchangeAssetsInDir') ? this.plugin.settings.downloadStackExchangeAssetsInDir : DEFAULT_SETTINGS.downloadStackExchangeAssetsInDir, ) .onChange(async (value) => { this.plugin.settings.downloadStackExchangeAssetsInDir = value; if (value === true) { this.plugin.settings.downloadStackExchangeAssets = true; } await this.plugin.saveSettings(); this.display(); }), ); containerEl.createEl('hr', { cls: 'readitlater-setting-hr' }); detailsEl = this.createDetailsElement(containerEl, DetailsItem.Pinterest); detailsEl.createEl('summary', { text: 'Pinterest', cls: 'readitlater-setting-h3', }); new Setting(detailsEl) .setName('Pinterest content type slug') .setDesc(this.createTemplateVariableReferenceDiv()) .addText((text) => text .setPlaceholder(DEFAULT_SETTINGS.pinterestContentTypeSlug) .setValue( typeof this.plugin.settings.pinterestContentTypeSlug === 'undefined' ? DEFAULT_SETTINGS.pinterestContentTypeSlug : this.plugin.settings.pinterestContentTypeSlug, ) .onChange(async (value) => { this.plugin.settings.pinterestContentTypeSlug = value; await this.plugin.saveSettings(); }), ); new Setting(detailsEl) .setName('Pinterest note template title') .setDesc(this.createTemplateVariableReferenceDiv()) .addText((text) => text .setPlaceholder(DEFAULT_SETTINGS.pinterestNoteTitle) .setValue(this.plugin.settings.pinterestNoteTitle || DEFAULT_SETTINGS.pinterestNoteTitle) .onChange(async (value) => { this.plugin.settings.pinterestNoteTitle = value; await this.plugin.saveSettings(); }), ); new Setting(detailsEl) .setName('Pinterest note template') .setDesc(this.createTemplateVariableReferenceDiv()) .addTextArea((textarea) => { textarea .setValue(this.plugin.settings.pinterestNote || DEFAULT_SETTINGS.pinterestNote) .onChange(async (value) => { this.plugin.settings.pinterestNote = value; await this.plugin.saveSettings(); }); textarea.inputEl.rows = 10; textarea.inputEl.cols = 25; }); new Setting(detailsEl) .setName('Download image') .setDesc( 'Image will be downloaded to the assets directory (Desktop App feature only). To dynamically change destination directory you can use variables. Check variables reference to learn more.', ) .addToggle((toggle) => toggle .setValue( Object.prototype.hasOwnProperty.call(this.plugin.settings, 'downloadPinterestImage') ? this.plugin.settings.downloadPinterestImage : DEFAULT_SETTINGS.downloadPinterestImage, ) .onChange(async (value) => { this.plugin.settings.downloadPinterestImage = value; await this.plugin.saveSettings(); }), ); containerEl.createEl('hr', { cls: 'readitlater-setting-hr' }); detailsEl = this.createDetailsElement(containerEl, DetailsItem.Mastodon); detailsEl.createEl('summary', { text: 'Mastodon', cls: 'readitlater-setting-h3', }); new Setting(detailsEl) .setName('Mastodon content type slug') .setDesc(this.createTemplateVariableReferenceDiv()) .addText((text) => text .setPlaceholder(DEFAULT_SETTINGS.mastodonContentTypeSlug) .setValue( typeof this.plugin.settings.mastodonContentTypeSlug === 'undefined' ? DEFAULT_SETTINGS.mastodonContentTypeSlug : this.plugin.settings.mastodonContentTypeSlug, ) .onChange(async (value) => { this.plugin.settings.mastodonContentTypeSlug = value; await this.plugin.saveSettings(); }), ); new Setting(detailsEl) .setName('Mastodon note template title') .setDesc(this.createTemplateVariableReferenceDiv()) .addText((text) => text .setPlaceholder(DEFAULT_SETTINGS.mastodonNoteTitle) .setValue(this.plugin.settings.mastodonNoteTitle || DEFAULT_SETTINGS.mastodonNoteTitle) .onChange(async (value) => { this.plugin.settings.mastodonNoteTitle = value; await this.plugin.saveSettings(); }), ); new Setting(detailsEl) .setName('Mastodon note template') .setDesc(this.createTemplateVariableReferenceDiv()) .addTextArea((textarea) => { textarea .setValue(this.plugin.settings.mastodonNote || DEFAULT_SETTINGS.mastodonNote) .onChange(async (value) => { this.plugin.settings.mastodonNote = value; await this.plugin.saveSettings(); }); textarea.inputEl.rows = 10; textarea.inputEl.cols = 25; }); new Setting(detailsEl) .setName('Download media attachments') .setDesc( 'Media attachments will be downloaded to the assets directory (Desktop App feature only). To dynamically change destination directory you can use variables. Check variables reference to learn more.', ) .addToggle((toggle) => toggle .setValue( Object.prototype.hasOwnProperty.call(this.plugin.settings, 'downloadMastodonMediaAttachments') ? this.plugin.settings.downloadMastodonMediaAttachments : DEFAULT_SETTINGS.downloadMastodonMediaAttachments, ) .onChange(async (value) => { this.plugin.settings.downloadMastodonMediaAttachments = value; if (value === false) { this.plugin.settings.downloadMastodonMediaAttachmentsInDir = false; } await this.plugin.saveSettings(); this.display(); }), ); new Setting(detailsEl) .setName('Download media attachments to note directory') .setDesc( 'Media attachments will be downloaded to the dedicated note assets directory (Desktop App feature only). Overrides assets directory template.', ) .addToggle((toggle) => toggle .setValue( Object.prototype.hasOwnProperty.call( this.plugin.settings, 'downloadMastodonMediaAttachmentsInDir', ) ? this.plugin.settings.downloadMastodonMediaAttachmentsInDir : DEFAULT_SETTINGS.downloadMastodonMediaAttachmentsInDir, ) .onChange(async (value) => { this.plugin.settings.downloadMastodonMediaAttachmentsInDir = value; if (value === true) { this.plugin.settings.downloadMastodonMediaAttachments = true; } await this.plugin.saveSettings(); this.display(); }), ); new Setting(detailsEl) .setName('Save replies') .setDesc('If enabled, replies of toot will be saved.') .addToggle((toggle) => toggle .setValue( Object.prototype.hasOwnProperty.call(this.plugin.settings, 'saveMastodonReplies') ? this.plugin.settings.saveMastodonReplies : DEFAULT_SETTINGS.saveMastodonReplies, ) .onChange(async (value) => { this.plugin.settings.saveMastodonReplies = value; await this.plugin.saveSettings(); }), ); new Setting(detailsEl).setName('Mastodon reply template').addTextArea((textarea) => { textarea .setValue(this.plugin.settings.mastodonReply || DEFAULT_SETTINGS.mastodonReply) .onChange(async (value) => { this.plugin.settings.mastodonReply = value; await this.plugin.saveSettings(); }); textarea.inputEl.rows = 10; textarea.inputEl.cols = 25; }); containerEl.createEl('hr', { cls: 'readitlater-setting-hr' }); detailsEl = this.createDetailsElement(containerEl, DetailsItem.Vimeo); detailsEl.createEl('summary', { text: 'Vimeo', cls: 'readitlater-setting-h3', }); new Setting(detailsEl) .setName('Vimeo content type slug') .setDesc(this.createTemplateVariableReferenceDiv()) .addText((text) => text .setPlaceholder(DEFAULT_SETTINGS.vimeoContentTypeSlug) .setValue( typeof this.plugin.settings.vimeoContentTypeSlug === 'undefined' ? DEFAULT_SETTINGS.vimeoContentTypeSlug : this.plugin.settings.vimeoContentTypeSlug, ) .onChange(async (value) => { this.plugin.settings.vimeoContentTypeSlug = value; await this.plugin.saveSettings(); }), ); new Setting(detailsEl) .setName('Vimeo note title template') .setDesc(this.createTemplateVariableReferenceDiv()) .addText((text) => text .setPlaceholder(DEFAULT_SETTINGS.vimeoNoteTitle) .setValue(this.plugin.settings.vimeoNoteTitle || DEFAULT_SETTINGS.vimeoNoteTitle) .onChange(async (value) => { this.plugin.settings.vimeoNoteTitle = value; await this.plugin.saveSettings(); }), ); new Setting(detailsEl) .setName('Vimeo note template') .setDesc(this.createTemplateVariableReferenceDiv()) .addTextArea((textarea) => { textarea .setValue(this.plugin.settings.vimeoNote || DEFAULT_SETTINGS.vimeoNote) .onChange(async (value) => { this.plugin.settings.vimeoNote = value; await this.plugin.saveSettings(); }); textarea.inputEl.rows = 10; textarea.inputEl.cols = 25; }); new Setting(detailsEl).setName('Vimeo embed player width').addText((text) => text .setPlaceholder(DEFAULT_SETTINGS.vimeoEmbedWidth) .setValue(this.plugin.settings.vimeoEmbedWidth || DEFAULT_SETTINGS.vimeoEmbedWidth) .onChange(async (value) => { this.plugin.settings.vimeoEmbedWidth = value; await this.plugin.saveSettings(); }), ); new Setting(detailsEl).setName('Vimeo embed player height').addText((text) => text .setPlaceholder(DEFAULT_SETTINGS.vimeoEmbedHeight) .setValue(this.plugin.settings.vimeoEmbedHeight || DEFAULT_SETTINGS.vimeoEmbedHeight) .onChange(async (value) => { this.plugin.settings.vimeoEmbedHeight = value; await this.plugin.saveSettings(); }), ); containerEl.createEl('hr', { cls: 'readitlater-setting-hr' }); detailsEl = this.createDetailsElement(containerEl, DetailsItem.Bilibili); detailsEl.createEl('summary', { text: 'Bilibili', cls: 'readitlater-setting-h3', }); new Setting(detailsEl) .setName('Bilibili content type slug') .setDesc(this.createTemplateVariableReferenceDiv()) .addText((text) => text .setPlaceholder(DEFAULT_SETTINGS.bilibiliContentTypeSlug) .setValue( typeof this.plugin.settings.bilibiliContentTypeSlug === 'undefined' ? DEFAULT_SETTINGS.bilibiliContentTypeSlug : this.plugin.settings.bilibiliContentTypeSlug, ) .onChange(async (value) => { this.plugin.settings.bilibiliContentTypeSlug = value; await this.plugin.saveSettings(); }), ); new Setting(detailsEl) .setName('Bilibili note template title') .setDesc(this.createTemplateVariableReferenceDiv()) .addText((text) => text .setPlaceholder(DEFAULT_SETTINGS.bilibiliNoteTitle) .setValue(this.plugin.settings.bilibiliNoteTitle || DEFAULT_SETTINGS.bilibiliNoteTitle) .onChange(async (value) => { this.plugin.settings.bilibiliNoteTitle = value; await this.plugin.saveSettings(); }), ); new Setting(detailsEl) .setName('Bilibili note template') .setDesc(this.createTemplateVariableReferenceDiv()) .addTextArea((textarea) => { textarea .setValue(this.plugin.settings.bilibiliNote || DEFAULT_SETTINGS.bilibiliNote) .onChange(async (value) => { this.plugin.settings.bilibiliNote = value; await this.plugin.saveSettings(); }); textarea.inputEl.rows = 10; textarea.inputEl.cols = 25; }); new Setting(detailsEl).setName('Bilibili embed player width').addText((text) => text .setPlaceholder(DEFAULT_SETTINGS.bilibiliEmbedWidth) .setValue(this.plugin.settings.bilibiliEmbedWidth || DEFAULT_SETTINGS.bilibiliEmbedWidth) .onChange(async (value) => { this.plugin.settings.bilibiliEmbedWidth = value; await this.plugin.saveSettings(); }), ); new Setting(detailsEl).setName('Bilibili embed player height').addText((text) => text .setPlaceholder(DEFAULT_SETTINGS.bilibiliEmbedHeight) .setValue(this.plugin.settings.bilibiliEmbedHeight || DEFAULT_SETTINGS.bilibiliEmbedHeight) .onChange(async (value) => { this.plugin.settings.bilibiliEmbedHeight = value; await this.plugin.saveSettings(); }), ); containerEl.createEl('hr', { cls: 'readitlater-setting-hr' }); detailsEl = this.createDetailsElement(containerEl, DetailsItem.TikTok); detailsEl.createEl('summary', { text: 'TikTok', cls: 'readitlater-setting-h3', }); new Setting(detailsEl) .setName('TikTok content type slug') .setDesc(this.createTemplateVariableReferenceDiv()) .addText((text) => text .setPlaceholder(DEFAULT_SETTINGS.tikTokContentTypeSlug) .setValue( typeof this.plugin.settings.tikTokContentTypeSlug === 'undefined' ? DEFAULT_SETTINGS.tikTokContentTypeSlug : this.plugin.settings.tikTokContentTypeSlug, ) .onChange(async (value) => { this.plugin.settings.tikTokContentTypeSlug = value; await this.plugin.saveSettings(); }), ); new Setting(detailsEl) .setName('TikTok note title template') .setDesc(this.createTemplateVariableReferenceDiv()) .addText((text) => text .setPlaceholder(DEFAULT_SETTINGS.tikTokNoteTitle) .setValue(this.plugin.settings.tikTokNoteTitle || DEFAULT_SETTINGS.tikTokNoteTitle) .onChange(async (value) => { this.plugin.settings.tikTokNoteTitle = value; await this.plugin.saveSettings(); }), ); new Setting(detailsEl) .setName('TikTok note template') .setDesc(this.createTemplateVariableReferenceDiv()) .addTextArea((textarea) => { textarea .setValue(this.plugin.settings.tikTokNote || DEFAULT_SETTINGS.tikTokNote) .onChange(async (value) => { this.plugin.settings.tikTokNote = value; await this.plugin.saveSettings(); }); textarea.inputEl.rows = 10; textarea.inputEl.cols = 25; }); new Setting(detailsEl).setName('TikTok embed player width').addText((text) => text .setPlaceholder(DEFAULT_SETTINGS.tikTokEmbedWidth) .setValue(this.plugin.settings.tikTokEmbedWidth || DEFAULT_SETTINGS.tikTokEmbedWidth) .onChange(async (value) => { this.plugin.settings.tikTokEmbedWidth = value; await this.plugin.saveSettings(); }), ); new Setting(detailsEl).setName('TikTok embed player height').addText((text) => text .setPlaceholder(DEFAULT_SETTINGS.tikTokEmbedHeight) .setValue(this.plugin.settings.tikTokEmbedHeight || DEFAULT_SETTINGS.tikTokEmbedHeight) .onChange(async (value) => { this.plugin.settings.tikTokEmbedHeight = value; await this.plugin.saveSettings(); }), ); containerEl.createEl('hr', { cls: 'readitlater-setting-hr' }); detailsEl = this.createDetailsElement(containerEl, DetailsItem.NonReadableArticle); detailsEl.createEl('summary', { text: 'Nonreadable Article', cls: 'readitlater-setting-h3', }); new Setting(detailsEl) .setName('Nonreadable content type slug') .setDesc(this.createTemplateVariableReferenceDiv()) .addText((text) => text .setPlaceholder(DEFAULT_SETTINGS.notParseableArticleContentType) .setValue( typeof this.plugin.settings.notParseableArticleContentType === 'undefined' ? DEFAULT_SETTINGS.notParseableArticleContentType : this.plugin.settings.notParseableArticleContentType, ) .onChange(async (value) => { this.plugin.settings.notParseableArticleContentType = value; await this.plugin.saveSettings(); }), ); new Setting(detailsEl) .setName('Nonreadable article note template title') .setDesc(this.createTemplateVariableReferenceDiv()) .addText((text) => text .setPlaceholder(DEFAULT_SETTINGS.notParseableArticleNoteTitle) .setValue( this.plugin.settings.notParseableArticleNoteTitle || DEFAULT_SETTINGS.notParseableArticleNoteTitle, ) .onChange(async (value) => { this.plugin.settings.notParseableArticleNoteTitle = value; await this.plugin.saveSettings(); }), ); new Setting(detailsEl) .setName('Nonreadable article note template') .setDesc(this.createTemplateVariableReferenceDiv()) .addTextArea((textarea) => { textarea .setValue(this.plugin.settings.notParsableArticleNote || DEFAULT_SETTINGS.notParsableArticleNote) .onChange(async (value) => { this.plugin.settings.notParsableArticleNote = value; await this.plugin.saveSettings(); }); textarea.inputEl.rows = 10; textarea.inputEl.cols = 25; }); containerEl.createEl('hr', { cls: 'readitlater-setting-hr' }); detailsEl = this.createDetailsElement(containerEl, DetailsItem.TextSnippet); detailsEl.createEl('summary', { text: 'Text Snippet', cls: 'readitlater-setting-h3', }); new Setting(detailsEl) .setName('Text Snippet content type slug') .setDesc(this.createTemplateVariableReferenceDiv()) .addText((text) => text .setPlaceholder(DEFAULT_SETTINGS.textSnippetContentType) .setValue( typeof this.plugin.settings.textSnippetContentType === 'undefined' ? DEFAULT_SETTINGS.textSnippetContentType : this.plugin.settings.textSnippetContentType, ) .onChange(async (value) => { this.plugin.settings.textSnippetContentType = value; await this.plugin.saveSettings(); }), ); new Setting(detailsEl) .setName('Text snippet note template title') .setDesc(this.createTemplateVariableReferenceDiv()) .addText((text) => text .setPlaceholder(DEFAULT_SETTINGS.textSnippetNoteTitle) .setValue(this.plugin.settings.textSnippetNoteTitle || DEFAULT_SETTINGS.textSnippetNoteTitle) .onChange(async (value) => { this.plugin.settings.textSnippetNoteTitle = value; await this.plugin.saveSettings(); }), ); new Setting(detailsEl) .setName('Text snippet note template') .setDesc(this.createTemplateVariableReferenceDiv()) .addTextArea((textarea) => { textarea .setValue(this.plugin.settings.textSnippetNote || DEFAULT_SETTINGS.textSnippetNote) .onChange(async (value) => { this.plugin.settings.textSnippetNote = value; await this.plugin.saveSettings(); }); textarea.inputEl.rows = 10; textarea.inputEl.cols = 25; }); containerEl.createEl('h2', { text: 'Advanced' }); const defaultFilesystemLimits = getDefaultFilesystenLimits(Platform); new Setting(containerEl) .setName('Maximum file path length') .setDesc(`Defaults to ${defaultFilesystemLimits.path} characters on your current platform.`) .addText((text) => text.setPlaceholder(String(defaultFilesystemLimits.path)).onChange(async (value) => { const trimmedValue = value.trim(); if (trimmedValue !== '' && Number.isNaN(Number(trimmedValue))) { new Notice('Maximum file path length must be a number.'); return; } if (trimmedValue === '') { this.plugin.settings.filesystemLimitPath = null; } else { this.plugin.settings.filesystemLimitPath = Number(trimmedValue); } await this.plugin.saveSettings(); }), ); new Setting(containerEl) .setName('Maximum file name length') .setDesc(`Defaults to ${defaultFilesystemLimits.fileName} characters on your current platform.`) .addText((text) => text.setPlaceholder(String(defaultFilesystemLimits.fileName)).onChange(async (value) => { const trimmedValue = value.trim(); if (trimmedValue !== '' && Number.isNaN(Number(trimmedValue))) { new Notice('Maximum file name length must be a number.'); return; } if (trimmedValue === '') { this.plugin.settings.filesystemLimitFileName = null; } else { this.plugin.settings.filesystemLimitFileName = Number(trimmedValue); } await this.plugin.saveSettings(); }), ); } private createDetailsElement(parentElement: HTMLElement, itemId: DetailsItem): HTMLElement { const details = parentElement.createEl('details'); details.addEventListener('toggle', () => { if (details.open) { this.activeDetatils.push(itemId); } else { this.activeDetatils = this.activeDetatils.filter((item) => item !== itemId); } }); if (this.activeDetatils.includes(itemId)) { details.setAttribute('open', ''); } return details; } private createTemplateVariableReferenceDiv(prepend: string = ''): DocumentFragment { return createHTMLDiv( `<p>${prepend} See the <a href="https://github.com/DominikPieper/obsidian-ReadItLater?tab=readme-ov-file#template-engine">template variables reference</a></p>`, ); } } ================================================ FILE: styles.css ================================================ summary.readitlater-setting-h1 { font-variant: var(--h1-variant); letter-spacing: -0.015em; line-height: var(--h1-line-height); font-size: var(--h1-size); color: var(--h1-color); font-weight: var(--h1-weight); font-style: var(--h1-style); font-family: var(--h1-font); /*margin-block-start: var(--p-spacing);*/ margin-block-end: var(--p-spacing); } summary.readitlater-setting-h3 { font-variant: var(--h3-variant); letter-spacing: -0.015em; line-height: var(--h3-line-height); font-size: var(--h3-size); color: var(--h3-color); font-weight: var(--h3-weight); font-style: var(--h3-style); font-family: var(--h3-font); margin-block-start: var(--p-spacing); margin-block-end: var(--p-spacing); } summary.readitlater-setting-h4 { font-variant: var(--h4-variant); letter-spacing: -0.015em; line-height: var(--h4-line-height); font-size: var(--h4-size); color: var(--h4-color); font-weight: var(--h4-weight); font-style: var(--h4-style); font-family: var(--h4-font); margin-block-start: var(--p-spacing); margin-block-end: var(--p-spacing); } hr.readitlater-setting-hr { margin: 1rem 0rem 0rem 0rem; } ================================================ FILE: tsconfig.json ================================================ { "compilerOptions": { "baseUrl": ".", "inlineSourceMap": true, "inlineSources": true, "module": "ESNext", "target": "ES2022", "allowJs": true, "noImplicitAny": true, "moduleResolution": "node", "importHelpers": true, "allowSyntheticDefaultImports": true, "lib": [ "dom", "dom.iterable", "ES2022", "scripthost" ] }, "include": [ "**/*.ts" ] } ================================================ FILE: versions.json ================================================ { "0.11.4": "1.7.7", "0.11.3": "1.7.7", "0.11.2": "1.7.7", "0.11.1": "1.7.7", "0.11.0": "1.7.7", "0.10.1": "1.7.2", "0.10.0": "1.7.2", "0.9.1": "1.7.2", "0.9.0": "1.7.2", "0.8.0": "1.6.2", "0.7.0": "1.6.2", "0.6.0": "1.6.2", "0.5.1": "1.6.2", "0.5.0": "1.6.2", "0.4.0": "0.15.9", "0.3.1": "0.15.9", "0.3.0": "0.15.9", "0.2.0": "0.15.9", "0.1.0": "0.15.9", "0.0.19": "0.15.9", "0.0.18": "0.15.9", "0.0.17": "0.9.12", "0.0.16": "0.9.12", "0.0.15": "0.9.12", "0.0.14": "0.9.12", "0.0.13": "0.9.12", "0.0.12": "0.9.12", "0.0.11": "0.9.12", "0.0.10": "0.9.12", "0.0.9": "0.9.12", "0.0.8": "0.9.12", "0.0.7": "0.9.12", "0.0.6": "0.9.12", "0.0.5": "0.9.12", "0.0.4": "0.9.12", "0.0.3": "0.9.12", "0.0.2": "0.9.12", "0.0.1": "0.9.12" }