Repository: luyuhuang/vscode-rss
Branch: master
Commit: 3182a36ac11c
Files: 35
Total size: 143.0 KB
Directory structure:
gitextract_gwihor8x/
├── .eslintrc.json
├── .github/
│ └── workflows/
│ ├── release.yml
│ └── test.yml
├── .gitignore
├── .vscode/
│ ├── extensions.json
│ ├── launch.json
│ ├── settings.json
│ └── tasks.json
├── .vscodeignore
├── CHANGELOG.md
├── LICENSE
├── README.md
├── README_zh.md
├── package.json
├── src/
│ ├── account.ts
│ ├── app.ts
│ ├── articles.ts
│ ├── collection.ts
│ ├── config.ts
│ ├── content.ts
│ ├── extension.ts
│ ├── favorites.ts
│ ├── feeds.ts
│ ├── inoreader_collection.ts
│ ├── local_collection.ts
│ ├── migrate.ts
│ ├── parser.ts
│ ├── status_bar.ts
│ ├── test/
│ │ ├── runTest.ts
│ │ └── suite/
│ │ ├── index.ts
│ │ └── parser.test.ts
│ ├── ttrss_collection.ts
│ └── utils.ts
├── tsconfig.json
└── webpack.config.js
================================================
FILE CONTENTS
================================================
================================================
FILE: .eslintrc.json
================================================
{
"root": true,
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": 6,
"sourceType": "module"
},
"plugins": [
"@typescript-eslint"
],
"rules": {
"@typescript-eslint/semi": "warn",
"curly": "warn",
"eqeqeq": "warn",
"no-throw-literal": "warn",
"semi": "off"
}
}
================================================
FILE: .github/workflows/release.yml
================================================
name: release
on:
push:
tags:
- 'v*'
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
- name: Install Node
uses: actions/setup-node@v1
with:
node-version: 14.x
- run: npm install
- run: npm install -g vsce
- name: Publish
run: vsce publish -p $VSCE_TOKEN
env:
VSCE_TOKEN: ${{ secrets.VSCE_TOKEN }}
================================================
FILE: .github/workflows/test.yml
================================================
name: test
on:
push:
branches: [ master ]
pull_request:
branches: [ master ]
jobs:
build:
strategy:
matrix:
os: [macos-latest, ubuntu-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- name: Checkout
uses: actions/checkout@v2
- name: Install Node
uses: actions/setup-node@v1
with:
node-version: 14.x
- run: npm install
- name: Run tests
uses: GabrielBB/xvfb-action@v1.0
with:
run: npm test
================================================
FILE: .gitignore
================================================
out
node_modules
.vscode-test/
*.vsix
================================================
FILE: .vscode/extensions.json
================================================
{
// See http://go.microsoft.com/fwlink/?LinkId=827846
// for the documentation about the extensions.json format
"recommendations": [
"dbaeumer.vscode-eslint"
]
}
================================================
FILE: .vscode/launch.json
================================================
// A launch configuration that compiles the extension and then opens it inside a new window
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
{
"version": "0.2.0",
"configurations": [
{
"name": "Run Extension",
"type": "extensionHost",
"request": "launch",
"runtimeExecutable": "${execPath}",
"args": [
"--extensionDevelopmentPath=${workspaceFolder}"
],
"outFiles": [
"${workspaceFolder}/out/**/*.js"
],
"preLaunchTask": "npm: compile"
},
{
"name": "Extension Tests",
"type": "extensionHost",
"request": "launch",
"runtimeExecutable": "${execPath}",
"args": [
"--extensionDevelopmentPath=${workspaceFolder}",
"--extensionTestsPath=${workspaceFolder}/out/test/suite/index"
],
"outFiles": [
"${workspaceFolder}/out/test/**/*.js"
],
"preLaunchTask": "npm: compile"
}
]
}
================================================
FILE: .vscode/settings.json
================================================
// Place your settings in this file to overwrite default and user settings.
{
"files.exclude": {
"out": false // set this to true to hide the "out" folder with the compiled JS files
},
"search.exclude": {
"out": true // set this to false to include "out" folder in search results
},
// Turn off tsc task auto detection since we have the necessary tasks as npm scripts
"typescript.tsc.autoDetect": "off",
"typescript.tsdk": "node_modules/typescript/lib"
}
================================================
FILE: .vscode/tasks.json
================================================
// See https://go.microsoft.com/fwlink/?LinkId=733558
// for the documentation about the tasks.json format
{
"version": "2.0.0",
"tasks": [
{
"type": "npm",
"script": "watch",
"problemMatcher": "$tsc-watch",
"isBackground": true,
"presentation": {
"reveal": "never"
},
"group": {
"kind": "build",
"isDefault": true
}
}
]
}
================================================
FILE: .vscodeignore
================================================
.github/**
.vscode/**
.vscode-test/**
node_modules/**
webpack.config.js
out/test/**
src/**
.gitignore
**/tsconfig.json
**/.eslintrc.json
**/*.map
**/*.ts
demonstrate1.gif
demonstrate2.gif
create_app.png
id_and_key.png
yarn.lock
================================================
FILE: CHANGELOG.md
================================================
# Change Log
## v0.10.4 (2022-03-26)
- Quick access buttons (close #47)
- Update some dependencies
## v0.10.3 (2022-02-02)
- The limit of the number of articles fetched by Inoreader at a time is configurable(close #46)
## v0.10.2 (2021-09-20)
- Resolve reletive URLs of audio and video
## v0.10.1 (2021-06-12)
- Local accounts always update old articles(close #38)
## v0.10.0 (2021-03-16)
- New RSS parser based on cheerio
## v0.9.3 (2021-02-14)
Happy Valentine's Day
- Fix parsing opml error(close #28)
- Compatible with some format(close #29)
## v0.9.2 (2021-01-15)
- Using Etag to improve update efficiency
- The domain of Inoreader is configurable
## v0.9.1 (2020-11-19)
- fix activating extension error(#23)
## v0.9.0 (2020-11-14)
- Export / import from OPML(close #22)
- Clean old articles
- Data storage path is configurable(close #20 close #21)
## v0.8.1 (2020-09-24)
- Fix the problem that unable sync read status when open fetch unread only
## v0.8.0 (2020-09-04)
- **Support Inoreader**(close #14)
- Improve some user experience
## v0.7.2 (2020-08-04)
- Compatible with feeds with missing dates(close #15)
- Hide commands from command palette
## v0.7.1 (2020-07-20)
- Use `sha256(link + id)` as primary key to resolve key conflicts
## v0.7.0 (2020-07-19)
- Use id instead of link as primary key(close #7)
- Add status bar scroll notification(close #11)
## v0.6.1 (2020-06-21)
- Deal with links with CDATA(close #6)
## v0.6.0 (2020-06-18)
- Add shortcut floating Buttons at the right bottom
- Add an option to fetch unread articles only
- Fix #2
## v0.5.0 (2020-05-31)
- Support category
- Summary of unread articles
- Favorites sync with TTRSS server
- Configuration `favorites` in `rss.accounts` is now obsolete, you can remove them after updating
## v0.4.1 (2020-05-22)
- Show progress when fetching content from server
- Optimize updating single feed
## v0.4.0 (2020-05-20)
Major update:
- Support multiple accounts
- **Support Tiny Tiny RSS**
- Configurations `rss.feeds` and `rss.favorites` are now obsolete, you can remove them after updating
## v0.3.1 (2020-05-12)
- Modify the way articles are stored
- Remove redundant menus in favorites
## v0.3.0 (2020-05-08)
- Support favorites
- Prevent page refresh when hidden
## v0.2.2 (2020-05-06)
Fix the bug that some pictures are displayed out of proportion(close #3)
## v0.2.1 (2020-05-05)
Deal with different encodings
## v0.2.0 (2020-05-03)
- Add or remove feeds in the UI
- Optimize performance
## v0.1.0 (2020-04-16)
- Fix some bugs
- Optimize feed list
- Add width limit
## v0.0.5 (2020-04-15)
Fix a feed loading error on some pages.
## v0.0.4 (2020-04-10)
Make `timeout` and `retry` configurable
## v0.0.3 (2020-04-07)
Fix errors when adding feeds
## v0.0.2 (2020-04-07)
Do some optimization
- Add progress bar
- Add timeout
- Adjust font size
## v0.0.1 (2020-04-06)
First release
================================================
FILE: LICENSE
================================================
MIT License
Copyright (c) 2020 Luyu Huang
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
================================================
# VSCode-RSS
An RSS reader embedded in Visual Studio Code
[](https://marketplace.visualstudio.com/items?itemName=luyuhuang.rss)
[](https://marketplace.visualstudio.com/items?itemName=luyuhuang.rss)
[](https://marketplace.visualstudio.com/items?itemName=luyuhuang.rss)
[](https://github.com/luyuhuang/vscode-rss/actions/)

[简体中文](README_zh.md)
## Introduction
VSCode-RSS is a Visual Studio Code extension that provides an embedded RSS reader. With it, you can read news and blog freely in VSCode after a long time of coding. [Tiny Tiny RSS](https://tt-rss.org/) and [Inoreader](https://inoreader.com) are supported, which allows you to sync RSS between devices. VSCode-RSS is easy to use and requires little to manually modify the configuration.
- [x] Multiple accounts;
- [x] Support Tiny Tiny RSS;
- [x] Support Inoreader;
- [x] Support multiple RSS formats;
- [x] Automatic update;
- [x] Support favorites;
- [x] Scrolling notification;
- [x] Read / unread marks;
## Usage
### Accounts
VSCode-RSS has three types of accounts, local account, TTRSS(Tiny Tiny RSS) account, and Inoreader account. VSCode-RSS will create a local account by default.
#### Local Account
For local account, it will store the data locally. Click the "+" button on the "ACCOUNTS" view and select "local" option, then enter the account name to create a local account. Account name is arbitrary, just for display.
#### TTRSS Account
For TTRSS account, it will fetch data from Tiny Tiny RSS server and synchronize reading records with the server, so it has the same data as other clients(such as Reeder on your Mac or FeedMe on your phone). If you don't know TTRSS, see [https://tt-rss.org/](https://tt-rss.org/) for more information. To create a TTRSS account, click the "+" button on the "ACCOUNTS" view and select "ttrss" option, and then enter the account name, server address, username and password. Account name is just for display, while server address, username and password depends on your TTRSS server.

#### Inoreader Account
For Inoreader account, similar with TTRSS account, it'll fetch and synchronize data with the Inoreader server. If you don't know Inoreader, see [https://inoreader.com](https://inoreader.com) for more information. The simplest way to create an Inoreader account is to click the add account button and select "inoreader" option, enter the account name and select "no" (using default app ID and app key). Then, you'll be prompted to open the authorization page and you should follow the tips to authenticate your Inoreader account. If it goes well, the account will be created.
Because Inoreader has a limit on the number of requests for a single app, maybe you need to create and use your own app ID and app key. Open your Inoreader preferences page, click the "Developer" in "Other", and then click the "New application". Enter an arbitrary name and set the scope to "Read and write", then click "Save".

Then, you'll get your app ID and app key.

Create an account, select "yes" after entering the account name to use custom app ID and app key, and enter the app ID and app key. If you already have an account, right-click on the account list item and select "Modify" to alter the app ID and app key, or edit `setting.json`.
### Add Feeds
Just as demonstrated at the beginning of this README, click the "+" button on the "FEEDS" view and enter the feed URL to add a feed. For TTRSS and Inoreader account, it'll sync to the server.
## Configuration
You can modify the configuration as needed.
| Name | Type | Description |
|:-----|:-----|:------------|
| `rss.accounts` | `object` | Feed accounts, you can modify `name` field or adjust the order of the lists if you want, but **NEVER** modify the key and `type` field. |
| `rss.interval` | `integer` | Automatic refresh interval (s) |
| `rss.timeout` | `integer` | Request timeout (s) |
| `rss.retry` | `integer` | Request retries |
| `rss.fetch-unread-only` | `boolean` | Whether to fetch unread articles only, for TTRSS and Inoreader |
| `rss.status-bar-notify` | `boolean` | Whether to show scrolling notification in status bar |
| `rss.status-bar-update` | `integer` | Scrolling notification update interval(s) |
| `rss.status-bar-length` | `integer` | Max length of notification displayed in status bar |
| `rss.storage-path` | `string` | Data storage path, must be an absolute path |
| `rss.inoreader-domain` | `string` | Domain of Inoreader |
| `rss.inoreader-limit` | `string` | Limit of the number of articles fetched by Inoreader at a time |
Enjoy it!
================================================
FILE: README_zh.md
================================================
# VSCode-RSS
嵌入在 Visual Studio Code 中的 RSS 阅读器
[](https://marketplace.visualstudio.com/items?itemName=luyuhuang.rss)
[](https://marketplace.visualstudio.com/items?itemName=luyuhuang.rss)
[](https://marketplace.visualstudio.com/items?itemName=luyuhuang.rss)
[](https://github.com/luyuhuang/vscode-rss/actions/)

[English](README.md)
## 介绍
VSCode-RSS 是一个 Visual Studio Code 扩展, 它提供了一个嵌入式的 RSS 阅读器. 有了它你就可以在长时间写代码之后在 VScode 中自由地阅读新闻和博客. 支持 [Tiny Tiny RSS](https://tt-rss.org/) 和 [Inoreader](https://inoreader.com), 它们可以让你在不同的设备之间同步 RSS. VSCode-RSS 很容易使用, 基本不需要手动修改配置文件.
- [x] 多账户;
- [x] 支持 Tiny Tiny RSS;
- [x] 支持 Inoreader;
- [x] 支持多种 RSS 格式;
- [x] 自动更新;
- [x] 支持收藏夹;
- [x] 滚动通知;
- [x] 阅读标记;
## 使用
### 账户
VSCode-RSS 支持三种类型的账户, 本地账户, TTRSS(Tiny Tiny RSS) 账户, 和 Inoreader 账户. VSCode-RSS 默认会创建一个本地账户.
#### 本地账户
对于本地账户, 它会将数据存储在本地. 点击 "ACCOUNTS" 面板上的 "+" 按钮并选择 "local" 选项, 然后输入一个账户名即可创建一个本地账户. 账户名是随意的, 仅用于显示.
#### TTRSS 账户
对于 TTRSS 账户, 它会从 Tiny Tiny RSS 服务器上获取数据并且与服务器同步阅读记录, 因此它会与其他客户端 (例如你 Mac 上的 Reeder 或者是你手机上的 FeedMe) 有着同样的数据. 如果你不了解 TTRSS, 见 [https://tt-rss.org/](https://tt-rss.org/). 要创建一个 ttrss 账户, 点击 "ACCOUNTS" 面板上的 "+" 按钮并选择 "ttrss" 选项, 然后输入账户名, 服务器地址, 用户名和密码. 账户名仅用于显示, 服务器地址, 用户名和密码则取决于你的 TTRSS 服务器.

#### Inoreader 账户
对于 Inoreader 账户, 类似于 TTRSS, 它会向 Inoreader 服务器获取和同步数据. 如果你不了解 Inoreader, 见 [https://inoreader.com](https://inoreader.com). 创建 Inoreader 账户最简单的方法就是点击创建账户按钮并选择 "inoreader", 接着输入账户名然后选择 "no" (使用默认的 app ID 和 app key). 然后, 它会提示你打开认证页面, 你只需根据提示认证你的账户即可. 一切顺利的话, 账户就创建好了.
由于 Inoreader 对单个 app 的请求数量有限制, 因此你可能需要创建并使用你自己的 app ID 和 app key. 打开你的 Inoreader 偏好设置, 点击 "其它" 中的 "开发者", 然后点击 "新应用". 任意设置一个名称并将权限范围设置为 "可读写", 然后点击 "保存".

然后你就能得到你的 app ID 和 app key 了.

创建一个账户, 在输入账户名后选择 "yes" 以自定义 app ID 和 app key, 然后依次输入 app ID 和 app key. 如果已经有一个账户, 则在账户列表项上右击, 选择 "Modify" 然后更改 app ID 和 app key 即可. 或者直接编辑 `setting.json`
### 添加订阅
正如本文开头所演示的, 点击 "FEEDS" 面板上的 "+" 按钮并输入订阅源的 URL 即可添加订阅. 如果是 TTRSS 或 Inoreader 账户, 它还会与服务器同步.
## 配置
如果有需要也可以手动修改配置
| Name | Type | Description |
|:-----|:-----|:------------|
| `rss.accounts` | `object` | 订阅账户, 你可以修改 `name` 字段或者调整列表的顺序, 但是**千万不要**修改键值和 `type` 字段. |
| `rss.interval` | `integer` | 自动刷新的时间间隔 (秒) |
| `rss.timeout` | `integer` | 请求超时时间 (秒) |
| `rss.retry` | `integer` | 请求重试次数 |
| `rss.fetch-unread-only` | `boolean` | 对于 TTRSS 和 Inoreader, 是否仅获取未读文章 |
| `rss.status-bar-notify` | `boolean` | 是否在状态栏显示滚动通知 |
| `rss.status-bar-update` | `integer` | 滚动通知刷新间隔 (秒) |
| `rss.status-bar-length` | `integer` | 状态栏中显示的通知的最大长度 |
| `rss.storage-path` | `string` | 数据存储路径, 必须是绝对路径 |
| `rss.inoreader-domain` | `string` | Inoreader 的域名 |
| `rss.inoreader-limit` | `string` | Inoreader 单次获取文章数量的限制 |
Enjoy it!
================================================
FILE: package.json
================================================
{
"name": "rss",
"displayName": "RSS",
"description": "An RSS reader embedded in Visual Studio Code",
"license": "MIT",
"icon": "logo.png",
"version": "0.10.4",
"publisher": "luyuhuang",
"author": "luyuhuang",
"homepage": "https://github.com/luyuhuang/vscode-rss.git",
"repository": {
"type": "git",
"url": "https://github.com/luyuhuang/vscode-rss.git"
},
"engines": {
"vscode": "^1.40.0"
},
"categories": [
"Other"
],
"keywords": [
"news",
"rss",
"feed",
"reader"
],
"activationEvents": [
"onView:rss-feeds",
"onView:rss-articles"
],
"main": "./out/extension.js",
"contributes": {
"configuration": {
"title": "RSS",
"properties": {
"rss.accounts": {
"type": "object",
"default": {},
"description": "Feed accounts"
},
"rss.interval": {
"type": "integer",
"default": 3600,
"description": "Refresh interval(s)"
},
"rss.timeout": {
"type": "integer",
"default": 15,
"description": "Request timeout(s)"
},
"rss.retry": {
"type": "integer",
"default": 1,
"description": "Request retries"
},
"rss.fetch-unread-only": {
"type": "boolean",
"default": false,
"description": "Fetch unread articles only, for TTRSS and Inoreader"
},
"rss.status-bar-length": {
"type": "number",
"default": 20,
"description": "Max length displayed in status bar"
},
"rss.status-bar-notify": {
"type": "boolean",
"default": true,
"description": "Whether to show notification in status bar"
},
"rss.status-bar-update": {
"type": "number",
"default": 5,
"description": "Notification update interval(s)"
},
"rss.storage-path": {
"type": "string",
"default": null,
"description": "Data storage path"
},
"rss.inoreader-domain": {
"type": "string",
"default": "www.inoreader.com",
"description": "Domain of Inoreader"
},
"rss.inoreader-limit": {
"type": "integer",
"default": 100,
"minimum": 1,
"maximum": 1000,
"description": "Limit of the number of articles fetched by Inoreader at a time"
}
}
},
"commands": [
{
"command": "rss.select",
"title": "Select"
},
{
"command": "rss.new-account",
"title": "New account",
"icon": "$(add)"
},
{
"command": "rss.del-account",
"title": "Delete"
},
{
"command": "rss.account-rename",
"title": "Rename"
},
{
"command": "rss.account-modify",
"title": "Modify"
},
{
"command": "rss.articles",
"title": "Articles"
},
{
"command": "rss.read",
"title": "Read"
},
{
"command": "rss.read-notification",
"title": "Read from notification"
},
{
"command": "rss.refresh",
"title": "Refresh",
"icon": "$(refresh)"
},
{
"command": "rss.refresh-account",
"title": "Refresh",
"icon": "$(refresh)"
},
{
"command": "rss.refresh-one",
"title": "Refresh",
"icon": "$(refresh)"
},
{
"command": "rss.open-website",
"title": "Open website"
},
{
"command": "rss.open-link",
"title": "Open link",
"icon": "$(globe)"
},
{
"command": "rss.mark-read",
"title": "Mark as read",
"icon": "$(check)"
},
{
"command": "rss.mark-unread",
"title": "Mark as unread"
},
{
"command": "rss.mark-all-read",
"title": "Mark all as read",
"icon": "$(check)"
},
{
"command": "rss.mark-account-read",
"title": "Mark all as read",
"icon": "$(check)"
},
{
"command": "rss.add-feed",
"title": "Add feed",
"icon": "$(add)"
},
{
"command": "rss.remove-feed",
"title": "Remove"
},
{
"command": "rss.add-to-favorites",
"title": "Add to favorites",
"icon": "$(star-empty)"
},
{
"command": "rss.remove-from-favorites",
"title": "Remove from favorites"
},
{
"command": "rss.export-to-opml",
"title": "Export to OPML"
},
{
"command": "rss.import-from-opml",
"title": "Import from OPML"
},
{
"command": "rss.clean-old-articles",
"title": "Clean old articles"
},
{
"command": "rss.clean-all-old-articles",
"title": "Clean old articles"
}
],
"viewsContainers": {
"activitybar": [
{
"id": "rss-reader",
"title": "RSS Reader",
"icon": "resources/rss.svg"
}
]
},
"views": {
"rss-reader": [
{
"id": "rss-accounts",
"name": "Accounts"
},
{
"id": "rss-feeds",
"name": "Feeds"
},
{
"id": "rss-articles",
"name": "Articles"
},
{
"id": "rss-favorites",
"name": "Favorites"
}
]
},
"menus": {
"commandPalette": [
{
"command": "rss.select",
"when": "false"
},
{
"command": "rss.articles",
"when": "false"
},
{
"command": "rss.read",
"when": "false"
},
{
"command": "rss.mark-read",
"when": "false"
},
{
"command": "rss.mark-unread",
"when": "false"
},
{
"command": "rss.mark-all-read",
"when": "false"
},
{
"command": "rss.mark-account-read",
"when": "false"
},
{
"command": "rss.refresh",
"when": "false"
},
{
"command": "rss.refresh-account",
"when": "false"
},
{
"command": "rss.refresh-one",
"when": "false"
},
{
"command": "rss.open-website",
"when": "false"
},
{
"command": "rss.open-link",
"when": "false"
},
{
"command": "rss.add-feed",
"when": "false"
},
{
"command": "rss.remove-feed",
"when": "false"
},
{
"command": "rss.add-to-favorites",
"when": "false"
},
{
"command": "rss.remove-from-favorites",
"when": "false"
},
{
"command": "rss.new-account",
"when": "false"
},
{
"command": "rss.del-account",
"when": "false"
},
{
"command": "rss.account-rename",
"when": "false"
},
{
"command": "rss.account-modify",
"when": "false"
},
{
"command": "rss.export-to-opml",
"when": "false"
},
{
"command": "rss.import-from-opml",
"when": "false"
},
{
"command": "rss.clean-old-articles",
"when": "false"
},
{
"command": "rss.clean-all-old-articles",
"when": "false"
}
],
"view/title": [
{
"command": "rss.refresh",
"when": "view == rss-accounts",
"group": "navigation"
},
{
"command": "rss.new-account",
"when": "view == rss-accounts",
"group": "navigation"
},
{
"command": "rss.refresh-account",
"when": "view == rss-feeds",
"group": "navigation"
},
{
"command": "rss.add-feed",
"when": "view == rss-feeds",
"group": "navigation"
},
{
"command": "rss.mark-account-read",
"when": "view == rss-feeds",
"group": "navigation"
},
{
"command": "rss.refresh-one",
"when": "view == rss-articles",
"group": "navigation"
},
{
"command": "rss.mark-all-read",
"when": "view == rss-articles",
"group": "navigation"
}
],
"view/item/context": [
{
"command": "rss.refresh-account",
"when": "view == rss-accounts",
"group": "navigation@1"
},
{
"command": "rss.mark-account-read",
"when": "view == rss-accounts",
"group": "navigation@2"
},
{
"command": "rss.account-rename",
"when": "view == rss-accounts",
"group": "navigation@3"
},
{
"command": "rss.account-modify",
"when": "view == rss-accounts && viewItem != local",
"group": "navigation@4"
},
{
"command": "rss.export-to-opml",
"when": "view == rss-accounts && viewItem == local",
"group": "navigation@5"
},
{
"command": "rss.import-from-opml",
"when": "view == rss-accounts && viewItem == local",
"group": "navigation@6"
},
{
"command": "rss.clean-all-old-articles",
"when": "view == rss-accounts",
"group": "navigation@8"
},
{
"command": "rss.del-account",
"when": "view == rss-accounts",
"group": "navigation@9"
},
{
"command": "rss.open-link",
"when": "viewItem == article",
"group": "inline"
},
{
"command": "rss.mark-read",
"when": "view == rss-articles",
"group": "inline"
},
{
"command": "rss.mark-unread",
"when": "view == rss-articles"
},
{
"command": "rss.add-to-favorites",
"when": "view == rss-articles",
"group": "inline"
},
{
"command": "rss.remove-from-favorites",
"when": "view == rss-favorites && viewItem == article"
},
{
"command": "rss.refresh-one",
"when": "viewItem == feed",
"group": "navigation@1"
},
{
"command": "rss.mark-all-read",
"when": "viewItem == feed",
"group": "navigation@2"
},
{
"command": "rss.open-website",
"when": "viewItem == feed",
"group": "navigation@3"
},
{
"command": "rss.clean-old-articles",
"when": "viewItem == feed",
"group": "navigation@4"
},
{
"command": "rss.remove-feed",
"when": "viewItem == feed",
"group": "navigation@5"
}
]
}
},
"scripts": {
"vscode:prepublish": "npm run compile-release",
"compile-release": "rm -rf ./out && webpack --mode production",
"compile": "rm -rf ./out && tsc -p ./",
"lint": "eslint src --ext ts",
"pretest": "npm run compile && npm run lint",
"test": "node ./out/test/runTest.js"
},
"devDependencies": {
"@types/fs-extra": "^9.0.1",
"@types/glob": "^7.1.1",
"@types/he": "^1.1.0",
"@types/mocha": "^9.1.0",
"@types/node": "^13.11.0",
"@types/uuid": "^7.0.3",
"@types/vscode": "^1.40.0",
"@typescript-eslint/eslint-plugin": "^4.33.0",
"@typescript-eslint/parser": "^4.33.0",
"eslint": "^7.32.0",
"glob": "^7.1.6",
"mocha": "^9.2.0",
"ts-loader": "^7.0.5",
"typescript": "^4.2.4",
"vscode-test": "^1.3.0",
"webpack": "^5.76.0",
"webpack-cli": "^4.8.0"
},
"dependencies": {
"cheerio": "1.0.0-rc.10",
"fast-xml-parser": "^4.4.1",
"fs-extra": "^9.0.1",
"got": "12.5.3",
"he": "^1.2.0",
"iconv-lite": "^0.5.1",
"uuid": "^8.0.0"
}
}
================================================
FILE: src/account.ts
================================================
import * as vscode from 'vscode';
import { App } from './app';
import { Collection } from './collection';
export class AccountList implements vscode.TreeDataProvider<vscode.TreeItem> {
private _onDidChangeTreeData: vscode.EventEmitter<Account | undefined> = new vscode.EventEmitter<Account | undefined>();
readonly onDidChangeTreeData: vscode.Event<Account | undefined> = this._onDidChangeTreeData.event;
refresh(): void {
this._onDidChangeTreeData.fire(undefined);
}
getTreeItem(ele: vscode.TreeItem) {
return ele;
}
getChildren(element?: vscode.TreeItem): vscode.TreeItem[] {
if (element) {
return [];
}
return Object.values(App.instance.collections).map(c => new Account(c));
}
}
export class Account extends vscode.TreeItem {
public readonly key: string;
public readonly type: string;
constructor(collection: Collection) {
super(collection.name);
this.key = collection.account;
this.type = collection.type;
this.contextValue = this.type;
this.command = {command: 'rss.select', title: 'select', arguments: [this.key]};
const ids = collection.getArticleList();
const unread_num = ids.length === 0 ? 0
: ids.map(id => Number(!collection.getAbstract(id)?.read))
.reduce((a, b) => a + b);
if (unread_num > 0) {
this.label += ` (${unread_num})`;
this.iconPath = new vscode.ThemeIcon('rss');
}
}
}
================================================
FILE: src/app.ts
================================================
import * as vscode from 'vscode';
import { Collection } from './collection';
import { LocalCollection } from './local_collection';
import { TTRSSCollection } from './ttrss_collection';
import { join as pathJoin } from 'path';
import { readFile, TTRSSApiURL, walkFeedTree, writeFile } from './utils';
import { AccountList, Account } from './account';
import { FeedList, Feed } from './feeds';
import { ArticleList, Article } from './articles';
import { FavoritesList, Item } from './favorites';
import { Abstract } from './content';
import * as uuid from 'uuid';
import { StatusBar } from './status_bar';
import { InoreaderCollection } from './inoreader_collection';
import { assert } from 'console';
import { parseOPML } from './parser';
export class App {
private static _instance?: App;
private current_account?: string;
private current_feed?: string;
private updating = false;
private account_list = new AccountList();
private feed_list = new FeedList();
private article_list = new ArticleList();
private favorites_list = new FavoritesList();
private status_bar = new StatusBar();
public collections: {[key: string]: Collection} = {};
private constructor(
public readonly context: vscode.ExtensionContext,
public readonly root: string,
) {}
private async initAccounts() {
let keys = Object.keys(App.cfg.accounts);
if (keys.length <= 0) {
await this.createLocalAccount('Default');
keys = Object.keys(App.cfg.accounts);
}
for (const key of keys) {
if (this.collections[key]) {
continue;
}
const account = App.cfg.accounts[key];
const dir = pathJoin(this.root, key);
let c: Collection;
switch (account.type) {
case 'local':
c = new LocalCollection(dir, key);
break;
case 'ttrss':
c = new TTRSSCollection(dir, key);
break;
case 'inoreader':
c = new InoreaderCollection(dir, key);
break;
default:
throw new Error(`Unknown account type: ${account.type}`);
}
await c.init();
this.collections[key] = c;
}
for (const key in this.collections) {
if (!(key in App.cfg.accounts)) {
delete this.collections[key];
}
}
if (this.current_account === undefined || !(this.current_account in this.collections)) {
this.current_account = Object.keys(this.collections)[0];
}
}
private async createLocalAccount(name: string) {
const accounts = App.cfg.get<any>('accounts');
accounts[uuid.v1()] = {
name: name,
type: 'local',
feeds: [],
};
await App.cfg.update('accounts', accounts, true);
}
private async createTTRSSAccount(name: string, server: string, username: string, password: string) {
const accounts = App.cfg.get<any>('accounts');
accounts[uuid.v1()] = {
name: name,
type: 'ttrss',
server,
username,
password,
};
await App.cfg.update('accounts', accounts, true);
}
private async createInoreaderAccount(name: string, appid: string, appkey: string) {
const accounts = App.cfg.get<any>('accounts');
accounts[uuid.v1()] = {
name: name,
type: 'inoreader',
appid, appkey,
};
await App.cfg.update('accounts', accounts, true);
}
private async removeAccount(key: string) {
const collection = this.collections[key];
if (collection === undefined) {
return;
}
await collection.clean();
delete this.collections[key];
const accounts = {...App.cfg.get<any>('accounts')};
delete accounts[key];
await App.cfg.update('accounts', accounts, true);
}
async init() {
await this.initAccounts();
}
static async initInstance(context: vscode.ExtensionContext, root: string) {
App._instance = new App(context, root);
await App.instance.init();
}
static get instance(): App {
return App._instance!;
}
static get cfg() {
return vscode.workspace.getConfiguration('rss');
}
public static readonly ACCOUNT = 1;
public static readonly FEED = 1 << 1;
public static readonly ARTICLE = 1 << 2;
public static readonly FAVORITES = 1 << 3;
public static readonly STATUS_BAR = 1 << 4;
refreshLists(list: number=0b11111) {
if (list & App.ACCOUNT) {
this.account_list.refresh();
}
if (list & App.FEED) {
this.feed_list.refresh();
}
if (list & App.ARTICLE) {
this.article_list.refresh();
}
if (list & App.FAVORITES) {
this.favorites_list.refresh();
}
if (list & App.STATUS_BAR) {
this.status_bar.refresh();
}
}
currCollection() {
return this.collections[this.current_account!];
}
currArticles() {
if (this.current_feed === undefined) {
return [];
}
return this.currCollection().getArticles(this.current_feed);
}
currFavorites() {
return this.currCollection().getFavorites();
}
initViews() {
vscode.window.registerTreeDataProvider('rss-accounts', this.account_list);
vscode.window.registerTreeDataProvider('rss-feeds', this.feed_list);
vscode.window.registerTreeDataProvider('rss-articles', this.article_list);
vscode.window.registerTreeDataProvider('rss-favorites', this.favorites_list);
this.status_bar.init();
}
initCommands() {
const commands: [string, (...args: any[]) => any][] = [
['rss.select', this.rss_select],
['rss.articles', this.rss_articles],
['rss.read', this.rss_read],
['rss.mark-read', this.rss_mark_read],
['rss.mark-unread', this.rss_mark_unread],
['rss.mark-all-read', this.rss_mark_all_read],
['rss.mark-account-read', this.rss_mark_account_read],
['rss.refresh', this.rss_refresh],
['rss.refresh-account', this.rss_refresh_account],
['rss.refresh-one', this.rss_refresh_one],
['rss.open-website', this.rss_open_website],
['rss.open-link', this.rss_open_link],
['rss.add-feed', this.rss_add_feed],
['rss.remove-feed', this.rss_remove_feed],
['rss.add-to-favorites', this.rss_add_to_favorites],
['rss.remove-from-favorites', this.rss_remove_from_favorites],
['rss.new-account', this.rss_new_account],
['rss.del-account', this.rss_del_account],
['rss.account-rename', this.rss_account_rename],
['rss.account-modify', this.rss_account_modify],
['rss.export-to-opml', this.rss_export_to_opml],
['rss.import-from-opml', this.rss_import_from_opml],
['rss.clean-old-articles', this.rss_clean_old_articles],
['rss.clean-all-old-articles', this.rss_clean_all_old_articles],
];
for (const [cmd, handler] of commands) {
this.context.subscriptions.push(
vscode.commands.registerCommand(cmd, handler, this)
);
}
}
rss_select(account: string) {
this.current_account = account;
this.current_feed = undefined;
this.refreshLists(App.FEED | App.ARTICLE | App.FAVORITES);
}
rss_articles(feed: string) {
this.current_feed = feed;
this.refreshLists(App.ARTICLE);
}
private getHTML(content: string, panel: vscode.WebviewPanel) {
const css = '<style type="text/css">body{font-size:1em;max-width:960px;margin:auto;}</style>';
const star_path = vscode.Uri.file(pathJoin(this.context.extensionPath, 'resources/star.svg'));
const star_src = panel.webview.asWebviewUri(star_path);
const web_path = vscode.Uri.file(pathJoin(this.context.extensionPath, 'resources/web.svg'));
const web_src = panel.webview.asWebviewUri(web_path);
let html = css + content + `
<style>
.float-btn {
width: 2.2rem;
height: 2.2rem;
position: fixed;
right: 0.5rem;
z-index: 9999;
filter: drop-shadow(0 0 0.2rem rgba(0,0,0,.5));
transition-duration: 0.3s;
}
.float-btn:hover {
filter: drop-shadow(0 0 0.2rem rgba(0,0,0,.5))
brightness(130%);
}
.float-btn:active {
filter: drop-shadow(0 0 0.2rem rgba(0,0,0,.5))
brightness(80%);
}
</style>
<script type="text/javascript">
const vscode = acquireVsCodeApi();
function star() {
vscode.postMessage('star')
}
function next() {
vscode.postMessage('next')
}
function web() {
vscode.postMessage('web')
}
</script>
<img src="${web_src}" title="Open link" onclick="web()" class="float-btn" style="bottom:1rem;"/>
<img src="${star_src}" title="Add to favorites" onclick="star()" class="float-btn" style="bottom:4rem;"/>
`;
if (this.currCollection().getArticles('<unread>').length > 0) {
const next_path = vscode.Uri.file(pathJoin(this.context.extensionPath, 'resources/next.svg'));
const next_src = panel.webview.asWebviewUri(next_path);
html += `<img src="${next_src}" title="Next" onclick="next()" class="float-btn" style="bottom:7rem;"/>`;
}
return html;
}
async rss_read(abstract: Abstract) {
const content = await this.currCollection().getContent(abstract.id);
const panel = vscode.window.createWebviewPanel(
'rss', abstract.title, vscode.ViewColumn.One,
{retainContextWhenHidden: true, enableScripts: true});
abstract.read = true;
panel.title = abstract.title;
panel.webview.html = this.getHTML(content, panel);
panel.webview.onDidReceiveMessage(async (e) => {
if (e === 'web') {
if (abstract.link) {
vscode.env.openExternal(vscode.Uri.parse(abstract.link));
}
} else if (e === 'star') {
await this.currCollection().addToFavorites(abstract.id);
this.refreshLists(App.FAVORITES);
} else if (e === 'next') {
const unread = this.currCollection().getArticles('<unread>');
if (unread.length > 0) {
const abs = unread[0];
panel.dispose();
await this.rss_read(abs);
}
}
});
this.refreshLists();
await this.currCollection().updateAbstract(abstract.id, abstract).commit();
}
async rss_mark_read(article: Article) {
const abstract = article.abstract;
abstract.read = true;
this.refreshLists();
await this.currCollection().updateAbstract(abstract.id, abstract).commit();
}
async rss_mark_unread(article: Article) {
const abstract = article.abstract;
abstract.read = false;
this.refreshLists();
await this.currCollection().updateAbstract(abstract.id, abstract).commit();
}
async rss_mark_all_read(feed?: Feed) {
let abstracts: Abstract[];
if (feed) {
abstracts = this.currCollection().getArticles(feed.feed);
} else {
abstracts = this.currArticles();
}
for (const abstract of abstracts) {
abstract.read = true;
this.currCollection().updateAbstract(abstract.id, abstract);
}
this.refreshLists();
await this.currCollection().commit();
}
async rss_mark_account_read(account?: Account) {
const collection = account ?
this.collections[account.key] : this.currCollection();
for (const abstract of collection.getArticles('<unread>')) {
abstract.read = true;
collection.updateAbstract(abstract.id, abstract);
}
this.refreshLists();
await collection.commit();
}
async rss_refresh(auto: boolean) {
if (this.updating) {
return;
}
this.updating = true;
await vscode.window.withProgress({
location: auto ? vscode.ProgressLocation.Window: vscode.ProgressLocation.Notification,
title: "Updating RSS...",
cancellable: false
}, async () => {
await Promise.all(Object.values(this.collections).map(c => c.fetchAll(true)));
this.refreshLists();
this.updating = false;
});
}
async rss_refresh_account(account?: Account) {
if (this.updating) {
return;
}
this.updating = true;
await vscode.window.withProgress({
location: vscode.ProgressLocation.Notification,
title: "Updating RSS...",
cancellable: false
}, async () => {
const collection = account ?
this.collections[account.key] : this.currCollection();
await collection.fetchAll(true);
this.refreshLists();
this.updating = false;
});
}
async rss_refresh_one(feed?: Feed) {
if (this.updating) {
return;
}
const url = feed ? feed.feed : this.current_feed;
if (url === undefined) {
return;
}
this.updating = true;
await vscode.window.withProgress({
location: vscode.ProgressLocation.Notification,
title: "Updating RSS...",
cancellable: false
}, async () => {
await this.currCollection().fetchOne(url, true);
this.refreshLists();
this.updating = false;
});
}
rss_open_website(feed: Feed) {
vscode.env.openExternal(vscode.Uri.parse(feed.summary.link));
}
rss_open_link(article: Article) {
if (article.abstract.link) {
vscode.env.openExternal(vscode.Uri.parse(article.abstract.link));
}
}
async rss_add_feed() {
const feed = await vscode.window.showInputBox({prompt: 'Enter the feed URL'});
if (feed === undefined || feed.length <= 0) {return;}
await this.currCollection().addFeed(feed);
}
async rss_remove_feed(feed: Feed) {
await this.currCollection().delFeed(feed.feed);
}
async rss_add_to_favorites(article: Article) {
await this.currCollection().addToFavorites(article.abstract.id);
this.refreshLists(App.FAVORITES);
}
async rss_remove_from_favorites(item: Item) {
await this.currCollection().removeFromFavorites(item.abstract.id);
this.refreshLists(App.FAVORITES);
}
async rss_new_account() {
const type = await vscode.window.showQuickPick(
['local', 'ttrss', 'inoreader'],
{placeHolder: "Select account type"}
);
if (type === undefined) {return;}
const name = await vscode.window.showInputBox({prompt: 'Enter account name', value: type});
if (name === undefined || name.length <= 0) {return;}
if (type === 'local') {
await this.createLocalAccount(name);
} else if (type === 'ttrss') {
const url = await vscode.window.showInputBox({prompt: 'Enter server URL(SELF_URL_PATH)'});
if (url === undefined || url.length <= 0) {return;}
const username = await vscode.window.showInputBox({prompt: 'Enter user name'});
if (username === undefined || username.length <= 0) {return;}
const password = await vscode.window.showInputBox({prompt: 'Enter password', password: true});
if (password === undefined || password.length <= 0) {return;}
await this.createTTRSSAccount(name, TTRSSApiURL(url), username, password);
} else if (type === 'inoreader') {
const custom = await vscode.window.showQuickPick(
['no', 'yes'],
{placeHolder: "Using custom app ID & app key?"}
);
let appid, appkey;
if (custom === 'yes') {
appid = await vscode.window.showInputBox({prompt: 'Enter app ID'});
if (!appid) {return;}
appkey = await vscode.window.showInputBox({prompt: 'Enter app key', password: true});
if (!appkey) {return;}
} else {
appid = '999999367';
appkey = 'GOgPzs1RnPTok6q8kC8HgmUPji3DjspC';
}
await this.createInoreaderAccount(name, appid, appkey);
}
}
async rss_del_account(account: Account) {
const confirm = await vscode.window.showQuickPick(['no', 'yes'], {placeHolder: "Are you sure to delete?"});
if (confirm !== 'yes') {
return;
}
await this.removeAccount(account.key);
}
async rss_account_rename(account: Account) {
const name = await vscode.window.showInputBox({prompt: 'Enter the name'});
if (name === undefined || name.length <= 0) {return;}
const accounts = App.cfg.get<any>('accounts');
accounts[account.key].name = name;
await App.cfg.update('accounts', accounts, true);
}
async rss_account_modify(account: Account) {
const accounts = App.cfg.get<any>('accounts');
if (account.type === 'ttrss') {
const cfg = accounts[account.key] as TTRSSAccount;
const url = await vscode.window.showInputBox({
prompt: 'Enter server URL(SELF_URL_PATH)',
value: cfg.server.substr(0, cfg.server.length - 4)
});
if (url === undefined || url.length <= 0) {return;}
const username = await vscode.window.showInputBox({
prompt: 'Enter user name', value: cfg.username
});
if (username === undefined || username.length <= 0) {return;}
const password = await vscode.window.showInputBox({
prompt: 'Enter password', password: true, value: cfg.password
});
if (password === undefined || password.length <= 0) {return;}
cfg.server = TTRSSApiURL(url);
cfg.username = username;
cfg.password = password;
} else if (account.type === 'inoreader') {
const cfg = accounts[account.key] as InoreaderAccount;
const appid = await vscode.window.showInputBox({
prompt: 'Enter app ID', value: cfg.appid
});
if (!appid) {return;}
const appkey = await vscode.window.showInputBox({
prompt: 'Enter app key', password: true, value: cfg.appkey
});
if (!appkey) {return;}
cfg.appid = appid;
cfg.appkey = appkey;
}
await App.cfg.update('accounts', accounts, true);
}
async rss_export_to_opml(account: Account) {
const collection = this.collections[account.key];
const path = await vscode.window.showSaveDialog({
defaultUri: vscode.Uri.file(collection.name + '.opml')
});
if (!path) {
return;
}
const tree = collection.getFeedList();
const outlines: string[] = [];
for (const feed of walkFeedTree(tree)) {
const summary = collection.getSummary(feed);
if (!summary) {
continue;
}
outlines.push(`<outline text="${summary.title}" title="${summary.title}" type="rss" xmlUrl="${feed}" htmlUrl="${summary.link}"/>`);
}
const xml = `<?xml version="1.0" encoding="UTF-8"?>`
+ `<opml version="1.0">`
+ `<head><title>${collection.name}</title></head>`
+ `<body>${outlines.join('')}</body>`
+ `</opml>`;
await writeFile(path.fsPath, xml);
}
async rss_import_from_opml(account: Account) {
const collection = this.collections[account.key] as LocalCollection;
assert(collection.type === 'local');
const paths = await vscode.window.showOpenDialog({canSelectMany: false});
if (!paths) {
return;
}
const xml = await readFile(paths[0].fsPath);
await collection.addFeeds(parseOPML(xml));
}
private async selectExpire(): Promise<number|undefined> {
const s = ['1 month', '2 months', '3 months', '6 months'];
const t = [1 * 30, 2 * 30, 3 * 30, 6 * 30];
const time = await vscode.window.showQuickPick(s, {
placeHolder: "Choose a time. Unread and favorite articles will be kept."
});
if (!time) {
return undefined;
}
return t[s.indexOf(time)] * 86400 * 1000;
}
async rss_clean_old_articles(feed: Feed) {
const exprie = await this.selectExpire();
if (!exprie) {
return;
}
await vscode.window.withProgress({
location: vscode.ProgressLocation.Notification,
title: "Cleaning...",
cancellable: false
}, async () => {
await this.currCollection().cleanOldArticles(feed.feed, exprie);
});
this.refreshLists(App.ARTICLE | App.STATUS_BAR);
}
async rss_clean_all_old_articles(account: Account) {
const expire = await this.selectExpire();
if (!expire) {
return;
}
const collection = this.collections[account.key];
await vscode.window.withProgress({
location: vscode.ProgressLocation.Notification,
title: "Cleaning...",
cancellable: false
}, async () => {
await collection.cleanAllOldArticles(expire);
});
this.refreshLists(App.ARTICLE | App.STATUS_BAR);
}
initEvents() {
const do_refresh = () => vscode.commands.executeCommand('rss.refresh', true);
let timer = setInterval(do_refresh, App.cfg.interval * 1000);
const disposable = vscode.workspace.onDidChangeConfiguration(async (e) => {
if (e.affectsConfiguration('rss.interval')) {
clearInterval(timer);
timer = setInterval(do_refresh, App.cfg.interval * 1000);
}
if (e.affectsConfiguration('rss.status-bar-notify') || e.affectsConfiguration('rss.status-bar-update')) {
this.refreshLists(App.STATUS_BAR);
}
if (e.affectsConfiguration('rss.accounts') && !this.updating) {
this.updating = true;
await vscode.window.withProgress({
location: vscode.ProgressLocation.Notification,
title: "Updating RSS...",
cancellable: false
}, async () => {
await this.initAccounts();
await Promise.all(Object.values(this.collections).map(c => c.fetchAll(false)));
this.refreshLists();
this.updating = false;
});
}
if (e.affectsConfiguration('rss.storage-path')) {
const res = await vscode.window.showInformationMessage("Reload vscode to take effect", "Reload");
if (res === "Reload") {
vscode.commands.executeCommand("workbench.action.reloadWindow");
}
}
});
this.context.subscriptions.push(disposable);
}
}
================================================
FILE: src/articles.ts
================================================
import * as vscode from 'vscode';
import { Abstract } from './content';
import { App } from './app';
export class ArticleList implements vscode.TreeDataProvider<Article> {
private _onDidChangeTreeData: vscode.EventEmitter<Article | undefined> = new vscode.EventEmitter<Article | undefined>();
readonly onDidChangeTreeData: vscode.Event<Article | undefined> = this._onDidChangeTreeData.event;
refresh(): void {
this._onDidChangeTreeData.fire(undefined);
}
getTreeItem(ele: Article): vscode.TreeItem {
return ele;
}
getChildren(element?: Article): Article[] {
if (element) {return [];}
return App.instance.currArticles().map(abstract => new Article(abstract));
}
}
export class Article extends vscode.TreeItem {
constructor(
public abstract: Abstract
) {
super(abstract.title);
this.contextValue = "article";
this.description = new Date(abstract.date).toLocaleString();
this.command = {command: 'rss.read', title: 'Read', arguments: [abstract]};
if (!abstract.read) {
this.iconPath = new vscode.ThemeIcon('circle-outline');
}
}
}
================================================
FILE: src/collection.ts
================================================
import * as vscode from 'vscode';
import { join as pathJoin } from 'path';
import { Summary, Abstract, Storage } from './content';
import { App } from './app';
import { checkDir, writeFile, readFile, removeFile, removeDir, readDir } from './utils';
export abstract class Collection {
private summaries: {[url: string]: Summary} = {};
private abstracts: {[id: string]: Abstract} = {};
protected dirty_summaries = new Set<string>();
constructor(
protected dir: string,
public readonly account: string
) {}
async init() {
await checkDir(this.dir);
await checkDir(pathJoin(this.dir, 'feeds'));
await checkDir(pathJoin(this.dir, 'articles'));
const feeds = await readDir(pathJoin(this.dir, 'feeds'));
for (const feed of feeds) {
const json = await readFile(pathJoin(this.dir, 'feeds', feed));
const [url, summary] = Storage.fromJSON(json).toSummary((id, abstract) => {this.abstracts[id] = abstract;});
this.summaries[url] = summary;
}
}
protected get cfg(): Account {
return App.cfg.accounts[this.account];
}
public get name() {
return this.cfg.name;
}
protected async updateCfg() {
const cfg = App.cfg;
await cfg.update('accounts', cfg.accounts, true);
}
abstract get type(): string;
abstract addFeed(feed: string): Promise<void>;
abstract delFeed(feed: string): Promise<void>;
abstract addToFavorites(id: string): Promise<void>;
abstract removeFromFavorites(id: string): Promise<void>;
getSummary(url: string): Summary | undefined {
return this.summaries[url];
}
getAbstract(id: string): Abstract | undefined {
return this.abstracts[id];
}
getFeedList(): FeedTree {
return Object.keys(this.summaries);
}
getArticleList(): string[] {
return Object.keys(this.abstracts);
}
protected getFeeds() {
return Object.keys(this.summaries);
}
getArticles(feed: string): Abstract[] {
if (feed === '<unread>') {
const list = Object.values(this.abstracts).filter(a => !a.read);
list.sort((a, b) => b.date - a.date);
return list;
} else {
const summary = this.getSummary(feed);
const list: Abstract[] = [];
if (summary !== undefined) {
for (const id of summary.catelog) {
const abstract = this.getAbstract(id);
if (abstract) {
list.push(abstract);
}
}
}
return list;
}
}
async cleanAllOldArticles(expire: number) {
for (const feed in this.summaries) {
await this.cleanOldArticles(feed, expire);
}
}
async cleanOldArticles(feed: string, expire: number) {
const summary = this.getSummary(feed);
if (!summary) {
return;
}
this.dirty_summaries.add(feed);
const now = new Date().getTime();
for (let i = summary.catelog.length - 1; i >= 0; --i) {
const id = summary.catelog[i];
const abs = this.getAbstract(id);
if (abs && now - abs.date <= expire) { // remaining articles is not expired, break
break;
}
if (!abs || (abs.read && !abs.starred)) {
summary.catelog.splice(i, 1);
delete this.abstracts[id];
await removeFile(pathJoin(this.dir, 'articles', id.toString()));
}
}
await this.commit();
}
getFavorites() {
const list: Abstract[] = [];
for (const abstract of Object.values(this.abstracts)) {
if (abstract.starred) {
list.push(abstract);
}
}
return list;
}
async getContent(id: string) {
const file = pathJoin(this.dir, 'articles', id.toString());
try {
return await readFile(file);
} catch (error: any) {
vscode.window.showErrorMessage(error.toString());
throw error;
}
}
updateAbstract(id: string, abstract?: Abstract) {
if (abstract === undefined) {
const old = this.getAbstract(id);
if (old) {
this.dirty_summaries.add(old.feed);
delete this.abstracts[id];
}
} else {
this.dirty_summaries.add(abstract.feed);
this.abstracts[id] = abstract;
}
return this;
}
updateSummary(feed: string, summary?: Summary) {
if (summary === undefined) {
delete this.summaries[feed];
} else {
this.summaries[feed] = summary;
}
this.dirty_summaries.add(feed);
return this;
}
async updateContent(id: string, content: string | undefined) {
const file = pathJoin(this.dir, 'articles', id.toString());
if (content === undefined) {
await removeFile(file);
} else {
await writeFile(file, content);
}
}
async removeSummary(url: string) {
const summary = this.summaries[url];
if (!summary) {
return;
}
this.updateSummary(url, undefined);
for (const id of summary.catelog) {
this.updateAbstract(id, undefined);
await this.updateContent(id, undefined);
}
return this;
}
async commit() {
for (const feed of this.dirty_summaries) {
const summary = this.getSummary(feed);
const path = pathJoin(this.dir, 'feeds', encodeURIComponent(feed));
if (summary === undefined) {
await removeFile(path);
} else {
const json = Storage.fromSummary(feed, summary, id => this.abstracts[id]).toJSON();
await writeFile(path, json);
}
}
this.dirty_summaries.clear();
}
async clean() {
await removeDir(this.dir);
}
abstract fetchAll(update: boolean): Promise<void>;
abstract fetchOne(url: string, update: boolean): Promise<void>;
}
================================================
FILE: src/config.ts
================================================
interface Account {
name: string;
type: 'local' | 'ttrss';
}
type FeedTree = (string | Category)[];
interface Category {
name: string;
list: FeedTree;
custom_data?: any;
}
interface LocalAccount extends Account {
feeds: FeedTree;
}
interface TTRSSAccount extends Account {
server: string;
username: string;
password: string;
}
interface InoreaderAccount extends Account {
appid: string;
appkey: string;
}
================================================
FILE: src/content.ts
================================================
export class Entry {
constructor(
public id: string,
public title: string,
public content: string,
public date: number,
public link: string | undefined,
public read: boolean,
) {}
}
export class Abstract {
constructor(
public readonly id: string,
public title: string,
public date: number,
public link: string | undefined,
public read: boolean,
public feed: string,
public starred: boolean = false,
public custom_data?: any,
) {}
static fromEntry(entry: Entry, feed: string) {
return new Abstract(entry.id, entry.title, entry.date, entry.link, entry.read, feed);
}
}
export class Summary {
constructor(
public link: string,
public title: string,
public catelog: string[] = [],
public ok: boolean = true,
public custom_data?: any,
) {}
}
export class Storage {
private constructor(
private feed: string,
private link: string,
private title: string,
private abstracts: Abstract[],
private ok: boolean = true,
private custom_data?: any,
) {}
static fromSummary(feed: string, summary: Summary, get: (link: string) => Abstract) {
return new Storage(feed, summary.link, summary.title,
summary.catelog.map(get),
summary.ok, summary.custom_data);
}
static fromJSON(json: string) {
const obj = JSON.parse(json);
return new Storage(obj.feed, obj.link, obj.title, obj.abstracts, obj.ok, obj.custom_data);
}
toSummary(set: (id: string, abstract: Abstract) => void): [string, Summary] {
const summary = new Summary(this.link, this.title, this.abstracts.map(abs => abs.id),
this.ok, this.custom_data);
for (const abstract of this.abstracts) {
set(abstract.id, abstract);
}
return [this.feed, summary];
}
toJSON() {
return JSON.stringify({
feed: this.feed,
link: this.link,
title: this.title,
abstracts: this.abstracts,
ok: this.ok,
custom_data: this.custom_data,
});
}
}
================================================
FILE: src/extension.ts
================================================
import * as vscode from 'vscode';
import { checkStoragePath, migrate } from './migrate';
import { App } from './app';
export async function activate(context: vscode.ExtensionContext) {
const root = await checkStoragePath(context);
await migrate(context, root);
await App.initInstance(context, root);
App.instance.initViews();
App.instance.initCommands();
App.instance.initEvents();
}
================================================
FILE: src/favorites.ts
================================================
import * as vscode from 'vscode';
import { Article } from './articles';
import { Abstract } from './content';
import { App } from './app';
export class FavoritesList implements vscode.TreeDataProvider<vscode.TreeItem> {
private _onDidChangeTreeData: vscode.EventEmitter<Article | undefined> = new vscode.EventEmitter<Article | undefined>();
readonly onDidChangeTreeData: vscode.Event<Article | undefined> = this._onDidChangeTreeData.event;
refresh(): void {
this._onDidChangeTreeData.fire(undefined);
}
getTreeItem(ele: vscode.TreeItem) {
return ele;
}
getChildren(element?: vscode.TreeItem): vscode.TreeItem[] {
if (element) {
return [];
}
return App.instance.currFavorites().map((a, i) => new Item(a, i));
}
}
export class Item extends Article {
constructor(
public abstract: Abstract,
public index: number
) {
super(abstract);
}
}
================================================
FILE: src/feeds.ts
================================================
import * as vscode from 'vscode';
import { Summary } from './content';
import { App } from './app';
export class FeedList implements vscode.TreeDataProvider<vscode.TreeItem> {
private _onDidChangeTreeData = new vscode.EventEmitter<vscode.TreeItem | undefined>();
readonly onDidChangeTreeData = this._onDidChangeTreeData.event;
refresh(): void {
this._onDidChangeTreeData.fire(undefined);
}
getTreeItem(ele: vscode.TreeItem): vscode.TreeItem {
return ele;
}
private buildTree(tree: FeedTree): [vscode.TreeItem[], number] {
const collection = App.instance.currCollection();
const list: vscode.TreeItem[] = [];
let unread_sum = 0;
for (const item of tree) {
if (typeof(item) === 'string') {
const summary = collection.getSummary(item);
if (summary === undefined) {
continue;
}
const unread_num = summary.catelog.length ? summary.catelog.map((id): number => {
const abstract = collection.getAbstract(id);
return abstract && !abstract.read ? 1 : 0;
}).reduce((a, b) => a + b) : 0;
unread_sum += unread_num;
list.push(new Feed(item, summary, unread_num));
} else {
const [tree, unread_num] = this.buildTree(item.list);
unread_sum += unread_num;
list.push(new Folder(item, tree, unread_num));
}
}
return [list, unread_sum];
}
getChildren(element?: vscode.TreeItem): vscode.TreeItem[] {
if (element) {
if (element instanceof Folder) {
return element.list;
} else {
return [];
}
} else {
const [list, unread_num] = this.buildTree(App.instance.currCollection().getFeedList());
if (unread_num > 0) {
list.unshift(new Unread(unread_num));
}
return list;
}
}
}
export class Feed extends vscode.TreeItem {
constructor(
public feed: string,
public summary: Summary,
unread_num: number,
) {
super(summary.title);
this.command = {command: 'rss.articles', title: 'articles', arguments: [feed]};
this.contextValue = 'feed';
if (unread_num > 0) {
this.label += ` (${unread_num})`;
}
if (!summary.ok) {
this.iconPath = new vscode.ThemeIcon('error');
} else if (unread_num > 0) {
this.iconPath = new vscode.ThemeIcon('circle-filled');
}
}
}
class Unread extends vscode.TreeItem {
constructor(unread_num: number) {
super(`You have ${unread_num} unread article${unread_num > 1 ? 's' : ''}`);
this.command = {command: 'rss.articles', title: 'articles', arguments: ['<unread>']};
this.contextValue = 'unread';
this.iconPath = new vscode.ThemeIcon('bell-dot');
}
}
class Folder extends vscode.TreeItem {
constructor(
public category: Category,
public list: vscode.TreeItem[],
unread_num: number,
) {
super(category.name, vscode.TreeItemCollapsibleState.Expanded);
if (unread_num > 0) {
this.label += ` (${unread_num})`;
this.contextValue = 'folder';
this.iconPath = new vscode.ThemeIcon('circle-filled');
}
}
}
================================================
FILE: src/inoreader_collection.ts
================================================
import * as vscode from 'vscode';
import { Collection } from "./collection";
import { App } from "./app";
import { join as pathJoin, resolve } from 'path';
import { writeFile, readFile, removeFile, fileExists, got } from './utils';
import { Summary, Abstract } from "./content";
import * as http from 'http';
import { parse as url_parse } from 'url';
import { AddressInfo } from 'net';
import { IncomingMessage, ServerResponse } from 'http';
import he = require('he');
interface Token {
auth_code: string;
access_token: string;
refresh_token: string;
expire: number;
}
export class InoreaderCollection extends Collection {
private feed_tree: FeedTree = [];
private token?: Token;
private dirty_abstracts = new Set<string>();
get type(): string {
return "inoreader";
}
protected get cfg(): InoreaderAccount {
return super.cfg as InoreaderAccount;
}
private get domain(): string {
return App.cfg.get<string>('inoreader-domain')!;
}
async init() {
const list_path = pathJoin(this.dir, 'feed_list');
if (await fileExists(list_path)) {
this.feed_tree = JSON.parse(await readFile(list_path));
}
const code_path = pathJoin(this.dir, 'auth_code');
if (await fileExists(code_path)) {
this.token = JSON.parse(await readFile(code_path));
}
await super.init();
}
getFeedList(): FeedTree {
if (this.feed_tree.length > 0) {
return this.feed_tree;
} else {
return super.getFeedList();
}
}
private async saveToken(token: Token) {
await writeFile(pathJoin(this.dir, 'auth_code'), JSON.stringify(token));
}
private async authorize(): Promise<Token> {
const server = http.createServer().listen(0, '127.0.0.1');
const addr = await new Promise<AddressInfo>(resolve => {
server.on('listening', () => {
resolve(server.address() as AddressInfo);
});
});
const client_id = this.cfg.appid;
const redirect_uri = encodeURIComponent(`http://127.0.0.1:${addr.port}`);
const url = `https://${this.domain}/oauth2/auth?client_id=${client_id}&redirect_uri=${redirect_uri}&response_type=code&scope=read+write&state=1`;
await vscode.env.openExternal(vscode.Uri.parse(url));
const auth_code = await vscode.window.withProgress({
location: vscode.ProgressLocation.Notification,
title: 'Authorizing...',
cancellable: true
}, async (_, token) => new Promise<string>((resolve, reject) => {
const timer = setTimeout(() => {
reject('Authorization Timeout');
server.close();
}, 300000);
token.onCancellationRequested(() => {
reject('Cancelled');
server.close();
clearInterval(timer);
});
server.on('request', (req: IncomingMessage, res: ServerResponse) => {
const query = url_parse(req.url!, true).query;
if (query.code) {
resolve(query.code as string);
res.end('<h1>Authorization Succeeded</h1>');
server.close();
clearInterval(timer);
}
});
}));
const res = await got({
url: `https://${this.domain}/oauth2/token`,
method: 'POST',
form: {
code: auth_code,
redirect_uri: redirect_uri,
client_id: this.cfg.appid,
client_secret: this.cfg.appkey,
grant_type: 'authorization_code',
},
throwHttpErrors: false,
});
const response = JSON.parse(res.body);
if (!response.refresh_token || !response.access_token || !response.expires_in) {
throw Error('Get Token Fail: ' + response.error_description);
}
return {
auth_code: auth_code,
refresh_token: response.refresh_token,
access_token: response.access_token,
expire: new Date().getTime() + response.expires_in * 1000,
};
}
private async refreshToken(token: Token) {
const res = await got({
url: `https://${this.domain}/oauth2/token`,
method: 'POST',
form: {
client_id: this.cfg.appid,
client_secret: this.cfg.appkey,
grant_type: "refresh_token",
refresh_token: token.refresh_token,
},
throwHttpErrors: false,
});
const response = JSON.parse(res.body);
if (!response.refresh_token || !response.access_token || !response.expires_in) {
return undefined;
}
token.refresh_token = response.refresh_token;
token.access_token = response.access_token;
token.expire = new Date().getTime() + response.expires_in * 1000;
return token;
}
private async getAccessToken() {
if (!this.token) {
this.token = await this.authorize();
this.saveToken(this.token);
}
if (new Date().getTime() > this.token.expire) {
this.token = await this.refreshToken(this.token);
if (!this.token) {
this.token = await this.authorize();
}
this.saveToken(this.token);
}
return this.token.access_token;
}
private async request(cmd: string, param?: {[key: string]: any}, is_json: boolean=true): Promise<any> {
const access_token = await this.getAccessToken();
const res = await got({
url: `https://${this.domain}/reader/api/0/${cmd}`,
method: 'POST',
headers: {'Authorization': `Bearer ${access_token}`},
form: param,
throwHttpErrors: false,
timeout: App.cfg.timeout * 1000,
retry: App.cfg.retry,
});
if (res.statusCode !== 200) {
if (res.statusCode === 401) {
this.token = undefined;
return await this.request(cmd, param);
} else {
throw Error(res.body);
}
}
return is_json ? JSON.parse(res.body) : res.body;
}
async addFeed(feed: string) {
if (this.getSummary(feed) !== undefined) {
vscode.window.showInformationMessage('Feed already exists');
return;
}
await vscode.window.withProgress({
location: vscode.ProgressLocation.Notification,
title: "Updating RSS...",
cancellable: false
}, async () => {
try {
const res = await this.request('subscription/quickadd', {
quickadd: 'feed/' + feed,
});
if (res.numResults > 0) {
await this._fetchAll(false);
App.instance.refreshLists();
}
} catch (error: any) {
vscode.window.showErrorMessage('Add feed failed: ' + error.toString());
}
});
}
async delFeed(feed: string) {
const summary = this.getSummary(feed);
if (summary === undefined) {
return;
}
await vscode.window.withProgress({
location: vscode.ProgressLocation.Notification,
title: "Updating RSS...",
cancellable: false
}, async () => {
try {
await this.request('subscription/edit', {
ac: 'unsubscribe',
s: summary.custom_data,
}, false);
await this._fetchAll(false);
App.instance.refreshLists();
} catch (error: any) {
vscode.window.showErrorMessage('Remove feed failed: ' + error.toString());
}
});
}
async addToFavorites(id: string) {
const abstract = this.getAbstract(id);
if (!abstract) {
return;
}
abstract.starred = true;
this.updateAbstract(id, abstract);
await this.commit();
this.request('edit-tag', {
a: 'user/-/state/com.google/starred',
i: id,
}, false).catch(error => {
vscode.window.showErrorMessage('Add favorite failed: ' + error.toString());
});
}
async removeFromFavorites(id: string) {
const abstract = this.getAbstract(id);
if (!abstract) {
return;
}
abstract.starred = false;
this.updateAbstract(id, abstract);
await this.commit();
this.request('edit-tag', {
r: 'user/-/state/com.google/starred',
i: id,
}, false).catch(error => {
vscode.window.showErrorMessage('Remove favorite failed: ' + error.toString());
});
}
private async fetch(url: string, update: boolean) {
const summary = this.getSummary(url);
if (summary === undefined || summary.custom_data === undefined) {
throw Error('Feed dose not exist');
}
if (!update && summary.ok) {
return;
}
const param: {[key: string]: any} = {};
param.n = App.cfg.get('inoreader-limit');
if (App.cfg.get('fetch-unread-only')) {
param.xt = 'user/-/state/com.google/read';
}
const res = await this.request(
'stream/contents/' + encodeURIComponent(summary.custom_data),
param
);
const items = res.items as any[];
const id2abs = new Map<string, Abstract>();
for (const item of items) {
let read = false, starred = false;
for (const tag of item.categories as string[]) {
if (tag.endsWith('state/com.google/read')) {
read = true;
} else if (tag.endsWith('state/com.google/starred')) {
starred = true;
}
}
const id = item.id.split('/').pop();
const abs = new Abstract(id, he.decode(item.title), item.published * 1000,
item.canonical[0]?.href, read, url, starred);
this.updateAbstract(id, abs);
this.updateContent(id, item.summary.content);
id2abs.set(id, abs);
}
for (const id of summary.catelog) {
const abs = this.getAbstract(id);
if (abs !== undefined && !id2abs.has(id)) {
if (!abs.read) {
abs.read = true;
this.updateAbstract(id, abs);
}
id2abs.set(id, abs);
}
}
summary.catelog = [...id2abs.values()]
.sort((a, b) => b.date - a.date)
.map(a => a.id);
summary.ok = true;
this.updateSummary(url, summary);
}
private async _fetchAll(update: boolean) {
const res = await this.request('subscription/list');
const list = res.subscriptions as any[];
const feeds = new Set<string>();
const caties = new Map<string, Category>();
const no_caties: FeedTree = [];
for (const feed of list) {
let summary = this.getSummary(feed.url);
if (summary) {
summary.ok = true;
summary.title = feed.title;
summary.custom_data = feed.id;
} else {
summary = new Summary(feed.htmlUrl, feed.title, [], false, feed.id);
}
this.updateSummary(feed.url, summary);
feeds.add(feed.url);
for (const caty of feed.categories as {id: string, label: string}[]) {
let category = caties.get(caty.id);
if (!category) {
category = {
name: caty.label,
list: [],
custom_data: caty.id,
};
caties.set(caty.id, category);
}
category.list.push(feed.url);
}
if (feed.categories.length <= 0) {
no_caties.push(feed.url);
}
}
this.feed_tree = [];
for (const caty of caties.values()) {
this.feed_tree.push(caty);
}
this.feed_tree.push(...no_caties);
for (const feed of this.getFeeds()) {
if (!feeds.has(feed)) {
this.updateSummary(feed, undefined);
}
}
await Promise.all(this.getFeeds().map(url => this.fetch(url, update)));
await this.commit();
}
async fetchAll(update: boolean) {
try {
await this._fetchAll(update);
} catch (error: any) {
vscode.window.showErrorMessage('Update feeds failed: ' + error.toString());
}
}
async fetchOne(url: string, update: boolean) {
try {
await this.fetch(url, update);
await this.commit();
} catch (error: any) {
vscode.window.showErrorMessage('Update feed failed: ' + error.toString());
}
}
updateAbstract(id: string, abstract?: Abstract) {
this.dirty_abstracts.add(id);
return super.updateAbstract(id, abstract);
}
private async syncReadStatus(list: string[], read: boolean) {
if (list.length <= 0) {
return;
}
const param = list.map(i => ['i', i]);
param.push([read ? 'a' : 'r', 'user/-/state/com.google/read']);
await this.request('edit-tag', param, false);
}
async commit() {
const read_list: string[] = [];
const unread_list: string[] = [];
for (const id of this.dirty_abstracts) {
const abstract = this.getAbstract(id);
if (abstract) {
if (abstract.read) {
read_list.push(abstract.id);
} else {
unread_list.push(abstract.id);
}
}
}
this.dirty_abstracts.clear();
Promise.all([
this.syncReadStatus(read_list, true),
this.syncReadStatus(unread_list, false),
]).catch(error => {
vscode.window.showErrorMessage('Sync read status failed: ' + error.toString());
});
await writeFile(pathJoin(this.dir, 'feed_list'), JSON.stringify(this.feed_tree));
await super.commit();
}
}
================================================
FILE: src/local_collection.ts
================================================
import * as vscode from 'vscode';
import { parseXML2 } from './parser';
import { Entry, Summary, Abstract } from './content';
import { App } from './app';
import { Collection } from './collection';
import { walkFeedTree, got } from './utils';
export class LocalCollection extends Collection {
private etags = new Map<string, string>();
get type() {
return 'local';
}
protected get cfg(): LocalAccount {
return super.cfg as LocalAccount;
}
getFeedList(): FeedTree {
return this.cfg.feeds;
}
private async addToTree(tree: FeedTree, feed: string) {
const categories: Category[] = [];
for (const item of tree) {
if (typeof(item) !== 'string') {
categories.push(item);
}
}
if (categories.length > 0) {
const choice = await vscode.window.showQuickPick([
'.', ...categories.map(c => c.name)
], {placeHolder: 'Select a category'});
if (choice === undefined) {
return;
} else if (choice === '.') {
tree.push(feed);
} else {
const caty = categories.find(c => c.name === choice)!;
await this.addToTree(caty.list, feed);
}
} else {
tree.push(feed);
}
}
async addFeed(feed: string) {
await this.addToTree(this.cfg.feeds, feed);
await this.updateCfg();
}
async addFeeds(feeds: string[]) {
this.cfg.feeds.push(...feeds);
await this.updateCfg();
}
private deleteFromTree(tree: FeedTree, feed: string) {
for (const [i, item] of tree.entries()) {
if (typeof(item) === 'string') {
if (item === feed) {
tree.splice(i, 1);
break;
}
} else {
this.deleteFromTree(item.list, feed);
}
}
}
async delFeed(feed: string) {
this.deleteFromTree(this.cfg.feeds, feed);
await this.updateCfg();
}
async addToFavorites(id: string) {
const abstract = this.getAbstract(id);
if (abstract) {
abstract.starred = true;
this.updateAbstract(id, abstract);
await this.commit();
}
}
async removeFromFavorites(id: string) {
const abstract = this.getAbstract(id);
if (abstract) {
abstract.starred = false;
this.updateAbstract(id, abstract);
await this.commit();
}
}
private async fetch(url: string, update: boolean) {
const summary = this.getSummary(url) || new Summary(url, url, [], false);
if (!update && summary.ok) {
return;
}
let entries: Entry[];
try {
const cfg = App.cfg;
const res = await got(url, {
timeout: cfg.timeout * 1000, retry: cfg.retry, encoding: 'binary',
headers: {
'If-None-Match': this.etags.get(url),
'Accept-Encoding': 'gzip, br',
}
});
if (res.statusCode === 304) {
return;
}
let etag = res.headers['etag'];
if (etag) {
if (Array.isArray(etag)) {
etag = etag[0];
}
this.etags.set(url, etag);
}
const [e, s] = parseXML2(res.body);
entries = e;
summary.title = s.title;
summary.link = s.link;
summary.ok = true;
} catch (error: any) {
vscode.window.showErrorMessage(error.toString());
entries = [];
summary.ok = false;
}
const id2abs = new Map<string, Abstract>();
for (const entry of entries) {
await this.updateContent(entry.id, entry.content);
const abstract = Abstract.fromEntry(entry, url);
const old = this.getAbstract(abstract.id);
if (old) {
abstract.read = old.read;
abstract.starred = old.starred;
}
this.updateAbstract(abstract.id, abstract);
id2abs.set(abstract.id, abstract);
}
for (const id of summary.catelog) {
const abstract = this.getAbstract(id);
if (abstract !== undefined && !id2abs.has(abstract.id)) {
id2abs.set(abstract.id, abstract);
}
}
summary.catelog = [...id2abs.values()]
.sort((a, b) => b.date - a.date)
.map(a => a.id);
this.updateSummary(url, summary);
}
async fetchOne(url: string, update: boolean) {
await this.fetch(url, update);
await this.commit();
}
async fetchAll(update: boolean) {
const feeds = [...walkFeedTree(this.cfg.feeds)];
await Promise.all(feeds.map(feed => this.fetch(feed, update)));
const feed_set = new Set(feeds);
for (const feed of this.getFeeds()) {
if (!feed_set.has(feed)) {
await this.removeSummary(feed);
}
}
await this.commit();
}
}
================================================
FILE: src/migrate.ts
================================================
import * as path from 'path';
import * as vscode from 'vscode';
import { join as pathJoin, isAbsolute } from 'path';
import { Summary, Entry, Abstract, Storage } from './content';
import { writeFile, readDir, checkDir, moveFile, readFile, fileExists, isDirEmpty } from './utils';
import * as uuid from 'uuid';
import * as crypto from 'crypto';
export async function checkStoragePath(context: vscode.ExtensionContext): Promise<string> {
const old = context.globalState.get<string>('root', context.globalStoragePath);
const cfg = vscode.workspace.getConfiguration('rss');
const root = cfg.get<string>('storage-path') || context.globalStoragePath;
if (old !== root) {
if (!isAbsolute(root)) {
throw Error(`"${root}" is not an absolute path`);
}
if (!await fileExists(root) || await isDirEmpty(root)) {
await vscode.window.withProgress({
location: vscode.ProgressLocation.Notification,
title: 'Moving data...',
cancellable: false
}, async () => {
await checkDir(old);
try {
await moveFile(old, root);
} catch (e: any) {
throw Error(`Move data failed: ${e.toString()}`);
}
});
} else {
const s = await vscode.window.showInformationMessage(
`Target directory "${root}" is not empty, use this directory?`,
'Yes', "Cancel"
);
if (s !== 'Yes') {
// revert the configuration
await cfg.update('storage-path', old, true);
await checkDir(old);
return old;
}
}
await context.globalState.update('root', root);
}
await checkDir(root);
return root;
}
async function getVersion(ctx: vscode.ExtensionContext, root: string) {
const path = pathJoin(root, 'version');
if (!await fileExists(path)) {
// an issue left over from history
await setVersion(root, ctx.globalState.get<string>('version', '0.0.1'));
}
return (await readFile(path)).trim();
}
async function setVersion(root: string, version: string) {
await writeFile(pathJoin(root, 'version'), version);
}
export async function migrate(context: vscode.ExtensionContext, root: string) {
const old = await getVersion(context, root);
const idx = VERSIONS.indexOf(old);
if (idx < 0) {
throw Error(`Invalid version "${old}". Current version is "${VERSIONS[VERSIONS.length - 1]}"`);
}
for (let i = idx + 1; i < VERSIONS.length; ++i) {
const v = VERSIONS[i];
if (v in alter) {
await alter[v](context, root);
}
}
await setVersion(root, VERSIONS[VERSIONS.length - 1]);
}
const VERSIONS = [
'0.0.1', '0.0.2', '0.0.3', '0.0.4', '0.0.5', '0.1.0', '0.2.0', '0.2.1',
'0.2.2', '0.3.0', '0.3.1', '0.4.0', '0.4.1', '0.5.0', '0.6.0', '0.6.1',
'0.7.0', '0.7.1', '0.7.2', '0.8.0', '0.8.1', '0.9.0', '0.9.1', '0.9.2',
'0.9.3', '0.10.0', '0.10.1', '0.10.2', '0.10.3', '0.10.4',
];
const alter: {[v: string]: (context: vscode.ExtensionContext, root: string) => Promise<void>} = {
'0.3.1': async (context, root) => {
await vscode.window.withProgress({
location: vscode.ProgressLocation.Notification,
title: 'Migrating data for the new version...',
cancellable: false
}, async () => {
const cfg = vscode.workspace.getConfiguration('rss');
await checkDir(root);
const summaries: {[url: string]: Summary} = {};
const abstracts: {[link: string]: Abstract} = {};
for (const feed of cfg.get<string[]>('feeds', [])) {
const summary = context.globalState.get<Summary>(feed);
if (summary === undefined) { continue; }
for (const link of summary.catelog) {
const entry = context.globalState.get<Entry>(link);
if (entry === undefined) { continue; }
await writeFile(path.join(root, encodeURIComponent(link)), entry.content);
abstracts[link] = Abstract.fromEntry(entry, feed);
await context.globalState.update(link, undefined);
}
summaries[feed] = summary;
await context.globalState.update(feed, undefined);
}
await context.globalState.update('summaries', summaries);
await context.globalState.update('abstracts', abstracts);
});
},
'0.4.0': async (context, root) => {
await checkDir(root);
const cfg = vscode.workspace.getConfiguration('rss');
const key = uuid.v1();
await cfg.update('accounts', {
[key]: {
name: 'Default',
type: 'local',
feeds: cfg.get('feeds', []),
}
}, true);
const summaries = context.globalState.get<{[url: string]: Summary}>('summaries', {});
const abstracts = context.globalState.get<{[link: string]: Abstract}>('abstracts', {});
await checkDir(pathJoin(root, key));
await checkDir(pathJoin(root, key, 'feeds'));
for (const url in summaries) {
const summary = summaries[url];
const json = Storage.fromSummary(url, summary, link => {
const abstract = abstracts[link];
abstract.feed = url;
return abstract;
}).toJSON();
await writeFile(pathJoin(root, key, 'feeds', encodeURIComponent(url)), json);
}
await checkDir(pathJoin(root, key, 'articles'));
const files = await readDir(root);
for (const file of files) {
if (file === key) {
continue;
}
await moveFile(pathJoin(root, file), pathJoin(root, key, 'articles', file));
}
await context.globalState.update('summaries', undefined);
await context.globalState.update('abstracts', undefined);
},
'0.7.0': async (context, root) => {
await checkDir(root);
const cfg = vscode.workspace.getConfiguration('rss');
for (const key in cfg.accounts) {
const dir = pathJoin(root, key);
await checkDir(dir);
await checkDir(pathJoin(dir, 'feeds'));
const feeds = await readDir(pathJoin(dir, 'feeds'));
for (const feed of feeds) {
const file_name = pathJoin(dir, 'feeds', feed);
const json = await readFile(file_name);
const storage = JSON.parse(json);
for (const abstract of storage.abstracts) {
if (cfg.accounts[key].type === 'local') {
abstract.id = abstract.link;
} else if (cfg.accounts[key].type === 'ttrss') {
abstract.id = abstract.custom_data;
const old = pathJoin(dir, 'articles', encodeURIComponent(abstract.link));
if (await fileExists(old)) {
await moveFile(old, pathJoin(dir, 'articles', encodeURIComponent(abstract.id)));
}
}
}
await writeFile(file_name, JSON.stringify(storage));
}
}
},
'0.7.1': async (context, root) => {
await checkDir(root);
const cfg = vscode.workspace.getConfiguration('rss');
for (const key in cfg.accounts) {
if (cfg.accounts[key].type === 'local') {
const dir = pathJoin(root, key);
await checkDir(dir);
await checkDir(pathJoin(dir, 'feeds'));
const feeds = await readDir(pathJoin(dir, 'feeds'));
for (const feed of feeds) {
const file_name = pathJoin(dir, 'feeds', feed);
const json = await readFile(file_name);
const storage = JSON.parse(json);
for (const abstract of storage.abstracts) {
const old = pathJoin(dir, 'articles', encodeURIComponent(abstract.id));
abstract.id = crypto.createHash("sha256")
.update(storage.link + abstract.id)
.digest('hex');
if (await fileExists(old)) {
await moveFile(old, pathJoin(dir, 'articles', abstract.id));
}
}
await writeFile(file_name, JSON.stringify(storage));
}
}
}
},
};
================================================
FILE: src/parser.ts
================================================
import * as parser from "fast-xml-parser";
import * as he from 'he';
import * as cheerio from 'cheerio';
import * as iconv from 'iconv-lite';
import { URL } from "url";
import { isString, isArray, isNumber } from "util";
import { Entry, Summary } from "./content";
import * as crypto from 'crypto';
import { CheerioAPI, Cheerio, Element } from "cheerio";
function isStringified(s: any) {
return isString(s) || isNumber(s);
}
function order(attr: any) {
if (!attr) {
return -2;
}
if (attr.rel === 'alternate') {
return 1;
} else if (!attr.rel) {
return 0;
} else {
return -1;
}
}
function parseLink(link: any) {
if (isArray(link) && link.length > 0) {
link = link.reduce((a, b) => order(a.__attr) > order(b.__attr) ? a : b);
}
let ans;
if (isStringified(link)) {
ans = link;
} else if (isStringified(link.__attr?.href)) {
ans = link.__attr.href;
} else if (isStringified(link.__text)) {
ans = link.__text;
} else if ('__cdata' in link) {
if (isStringified(link.__cdata)) {
ans = link.__cdata;
} else if(isArray(link.__cdata)) {
ans = link.__cdata.join('');
}
}
return ans;
}
function dom2html(name: string, node: any) {
if (isStringified(node)) {
return `<${name}>${node}</${name}>`;
}
let html = '<' + name;
if ('__attr' in node) {
for (const key in node.__attr) {
const value = node.__attr[key];
html += ` ${key}="${value}"`;
}
}
html += '>';
if (isStringified(node.__text)) {
html += node.__text;
}
for (const key in node) {
if (key.startsWith('__')) {continue;}
const value = node[key];
if (isArray(value)) {
for (const item of value) {
html += dom2html(key, item);
}
} else {
html += dom2html(key, value);
}
}
html += `</${name}>`;
return html;
}
function extractText(content: any) {
let ans;
if (isStringified(content)) {
ans = content;
} else if (isStringified(content.__text)) {
ans = content.__text;
} else if ('__cdata' in content) {
if (isStringified(content.__cdata)) {
ans = content.__cdata;
} else if(isArray(content.__cdata)) {
ans = content.__cdata.join('');
}
} else if (content.__attr?.type === 'html') {
// XXX: temporary solution. convert dom object to html string.
ans = dom2html('html', content);
}
return ans;
}
function parseEntry(dom: any, baseURL: string, exclude: Set<string>): Entry | undefined {
let link;
if (dom.link) {
link = parseLink(dom.link);
} else if (dom.source) {
link = dom.source;
}
if (isStringified(link)) {
link = new URL(link, baseURL).href;
} else {
link = undefined;
}
let id;
if (dom.id) {
id = extractText(dom.id);
} else if (dom.guid) {
id = extractText(dom.guid);
} else {
id = link;
}
if (!isStringified(id)) {
throw new Error("Feed Format Error: Entry Missing ID");
}
id = crypto.createHash("sha256").update(baseURL + id).digest('hex');
if (exclude.has(id)) {
return undefined;
}
let title;
if ('title' in dom) {
title = extractText(dom.title);
}
if (!isStringified(title)) {
throw new Error("Feed Format Error: Entry Missing Title");
}
title = he.decode(title);
let content;
if ('content' in dom) {
content = extractText(dom.content);
} else if ("content:encoded" in dom) {
content = extractText(dom["content:encoded"]);
} else if ('description' in dom) {
content = extractText(dom.description);
} else if ('summary' in dom) {
content = extractText(dom.summary);
} else {
content = title;
}
if (!isStringified(content)) {
throw new Error("Feed Format Error: Entry Missing Content");
}
content = he.decode(content);
const $ = cheerio.load(content);
$('a').each((_, ele) => {
const $ele = $(ele);
const href = $ele.attr('href');
if (href) {
try {
$ele.attr('href', new URL(href, baseURL).href);
} catch {}
}
});
$('img').each((_, ele) => {
const $ele = $(ele);
const src = $ele.attr('src');
if (src) {
try {
$ele.attr('src', new URL(src, baseURL).href);
} catch {}
}
$ele.removeAttr('height');
});
$('script').remove();
content = $.html();
let date;
if (dom.published) {
date = dom.published;
} else if (dom.pubDate) {
date = dom.pubDate;
} else if (dom.updated) {
date = dom.updated;
} else if (dom["dc:date"]) {
date = dom["dc:date"];
}
if (!isStringified(date)) {
date = new Date().getTime();
} else {
date = new Date(date).getTime();
}
if (isNaN(date)) {
throw new Error("Feed Format Error: Invalid Date");
}
return new Entry(id, title, content, date, link, false);
}
export function parseXML(xml: string, exclude: Set<string>): [Entry[], Summary] {
const match = xml.match(/<\?xml.*encoding="(\S+)".*\?>/);
xml = iconv.decode(Buffer.from(xml, 'binary'), match ? match[1]: 'utf-8');
const dom = parser.parse(xml, {
attributeNamePrefix: "",
attrNodeName: "__attr",
textNodeName: "__text",
cdataTagName: "__cdata",
cdataPositionChar: "",
ignoreAttributes: false,
parseAttributeValue: true,
});
let feed;
if (dom.rss) {
if (dom.rss.channel) {
feed = dom.rss.channel;
} else if (dom.rss.feed) {
feed = dom.rss.feed;
}
} else if (dom.channel) {
feed = dom.channel;
} else if (dom.feed) {
feed = dom.feed;
} else if (dom["rdf:RDF"]) {
feed = dom["rdf:RDF"];
}
if (!feed) {
throw new Error('Feed Format Error');
}
let title;
if ('title' in feed) {
title = extractText(feed.title);
} else if (feed.channel?.title !== undefined) {
title = extractText(feed.channel.title);
}
if (!isStringified(title)) {
throw new Error('Feed Format Error: Missing Title');
}
title = he.decode(title);
let link: any;
if (feed.link) {
link = parseLink(feed.link);
} else if (feed.channel?.link) {
link = parseLink(feed.channel.link);
}
if (!isStringified(link)) {
throw new Error('Feed Format Error: Missing Link');
}
if (!link.match(/^https?:\/\//)) {
if (link.match(/^\/\//)) {
link = 'http:' + link;
} else {
link = 'http://' + link;
}
}
let items: any;
if (feed.item) {
items = feed.item;
} else if (feed.entry) {
items = feed.entry;
}
if (!items) {
items = [];
} else if (!isArray(items)) {
items = [items];
}
const entries: Entry[] = [];
for (const item of items) {
const entry = parseEntry(item, link, exclude);
if (entry) {
entries.push(entry);
}
}
const summary = new Summary(link, title);
return [entries, summary];
}
function getLink($link: Cheerio<Element>): string {
let target = '';
$link.each((_, ele) => {
const $ele = cheerio.default(ele);
if (!target || $ele.attr('rel') === 'alternate') {
target = $ele.attr('href') || $ele.text();
}
});
return target;
}
function resolveAttr($: CheerioAPI, base: string, selector: string, attr: string) {
$(selector).each((_, ele) => {
const $ele = $(ele);
const url = $ele.attr(attr);
if (url) {
try {
$ele.attr(attr, new URL(url, base).href);
} catch {}
}
});
}
function resolveRelativeLinks(content: string, base: string): string {
const $ = cheerio.load(content);
resolveAttr($, base, 'a', 'href');
resolveAttr($, base, 'img', 'src');
resolveAttr($, base, 'video', 'src');
resolveAttr($, base, 'audio', 'src');
$('script').remove();
return $.html();
}
// https://www.rssboard.org/rss-2-0
function parseRSS($dom: CheerioAPI): [Entry[], Summary] {
const title = $dom('channel > title').text();
const base = getLink($dom('channel > link'));
const summary = new Summary(base, title);
const entries: Entry[] = [];
$dom('channel > item').each((_, ele) => {
const $ele = $dom(ele);
let id = $ele.find('guid').text();
let title = $ele.find('title').text();
let description = $ele.find('description').text();
let content = $ele.find('content').text() || $ele.find('content\\:encoded').text();
let date: string | number = $ele.find('pubDate').text();
let link = getLink($ele.find('link'));
id = id || link;
title = title || description || content;
content = content || description || title;
date = date ? new Date(date).getTime() : new Date().getTime();
if (!id) {
throw new Error('Feed Format Error: Entry Missing ID');
}
id = crypto.createHash("sha256").update(base + id).digest('hex');
content = resolveRelativeLinks(content, base);
entries.push(new Entry(id, title, content, date, link, false));
});
return [entries, summary];
}
// https://validator.w3.org/feed/docs/rss1.html
function parseRDF($dom: CheerioAPI): [Entry[], Summary] {
const title = $dom('channel > title').text();
const base = getLink($dom('channel > link'));
const summary = new Summary(base, title);
const entries: Entry[] = [];
$dom('rdf\\:RDF > item').each((_, ele) => {
const $ele = $dom(ele);
let title = $ele.find('title').text();
let content = $ele.find('description').text();
let date: string | number = $ele.find('dc\\:date').text();
let link = getLink($ele.find('link'));
if (!link) {
throw new Error('Feed Format Error: Entry Missing Link');
}
title = title || content;
content = content || title;
date = date ? new Date(date).getTime() : new Date().getTime();
const id = crypto.createHash("sha256").update(base + link).digest('hex');
content = resolveRelativeLinks(content, base);
entries.push(new Entry(id, title, content, date, link, false));
});
return [entries, summary];
}
// https://tools.ietf.org/html/rfc4287
function parseAtom($dom: CheerioAPI): [Entry[], Summary] {
const title = $dom('feed > title').text();
const base = getLink($dom('feed > link'));
const summary = new Summary(base, title);
const entries: Entry[] = [];
$dom('feed > entry').each((_, ele) => {
const $ele = $dom(ele);
let id = $ele.find('id').text();
let title = $ele.find('title').text();
let summary = $ele.find('summary').text();
let content = $ele.find('content').text();
let date: string | number = $ele.find('published').text();
let link = getLink($ele.find('link'));
id = id || link;
title = title || summary || content;
content = content || summary || title;
date = date ? new Date(date).getTime() : new Date().getTime();
if (!id) {
throw new Error('Feed Format Error: Entry Missing ID');
}
id = crypto.createHash("sha256").update(base + id).digest('hex');
content = resolveRelativeLinks(content, base);
entries.push(new Entry(id, title, content, date, link, false));
});
return [entries, summary];
}
export function parseXML2(xml: string): [Entry[], Summary] {
const match = xml.match(/<\?xml.*encoding="(\S+)".*\?>/);
xml = iconv.decode(Buffer.from(xml, 'binary'), match ? match[1]: 'utf-8');
const $dom = cheerio.load(xml, {xmlMode: true});
const root = $dom.root().children()[0].name;
switch (root) {
case 'rss':
return parseRSS($dom);
case 'rdf:RDF':
return parseRDF($dom);
case 'feed':
return parseAtom($dom);
default:
throw new Error('Unsupported format: ' + root);
}
}
export function parseOPML(opml: string): string[] {
const $dom = cheerio.load(opml, {xmlMode: true});
const ans: string[] = [];
$dom('outline').each((_, ele) => {
const url = $dom(ele).attr('xmlUrl');
if (url) {
ans.push(url);
}
});
return ans;
}
================================================
FILE: src/status_bar.ts
================================================
import * as vscode from 'vscode';
import { App } from './app';
import { Abstract } from './content';
export class StatusBar {
private status_bar_item = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left);
private unread_list: [string, string][] = [];
private index = 0;
private timer: NodeJS.Timeout | undefined;
private read_state: [string, Abstract] | undefined;
public init() {
App.instance.context.subscriptions.push(
vscode.commands.registerCommand('rss.read-notification', async () => {
if (!this.read_state) {
return;
}
const [account, abstract] = this.read_state;
App.instance.rss_select(account);
await App.instance.rss_read(abstract);
})
);
this.refresh();
}
public refresh() {
this.unread_list = Object.values(App.instance.collections)
.map(c => c.getArticles('<unread>').map((a): [string, string] => [c.account, a.id]))
.reduce((a, b) => a.concat(b));
if (this.timer) {
clearInterval(this.timer);
this.timer = undefined;
}
if (App.cfg.get('status-bar-notify')) {
const interval = App.cfg.get<number>('status-bar-update') || 5;
this.timer = setInterval(() => this.show(), interval * 1000);
this.show();
} else {
this.status_bar_item.hide();
}
}
private show() {
this.status_bar_item.hide();
this.read_state = undefined;
if (this.unread_list.length <= 0) {
return;
}
this.index %= this.unread_list.length;
let i = this.index;
do {
const [account, id] = this.unread_list[i];
const collection = App.instance.collections[account];
if (collection) {
const abs = collection.getAbstract(id);
if (abs && !abs.read) {
this.status_bar_item.show();
const max_len = App.cfg.get<number>('status-bar-length');
let title = abs.title;
if (max_len && title.length > max_len) {
title = title.substr(0, max_len - 3) + '...';
}
this.status_bar_item.text = '$(rss) ' + title;
this.status_bar_item.tooltip = abs.title;
this.status_bar_item.command = 'rss.read-notification',
this.read_state = [account, abs];
this.index = (i + 1) % this.unread_list.length;
break;
}
}
i = (i + 1) % this.unread_list.length;
} while (i !== this.index);
}
}
================================================
FILE: src/test/runTest.ts
================================================
import * as path from 'path';
import { runTests } from 'vscode-test';
async function main() {
try {
// The folder containing the Extension Manifest package.json
// Passed to `--extensionDevelopmentPath`
const extensionDevelopmentPath = path.resolve(__dirname, '../../');
// The path to test runner
// Passed to --extensionTestsPath
const extensionTestsPath = path.resolve(__dirname, './suite/index');
// Download VS Code, unzip it and run the integration test
await runTests({ extensionDevelopmentPath, extensionTestsPath });
} catch (err) {
console.error('Failed to run tests');
process.exit(1);
}
}
main();
================================================
FILE: src/test/suite/index.ts
================================================
import * as path from 'path';
import * as Mocha from 'mocha';
import * as glob from 'glob';
export function run(): Promise<void> {
// Create the mocha test
const mocha = new Mocha({
ui: 'tdd',
color: true
});
const testsRoot = path.resolve(__dirname, '..');
return new Promise((c, e) => {
glob('**/**.test.js', { cwd: testsRoot }, (err, files) => {
if (err) {
return e(err);
}
// Add files to the test suite
files.forEach(f => mocha.addFile(path.resolve(testsRoot, f)));
try {
// Run the mocha test
mocha.run(failures => {
if (failures > 0) {
e(new Error(`${failures} tests failed.`));
} else {
c();
}
});
} catch (err) {
console.error(err);
e(err);
}
});
});
}
================================================
FILE: src/test/suite/parser.test.ts
================================================
import * as assert from 'assert';
import * as vscode from 'vscode';
import * as parser from '../../parser';
import * as crypto from 'crypto';
function sha256(s: string) {
return crypto.createHash('sha256').update(s).digest('hex');
}
suite('test parser', () => {
test('basic', () => {
const xml = `
<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en">
<link href="https://luyuhuang.tech/"/>
<id>https://luyuhuang.tech/feed.xml</id>
<title type="html">Luyu Huang's Tech Blog</title>
<entry>
<title type="html">Title 1</title>
<link href="https://luyuhuang.tech/2020/06/03/cloudflare-free-https.html"/>
<published>2020-06-03T00:00:00+08:00</published>
<content type="html">Some Content</content>
</entry>
</feed>
`;
const [entries, summary] = parser.parseXML2(xml);
assert.equal(summary.title, "Luyu Huang's Tech Blog");
assert.equal(summary.link, 'https://luyuhuang.tech/');
assert.equal(entries.length, 1);
assert.equal(entries[0].title, 'Title 1');
assert.equal(entries[0].link, 'https://luyuhuang.tech/2020/06/03/cloudflare-free-https.html');
assert.equal(entries[0].content, '<html><head></head><body>Some Content</body></html>');
});
test('empty entry', () => {
const xml = `
<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en">
<link href="https://luyuhuang.tech/"/>
<id>https://luyuhuang.tech/feed.xml</id>
<title type="html">Luyu Huang's Tech Blog</title>
</feed>
`;
const [entries, summary] = parser.parseXML2(xml);
assert.equal(summary.title, "Luyu Huang's Tech Blog");
assert.equal(summary.link, 'https://luyuhuang.tech/');
assert.equal(entries.length, 0);
});
test('multiple entries', () => {
const xml = `
<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en">
<link href="https://luyuhuang.tech/"/>
<id>https://luyuhuang.tech/feed.xml</id>
<title type="html">Luyu Huang's Tech Blog</title>
<entry>
<title type="html">Title 1</title>
<link href="https://luyuhuang.tech/2020/06/03/cloudflare-free-https.html"/>
<published>2020-06-03T00:00:00+08:00</published>
<content type="html">Some Content</content>
</entry>
<entry>
<title type="html">Title 2</title>
<link href="https://luyuhuang.tech/2020/05/22/nginx-beginners-guide.html"/>
<published>2020-05-22T00:00:00+08:00</published>
<content type="html">Another Content</content>
</entry>
</feed>
`;
const [entries, summary] = parser.parseXML2(xml);
assert.equal(summary.title, "Luyu Huang's Tech Blog");
assert.equal(summary.link, 'https://luyuhuang.tech/');
assert.equal(entries.length, 2);
assert.equal(entries[0].title, 'Title 1');
assert.equal(entries[0].link, 'https://luyuhuang.tech/2020/06/03/cloudflare-free-https.html');
assert.equal(entries[0].content, '<html><head></head><body>Some Content</body></html>');
assert.equal(entries[1].title, 'Title 2');
assert.equal(entries[1].link, 'https://luyuhuang.tech/2020/05/22/nginx-beginners-guide.html');
assert.equal(entries[1].content, '<html><head></head><body>Another Content</body></html>');
});
test('dual links', () => {
const xml = `
<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en">
<link href="https://luyuhuang.tech/feed.xml" rel="self"/>
<link href="https://luyuhuang.tech/" rel="alternate"/>
<id>https://luyuhuang.tech/feed.xml</id>
<title type="html">Luyu Huang's Tech Blog</title>
<entry>
<title type="html">Title 1</title>
<link>https://luyuhuang.tech/</link>
<link rel="alternate">https://luyuhuang.tech/2020/06/03/cloudflare-free-https.html</link>
<published>2020-06-03T00:00:00+08:00</published>
<content type="html">Some Content</content>
</entry>
</feed>
`;
const [entries, summary] = parser.parseXML2(xml);
assert.equal(summary.title, "Luyu Huang's Tech Blog");
assert.equal(summary.link, 'https://luyuhuang.tech/');
assert.equal(entries.length, 1);
assert.equal(entries[0].title, 'Title 1');
assert.equal(entries[0].link, 'https://luyuhuang.tech/2020/06/03/cloudflare-free-https.html');
assert.equal(entries[0].content, '<html><head></head><body>Some Content</body></html>');
});
test('cdata link', () => {
const xml = `
<rss version="2.0">
<channel>
<title>Site Title</title>
<link>http://world.huanqiu.com</link>
<item>
<title><![CDATA[Title 1]]></title>
<link><![CDATA[http://world.huanqiu.com/exclusive/2020-06/16558145.html]]></link>
<description><![CDATA[Description 1]]></description>
<content><![CDATA[Content 1]]></content>
<pubDate>2020-06-18</pubDate>
</item>
</channel>
`;
const [entries, summary] = parser.parseXML2(xml);
assert.equal(summary.title, "Site Title");
assert.equal(summary.link, 'http://world.huanqiu.com');
assert.equal(entries.length, 1);
assert.equal(entries[0].title, 'Title 1');
assert.equal(entries[0].link, 'http://world.huanqiu.com/exclusive/2020-06/16558145.html');
assert.equal(entries[0].content, '<html><head></head><body>Content 1</body></html>');
});
test('content encoded', () => {
const xml = `
<rss version="2.0">
<channel>
<title>Site Title</title>
<link>http://world.huanqiu.com</link>
<item>
<title><![CDATA[Title 1]]></title>
<link><![CDATA[http://world.huanqiu.com/exclusive/2020-06/16558145.html]]></link>
<description><![CDATA[Description 1]]></description>
<content:encoded><![CDATA[Content 1]]></content:encoded>
<pubDate>2020-06-18</pubDate>
</item>
</channel>
`;
const [entries, summary] = parser.parseXML2(xml);
assert.equal(summary.title, "Site Title");
assert.equal(summary.link, 'http://world.huanqiu.com');
assert.equal(entries.length, 1);
assert.equal(entries[0].title, 'Title 1');
assert.equal(entries[0].link, 'http://world.huanqiu.com/exclusive/2020-06/16558145.html');
assert.equal(entries[0].content, '<html><head></head><body>Content 1</body></html>');
});
test('id', () => {
const xml = `
<rss version="2.0">
<channel>
<title>Site Title</title>
<link>http://world.huanqiu.com</link>
<item>
<title><![CDATA[Title 1]]></title>
<link>http://world.huanqiu.com/exclusive/2020-06/16558145.html</link>
<guid>41d2104c-3453-42d9-9aff-7c3447913a42</guid>
<description><![CDATA[Description 1]]></description>
<content><![CDATA[Content 1]]></content>
<pubDate>2020-06-18</pubDate>
</item>
</channel>
`;
const [entries, summary] = parser.parseXML2(xml);
assert.equal(summary.title, "Site Title");
assert.equal(summary.link, 'http://world.huanqiu.com');
assert.equal(entries.length, 1);
assert.equal(entries[0].title, 'Title 1');
assert.equal(entries[0].link, 'http://world.huanqiu.com/exclusive/2020-06/16558145.html');
assert.equal(entries[0].id, sha256('http://world.huanqiu.com41d2104c-3453-42d9-9aff-7c3447913a42'));
assert.equal(entries[0].content, '<html><head></head><body>Content 1</body></html>');
});
test('use link as id', () => {
const xml = `
<rss version="2.0">
<channel>
<title>Site Title</title>
<link>http://world.huanqiu.com</link>
<item>
<title><![CDATA[Title 1]]></title>
<link>http://world.huanqiu.com/exclusive/2020-06/16558145.html</link>
<description><![CDATA[Description 1]]></description>
<content><![CDATA[Content 1]]></content>
<pubDate>2020-06-18</pubDate>
</item>
</channel>
`;
const [entries, summary] = parser.parseXML2(xml);
assert.equal(summary.title, "Site Title");
assert.equal(summary.link, 'http://world.huanqiu.com');
assert.equal(entries.length, 1);
assert.equal(entries[0].title, 'Title 1');
assert.equal(entries[0].link, 'http://world.huanqiu.com/exclusive/2020-06/16558145.html');
assert.equal(entries[0].id, sha256('http://world.huanqiu.comhttp://world.huanqiu.com/exclusive/2020-06/16558145.html'));
assert.equal(entries[0].content, '<html><head></head><body>Content 1</body></html>');
});
test('atom date', () => {
const xml = `
<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en">
<link href="https://luyuhuang.tech/"/>
<id>https://luyuhuang.tech/feed.xml</id>
<title type="html">Luyu Huang's Tech Blog</title>
<entry>
<title type="html">Title 1</title>
<link href="https://luyuhuang.tech/2020/06/03/cloudflare-free-https.html"/>
<published>2020-06-03T00:00:00+08:00</published>
<content type="html">Some Content</content>
</entry>
</feed>
`;
const [entries, summary] = parser.parseXML2(xml);
assert.equal(entries[0].date, new Date('2020-06-03T00:00:00+08:00').getTime());
});
test('rss2 date', () => {
const xml = `
<rss version="2.0">
<channel>
<title>Site Title</title>
<link>http://world.huanqiu.com</link>
<item>
<title><![CDATA[Title 1]]></title>
<link><![CDATA[http://world.huanqiu.com/exclusive/2020-06/16558145.html]]></link>
<description><![CDATA[Description 1]]></description>
<content><![CDATA[Content 1]]></content>
<pubDate>2020-06-18</pubDate>
</item>
</channel>
`;
const [entries, summary] = parser.parseXML2(xml);
assert.equal(entries[0].date, new Date('2020-06-18').getTime());
});
test('missing date', () => {
const xml = `
<rss version="2.0">
<channel>
<title>Site Title</title>
<link>http://world.huanqiu.com</link>
<item>
<title><![CDATA[Title 1]]></title>
<link><![CDATA[http://world.huanqiu.com/exclusive/2020-06/16558145.html]]></link>
<description><![CDATA[Description 1]]></description>
<content><![CDATA[Content 1]]></content>
</item>
</channel>
`;
const [entries, summary] = parser.parseXML2(xml);
assert(new Date().getTime() - entries[0].date < 500);
});
});
================================================
FILE: src/ttrss_collection.ts
================================================
import * as vscode from 'vscode';
import * as cheerio from 'cheerio';
import { join as pathJoin } from 'path';
import { Summary, Abstract } from './content';
import { App } from './app';
import { writeFile, readFile, removeFile, removeDir, fileExists, got } from './utils';
import { Collection } from './collection';
export class TTRSSCollection extends Collection {
private session_id?: string;
private dirty_abstracts = new Set<string>();
private feed_tree: FeedTree = [];
get type() {
return 'ttrss';
}
protected get cfg(): TTRSSAccount {
return super.cfg as TTRSSAccount;
}
async init() {
const path = pathJoin(this.dir, 'feed_list');
if (await fileExists(path)) {
this.feed_tree = JSON.parse(await readFile(path));
}
await super.init();
}
getFeedList(): FeedTree {
if (this.feed_tree.length > 0) {
return this.feed_tree;
} else {
return super.getFeedList();
}
}
private async login() {
const cfg = this.cfg;
const res = await got({
url: cfg.server,
method: 'POST',
json: {
op: "login",
user: cfg.username,
password: cfg.password,
},
timeout: App.cfg.timeout * 1000,
retry: App.cfg.retry,
});
const response = JSON.parse(res.body);
if (response.status !== 0) {
throw Error(`Login failed: ${response.content.error}`);
}
this.session_id = response.content.session_id;
}
private async request(req: {[key: string]: any}): Promise<any> {
if (this.session_id === undefined) {
await this.login();
}
const res = await got({
url: this.cfg.server,
method: 'POST',
json: {
sid: this.session_id,
...req
},
timeout: App.cfg.timeout * 1000,
retry: App.cfg.retry
});
const response = JSON.parse(res.body);
if (response.status !== 0) {
if (response.content.error === 'NOT_LOGGED_IN') {
this.session_id = undefined;
return this.request(req);
} else {
throw Error(response.content.error);
}
}
return response;
}
private async _addFeed(feed: string, category_id: number) {
if (this.getSummary(feed) !== undefined) {
vscode.window.showInformationMessage('Feed already exists');
return;
}
const response = await this.request({
op: 'subscribeToFeed', feed_url: feed, category_id
});
await this.request({op: 'updateFeed', feed_id: response.content.status.feed_id});
await this._fetchAll(false);
App.instance.refreshLists();
}
private async selectCategory(id: number, tree: FeedTree): Promise<number | undefined> {
const categories: Category[] = [];
for (const item of tree) {
if (typeof(item) !== 'string') {
categories.push(item);
}
}
if (categories.length > 0) {
const choice = await vscode.window.showQuickPick([
'.', ...categories.map(c => c.name)
], {placeHolder: 'Select a category'});
if (choice === undefined) {
return undefined;
} else if (choice === '.') {
return id;
} else {
const caty = categories.find(c => c.name === choice)!;
return this.selectCategory(caty.custom_data, caty.list);
}
} else {
return id;
}
}
async addFeed(feed: string) {
const category_id = await this.selectCategory(0, this.feed_tree);
if (category_id === undefined) {
return;
}
await vscode.window.withProgress({
location: vscode.ProgressLocation.Notification,
title: "Updating RSS...",
cancellable: false
}, async () => {
try {
await this._addFeed(feed, category_id);
} catch (error: any) {
vscode.window.showErrorMessage('Add feed failed: ' + error.toString());
}
});
}
async _delFeed(feed: string) {
const summary = this.getSummary(feed);
if (summary === undefined) {
return;
}
await this.request({op: 'unsubscribeFeed', feed_id: summary.custom_data});
await this._fetchAll(false);
App.instance.refreshLists();
}
async delFeed(feed: string) {
await vscode.window.withProgress({
location: vscode.ProgressLocation.Notification,
title: "Updating RSS...",
cancellable: false
}, async () => {
try {
await this._delFeed(feed);
} catch (error: any) {
vscode.window.showErrorMessage('Remove feed failed: ' + error.toString());
}
});
}
async addToFavorites(id: string) {
const abstract = this.getAbstract(id);
if (!abstract) {
return;
}
abstract.starred = true;
this.updateAbstract(id, abstract);
await this.commit();
this.request({
op: "updateArticle",
article_ids: `${abstract.custom_data}`,
field: 0,
mode: 1,
}).catch(error => {
vscode.window.showErrorMessage('Add favorite failed: ' + error.toString());
});
}
async removeFromFavorites(id: string) {
const abstract = this.getAbstract(id);
if (!abstract) {
return;
}
abstract.starred = false;
this.updateAbstract(id, abstract);
await this.commit();
this.request({
op: "updateArticle",
article_ids: `${abstract.custom_data}`,
field: 0,
mode: 0,
}).catch(error => {
vscode.window.showErrorMessage('Remove favorite failed: ' + error.toString());
});
}
private async fetch(url: string, update: boolean) {
const summary = this.getSummary(url);
if (summary === undefined || summary.custom_data === undefined) {
throw Error('Feed dose not exist');
}
if (!update && summary.ok) {
return;
}
const response = await this.request({
op: 'getHeadlines',
feed_id: summary.custom_data,
view_mode: App.cfg.get('fetch-unread-only') ? 'unread': 'all_articles',
});
const headlines = response.content as any[];
const abstracts: Abstract[] = [];
const ids = new Set<string>();
for (const h of headlines) {
const abstract = new Abstract(h.id, h.title, h.updated * 1000, h.link,
!h.unread, url, h.marked, h.id);
abstracts.push(abstract);
ids.add(abstract.id);
this.updateAbstract(abstract.id, abstract);
}
for (const id of summary.catelog) {
if (!ids.has(id)) {
const abstract = this.getAbstract(id);
if (abstract) {
if (!abstract.read) {
abstract.read = true;
this.updateAbstract(abstract.id, abstract);
}
abstracts.push(abstract);
}
}
}
abstracts.sort((a, b) => b.date - a.date);
summary.catelog = abstracts.map(a => a.id);
this.updateSummary(url, summary);
}
async getContent(id: string) {
if (!await fileExists(pathJoin(this.dir, 'articles', id.toString()))) {
return await vscode.window.withProgress({
location: vscode.ProgressLocation.Notification,
title: "Fetching content...",
cancellable: false
}, async () => {
try {
const abstract = this.getAbstract(id)!;
const response = await this.request({
op: 'getArticle', article_id: abstract.custom_data
});
const content = response.content[0].content;
const $ = cheerio.load(content);
$('script').remove();
const html = $.html();
await this.updateContent(id, html);
return html;
} catch (error: any) {
vscode.window.showErrorMessage('Fetch content failed: ' + error.toString());
throw error;
}
});
} else {
return await super.getContent(id);
}
}
private async _fetchAll(update: boolean) {
const res1 = await this.request({op: 'getFeedTree'});
const res2 = await this.request({op: 'getFeeds', cat_id: -3});
const list: any[] = res2.content;
const feed_map = new Map(list.map(
(feed: any): [number, string] => [feed.id, feed.feed_url]
));
const feeds = new Set(feed_map.values());
const walk = (node: any[]) => {
const list: FeedTree = [];
for (const item of node) {
if (item.type === 'category') {
if (item.bare_id < 0) {
continue;
}
const sub = walk(item.items);
if (item.bare_id === 0) {
list.push(...sub);
} else {
list.push({
name: item.name,
list: sub,
custom_data: item.bare_id
});
}
} else {
const feed = feed_map.get(item.bare_id);
if (feed === undefined) {
continue;
}
list.push(feed);
let summary = this.getSummary(feed);
if (summary) {
summary.ok = item.error.length <= 0;
summary.title = item.name;
summary.custom_data = item.bare_id;
} else {
summary = new Summary(feed, item.name, [], true, item.bare_id);
}
this.updateSummary(feed, summary);
}
}
return list;
};
this.feed_tree = walk(res1.content.categories.items);
for (const feed of this.getFeeds()) {
if (!feeds.has(feed)) {
this.updateSummary(feed, undefined);
}
}
await Promise.all(this.getFeeds().map(url => this.fetch(url, update)));
await this.commit();
}
async fetchOne(url: string, update: boolean) {
try {
if (update) {
const summary = this.getSummary(url);
if (summary === undefined || summary.custom_data === undefined) {
throw Error('Feed dose not exist');
}
await this.request({op: 'updateFeed', feed_id: summary.custom_data});
}
await this.fetch(url, update);
await this.commit();
} catch (error: any) {
vscode.window.showErrorMessage('Update feed failed: ' + error.toString());
}
}
async fetchAll(update: boolean) {
try {
await this._fetchAll(update);
} catch (error: any) {
vscode.window.showErrorMessage('Update feeds failed: ' + error.toString());
}
}
updateAbstract(id: string, abstract?: Abstract) {
this.dirty_abstracts.add(id);
return super.updateAbstract(id, abstract);
}
private async syncReadStatus(list: number[], read: boolean) {
if (list.length <= 0) {
return;
}
await this.request({
op: 'updateArticle',
article_ids: list.join(','),
mode: Number(!read),
field: 2,
});
}
async commit() {
const read_list: number[] = [];
const unread_list: number[] = [];
for (const id of this.dirty_abstracts) {
const abstract = this.getAbstract(id);
if (abstract) {
if (abstract.read) {
read_list.push(abstract.custom_data);
} else {
unread_list.push(abstract.custom_data);
}
}
}
this.dirty_abstracts.clear();
Promise.all([
this.syncReadStatus(read_list, true),
this.syncReadStatus(unread_list, false),
]).catch(error => {
vscode.window.showErrorMessage('Sync read status failed: ' + error.toString());
});
await writeFile(pathJoin(this.dir, 'feed_list'), JSON.stringify(this.feed_tree));
await super.commit();
}
}
================================================
FILE: src/utils.ts
================================================
import * as fs from 'fs';
import * as fse from 'fs-extra';
import * as tls from 'tls';
import got_ from 'got';
export const got = got_.extend({https: {certificateAuthority: [...tls.rootCertificates]}});
export function checkDir(path: string) {
return new Promise(resolve => fs.mkdir(path, resolve));
}
export function writeFile(path: string, data: string) {
return new Promise<void>((resolve, reject) => {
fs.writeFile(path, data, {encoding: 'utf-8'}, err => {
if (err) {
reject(err);
} else {
resolve();
}
});
});
}
export function readFile(path: string) {
return new Promise<string>((resolve, reject) => {
fs.readFile(path, (err, data) => {
if (err) {
reject(err);
} else {
resolve(data.toString('utf-8'));
}
});
});
}
export function moveFile(oldPath: string, newPath: string) {
return fse.move(oldPath, newPath);
}
export function readDir(path: string) {
return new Promise<string[]>((resolve, reject) => {
fs.readdir(path, (err, files) => {
if (err) {
reject(err);
} else {
resolve(files);
}
});
});
}
export function removeFile(path: string) {
return new Promise(resolve => {
fs.unlink(path, resolve);
});
}
export function removeDir(path: string) {
return fse.remove(path);
}
export function fileExists(path: string): Promise<boolean> {
return new Promise(resolve => {
fs.exists(path, resolve);
});
}
export function isDirEmpty(path: string): Promise<boolean> {
return new Promise((resolve, reject) => {
fs.readdir(path, (err, files) => {
if (err) {
reject(err);
} else {
resolve(files.length === 0);
}
});
});
}
export function TTRSSApiURL(server_url: string) {
return server_url.endsWith('/') ? server_url + 'api/' : server_url + '/api/';
}
export function *walkFeedTree(tree: FeedTree): Generator<string> {
for (const item of tree) {
if (typeof(item) === 'string') {
yield item;
} else {
for (const feed of walkFeedTree(item.list)) {
yield feed;
}
}
}
}
================================================
FILE: tsconfig.json
================================================
{
"compilerOptions": {
"module": "commonjs",
"target": "es6",
"outDir": "out",
"lib": [
"es6"
],
"sourceMap": true,
"rootDir": "src",
"strict": true /* enable all strict type-checking options */
/* Additional Checks */
// "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
// "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */
// "noUnusedParameters": true, /* Report errors on unused parameters. */
},
"exclude": [
"node_modules",
".vscode-test"
]
}
================================================
FILE: webpack.config.js
================================================
'use strict';
const path = require('path');
module.exports = {
target: 'node',
entry: './src/extension.ts',
output: {
path: path.resolve(__dirname, 'out'),
filename: 'extension.js',
libraryTarget: 'commonjs2',
devtoolModuleFilenameTemplate: '../[resource-path]',
},
devtool: 'source-map',
externals: {
vscode: 'commonjs vscode',
},
resolve: {
extensions: ['.ts', '.js'],
},
module: {
rules: [
{
test: /\.ts$/,
exclude: /node_modules/,
use: [
{
loader: 'ts-loader'
}
]
}
]
}
};
gitextract_gwihor8x/ ├── .eslintrc.json ├── .github/ │ └── workflows/ │ ├── release.yml │ └── test.yml ├── .gitignore ├── .vscode/ │ ├── extensions.json │ ├── launch.json │ ├── settings.json │ └── tasks.json ├── .vscodeignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── README_zh.md ├── package.json ├── src/ │ ├── account.ts │ ├── app.ts │ ├── articles.ts │ ├── collection.ts │ ├── config.ts │ ├── content.ts │ ├── extension.ts │ ├── favorites.ts │ ├── feeds.ts │ ├── inoreader_collection.ts │ ├── local_collection.ts │ ├── migrate.ts │ ├── parser.ts │ ├── status_bar.ts │ ├── test/ │ │ ├── runTest.ts │ │ └── suite/ │ │ ├── index.ts │ │ └── parser.test.ts │ ├── ttrss_collection.ts │ └── utils.ts ├── tsconfig.json └── webpack.config.js
SYMBOL INDEX (210 symbols across 19 files)
FILE: src/account.ts
class AccountList (line 5) | class AccountList implements vscode.TreeDataProvider<vscode.TreeItem> {
method refresh (line 9) | refresh(): void {
method getTreeItem (line 13) | getTreeItem(ele: vscode.TreeItem) {
method getChildren (line 17) | getChildren(element?: vscode.TreeItem): vscode.TreeItem[] {
class Account (line 25) | class Account extends vscode.TreeItem {
method constructor (line 28) | constructor(collection: Collection) {
FILE: src/app.ts
class App (line 18) | class App {
method constructor (line 34) | private constructor(
method initAccounts (line 39) | private async initAccounts() {
method createLocalAccount (line 78) | private async createLocalAccount(name: string) {
method createTTRSSAccount (line 88) | private async createTTRSSAccount(name: string, server: string, usernam...
method createInoreaderAccount (line 100) | private async createInoreaderAccount(name: string, appid: string, appk...
method removeAccount (line 110) | private async removeAccount(key: string) {
method init (line 123) | async init() {
method initInstance (line 127) | static async initInstance(context: vscode.ExtensionContext, root: stri...
method instance (line 132) | static get instance(): App {
method cfg (line 136) | static get cfg() {
method refreshLists (line 146) | refreshLists(list: number=0b11111) {
method currCollection (line 164) | currCollection() {
method currArticles (line 168) | currArticles() {
method currFavorites (line 175) | currFavorites() {
method initViews (line 179) | initViews() {
method initCommands (line 187) | initCommands() {
method rss_select (line 222) | rss_select(account: string) {
method rss_articles (line 228) | rss_articles(feed: string) {
method getHTML (line 233) | private getHTML(content: string, panel: vscode.WebviewPanel) {
method rss_read (line 285) | async rss_read(abstract: Abstract) {
method rss_mark_read (line 317) | async rss_mark_read(article: Article) {
method rss_mark_unread (line 325) | async rss_mark_unread(article: Article) {
method rss_mark_all_read (line 333) | async rss_mark_all_read(feed?: Feed) {
method rss_mark_account_read (line 349) | async rss_mark_account_read(account?: Account) {
method rss_refresh (line 360) | async rss_refresh(auto: boolean) {
method rss_refresh_account (line 376) | async rss_refresh_account(account?: Account) {
method rss_refresh_one (line 394) | async rss_refresh_one(feed?: Feed) {
method rss_open_website (line 414) | rss_open_website(feed: Feed) {
method rss_open_link (line 418) | rss_open_link(article: Article) {
method rss_add_feed (line 424) | async rss_add_feed() {
method rss_remove_feed (line 430) | async rss_remove_feed(feed: Feed) {
method rss_add_to_favorites (line 434) | async rss_add_to_favorites(article: Article) {
method rss_remove_from_favorites (line 439) | async rss_remove_from_favorites(item: Item) {
method rss_new_account (line 444) | async rss_new_account() {
method rss_del_account (line 483) | async rss_del_account(account: Account) {
method rss_account_rename (line 491) | async rss_account_rename(account: Account) {
method rss_account_modify (line 499) | async rss_account_modify(account: Account) {
method rss_export_to_opml (line 540) | async rss_export_to_opml(account: Account) {
method rss_import_from_opml (line 568) | async rss_import_from_opml(account: Account) {
method selectExpire (line 580) | private async selectExpire(): Promise<number|undefined> {
method rss_clean_old_articles (line 592) | async rss_clean_old_articles(feed: Feed) {
method rss_clean_all_old_articles (line 607) | async rss_clean_all_old_articles(account: Account) {
method initEvents (line 623) | initEvents() {
FILE: src/articles.ts
class ArticleList (line 5) | class ArticleList implements vscode.TreeDataProvider<Article> {
method refresh (line 9) | refresh(): void {
method getTreeItem (line 13) | getTreeItem(ele: Article): vscode.TreeItem {
method getChildren (line 17) | getChildren(element?: Article): Article[] {
class Article (line 23) | class Article extends vscode.TreeItem {
method constructor (line 24) | constructor(
FILE: src/collection.ts
method constructor (line 12) | constructor(
method init (line 17) | async init() {
method cfg (line 29) | protected get cfg(): Account {
method name (line 33) | public get name() {
method updateCfg (line 37) | protected async updateCfg() {
method getSummary (line 48) | getSummary(url: string): Summary | undefined {
method getAbstract (line 52) | getAbstract(id: string): Abstract | undefined {
method getFeedList (line 56) | getFeedList(): FeedTree {
method getArticleList (line 60) | getArticleList(): string[] {
method getFeeds (line 64) | protected getFeeds() {
method getArticles (line 68) | getArticles(feed: string): Abstract[] {
method cleanAllOldArticles (line 88) | async cleanAllOldArticles(expire: number) {
method cleanOldArticles (line 94) | async cleanOldArticles(feed: string, expire: number) {
method getFavorites (line 117) | getFavorites() {
method getContent (line 127) | async getContent(id: string) {
method updateAbstract (line 137) | updateAbstract(id: string, abstract?: Abstract) {
method updateSummary (line 151) | updateSummary(feed: string, summary?: Summary) {
method updateContent (line 161) | async updateContent(id: string, content: string | undefined) {
method removeSummary (line 170) | async removeSummary(url: string) {
method commit (line 183) | async commit() {
method clean (line 197) | async clean() {
FILE: src/config.ts
type Account (line 1) | interface Account {
type FeedTree (line 6) | type FeedTree = (string | Category)[];
type Category (line 8) | interface Category {
type LocalAccount (line 14) | interface LocalAccount extends Account {
type TTRSSAccount (line 18) | interface TTRSSAccount extends Account {
type InoreaderAccount (line 24) | interface InoreaderAccount extends Account {
FILE: src/content.ts
class Entry (line 1) | class Entry {
method constructor (line 2) | constructor(
class Abstract (line 12) | class Abstract {
method constructor (line 13) | constructor(
method fromEntry (line 24) | static fromEntry(entry: Entry, feed: string) {
class Summary (line 29) | class Summary {
method constructor (line 30) | constructor(
class Storage (line 39) | class Storage {
method constructor (line 40) | private constructor(
method fromSummary (line 49) | static fromSummary(feed: string, summary: Summary, get: (link: string)...
method fromJSON (line 55) | static fromJSON(json: string) {
method toSummary (line 60) | toSummary(set: (id: string, abstract: Abstract) => void): [string, Sum...
method toJSON (line 69) | toJSON() {
FILE: src/extension.ts
function activate (line 5) | async function activate(context: vscode.ExtensionContext) {
FILE: src/favorites.ts
class FavoritesList (line 6) | class FavoritesList implements vscode.TreeDataProvider<vscode.TreeItem> {
method refresh (line 10) | refresh(): void {
method getTreeItem (line 14) | getTreeItem(ele: vscode.TreeItem) {
method getChildren (line 18) | getChildren(element?: vscode.TreeItem): vscode.TreeItem[] {
class Item (line 26) | class Item extends Article {
method constructor (line 27) | constructor(
FILE: src/feeds.ts
class FeedList (line 5) | class FeedList implements vscode.TreeDataProvider<vscode.TreeItem> {
method refresh (line 9) | refresh(): void {
method getTreeItem (line 13) | getTreeItem(ele: vscode.TreeItem): vscode.TreeItem {
method buildTree (line 17) | private buildTree(tree: FeedTree): [vscode.TreeItem[], number] {
method getChildren (line 42) | getChildren(element?: vscode.TreeItem): vscode.TreeItem[] {
class Feed (line 59) | class Feed extends vscode.TreeItem {
method constructor (line 60) | constructor(
class Unread (line 80) | class Unread extends vscode.TreeItem {
method constructor (line 81) | constructor(unread_num: number) {
class Folder (line 89) | class Folder extends vscode.TreeItem {
method constructor (line 90) | constructor(
FILE: src/inoreader_collection.ts
type Token (line 13) | interface Token {
class InoreaderCollection (line 20) | class InoreaderCollection extends Collection {
method type (line 25) | get type(): string {
method cfg (line 29) | protected get cfg(): InoreaderAccount {
method domain (line 33) | private get domain(): string {
method init (line 37) | async init() {
method getFeedList (line 51) | getFeedList(): FeedTree {
method saveToken (line 59) | private async saveToken(token: Token) {
method authorize (line 63) | private async authorize(): Promise<Token> {
method refreshToken (line 128) | private async refreshToken(token: Token) {
method getAccessToken (line 150) | private async getAccessToken() {
method request (line 166) | private async request(cmd: string, param?: {[key: string]: any}, is_js...
method addFeed (line 189) | async addFeed(feed: string) {
method delFeed (line 213) | async delFeed(feed: string) {
method addToFavorites (line 236) | async addToFavorites(id: string) {
method removeFromFavorites (line 253) | async removeFromFavorites(id: string) {
method fetch (line 270) | private async fetch(url: string, update: boolean) {
method _fetchAll (line 327) | private async _fetchAll(update: boolean) {
method fetchAll (line 378) | async fetchAll(update: boolean) {
method fetchOne (line 386) | async fetchOne(url: string, update: boolean) {
method updateAbstract (line 395) | updateAbstract(id: string, abstract?: Abstract) {
method syncReadStatus (line 400) | private async syncReadStatus(list: string[], read: boolean) {
method commit (line 409) | async commit() {
FILE: src/local_collection.ts
class LocalCollection (line 8) | class LocalCollection extends Collection {
method type (line 11) | get type() {
method cfg (line 15) | protected get cfg(): LocalAccount {
method getFeedList (line 19) | getFeedList(): FeedTree {
method addToTree (line 23) | private async addToTree(tree: FeedTree, feed: string) {
method addFeed (line 47) | async addFeed(feed: string) {
method addFeeds (line 52) | async addFeeds(feeds: string[]) {
method deleteFromTree (line 57) | private deleteFromTree(tree: FeedTree, feed: string) {
method delFeed (line 70) | async delFeed(feed: string) {
method addToFavorites (line 75) | async addToFavorites(id: string) {
method removeFromFavorites (line 84) | async removeFromFavorites(id: string) {
method fetch (line 93) | private async fetch(url: string, update: boolean) {
method fetchOne (line 156) | async fetchOne(url: string, update: boolean) {
method fetchAll (line 161) | async fetchAll(update: boolean) {
FILE: src/migrate.ts
function checkStoragePath (line 9) | async function checkStoragePath(context: vscode.ExtensionContext): Promi...
function getVersion (line 48) | async function getVersion(ctx: vscode.ExtensionContext, root: string) {
function setVersion (line 57) | async function setVersion(root: string, version: string) {
function migrate (line 61) | async function migrate(context: vscode.ExtensionContext, root: string) {
constant VERSIONS (line 77) | const VERSIONS = [
FILE: src/parser.ts
function isStringified (line 11) | function isStringified(s: any) {
function order (line 15) | function order(attr: any) {
function parseLink (line 28) | function parseLink(link: any) {
function dom2html (line 50) | function dom2html(name: string, node: any) {
function extractText (line 82) | function extractText(content: any) {
function parseEntry (line 101) | function parseEntry(dom: any, baseURL: string, exclude: Set<string>): En...
function parseXML (line 201) | function parseXML(xml: string, exclude: Set<string>): [Entry[], Summary] {
function getLink (line 283) | function getLink($link: Cheerio<Element>): string {
function resolveAttr (line 294) | function resolveAttr($: CheerioAPI, base: string, selector: string, attr...
function resolveRelativeLinks (line 306) | function resolveRelativeLinks(content: string, base: string): string {
function parseRSS (line 317) | function parseRSS($dom: CheerioAPI): [Entry[], Summary] {
function parseRDF (line 349) | function parseRDF($dom: CheerioAPI): [Entry[], Summary] {
function parseAtom (line 378) | function parseAtom($dom: CheerioAPI): [Entry[], Summary] {
function parseXML2 (line 409) | function parseXML2(xml: string): [Entry[], Summary] {
function parseOPML (line 427) | function parseOPML(opml: string): string[] {
FILE: src/status_bar.ts
class StatusBar (line 5) | class StatusBar {
method init (line 12) | public init() {
method refresh (line 26) | public refresh() {
method show (line 44) | private show() {
FILE: src/test/runTest.ts
function main (line 5) | async function main() {
FILE: src/test/suite/index.ts
function run (line 5) | function run(): Promise<void> {
FILE: src/test/suite/parser.test.ts
function sha256 (line 6) | function sha256(s: string) {
FILE: src/ttrss_collection.ts
class TTRSSCollection (line 9) | class TTRSSCollection extends Collection {
method type (line 14) | get type() {
method cfg (line 18) | protected get cfg(): TTRSSAccount {
method init (line 22) | async init() {
method getFeedList (line 30) | getFeedList(): FeedTree {
method login (line 38) | private async login() {
method request (line 58) | private async request(req: {[key: string]: any}): Promise<any> {
method _addFeed (line 84) | private async _addFeed(feed: string, category_id: number) {
method selectCategory (line 97) | private async selectCategory(id: number, tree: FeedTree): Promise<numb...
method addFeed (line 121) | async addFeed(feed: string) {
method _delFeed (line 139) | async _delFeed(feed: string) {
method delFeed (line 149) | async delFeed(feed: string) {
method addToFavorites (line 163) | async addToFavorites(id: string) {
method removeFromFavorites (line 182) | async removeFromFavorites(id: string) {
method fetch (line 201) | private async fetch(url: string, update: boolean) {
method getContent (line 244) | async getContent(id: string) {
method _fetchAll (line 272) | private async _fetchAll(update: boolean) {
method fetchOne (line 328) | async fetchOne(url: string, update: boolean) {
method fetchAll (line 344) | async fetchAll(update: boolean) {
method updateAbstract (line 352) | updateAbstract(id: string, abstract?: Abstract) {
method syncReadStatus (line 357) | private async syncReadStatus(list: number[], read: boolean) {
method commit (line 369) | async commit() {
FILE: src/utils.ts
function checkDir (line 8) | function checkDir(path: string) {
function writeFile (line 12) | function writeFile(path: string, data: string) {
function readFile (line 24) | function readFile(path: string) {
function moveFile (line 36) | function moveFile(oldPath: string, newPath: string) {
function readDir (line 40) | function readDir(path: string) {
function removeFile (line 52) | function removeFile(path: string) {
function removeDir (line 58) | function removeDir(path: string) {
function fileExists (line 62) | function fileExists(path: string): Promise<boolean> {
function isDirEmpty (line 68) | function isDirEmpty(path: string): Promise<boolean> {
function TTRSSApiURL (line 80) | function TTRSSApiURL(server_url: string) {
Condensed preview — 35 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (156K chars).
[
{
"path": ".eslintrc.json",
"chars": 378,
"preview": "{\n \"root\": true,\n \"parser\": \"@typescript-eslint/parser\",\n \"parserOptions\": {\n \"ecmaVersion\": 6,\n "
},
{
"path": ".github/workflows/release.yml",
"chars": 425,
"preview": "name: release\n\non:\n push:\n tags:\n - 'v*'\n\njobs:\n release:\n runs-on: ubuntu-latest\n steps:\n - name: Chec"
},
{
"path": ".github/workflows/test.yml",
"chars": 506,
"preview": "name: test\n\non:\n push:\n branches: [ master ]\n pull_request:\n branches: [ master ]\n\njobs:\n build:\n\n strategy:"
},
{
"path": ".gitignore",
"chars": 38,
"preview": "out\nnode_modules\n.vscode-test/\n*.vsix\n"
},
{
"path": ".vscode/extensions.json",
"chars": 169,
"preview": "{\n\t// See http://go.microsoft.com/fwlink/?LinkId=827846\n\t// for the documentation about the extensions.json format\n\t\"rec"
},
{
"path": ".vscode/launch.json",
"chars": 992,
"preview": "// A launch configuration that compiles the extension and then opens it inside a new window\n// Use IntelliSense to learn"
},
{
"path": ".vscode/settings.json",
"chars": 499,
"preview": "// Place your settings in this file to overwrite default and user settings.\n{\n \"files.exclude\": {\n \"out\": fals"
},
{
"path": ".vscode/tasks.json",
"chars": 366,
"preview": "// See https://go.microsoft.com/fwlink/?LinkId=733558\n// for the documentation about the tasks.json format\n{\n\t\"version\":"
},
{
"path": ".vscodeignore",
"chars": 228,
"preview": ".github/**\n.vscode/**\n.vscode-test/**\nnode_modules/**\nwebpack.config.js\nout/test/**\nsrc/**\n.gitignore\n**/tsconfig.json\n*"
},
{
"path": "CHANGELOG.md",
"chars": 2916,
"preview": "# Change Log\n\n## v0.10.4 (2022-03-26)\n\n- Quick access buttons (close #47)\n- Update some dependencies\n\n## v0.10.3 (2022-0"
},
{
"path": "LICENSE",
"chars": 1067,
"preview": "MIT License\n\nCopyright (c) 2020 Luyu Huang\n\nPermission is hereby granted, free of charge, to any person obtaining a copy"
},
{
"path": "README.md",
"chars": 5088,
"preview": "# VSCode-RSS\n\nAn RSS reader embedded in Visual Studio Code\n\n[[];\n\ninterface "
},
{
"path": "src/content.ts",
"chars": 2291,
"preview": "export class Entry {\n constructor(\n public id: string,\n public title: string,\n public content: s"
},
{
"path": "src/extension.ts",
"chars": 410,
"preview": "import * as vscode from 'vscode';\nimport { checkStoragePath, migrate } from './migrate';\nimport { App } from './app';\n\ne"
},
{
"path": "src/favorites.ts",
"chars": 959,
"preview": "import * as vscode from 'vscode';\nimport { Article } from './articles';\nimport { Abstract } from './content';\nimport { A"
},
{
"path": "src/feeds.ts",
"chars": 3482,
"preview": "import * as vscode from 'vscode';\nimport { Summary } from './content';\nimport { App } from './app';\n\nexport class FeedLi"
},
{
"path": "src/inoreader_collection.ts",
"chars": 14661,
"preview": "import * as vscode from 'vscode';\nimport { Collection } from \"./collection\";\nimport { App } from \"./app\";\nimport { join "
},
{
"path": "src/local_collection.ts",
"chars": 5267,
"preview": "import * as vscode from 'vscode';\nimport { parseXML2 } from './parser';\nimport { Entry, Summary, Abstract } from './cont"
},
{
"path": "src/migrate.ts",
"chars": 8807,
"preview": "import * as path from 'path';\nimport * as vscode from 'vscode';\nimport { join as pathJoin, isAbsolute } from 'path';\nimp"
},
{
"path": "src/parser.ts",
"chars": 12768,
"preview": "import * as parser from \"fast-xml-parser\";\nimport * as he from 'he';\nimport * as cheerio from 'cheerio';\nimport * as ico"
},
{
"path": "src/status_bar.ts",
"chars": 2813,
"preview": "import * as vscode from 'vscode';\nimport { App } from './app';\nimport { Abstract } from './content';\n\nexport class Statu"
},
{
"path": "src/test/runTest.ts",
"chars": 637,
"preview": "import * as path from 'path';\n\nimport { runTests } from 'vscode-test';\n\nasync function main() {\n\ttry {\n\t\t// The folder c"
},
{
"path": "src/test/suite/index.ts",
"chars": 753,
"preview": "import * as path from 'path';\nimport * as Mocha from 'mocha';\nimport * as glob from 'glob';\n\nexport function run(): Prom"
},
{
"path": "src/test/suite/parser.test.ts",
"chars": 11108,
"preview": "import * as assert from 'assert';\nimport * as vscode from 'vscode';\nimport * as parser from '../../parser';\nimport * as "
},
{
"path": "src/ttrss_collection.ts",
"chars": 13286,
"preview": "import * as vscode from 'vscode';\nimport * as cheerio from 'cheerio';\nimport { join as pathJoin } from 'path';\nimport { "
},
{
"path": "src/utils.ts",
"chars": 2376,
"preview": "import * as fs from 'fs';\nimport * as fse from 'fs-extra';\nimport * as tls from 'tls';\nimport got_ from 'got';\n\nexport c"
},
{
"path": "tsconfig.json",
"chars": 584,
"preview": "{\n\t\"compilerOptions\": {\n\t\t\"module\": \"commonjs\",\n\t\t\"target\": \"es6\",\n\t\t\"outDir\": \"out\",\n\t\t\"lib\": [\n\t\t\t\"es6\"\n\t\t],\n\t\t\"source"
},
{
"path": "webpack.config.js",
"chars": 742,
"preview": "'use strict';\n\nconst path = require('path');\n\nmodule.exports = {\n target: 'node',\n entry: './src/extension.ts',\n "
}
]
About this extraction
This page contains the full source code of the luyuhuang/vscode-rss GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 35 files (143.0 KB), approximately 34.6k tokens, and a symbol index with 210 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.