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