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