├── .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 ├── create_app.png ├── demonstrate1.gif ├── demonstrate2.gif ├── id_and_key.png ├── logo.png ├── package-lock.json ├── package.json ├── resources ├── next.svg ├── rss.svg ├── star.svg └── web.svg ├── 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 /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "parserOptions": { 5 | "ecmaVersion": 6, 6 | "sourceType": "module" 7 | }, 8 | "plugins": [ 9 | "@typescript-eslint" 10 | ], 11 | "rules": { 12 | "@typescript-eslint/semi": "warn", 13 | "curly": "warn", 14 | "eqeqeq": "warn", 15 | "no-throw-literal": "warn", 16 | "semi": "off" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | jobs: 9 | release: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v2 14 | - name: Install Node 15 | uses: actions/setup-node@v1 16 | with: 17 | node-version: 14.x 18 | - run: npm install 19 | - run: npm install -g vsce 20 | - name: Publish 21 | run: vsce publish -p $VSCE_TOKEN 22 | env: 23 | VSCE_TOKEN: ${{ secrets.VSCE_TOKEN }} 24 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | build: 11 | 12 | strategy: 13 | matrix: 14 | os: [macos-latest, ubuntu-latest, windows-latest] 15 | 16 | runs-on: ${{ matrix.os }} 17 | 18 | steps: 19 | - name: Checkout 20 | uses: actions/checkout@v2 21 | - name: Install Node 22 | uses: actions/setup-node@v1 23 | with: 24 | node-version: 14.x 25 | - run: npm install 26 | - name: Run tests 27 | uses: GabrielBB/xvfb-action@v1.0 28 | with: 29 | run: npm test 30 | 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | out 2 | node_modules 3 | .vscode-test/ 4 | *.vsix 5 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // See http://go.microsoft.com/fwlink/?LinkId=827846 3 | // for the documentation about the extensions.json format 4 | "recommendations": [ 5 | "dbaeumer.vscode-eslint" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | // A launch configuration that compiles the extension and then opens it inside a new window 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | { 6 | "version": "0.2.0", 7 | "configurations": [ 8 | { 9 | "name": "Run Extension", 10 | "type": "extensionHost", 11 | "request": "launch", 12 | "runtimeExecutable": "${execPath}", 13 | "args": [ 14 | "--extensionDevelopmentPath=${workspaceFolder}" 15 | ], 16 | "outFiles": [ 17 | "${workspaceFolder}/out/**/*.js" 18 | ], 19 | "preLaunchTask": "npm: compile" 20 | }, 21 | { 22 | "name": "Extension Tests", 23 | "type": "extensionHost", 24 | "request": "launch", 25 | "runtimeExecutable": "${execPath}", 26 | "args": [ 27 | "--extensionDevelopmentPath=${workspaceFolder}", 28 | "--extensionTestsPath=${workspaceFolder}/out/test/suite/index" 29 | ], 30 | "outFiles": [ 31 | "${workspaceFolder}/out/test/**/*.js" 32 | ], 33 | "preLaunchTask": "npm: compile" 34 | } 35 | ] 36 | } 37 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | // Place your settings in this file to overwrite default and user settings. 2 | { 3 | "files.exclude": { 4 | "out": false // set this to true to hide the "out" folder with the compiled JS files 5 | }, 6 | "search.exclude": { 7 | "out": true // set this to false to include "out" folder in search results 8 | }, 9 | // Turn off tsc task auto detection since we have the necessary tasks as npm scripts 10 | "typescript.tsc.autoDetect": "off", 11 | "typescript.tsdk": "node_modules/typescript/lib" 12 | } 13 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | // See https://go.microsoft.com/fwlink/?LinkId=733558 2 | // for the documentation about the tasks.json format 3 | { 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "type": "npm", 8 | "script": "watch", 9 | "problemMatcher": "$tsc-watch", 10 | "isBackground": true, 11 | "presentation": { 12 | "reveal": "never" 13 | }, 14 | "group": { 15 | "kind": "build", 16 | "isDefault": true 17 | } 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /.vscodeignore: -------------------------------------------------------------------------------- 1 | .github/** 2 | .vscode/** 3 | .vscode-test/** 4 | node_modules/** 5 | webpack.config.js 6 | out/test/** 7 | src/** 8 | .gitignore 9 | **/tsconfig.json 10 | **/.eslintrc.json 11 | **/*.map 12 | **/*.ts 13 | demonstrate1.gif 14 | demonstrate2.gif 15 | create_app.png 16 | id_and_key.png 17 | yarn.lock 18 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | ## v0.10.4 (2022-03-26) 4 | 5 | - Quick access buttons (close #47) 6 | - Update some dependencies 7 | 8 | ## v0.10.3 (2022-02-02) 9 | 10 | - The limit of the number of articles fetched by Inoreader at a time is configurable(close #46) 11 | 12 | ## v0.10.2 (2021-09-20) 13 | 14 | - Resolve reletive URLs of audio and video 15 | 16 | ## v0.10.1 (2021-06-12) 17 | 18 | - Local accounts always update old articles(close #38) 19 | 20 | ## v0.10.0 (2021-03-16) 21 | 22 | - New RSS parser based on cheerio 23 | 24 | ## v0.9.3 (2021-02-14) 25 | 26 | Happy Valentine's Day 27 | 28 | - Fix parsing opml error(close #28) 29 | - Compatible with some format(close #29) 30 | 31 | ## v0.9.2 (2021-01-15) 32 | 33 | - Using Etag to improve update efficiency 34 | - The domain of Inoreader is configurable 35 | 36 | ## v0.9.1 (2020-11-19) 37 | 38 | - fix activating extension error(#23) 39 | 40 | ## v0.9.0 (2020-11-14) 41 | 42 | - Export / import from OPML(close #22) 43 | - Clean old articles 44 | - Data storage path is configurable(close #20 close #21) 45 | 46 | ## v0.8.1 (2020-09-24) 47 | 48 | - Fix the problem that unable sync read status when open fetch unread only 49 | 50 | ## v0.8.0 (2020-09-04) 51 | 52 | - **Support Inoreader**(close #14) 53 | - Improve some user experience 54 | 55 | ## v0.7.2 (2020-08-04) 56 | 57 | - Compatible with feeds with missing dates(close #15) 58 | - Hide commands from command palette 59 | 60 | ## v0.7.1 (2020-07-20) 61 | 62 | - Use `sha256(link + id)` as primary key to resolve key conflicts 63 | 64 | ## v0.7.0 (2020-07-19) 65 | 66 | - Use id instead of link as primary key(close #7) 67 | - Add status bar scroll notification(close #11) 68 | 69 | ## v0.6.1 (2020-06-21) 70 | 71 | - Deal with links with CDATA(close #6) 72 | 73 | ## v0.6.0 (2020-06-18) 74 | 75 | - Add shortcut floating Buttons at the right bottom 76 | - Add an option to fetch unread articles only 77 | - Fix #2 78 | 79 | ## v0.5.0 (2020-05-31) 80 | 81 | - Support category 82 | - Summary of unread articles 83 | - Favorites sync with TTRSS server 84 | - Configuration `favorites` in `rss.accounts` is now obsolete, you can remove them after updating 85 | 86 | ## v0.4.1 (2020-05-22) 87 | 88 | - Show progress when fetching content from server 89 | - Optimize updating single feed 90 | 91 | ## v0.4.0 (2020-05-20) 92 | 93 | Major update: 94 | 95 | - Support multiple accounts 96 | - **Support Tiny Tiny RSS** 97 | - Configurations `rss.feeds` and `rss.favorites` are now obsolete, you can remove them after updating 98 | 99 | ## v0.3.1 (2020-05-12) 100 | 101 | - Modify the way articles are stored 102 | - Remove redundant menus in favorites 103 | 104 | ## v0.3.0 (2020-05-08) 105 | 106 | - Support favorites 107 | - Prevent page refresh when hidden 108 | 109 | ## v0.2.2 (2020-05-06) 110 | 111 | Fix the bug that some pictures are displayed out of proportion(close #3) 112 | 113 | ## v0.2.1 (2020-05-05) 114 | 115 | Deal with different encodings 116 | 117 | ## v0.2.0 (2020-05-03) 118 | 119 | - Add or remove feeds in the UI 120 | - Optimize performance 121 | 122 | ## v0.1.0 (2020-04-16) 123 | 124 | - Fix some bugs 125 | - Optimize feed list 126 | - Add width limit 127 | 128 | ## v0.0.5 (2020-04-15) 129 | 130 | Fix a feed loading error on some pages. 131 | 132 | ## v0.0.4 (2020-04-10) 133 | 134 | Make `timeout` and `retry` configurable 135 | 136 | ## v0.0.3 (2020-04-07) 137 | 138 | Fix errors when adding feeds 139 | 140 | ## v0.0.2 (2020-04-07) 141 | 142 | Do some optimization 143 | 144 | - Add progress bar 145 | - Add timeout 146 | - Adjust font size 147 | 148 | ## v0.0.1 (2020-04-06) 149 | 150 | First release 151 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Luyu Huang 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # VSCode-RSS 2 | 3 | An RSS reader embedded in Visual Studio Code 4 | 5 | [![version](https://vsmarketplacebadge.apphb.com/version-short/luyuhuang.rss.svg)](https://marketplace.visualstudio.com/items?itemName=luyuhuang.rss) 6 | [![rating](https://vsmarketplacebadge.apphb.com/rating-short/luyuhuang.rss.svg)](https://marketplace.visualstudio.com/items?itemName=luyuhuang.rss) 7 | [![rating](https://vsmarketplacebadge.apphb.com/installs-short/luyuhuang.rss.svg)](https://marketplace.visualstudio.com/items?itemName=luyuhuang.rss) 8 | [![test](https://github.com/luyuhuang/vscode-rss/workflows/test/badge.svg)](https://github.com/luyuhuang/vscode-rss/actions/) 9 | 10 | ![demonstrate1](https://s1.ax1x.com/2020/06/18/Nmyedf.gif) 11 | 12 | [简体中文](README_zh.md) 13 | 14 | ## Introduction 15 | 16 | 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. 17 | 18 | - [x] Multiple accounts; 19 | - [x] Support Tiny Tiny RSS; 20 | - [x] Support Inoreader; 21 | - [x] Support multiple RSS formats; 22 | - [x] Automatic update; 23 | - [x] Support favorites; 24 | - [x] Scrolling notification; 25 | - [x] Read / unread marks; 26 | 27 | ## Usage 28 | 29 | ### Accounts 30 | 31 | 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. 32 | 33 | #### Local Account 34 | 35 | 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. 36 | 37 | #### TTRSS Account 38 | 39 | 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. 40 | 41 | ![demonstrate2](https://s1.ax1x.com/2020/05/20/YoIWvR.gif) 42 | 43 | #### Inoreader Account 44 | 45 | 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. 46 | 47 | 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". 48 | 49 | ![create_app](https://s1.ax1x.com/2020/09/04/wk0zdK.png) 50 | 51 | Then, you'll get your app ID and app key. 52 | 53 | ![id_and_key](https://s1.ax1x.com/2020/09/04/wkBcTK.png) 54 | 55 | 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`. 56 | 57 | ### Add Feeds 58 | 59 | 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. 60 | 61 | ## Configuration 62 | 63 | You can modify the configuration as needed. 64 | 65 | | Name | Type | Description | 66 | |:-----|:-----|:------------| 67 | | `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. | 68 | | `rss.interval` | `integer` | Automatic refresh interval (s) | 69 | | `rss.timeout` | `integer` | Request timeout (s) | 70 | | `rss.retry` | `integer` | Request retries | 71 | | `rss.fetch-unread-only` | `boolean` | Whether to fetch unread articles only, for TTRSS and Inoreader | 72 | | `rss.status-bar-notify` | `boolean` | Whether to show scrolling notification in status bar | 73 | | `rss.status-bar-update` | `integer` | Scrolling notification update interval(s) | 74 | | `rss.status-bar-length` | `integer` | Max length of notification displayed in status bar | 75 | | `rss.storage-path` | `string` | Data storage path, must be an absolute path | 76 | | `rss.inoreader-domain` | `string` | Domain of Inoreader | 77 | | `rss.inoreader-limit` | `string` | Limit of the number of articles fetched by Inoreader at a time | 78 | 79 | Enjoy it! 80 | -------------------------------------------------------------------------------- /README_zh.md: -------------------------------------------------------------------------------- 1 | # VSCode-RSS 2 | 3 | 嵌入在 Visual Studio Code 中的 RSS 阅读器 4 | 5 | [![version](https://vsmarketplacebadge.apphb.com/version-short/luyuhuang.rss.svg)](https://marketplace.visualstudio.com/items?itemName=luyuhuang.rss) 6 | [![rating](https://vsmarketplacebadge.apphb.com/rating-short/luyuhuang.rss.svg)](https://marketplace.visualstudio.com/items?itemName=luyuhuang.rss) 7 | [![rating](https://vsmarketplacebadge.apphb.com/installs-short/luyuhuang.rss.svg)](https://marketplace.visualstudio.com/items?itemName=luyuhuang.rss) 8 | [![test](https://github.com/luyuhuang/vscode-rss/workflows/test/badge.svg)](https://github.com/luyuhuang/vscode-rss/actions/) 9 | 10 | ![demonstrate1](https://s1.ax1x.com/2020/06/18/Nmyedf.gif) 11 | 12 | [English](README.md) 13 | 14 | ## 介绍 15 | 16 | VSCode-RSS 是一个 Visual Studio Code 扩展, 它提供了一个嵌入式的 RSS 阅读器. 有了它你就可以在长时间写代码之后在 VScode 中自由地阅读新闻和博客. 支持 [Tiny Tiny RSS](https://tt-rss.org/) 和 [Inoreader](https://inoreader.com), 它们可以让你在不同的设备之间同步 RSS. VSCode-RSS 很容易使用, 基本不需要手动修改配置文件. 17 | 18 | - [x] 多账户; 19 | - [x] 支持 Tiny Tiny RSS; 20 | - [x] 支持 Inoreader; 21 | - [x] 支持多种 RSS 格式; 22 | - [x] 自动更新; 23 | - [x] 支持收藏夹; 24 | - [x] 滚动通知; 25 | - [x] 阅读标记; 26 | 27 | ## 使用 28 | 29 | ### 账户 30 | 31 | VSCode-RSS 支持三种类型的账户, 本地账户, TTRSS(Tiny Tiny RSS) 账户, 和 Inoreader 账户. VSCode-RSS 默认会创建一个本地账户. 32 | 33 | #### 本地账户 34 | 35 | 对于本地账户, 它会将数据存储在本地. 点击 "ACCOUNTS" 面板上的 "+" 按钮并选择 "local" 选项, 然后输入一个账户名即可创建一个本地账户. 账户名是随意的, 仅用于显示. 36 | 37 | #### TTRSS 账户 38 | 39 | 对于 TTRSS 账户, 它会从 Tiny Tiny RSS 服务器上获取数据并且与服务器同步阅读记录, 因此它会与其他客户端 (例如你 Mac 上的 Reeder 或者是你手机上的 FeedMe) 有着同样的数据. 如果你不了解 TTRSS, 见 [https://tt-rss.org/](https://tt-rss.org/). 要创建一个 ttrss 账户, 点击 "ACCOUNTS" 面板上的 "+" 按钮并选择 "ttrss" 选项, 然后输入账户名, 服务器地址, 用户名和密码. 账户名仅用于显示, 服务器地址, 用户名和密码则取决于你的 TTRSS 服务器. 40 | 41 | ![demonstrate2](https://s1.ax1x.com/2020/05/20/YoIWvR.gif) 42 | 43 | #### Inoreader 账户 44 | 45 | 对于 Inoreader 账户, 类似于 TTRSS, 它会向 Inoreader 服务器获取和同步数据. 如果你不了解 Inoreader, 见 [https://inoreader.com](https://inoreader.com). 创建 Inoreader 账户最简单的方法就是点击创建账户按钮并选择 "inoreader", 接着输入账户名然后选择 "no" (使用默认的 app ID 和 app key). 然后, 它会提示你打开认证页面, 你只需根据提示认证你的账户即可. 一切顺利的话, 账户就创建好了. 46 | 47 | 由于 Inoreader 对单个 app 的请求数量有限制, 因此你可能需要创建并使用你自己的 app ID 和 app key. 打开你的 Inoreader 偏好设置, 点击 "其它" 中的 "开发者", 然后点击 "新应用". 任意设置一个名称并将权限范围设置为 "可读写", 然后点击 "保存". 48 | 49 | ![create_app](https://s1.ax1x.com/2020/09/04/wk0zdK.png) 50 | 51 | 然后你就能得到你的 app ID 和 app key 了. 52 | 53 | ![id_and_key](https://s1.ax1x.com/2020/09/04/wkBcTK.png) 54 | 55 | 创建一个账户, 在输入账户名后选择 "yes" 以自定义 app ID 和 app key, 然后依次输入 app ID 和 app key. 如果已经有一个账户, 则在账户列表项上右击, 选择 "Modify" 然后更改 app ID 和 app key 即可. 或者直接编辑 `setting.json` 56 | 57 | ### 添加订阅 58 | 59 | 正如本文开头所演示的, 点击 "FEEDS" 面板上的 "+" 按钮并输入订阅源的 URL 即可添加订阅. 如果是 TTRSS 或 Inoreader 账户, 它还会与服务器同步. 60 | 61 | ## 配置 62 | 63 | 如果有需要也可以手动修改配置 64 | 65 | | Name | Type | Description | 66 | |:-----|:-----|:------------| 67 | | `rss.accounts` | `object` | 订阅账户, 你可以修改 `name` 字段或者调整列表的顺序, 但是**千万不要**修改键值和 `type` 字段. | 68 | | `rss.interval` | `integer` | 自动刷新的时间间隔 (秒) | 69 | | `rss.timeout` | `integer` | 请求超时时间 (秒) | 70 | | `rss.retry` | `integer` | 请求重试次数 | 71 | | `rss.fetch-unread-only` | `boolean` | 对于 TTRSS 和 Inoreader, 是否仅获取未读文章 | 72 | | `rss.status-bar-notify` | `boolean` | 是否在状态栏显示滚动通知 | 73 | | `rss.status-bar-update` | `integer` | 滚动通知刷新间隔 (秒) | 74 | | `rss.status-bar-length` | `integer` | 状态栏中显示的通知的最大长度 | 75 | | `rss.storage-path` | `string` | 数据存储路径, 必须是绝对路径 | 76 | | `rss.inoreader-domain` | `string` | Inoreader 的域名 | 77 | | `rss.inoreader-limit` | `string` | Inoreader 单次获取文章数量的限制 | 78 | 79 | Enjoy it! 80 | -------------------------------------------------------------------------------- /create_app.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luyuhuang/vscode-rss/3182a36ac11c88747873dfdf512e9e6ea7754753/create_app.png -------------------------------------------------------------------------------- /demonstrate1.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luyuhuang/vscode-rss/3182a36ac11c88747873dfdf512e9e6ea7754753/demonstrate1.gif -------------------------------------------------------------------------------- /demonstrate2.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luyuhuang/vscode-rss/3182a36ac11c88747873dfdf512e9e6ea7754753/demonstrate2.gif -------------------------------------------------------------------------------- /id_and_key.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luyuhuang/vscode-rss/3182a36ac11c88747873dfdf512e9e6ea7754753/id_and_key.png -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luyuhuang/vscode-rss/3182a36ac11c88747873dfdf512e9e6ea7754753/logo.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rss", 3 | "displayName": "RSS", 4 | "description": "An RSS reader embedded in Visual Studio Code", 5 | "license": "MIT", 6 | "icon": "logo.png", 7 | "version": "0.10.4", 8 | "publisher": "luyuhuang", 9 | "author": "luyuhuang", 10 | "homepage": "https://github.com/luyuhuang/vscode-rss.git", 11 | "repository": { 12 | "type": "git", 13 | "url": "https://github.com/luyuhuang/vscode-rss.git" 14 | }, 15 | "engines": { 16 | "vscode": "^1.40.0" 17 | }, 18 | "categories": [ 19 | "Other" 20 | ], 21 | "keywords": [ 22 | "news", 23 | "rss", 24 | "feed", 25 | "reader" 26 | ], 27 | "activationEvents": [ 28 | "onView:rss-feeds", 29 | "onView:rss-articles" 30 | ], 31 | "main": "./out/extension.js", 32 | "contributes": { 33 | "configuration": { 34 | "title": "RSS", 35 | "properties": { 36 | "rss.accounts": { 37 | "type": "object", 38 | "default": {}, 39 | "description": "Feed accounts" 40 | }, 41 | "rss.interval": { 42 | "type": "integer", 43 | "default": 3600, 44 | "description": "Refresh interval(s)" 45 | }, 46 | "rss.timeout": { 47 | "type": "integer", 48 | "default": 15, 49 | "description": "Request timeout(s)" 50 | }, 51 | "rss.retry": { 52 | "type": "integer", 53 | "default": 1, 54 | "description": "Request retries" 55 | }, 56 | "rss.fetch-unread-only": { 57 | "type": "boolean", 58 | "default": false, 59 | "description": "Fetch unread articles only, for TTRSS and Inoreader" 60 | }, 61 | "rss.status-bar-length": { 62 | "type": "number", 63 | "default": 20, 64 | "description": "Max length displayed in status bar" 65 | }, 66 | "rss.status-bar-notify": { 67 | "type": "boolean", 68 | "default": true, 69 | "description": "Whether to show notification in status bar" 70 | }, 71 | "rss.status-bar-update": { 72 | "type": "number", 73 | "default": 5, 74 | "description": "Notification update interval(s)" 75 | }, 76 | "rss.storage-path": { 77 | "type": "string", 78 | "default": null, 79 | "description": "Data storage path" 80 | }, 81 | "rss.inoreader-domain": { 82 | "type": "string", 83 | "default": "www.inoreader.com", 84 | "description": "Domain of Inoreader" 85 | }, 86 | "rss.inoreader-limit": { 87 | "type": "integer", 88 | "default": 100, 89 | "minimum": 1, 90 | "maximum": 1000, 91 | "description": "Limit of the number of articles fetched by Inoreader at a time" 92 | } 93 | } 94 | }, 95 | "commands": [ 96 | { 97 | "command": "rss.select", 98 | "title": "Select" 99 | }, 100 | { 101 | "command": "rss.new-account", 102 | "title": "New account", 103 | "icon": "$(add)" 104 | }, 105 | { 106 | "command": "rss.del-account", 107 | "title": "Delete" 108 | }, 109 | { 110 | "command": "rss.account-rename", 111 | "title": "Rename" 112 | }, 113 | { 114 | "command": "rss.account-modify", 115 | "title": "Modify" 116 | }, 117 | { 118 | "command": "rss.articles", 119 | "title": "Articles" 120 | }, 121 | { 122 | "command": "rss.read", 123 | "title": "Read" 124 | }, 125 | { 126 | "command": "rss.read-notification", 127 | "title": "Read from notification" 128 | }, 129 | { 130 | "command": "rss.refresh", 131 | "title": "Refresh", 132 | "icon": "$(refresh)" 133 | }, 134 | { 135 | "command": "rss.refresh-account", 136 | "title": "Refresh", 137 | "icon": "$(refresh)" 138 | }, 139 | { 140 | "command": "rss.refresh-one", 141 | "title": "Refresh", 142 | "icon": "$(refresh)" 143 | }, 144 | { 145 | "command": "rss.open-website", 146 | "title": "Open website" 147 | }, 148 | { 149 | "command": "rss.open-link", 150 | "title": "Open link", 151 | "icon": "$(globe)" 152 | }, 153 | { 154 | "command": "rss.mark-read", 155 | "title": "Mark as read", 156 | "icon": "$(check)" 157 | }, 158 | { 159 | "command": "rss.mark-unread", 160 | "title": "Mark as unread" 161 | }, 162 | { 163 | "command": "rss.mark-all-read", 164 | "title": "Mark all as read", 165 | "icon": "$(check)" 166 | }, 167 | { 168 | "command": "rss.mark-account-read", 169 | "title": "Mark all as read", 170 | "icon": "$(check)" 171 | }, 172 | { 173 | "command": "rss.add-feed", 174 | "title": "Add feed", 175 | "icon": "$(add)" 176 | }, 177 | { 178 | "command": "rss.remove-feed", 179 | "title": "Remove" 180 | }, 181 | { 182 | "command": "rss.add-to-favorites", 183 | "title": "Add to favorites", 184 | "icon": "$(star-empty)" 185 | }, 186 | { 187 | "command": "rss.remove-from-favorites", 188 | "title": "Remove from favorites" 189 | }, 190 | { 191 | "command": "rss.export-to-opml", 192 | "title": "Export to OPML" 193 | }, 194 | { 195 | "command": "rss.import-from-opml", 196 | "title": "Import from OPML" 197 | }, 198 | { 199 | "command": "rss.clean-old-articles", 200 | "title": "Clean old articles" 201 | }, 202 | { 203 | "command": "rss.clean-all-old-articles", 204 | "title": "Clean old articles" 205 | } 206 | ], 207 | "viewsContainers": { 208 | "activitybar": [ 209 | { 210 | "id": "rss-reader", 211 | "title": "RSS Reader", 212 | "icon": "resources/rss.svg" 213 | } 214 | ] 215 | }, 216 | "views": { 217 | "rss-reader": [ 218 | { 219 | "id": "rss-accounts", 220 | "name": "Accounts" 221 | }, 222 | { 223 | "id": "rss-feeds", 224 | "name": "Feeds" 225 | }, 226 | { 227 | "id": "rss-articles", 228 | "name": "Articles" 229 | }, 230 | { 231 | "id": "rss-favorites", 232 | "name": "Favorites" 233 | } 234 | ] 235 | }, 236 | "menus": { 237 | "commandPalette": [ 238 | { 239 | "command": "rss.select", 240 | "when": "false" 241 | }, 242 | { 243 | "command": "rss.articles", 244 | "when": "false" 245 | }, 246 | { 247 | "command": "rss.read", 248 | "when": "false" 249 | }, 250 | { 251 | "command": "rss.mark-read", 252 | "when": "false" 253 | }, 254 | { 255 | "command": "rss.mark-unread", 256 | "when": "false" 257 | }, 258 | { 259 | "command": "rss.mark-all-read", 260 | "when": "false" 261 | }, 262 | { 263 | "command": "rss.mark-account-read", 264 | "when": "false" 265 | }, 266 | { 267 | "command": "rss.refresh", 268 | "when": "false" 269 | }, 270 | { 271 | "command": "rss.refresh-account", 272 | "when": "false" 273 | }, 274 | { 275 | "command": "rss.refresh-one", 276 | "when": "false" 277 | }, 278 | { 279 | "command": "rss.open-website", 280 | "when": "false" 281 | }, 282 | { 283 | "command": "rss.open-link", 284 | "when": "false" 285 | }, 286 | { 287 | "command": "rss.add-feed", 288 | "when": "false" 289 | }, 290 | { 291 | "command": "rss.remove-feed", 292 | "when": "false" 293 | }, 294 | { 295 | "command": "rss.add-to-favorites", 296 | "when": "false" 297 | }, 298 | { 299 | "command": "rss.remove-from-favorites", 300 | "when": "false" 301 | }, 302 | { 303 | "command": "rss.new-account", 304 | "when": "false" 305 | }, 306 | { 307 | "command": "rss.del-account", 308 | "when": "false" 309 | }, 310 | { 311 | "command": "rss.account-rename", 312 | "when": "false" 313 | }, 314 | { 315 | "command": "rss.account-modify", 316 | "when": "false" 317 | }, 318 | { 319 | "command": "rss.export-to-opml", 320 | "when": "false" 321 | }, 322 | { 323 | "command": "rss.import-from-opml", 324 | "when": "false" 325 | }, 326 | { 327 | "command": "rss.clean-old-articles", 328 | "when": "false" 329 | }, 330 | { 331 | "command": "rss.clean-all-old-articles", 332 | "when": "false" 333 | } 334 | ], 335 | "view/title": [ 336 | { 337 | "command": "rss.refresh", 338 | "when": "view == rss-accounts", 339 | "group": "navigation" 340 | }, 341 | { 342 | "command": "rss.new-account", 343 | "when": "view == rss-accounts", 344 | "group": "navigation" 345 | }, 346 | { 347 | "command": "rss.refresh-account", 348 | "when": "view == rss-feeds", 349 | "group": "navigation" 350 | }, 351 | { 352 | "command": "rss.add-feed", 353 | "when": "view == rss-feeds", 354 | "group": "navigation" 355 | }, 356 | { 357 | "command": "rss.mark-account-read", 358 | "when": "view == rss-feeds", 359 | "group": "navigation" 360 | }, 361 | { 362 | "command": "rss.refresh-one", 363 | "when": "view == rss-articles", 364 | "group": "navigation" 365 | }, 366 | { 367 | "command": "rss.mark-all-read", 368 | "when": "view == rss-articles", 369 | "group": "navigation" 370 | } 371 | ], 372 | "view/item/context": [ 373 | { 374 | "command": "rss.refresh-account", 375 | "when": "view == rss-accounts", 376 | "group": "navigation@1" 377 | }, 378 | { 379 | "command": "rss.mark-account-read", 380 | "when": "view == rss-accounts", 381 | "group": "navigation@2" 382 | }, 383 | { 384 | "command": "rss.account-rename", 385 | "when": "view == rss-accounts", 386 | "group": "navigation@3" 387 | }, 388 | { 389 | "command": "rss.account-modify", 390 | "when": "view == rss-accounts && viewItem != local", 391 | "group": "navigation@4" 392 | }, 393 | { 394 | "command": "rss.export-to-opml", 395 | "when": "view == rss-accounts && viewItem == local", 396 | "group": "navigation@5" 397 | }, 398 | { 399 | "command": "rss.import-from-opml", 400 | "when": "view == rss-accounts && viewItem == local", 401 | "group": "navigation@6" 402 | }, 403 | { 404 | "command": "rss.clean-all-old-articles", 405 | "when": "view == rss-accounts", 406 | "group": "navigation@8" 407 | }, 408 | { 409 | "command": "rss.del-account", 410 | "when": "view == rss-accounts", 411 | "group": "navigation@9" 412 | }, 413 | { 414 | "command": "rss.open-link", 415 | "when": "viewItem == article", 416 | "group": "inline" 417 | }, 418 | { 419 | "command": "rss.mark-read", 420 | "when": "view == rss-articles", 421 | "group": "inline" 422 | }, 423 | { 424 | "command": "rss.mark-unread", 425 | "when": "view == rss-articles" 426 | }, 427 | { 428 | "command": "rss.add-to-favorites", 429 | "when": "view == rss-articles", 430 | "group": "inline" 431 | }, 432 | { 433 | "command": "rss.remove-from-favorites", 434 | "when": "view == rss-favorites && viewItem == article" 435 | }, 436 | { 437 | "command": "rss.refresh-one", 438 | "when": "viewItem == feed", 439 | "group": "navigation@1" 440 | }, 441 | { 442 | "command": "rss.mark-all-read", 443 | "when": "viewItem == feed", 444 | "group": "navigation@2" 445 | }, 446 | { 447 | "command": "rss.open-website", 448 | "when": "viewItem == feed", 449 | "group": "navigation@3" 450 | }, 451 | { 452 | "command": "rss.clean-old-articles", 453 | "when": "viewItem == feed", 454 | "group": "navigation@4" 455 | }, 456 | { 457 | "command": "rss.remove-feed", 458 | "when": "viewItem == feed", 459 | "group": "navigation@5" 460 | } 461 | ] 462 | } 463 | }, 464 | "scripts": { 465 | "vscode:prepublish": "npm run compile-release", 466 | "compile-release": "rm -rf ./out && webpack --mode production", 467 | "compile": "rm -rf ./out && tsc -p ./", 468 | "lint": "eslint src --ext ts", 469 | "pretest": "npm run compile && npm run lint", 470 | "test": "node ./out/test/runTest.js" 471 | }, 472 | "devDependencies": { 473 | "@types/fs-extra": "^9.0.1", 474 | "@types/glob": "^7.1.1", 475 | "@types/he": "^1.1.0", 476 | "@types/mocha": "^9.1.0", 477 | "@types/node": "^13.11.0", 478 | "@types/uuid": "^7.0.3", 479 | "@types/vscode": "^1.40.0", 480 | "@typescript-eslint/eslint-plugin": "^4.33.0", 481 | "@typescript-eslint/parser": "^4.33.0", 482 | "eslint": "^7.32.0", 483 | "glob": "^7.1.6", 484 | "mocha": "^9.2.0", 485 | "ts-loader": "^7.0.5", 486 | "typescript": "^4.2.4", 487 | "vscode-test": "^1.3.0", 488 | "webpack": "^5.76.0", 489 | "webpack-cli": "^4.8.0" 490 | }, 491 | "dependencies": { 492 | "cheerio": "1.0.0-rc.10", 493 | "fast-xml-parser": "^4.4.1", 494 | "fs-extra": "^9.0.1", 495 | "got": "12.5.3", 496 | "he": "^1.2.0", 497 | "iconv-lite": "^0.5.1", 498 | "uuid": "^8.0.0" 499 | } 500 | } 501 | -------------------------------------------------------------------------------- /resources/next.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 19 | 21 | 43 | 45 | 46 | 48 | image/svg+xml 49 | 51 | 52 | 53 | 54 | 55 | 60 | 62 | 68 | 85 | 86 | 87 | 88 | -------------------------------------------------------------------------------- /resources/rss.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | image/svg+xml 46 | 52 | 58 | 64 | -------------------------------------------------------------------------------- /resources/star.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 19 | 21 | 43 | 45 | 46 | 48 | image/svg+xml 49 | 51 | 52 | 53 | 54 | 55 | 60 | 63 | 69 | 86 | 87 | 88 | 89 | -------------------------------------------------------------------------------- /resources/web.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 19 | 21 | 43 | 45 | 46 | 48 | image/svg+xml 49 | 51 | 52 | 53 | 54 | 55 | 60 | 63 | 69 | 70 | 71 | 75 | 81 | 84 | 90 | 97 | 105 | 106 | 107 | 108 | -------------------------------------------------------------------------------- /src/account.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { App } from './app'; 3 | import { Collection } from './collection'; 4 | 5 | export class AccountList implements vscode.TreeDataProvider { 6 | private _onDidChangeTreeData: vscode.EventEmitter = new vscode.EventEmitter(); 7 | readonly onDidChangeTreeData: vscode.Event = this._onDidChangeTreeData.event; 8 | 9 | refresh(): void { 10 | this._onDidChangeTreeData.fire(undefined); 11 | } 12 | 13 | getTreeItem(ele: vscode.TreeItem) { 14 | return ele; 15 | } 16 | 17 | getChildren(element?: vscode.TreeItem): vscode.TreeItem[] { 18 | if (element) { 19 | return []; 20 | } 21 | return Object.values(App.instance.collections).map(c => new Account(c)); 22 | } 23 | } 24 | 25 | export class Account extends vscode.TreeItem { 26 | public readonly key: string; 27 | public readonly type: string; 28 | constructor(collection: Collection) { 29 | super(collection.name); 30 | this.key = collection.account; 31 | this.type = collection.type; 32 | this.contextValue = this.type; 33 | this.command = {command: 'rss.select', title: 'select', arguments: [this.key]}; 34 | 35 | const ids = collection.getArticleList(); 36 | const unread_num = ids.length === 0 ? 0 37 | : ids.map(id => Number(!collection.getAbstract(id)?.read)) 38 | .reduce((a, b) => a + b); 39 | 40 | if (unread_num > 0) { 41 | this.label += ` (${unread_num})`; 42 | this.iconPath = new vscode.ThemeIcon('rss'); 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/app.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { Collection } from './collection'; 3 | import { LocalCollection } from './local_collection'; 4 | import { TTRSSCollection } from './ttrss_collection'; 5 | import { join as pathJoin } from 'path'; 6 | import { readFile, TTRSSApiURL, walkFeedTree, writeFile } from './utils'; 7 | import { AccountList, Account } from './account'; 8 | import { FeedList, Feed } from './feeds'; 9 | import { ArticleList, Article } from './articles'; 10 | import { FavoritesList, Item } from './favorites'; 11 | import { Abstract } from './content'; 12 | import * as uuid from 'uuid'; 13 | import { StatusBar } from './status_bar'; 14 | import { InoreaderCollection } from './inoreader_collection'; 15 | import { assert } from 'console'; 16 | import { parseOPML } from './parser'; 17 | 18 | export class App { 19 | private static _instance?: App; 20 | 21 | private current_account?: string; 22 | private current_feed?: string; 23 | private updating = false; 24 | 25 | private account_list = new AccountList(); 26 | private feed_list = new FeedList(); 27 | private article_list = new ArticleList(); 28 | private favorites_list = new FavoritesList(); 29 | 30 | private status_bar = new StatusBar(); 31 | 32 | public collections: {[key: string]: Collection} = {}; 33 | 34 | private constructor( 35 | public readonly context: vscode.ExtensionContext, 36 | public readonly root: string, 37 | ) {} 38 | 39 | private async initAccounts() { 40 | let keys = Object.keys(App.cfg.accounts); 41 | if (keys.length <= 0) { 42 | await this.createLocalAccount('Default'); 43 | keys = Object.keys(App.cfg.accounts); 44 | } 45 | for (const key of keys) { 46 | if (this.collections[key]) { 47 | continue; 48 | } 49 | const account = App.cfg.accounts[key]; 50 | const dir = pathJoin(this.root, key); 51 | let c: Collection; 52 | switch (account.type) { 53 | case 'local': 54 | c = new LocalCollection(dir, key); 55 | break; 56 | case 'ttrss': 57 | c = new TTRSSCollection(dir, key); 58 | break; 59 | case 'inoreader': 60 | c = new InoreaderCollection(dir, key); 61 | break; 62 | default: 63 | throw new Error(`Unknown account type: ${account.type}`); 64 | } 65 | await c.init(); 66 | this.collections[key] = c; 67 | } 68 | for (const key in this.collections) { 69 | if (!(key in App.cfg.accounts)) { 70 | delete this.collections[key]; 71 | } 72 | } 73 | if (this.current_account === undefined || !(this.current_account in this.collections)) { 74 | this.current_account = Object.keys(this.collections)[0]; 75 | } 76 | } 77 | 78 | private async createLocalAccount(name: string) { 79 | const accounts = App.cfg.get('accounts'); 80 | accounts[uuid.v1()] = { 81 | name: name, 82 | type: 'local', 83 | feeds: [], 84 | }; 85 | await App.cfg.update('accounts', accounts, true); 86 | } 87 | 88 | private async createTTRSSAccount(name: string, server: string, username: string, password: string) { 89 | const accounts = App.cfg.get('accounts'); 90 | accounts[uuid.v1()] = { 91 | name: name, 92 | type: 'ttrss', 93 | server, 94 | username, 95 | password, 96 | }; 97 | await App.cfg.update('accounts', accounts, true); 98 | } 99 | 100 | private async createInoreaderAccount(name: string, appid: string, appkey: string) { 101 | const accounts = App.cfg.get('accounts'); 102 | accounts[uuid.v1()] = { 103 | name: name, 104 | type: 'inoreader', 105 | appid, appkey, 106 | }; 107 | await App.cfg.update('accounts', accounts, true); 108 | } 109 | 110 | private async removeAccount(key: string) { 111 | const collection = this.collections[key]; 112 | if (collection === undefined) { 113 | return; 114 | } 115 | await collection.clean(); 116 | delete this.collections[key]; 117 | 118 | const accounts = {...App.cfg.get('accounts')}; 119 | delete accounts[key]; 120 | await App.cfg.update('accounts', accounts, true); 121 | } 122 | 123 | async init() { 124 | await this.initAccounts(); 125 | } 126 | 127 | static async initInstance(context: vscode.ExtensionContext, root: string) { 128 | App._instance = new App(context, root); 129 | await App.instance.init(); 130 | } 131 | 132 | static get instance(): App { 133 | return App._instance!; 134 | } 135 | 136 | static get cfg() { 137 | return vscode.workspace.getConfiguration('rss'); 138 | } 139 | 140 | public static readonly ACCOUNT = 1; 141 | public static readonly FEED = 1 << 1; 142 | public static readonly ARTICLE = 1 << 2; 143 | public static readonly FAVORITES = 1 << 3; 144 | public static readonly STATUS_BAR = 1 << 4; 145 | 146 | refreshLists(list: number=0b11111) { 147 | if (list & App.ACCOUNT) { 148 | this.account_list.refresh(); 149 | } 150 | if (list & App.FEED) { 151 | this.feed_list.refresh(); 152 | } 153 | if (list & App.ARTICLE) { 154 | this.article_list.refresh(); 155 | } 156 | if (list & App.FAVORITES) { 157 | this.favorites_list.refresh(); 158 | } 159 | if (list & App.STATUS_BAR) { 160 | this.status_bar.refresh(); 161 | } 162 | } 163 | 164 | currCollection() { 165 | return this.collections[this.current_account!]; 166 | } 167 | 168 | currArticles() { 169 | if (this.current_feed === undefined) { 170 | return []; 171 | } 172 | return this.currCollection().getArticles(this.current_feed); 173 | } 174 | 175 | currFavorites() { 176 | return this.currCollection().getFavorites(); 177 | } 178 | 179 | initViews() { 180 | vscode.window.registerTreeDataProvider('rss-accounts', this.account_list); 181 | vscode.window.registerTreeDataProvider('rss-feeds', this.feed_list); 182 | vscode.window.registerTreeDataProvider('rss-articles', this.article_list); 183 | vscode.window.registerTreeDataProvider('rss-favorites', this.favorites_list); 184 | this.status_bar.init(); 185 | } 186 | 187 | initCommands() { 188 | const commands: [string, (...args: any[]) => any][] = [ 189 | ['rss.select', this.rss_select], 190 | ['rss.articles', this.rss_articles], 191 | ['rss.read', this.rss_read], 192 | ['rss.mark-read', this.rss_mark_read], 193 | ['rss.mark-unread', this.rss_mark_unread], 194 | ['rss.mark-all-read', this.rss_mark_all_read], 195 | ['rss.mark-account-read', this.rss_mark_account_read], 196 | ['rss.refresh', this.rss_refresh], 197 | ['rss.refresh-account', this.rss_refresh_account], 198 | ['rss.refresh-one', this.rss_refresh_one], 199 | ['rss.open-website', this.rss_open_website], 200 | ['rss.open-link', this.rss_open_link], 201 | ['rss.add-feed', this.rss_add_feed], 202 | ['rss.remove-feed', this.rss_remove_feed], 203 | ['rss.add-to-favorites', this.rss_add_to_favorites], 204 | ['rss.remove-from-favorites', this.rss_remove_from_favorites], 205 | ['rss.new-account', this.rss_new_account], 206 | ['rss.del-account', this.rss_del_account], 207 | ['rss.account-rename', this.rss_account_rename], 208 | ['rss.account-modify', this.rss_account_modify], 209 | ['rss.export-to-opml', this.rss_export_to_opml], 210 | ['rss.import-from-opml', this.rss_import_from_opml], 211 | ['rss.clean-old-articles', this.rss_clean_old_articles], 212 | ['rss.clean-all-old-articles', this.rss_clean_all_old_articles], 213 | ]; 214 | 215 | for (const [cmd, handler] of commands) { 216 | this.context.subscriptions.push( 217 | vscode.commands.registerCommand(cmd, handler, this) 218 | ); 219 | } 220 | } 221 | 222 | rss_select(account: string) { 223 | this.current_account = account; 224 | this.current_feed = undefined; 225 | this.refreshLists(App.FEED | App.ARTICLE | App.FAVORITES); 226 | } 227 | 228 | rss_articles(feed: string) { 229 | this.current_feed = feed; 230 | this.refreshLists(App.ARTICLE); 231 | } 232 | 233 | private getHTML(content: string, panel: vscode.WebviewPanel) { 234 | const css = ''; 235 | 236 | const star_path = vscode.Uri.file(pathJoin(this.context.extensionPath, 'resources/star.svg')); 237 | const star_src = panel.webview.asWebviewUri(star_path); 238 | 239 | const web_path = vscode.Uri.file(pathJoin(this.context.extensionPath, 'resources/web.svg')); 240 | const web_src = panel.webview.asWebviewUri(web_path); 241 | 242 | let html = css + content + ` 243 | 262 | 274 | 275 | 276 | `; 277 | if (this.currCollection().getArticles('').length > 0) { 278 | const next_path = vscode.Uri.file(pathJoin(this.context.extensionPath, 'resources/next.svg')); 279 | const next_src = panel.webview.asWebviewUri(next_path); 280 | html += ``; 281 | } 282 | return html; 283 | } 284 | 285 | async rss_read(abstract: Abstract) { 286 | const content = await this.currCollection().getContent(abstract.id); 287 | const panel = vscode.window.createWebviewPanel( 288 | 'rss', abstract.title, vscode.ViewColumn.One, 289 | {retainContextWhenHidden: true, enableScripts: true}); 290 | 291 | abstract.read = true; 292 | panel.title = abstract.title; 293 | panel.webview.html = this.getHTML(content, panel); 294 | panel.webview.onDidReceiveMessage(async (e) => { 295 | if (e === 'web') { 296 | if (abstract.link) { 297 | vscode.env.openExternal(vscode.Uri.parse(abstract.link)); 298 | } 299 | } else if (e === 'star') { 300 | await this.currCollection().addToFavorites(abstract.id); 301 | this.refreshLists(App.FAVORITES); 302 | } else if (e === 'next') { 303 | const unread = this.currCollection().getArticles(''); 304 | if (unread.length > 0) { 305 | const abs = unread[0]; 306 | panel.dispose(); 307 | await this.rss_read(abs); 308 | } 309 | } 310 | }); 311 | 312 | this.refreshLists(); 313 | 314 | await this.currCollection().updateAbstract(abstract.id, abstract).commit(); 315 | } 316 | 317 | async rss_mark_read(article: Article) { 318 | const abstract = article.abstract; 319 | abstract.read = true; 320 | this.refreshLists(); 321 | 322 | await this.currCollection().updateAbstract(abstract.id, abstract).commit(); 323 | } 324 | 325 | async rss_mark_unread(article: Article) { 326 | const abstract = article.abstract; 327 | abstract.read = false; 328 | this.refreshLists(); 329 | 330 | await this.currCollection().updateAbstract(abstract.id, abstract).commit(); 331 | } 332 | 333 | async rss_mark_all_read(feed?: Feed) { 334 | let abstracts: Abstract[]; 335 | if (feed) { 336 | abstracts = this.currCollection().getArticles(feed.feed); 337 | } else { 338 | abstracts = this.currArticles(); 339 | } 340 | for (const abstract of abstracts) { 341 | abstract.read = true; 342 | this.currCollection().updateAbstract(abstract.id, abstract); 343 | } 344 | this.refreshLists(); 345 | 346 | await this.currCollection().commit(); 347 | } 348 | 349 | async rss_mark_account_read(account?: Account) { 350 | const collection = account ? 351 | this.collections[account.key] : this.currCollection(); 352 | for (const abstract of collection.getArticles('')) { 353 | abstract.read = true; 354 | collection.updateAbstract(abstract.id, abstract); 355 | } 356 | this.refreshLists(); 357 | await collection.commit(); 358 | } 359 | 360 | async rss_refresh(auto: boolean) { 361 | if (this.updating) { 362 | return; 363 | } 364 | this.updating = true; 365 | await vscode.window.withProgress({ 366 | location: auto ? vscode.ProgressLocation.Window: vscode.ProgressLocation.Notification, 367 | title: "Updating RSS...", 368 | cancellable: false 369 | }, async () => { 370 | await Promise.all(Object.values(this.collections).map(c => c.fetchAll(true))); 371 | this.refreshLists(); 372 | this.updating = false; 373 | }); 374 | } 375 | 376 | async rss_refresh_account(account?: Account) { 377 | if (this.updating) { 378 | return; 379 | } 380 | this.updating = true; 381 | await vscode.window.withProgress({ 382 | location: vscode.ProgressLocation.Notification, 383 | title: "Updating RSS...", 384 | cancellable: false 385 | }, async () => { 386 | const collection = account ? 387 | this.collections[account.key] : this.currCollection(); 388 | await collection.fetchAll(true); 389 | this.refreshLists(); 390 | this.updating = false; 391 | }); 392 | } 393 | 394 | async rss_refresh_one(feed?: Feed) { 395 | if (this.updating) { 396 | return; 397 | } 398 | const url = feed ? feed.feed : this.current_feed; 399 | if (url === undefined) { 400 | return; 401 | } 402 | this.updating = true; 403 | await vscode.window.withProgress({ 404 | location: vscode.ProgressLocation.Notification, 405 | title: "Updating RSS...", 406 | cancellable: false 407 | }, async () => { 408 | await this.currCollection().fetchOne(url, true); 409 | this.refreshLists(); 410 | this.updating = false; 411 | }); 412 | } 413 | 414 | rss_open_website(feed: Feed) { 415 | vscode.env.openExternal(vscode.Uri.parse(feed.summary.link)); 416 | } 417 | 418 | rss_open_link(article: Article) { 419 | if (article.abstract.link) { 420 | vscode.env.openExternal(vscode.Uri.parse(article.abstract.link)); 421 | } 422 | } 423 | 424 | async rss_add_feed() { 425 | const feed = await vscode.window.showInputBox({prompt: 'Enter the feed URL'}); 426 | if (feed === undefined || feed.length <= 0) {return;} 427 | await this.currCollection().addFeed(feed); 428 | } 429 | 430 | async rss_remove_feed(feed: Feed) { 431 | await this.currCollection().delFeed(feed.feed); 432 | } 433 | 434 | async rss_add_to_favorites(article: Article) { 435 | await this.currCollection().addToFavorites(article.abstract.id); 436 | this.refreshLists(App.FAVORITES); 437 | } 438 | 439 | async rss_remove_from_favorites(item: Item) { 440 | await this.currCollection().removeFromFavorites(item.abstract.id); 441 | this.refreshLists(App.FAVORITES); 442 | } 443 | 444 | async rss_new_account() { 445 | const type = await vscode.window.showQuickPick( 446 | ['local', 'ttrss', 'inoreader'], 447 | {placeHolder: "Select account type"} 448 | ); 449 | if (type === undefined) {return;} 450 | const name = await vscode.window.showInputBox({prompt: 'Enter account name', value: type}); 451 | if (name === undefined || name.length <= 0) {return;} 452 | 453 | if (type === 'local') { 454 | await this.createLocalAccount(name); 455 | } else if (type === 'ttrss') { 456 | const url = await vscode.window.showInputBox({prompt: 'Enter server URL(SELF_URL_PATH)'}); 457 | if (url === undefined || url.length <= 0) {return;} 458 | const username = await vscode.window.showInputBox({prompt: 'Enter user name'}); 459 | if (username === undefined || username.length <= 0) {return;} 460 | const password = await vscode.window.showInputBox({prompt: 'Enter password', password: true}); 461 | if (password === undefined || password.length <= 0) {return;} 462 | await this.createTTRSSAccount(name, TTRSSApiURL(url), username, password); 463 | } else if (type === 'inoreader') { 464 | const custom = await vscode.window.showQuickPick( 465 | ['no', 'yes'], 466 | {placeHolder: "Using custom app ID & app key?"} 467 | ); 468 | let appid, appkey; 469 | if (custom === 'yes') { 470 | appid = await vscode.window.showInputBox({prompt: 'Enter app ID'}); 471 | if (!appid) {return;} 472 | appkey = await vscode.window.showInputBox({prompt: 'Enter app key', password: true}); 473 | if (!appkey) {return;} 474 | } else { 475 | appid = '999999367'; 476 | appkey = 'GOgPzs1RnPTok6q8kC8HgmUPji3DjspC'; 477 | } 478 | 479 | await this.createInoreaderAccount(name, appid, appkey); 480 | } 481 | } 482 | 483 | async rss_del_account(account: Account) { 484 | const confirm = await vscode.window.showQuickPick(['no', 'yes'], {placeHolder: "Are you sure to delete?"}); 485 | if (confirm !== 'yes') { 486 | return; 487 | } 488 | await this.removeAccount(account.key); 489 | } 490 | 491 | async rss_account_rename(account: Account) { 492 | const name = await vscode.window.showInputBox({prompt: 'Enter the name'}); 493 | if (name === undefined || name.length <= 0) {return;} 494 | const accounts = App.cfg.get('accounts'); 495 | accounts[account.key].name = name; 496 | await App.cfg.update('accounts', accounts, true); 497 | } 498 | 499 | async rss_account_modify(account: Account) { 500 | const accounts = App.cfg.get('accounts'); 501 | if (account.type === 'ttrss') { 502 | const cfg = accounts[account.key] as TTRSSAccount; 503 | 504 | const url = await vscode.window.showInputBox({ 505 | prompt: 'Enter server URL(SELF_URL_PATH)', 506 | value: cfg.server.substr(0, cfg.server.length - 4) 507 | }); 508 | if (url === undefined || url.length <= 0) {return;} 509 | const username = await vscode.window.showInputBox({ 510 | prompt: 'Enter user name', value: cfg.username 511 | }); 512 | if (username === undefined || username.length <= 0) {return;} 513 | const password = await vscode.window.showInputBox({ 514 | prompt: 'Enter password', password: true, value: cfg.password 515 | }); 516 | if (password === undefined || password.length <= 0) {return;} 517 | 518 | cfg.server = TTRSSApiURL(url); 519 | cfg.username = username; 520 | cfg.password = password; 521 | } else if (account.type === 'inoreader') { 522 | const cfg = accounts[account.key] as InoreaderAccount; 523 | 524 | const appid = await vscode.window.showInputBox({ 525 | prompt: 'Enter app ID', value: cfg.appid 526 | }); 527 | if (!appid) {return;} 528 | const appkey = await vscode.window.showInputBox({ 529 | prompt: 'Enter app key', password: true, value: cfg.appkey 530 | }); 531 | if (!appkey) {return;} 532 | 533 | cfg.appid = appid; 534 | cfg.appkey = appkey; 535 | } 536 | 537 | await App.cfg.update('accounts', accounts, true); 538 | } 539 | 540 | async rss_export_to_opml(account: Account) { 541 | const collection = this.collections[account.key]; 542 | const path = await vscode.window.showSaveDialog({ 543 | defaultUri: vscode.Uri.file(collection.name + '.opml') 544 | }); 545 | if (!path) { 546 | return; 547 | } 548 | 549 | const tree = collection.getFeedList(); 550 | const outlines: string[] = []; 551 | for (const feed of walkFeedTree(tree)) { 552 | const summary = collection.getSummary(feed); 553 | if (!summary) { 554 | continue; 555 | } 556 | outlines.push(``); 557 | } 558 | 559 | const xml = `` 560 | + `` 561 | + `${collection.name}` 562 | + `${outlines.join('')}` 563 | + ``; 564 | 565 | await writeFile(path.fsPath, xml); 566 | } 567 | 568 | async rss_import_from_opml(account: Account) { 569 | const collection = this.collections[account.key] as LocalCollection; 570 | assert(collection.type === 'local'); 571 | const paths = await vscode.window.showOpenDialog({canSelectMany: false}); 572 | if (!paths) { 573 | return; 574 | } 575 | 576 | const xml = await readFile(paths[0].fsPath); 577 | await collection.addFeeds(parseOPML(xml)); 578 | } 579 | 580 | private async selectExpire(): Promise { 581 | const s = ['1 month', '2 months', '3 months', '6 months']; 582 | const t = [1 * 30, 2 * 30, 3 * 30, 6 * 30]; 583 | const time = await vscode.window.showQuickPick(s, { 584 | placeHolder: "Choose a time. Unread and favorite articles will be kept." 585 | }); 586 | if (!time) { 587 | return undefined; 588 | } 589 | return t[s.indexOf(time)] * 86400 * 1000; 590 | } 591 | 592 | async rss_clean_old_articles(feed: Feed) { 593 | const exprie = await this.selectExpire(); 594 | if (!exprie) { 595 | return; 596 | } 597 | await vscode.window.withProgress({ 598 | location: vscode.ProgressLocation.Notification, 599 | title: "Cleaning...", 600 | cancellable: false 601 | }, async () => { 602 | await this.currCollection().cleanOldArticles(feed.feed, exprie); 603 | }); 604 | this.refreshLists(App.ARTICLE | App.STATUS_BAR); 605 | } 606 | 607 | async rss_clean_all_old_articles(account: Account) { 608 | const expire = await this.selectExpire(); 609 | if (!expire) { 610 | return; 611 | } 612 | const collection = this.collections[account.key]; 613 | await vscode.window.withProgress({ 614 | location: vscode.ProgressLocation.Notification, 615 | title: "Cleaning...", 616 | cancellable: false 617 | }, async () => { 618 | await collection.cleanAllOldArticles(expire); 619 | }); 620 | this.refreshLists(App.ARTICLE | App.STATUS_BAR); 621 | } 622 | 623 | initEvents() { 624 | const do_refresh = () => vscode.commands.executeCommand('rss.refresh', true); 625 | let timer = setInterval(do_refresh, App.cfg.interval * 1000); 626 | 627 | const disposable = vscode.workspace.onDidChangeConfiguration(async (e) => { 628 | if (e.affectsConfiguration('rss.interval')) { 629 | clearInterval(timer); 630 | timer = setInterval(do_refresh, App.cfg.interval * 1000); 631 | } 632 | 633 | if (e.affectsConfiguration('rss.status-bar-notify') || e.affectsConfiguration('rss.status-bar-update')) { 634 | this.refreshLists(App.STATUS_BAR); 635 | } 636 | 637 | if (e.affectsConfiguration('rss.accounts') && !this.updating) { 638 | this.updating = true; 639 | await vscode.window.withProgress({ 640 | location: vscode.ProgressLocation.Notification, 641 | title: "Updating RSS...", 642 | cancellable: false 643 | }, async () => { 644 | await this.initAccounts(); 645 | await Promise.all(Object.values(this.collections).map(c => c.fetchAll(false))); 646 | this.refreshLists(); 647 | this.updating = false; 648 | }); 649 | } 650 | 651 | if (e.affectsConfiguration('rss.storage-path')) { 652 | const res = await vscode.window.showInformationMessage("Reload vscode to take effect", "Reload"); 653 | if (res === "Reload") { 654 | vscode.commands.executeCommand("workbench.action.reloadWindow"); 655 | } 656 | } 657 | 658 | }); 659 | this.context.subscriptions.push(disposable); 660 | } 661 | } 662 | -------------------------------------------------------------------------------- /src/articles.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { Abstract } from './content'; 3 | import { App } from './app'; 4 | 5 | export class ArticleList implements vscode.TreeDataProvider
{ 6 | private _onDidChangeTreeData: vscode.EventEmitter
= new vscode.EventEmitter
(); 7 | readonly onDidChangeTreeData: vscode.Event
= this._onDidChangeTreeData.event; 8 | 9 | refresh(): void { 10 | this._onDidChangeTreeData.fire(undefined); 11 | } 12 | 13 | getTreeItem(ele: Article): vscode.TreeItem { 14 | return ele; 15 | } 16 | 17 | getChildren(element?: Article): Article[] { 18 | if (element) {return [];} 19 | return App.instance.currArticles().map(abstract => new Article(abstract)); 20 | } 21 | } 22 | 23 | export class Article extends vscode.TreeItem { 24 | constructor( 25 | public abstract: Abstract 26 | ) { 27 | super(abstract.title); 28 | 29 | this.contextValue = "article"; 30 | this.description = new Date(abstract.date).toLocaleString(); 31 | this.command = {command: 'rss.read', title: 'Read', arguments: [abstract]}; 32 | if (!abstract.read) { 33 | this.iconPath = new vscode.ThemeIcon('circle-outline'); 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/collection.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { join as pathJoin } from 'path'; 3 | import { Summary, Abstract, Storage } from './content'; 4 | import { App } from './app'; 5 | import { checkDir, writeFile, readFile, removeFile, removeDir, readDir } from './utils'; 6 | 7 | export abstract class Collection { 8 | private summaries: {[url: string]: Summary} = {}; 9 | private abstracts: {[id: string]: Abstract} = {}; 10 | protected dirty_summaries = new Set(); 11 | 12 | constructor( 13 | protected dir: string, 14 | public readonly account: string 15 | ) {} 16 | 17 | async init() { 18 | await checkDir(this.dir); 19 | await checkDir(pathJoin(this.dir, 'feeds')); 20 | await checkDir(pathJoin(this.dir, 'articles')); 21 | const feeds = await readDir(pathJoin(this.dir, 'feeds')); 22 | for (const feed of feeds) { 23 | const json = await readFile(pathJoin(this.dir, 'feeds', feed)); 24 | const [url, summary] = Storage.fromJSON(json).toSummary((id, abstract) => {this.abstracts[id] = abstract;}); 25 | this.summaries[url] = summary; 26 | } 27 | } 28 | 29 | protected get cfg(): Account { 30 | return App.cfg.accounts[this.account]; 31 | } 32 | 33 | public get name() { 34 | return this.cfg.name; 35 | } 36 | 37 | protected async updateCfg() { 38 | const cfg = App.cfg; 39 | await cfg.update('accounts', cfg.accounts, true); 40 | } 41 | 42 | abstract get type(): string; 43 | abstract addFeed(feed: string): Promise; 44 | abstract delFeed(feed: string): Promise; 45 | abstract addToFavorites(id: string): Promise; 46 | abstract removeFromFavorites(id: string): Promise; 47 | 48 | getSummary(url: string): Summary | undefined { 49 | return this.summaries[url]; 50 | } 51 | 52 | getAbstract(id: string): Abstract | undefined { 53 | return this.abstracts[id]; 54 | } 55 | 56 | getFeedList(): FeedTree { 57 | return Object.keys(this.summaries); 58 | } 59 | 60 | getArticleList(): string[] { 61 | return Object.keys(this.abstracts); 62 | } 63 | 64 | protected getFeeds() { 65 | return Object.keys(this.summaries); 66 | } 67 | 68 | getArticles(feed: string): Abstract[] { 69 | if (feed === '') { 70 | const list = Object.values(this.abstracts).filter(a => !a.read); 71 | list.sort((a, b) => b.date - a.date); 72 | return list; 73 | } else { 74 | const summary = this.getSummary(feed); 75 | const list: Abstract[] = []; 76 | if (summary !== undefined) { 77 | for (const id of summary.catelog) { 78 | const abstract = this.getAbstract(id); 79 | if (abstract) { 80 | list.push(abstract); 81 | } 82 | } 83 | } 84 | return list; 85 | } 86 | } 87 | 88 | async cleanAllOldArticles(expire: number) { 89 | for (const feed in this.summaries) { 90 | await this.cleanOldArticles(feed, expire); 91 | } 92 | } 93 | 94 | async cleanOldArticles(feed: string, expire: number) { 95 | const summary = this.getSummary(feed); 96 | if (!summary) { 97 | return; 98 | } 99 | this.dirty_summaries.add(feed); 100 | 101 | const now = new Date().getTime(); 102 | for (let i = summary.catelog.length - 1; i >= 0; --i) { 103 | const id = summary.catelog[i]; 104 | const abs = this.getAbstract(id); 105 | if (abs && now - abs.date <= expire) { // remaining articles is not expired, break 106 | break; 107 | } 108 | if (!abs || (abs.read && !abs.starred)) { 109 | summary.catelog.splice(i, 1); 110 | delete this.abstracts[id]; 111 | await removeFile(pathJoin(this.dir, 'articles', id.toString())); 112 | } 113 | } 114 | await this.commit(); 115 | } 116 | 117 | getFavorites() { 118 | const list: Abstract[] = []; 119 | for (const abstract of Object.values(this.abstracts)) { 120 | if (abstract.starred) { 121 | list.push(abstract); 122 | } 123 | } 124 | return list; 125 | } 126 | 127 | async getContent(id: string) { 128 | const file = pathJoin(this.dir, 'articles', id.toString()); 129 | try { 130 | return await readFile(file); 131 | } catch (error: any) { 132 | vscode.window.showErrorMessage(error.toString()); 133 | throw error; 134 | } 135 | } 136 | 137 | updateAbstract(id: string, abstract?: Abstract) { 138 | if (abstract === undefined) { 139 | const old = this.getAbstract(id); 140 | if (old) { 141 | this.dirty_summaries.add(old.feed); 142 | delete this.abstracts[id]; 143 | } 144 | } else { 145 | this.dirty_summaries.add(abstract.feed); 146 | this.abstracts[id] = abstract; 147 | } 148 | return this; 149 | } 150 | 151 | updateSummary(feed: string, summary?: Summary) { 152 | if (summary === undefined) { 153 | delete this.summaries[feed]; 154 | } else { 155 | this.summaries[feed] = summary; 156 | } 157 | this.dirty_summaries.add(feed); 158 | return this; 159 | } 160 | 161 | async updateContent(id: string, content: string | undefined) { 162 | const file = pathJoin(this.dir, 'articles', id.toString()); 163 | if (content === undefined) { 164 | await removeFile(file); 165 | } else { 166 | await writeFile(file, content); 167 | } 168 | } 169 | 170 | async removeSummary(url: string) { 171 | const summary = this.summaries[url]; 172 | if (!summary) { 173 | return; 174 | } 175 | this.updateSummary(url, undefined); 176 | for (const id of summary.catelog) { 177 | this.updateAbstract(id, undefined); 178 | await this.updateContent(id, undefined); 179 | } 180 | return this; 181 | } 182 | 183 | async commit() { 184 | for (const feed of this.dirty_summaries) { 185 | const summary = this.getSummary(feed); 186 | const path = pathJoin(this.dir, 'feeds', encodeURIComponent(feed)); 187 | if (summary === undefined) { 188 | await removeFile(path); 189 | } else { 190 | const json = Storage.fromSummary(feed, summary, id => this.abstracts[id]).toJSON(); 191 | await writeFile(path, json); 192 | } 193 | } 194 | this.dirty_summaries.clear(); 195 | } 196 | 197 | async clean() { 198 | await removeDir(this.dir); 199 | } 200 | 201 | abstract fetchAll(update: boolean): Promise; 202 | abstract fetchOne(url: string, update: boolean): Promise; 203 | 204 | } 205 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | interface Account { 2 | name: string; 3 | type: 'local' | 'ttrss'; 4 | } 5 | 6 | type FeedTree = (string | Category)[]; 7 | 8 | interface Category { 9 | name: string; 10 | list: FeedTree; 11 | custom_data?: any; 12 | } 13 | 14 | interface LocalAccount extends Account { 15 | feeds: FeedTree; 16 | } 17 | 18 | interface TTRSSAccount extends Account { 19 | server: string; 20 | username: string; 21 | password: string; 22 | } 23 | 24 | interface InoreaderAccount extends Account { 25 | appid: string; 26 | appkey: string; 27 | } 28 | -------------------------------------------------------------------------------- /src/content.ts: -------------------------------------------------------------------------------- 1 | export class Entry { 2 | constructor( 3 | public id: string, 4 | public title: string, 5 | public content: string, 6 | public date: number, 7 | public link: string | undefined, 8 | public read: boolean, 9 | ) {} 10 | } 11 | 12 | export class Abstract { 13 | constructor( 14 | public readonly id: string, 15 | public title: string, 16 | public date: number, 17 | public link: string | undefined, 18 | public read: boolean, 19 | public feed: string, 20 | public starred: boolean = false, 21 | public custom_data?: any, 22 | ) {} 23 | 24 | static fromEntry(entry: Entry, feed: string) { 25 | return new Abstract(entry.id, entry.title, entry.date, entry.link, entry.read, feed); 26 | } 27 | } 28 | 29 | export class Summary { 30 | constructor( 31 | public link: string, 32 | public title: string, 33 | public catelog: string[] = [], 34 | public ok: boolean = true, 35 | public custom_data?: any, 36 | ) {} 37 | } 38 | 39 | export class Storage { 40 | private constructor( 41 | private feed: string, 42 | private link: string, 43 | private title: string, 44 | private abstracts: Abstract[], 45 | private ok: boolean = true, 46 | private custom_data?: any, 47 | ) {} 48 | 49 | static fromSummary(feed: string, summary: Summary, get: (link: string) => Abstract) { 50 | return new Storage(feed, summary.link, summary.title, 51 | summary.catelog.map(get), 52 | summary.ok, summary.custom_data); 53 | } 54 | 55 | static fromJSON(json: string) { 56 | const obj = JSON.parse(json); 57 | return new Storage(obj.feed, obj.link, obj.title, obj.abstracts, obj.ok, obj.custom_data); 58 | } 59 | 60 | toSummary(set: (id: string, abstract: Abstract) => void): [string, Summary] { 61 | const summary = new Summary(this.link, this.title, this.abstracts.map(abs => abs.id), 62 | this.ok, this.custom_data); 63 | for (const abstract of this.abstracts) { 64 | set(abstract.id, abstract); 65 | } 66 | return [this.feed, summary]; 67 | } 68 | 69 | toJSON() { 70 | return JSON.stringify({ 71 | feed: this.feed, 72 | link: this.link, 73 | title: this.title, 74 | abstracts: this.abstracts, 75 | ok: this.ok, 76 | custom_data: this.custom_data, 77 | }); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/extension.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { checkStoragePath, migrate } from './migrate'; 3 | import { App } from './app'; 4 | 5 | export async function activate(context: vscode.ExtensionContext) { 6 | const root = await checkStoragePath(context); 7 | await migrate(context, root); 8 | await App.initInstance(context, root); 9 | 10 | App.instance.initViews(); 11 | App.instance.initCommands(); 12 | App.instance.initEvents(); 13 | } 14 | -------------------------------------------------------------------------------- /src/favorites.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { Article } from './articles'; 3 | import { Abstract } from './content'; 4 | import { App } from './app'; 5 | 6 | export class FavoritesList implements vscode.TreeDataProvider { 7 | private _onDidChangeTreeData: vscode.EventEmitter
= new vscode.EventEmitter
(); 8 | readonly onDidChangeTreeData: vscode.Event
= this._onDidChangeTreeData.event; 9 | 10 | refresh(): void { 11 | this._onDidChangeTreeData.fire(undefined); 12 | } 13 | 14 | getTreeItem(ele: vscode.TreeItem) { 15 | return ele; 16 | } 17 | 18 | getChildren(element?: vscode.TreeItem): vscode.TreeItem[] { 19 | if (element) { 20 | return []; 21 | } 22 | return App.instance.currFavorites().map((a, i) => new Item(a, i)); 23 | } 24 | } 25 | 26 | export class Item extends Article { 27 | constructor( 28 | public abstract: Abstract, 29 | public index: number 30 | ) { 31 | super(abstract); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/feeds.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { Summary } from './content'; 3 | import { App } from './app'; 4 | 5 | export class FeedList implements vscode.TreeDataProvider { 6 | private _onDidChangeTreeData = new vscode.EventEmitter(); 7 | readonly onDidChangeTreeData = this._onDidChangeTreeData.event; 8 | 9 | refresh(): void { 10 | this._onDidChangeTreeData.fire(undefined); 11 | } 12 | 13 | getTreeItem(ele: vscode.TreeItem): vscode.TreeItem { 14 | return ele; 15 | } 16 | 17 | private buildTree(tree: FeedTree): [vscode.TreeItem[], number] { 18 | const collection = App.instance.currCollection(); 19 | const list: vscode.TreeItem[] = []; 20 | let unread_sum = 0; 21 | for (const item of tree) { 22 | if (typeof(item) === 'string') { 23 | const summary = collection.getSummary(item); 24 | if (summary === undefined) { 25 | continue; 26 | } 27 | const unread_num = summary.catelog.length ? summary.catelog.map((id): number => { 28 | const abstract = collection.getAbstract(id); 29 | return abstract && !abstract.read ? 1 : 0; 30 | }).reduce((a, b) => a + b) : 0; 31 | unread_sum += unread_num; 32 | list.push(new Feed(item, summary, unread_num)); 33 | } else { 34 | const [tree, unread_num] = this.buildTree(item.list); 35 | unread_sum += unread_num; 36 | list.push(new Folder(item, tree, unread_num)); 37 | } 38 | } 39 | return [list, unread_sum]; 40 | } 41 | 42 | getChildren(element?: vscode.TreeItem): vscode.TreeItem[] { 43 | if (element) { 44 | if (element instanceof Folder) { 45 | return element.list; 46 | } else { 47 | return []; 48 | } 49 | } else { 50 | const [list, unread_num] = this.buildTree(App.instance.currCollection().getFeedList()); 51 | if (unread_num > 0) { 52 | list.unshift(new Unread(unread_num)); 53 | } 54 | return list; 55 | } 56 | } 57 | } 58 | 59 | export class Feed extends vscode.TreeItem { 60 | constructor( 61 | public feed: string, 62 | public summary: Summary, 63 | unread_num: number, 64 | ) { 65 | super(summary.title); 66 | this.command = {command: 'rss.articles', title: 'articles', arguments: [feed]}; 67 | this.contextValue = 'feed'; 68 | 69 | if (unread_num > 0) { 70 | this.label += ` (${unread_num})`; 71 | } 72 | if (!summary.ok) { 73 | this.iconPath = new vscode.ThemeIcon('error'); 74 | } else if (unread_num > 0) { 75 | this.iconPath = new vscode.ThemeIcon('circle-filled'); 76 | } 77 | } 78 | } 79 | 80 | class Unread extends vscode.TreeItem { 81 | constructor(unread_num: number) { 82 | super(`You have ${unread_num} unread article${unread_num > 1 ? 's' : ''}`); 83 | this.command = {command: 'rss.articles', title: 'articles', arguments: ['']}; 84 | this.contextValue = 'unread'; 85 | this.iconPath = new vscode.ThemeIcon('bell-dot'); 86 | } 87 | } 88 | 89 | class Folder extends vscode.TreeItem { 90 | constructor( 91 | public category: Category, 92 | public list: vscode.TreeItem[], 93 | unread_num: number, 94 | ) { 95 | super(category.name, vscode.TreeItemCollapsibleState.Expanded); 96 | if (unread_num > 0) { 97 | this.label += ` (${unread_num})`; 98 | this.contextValue = 'folder'; 99 | this.iconPath = new vscode.ThemeIcon('circle-filled'); 100 | } 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/inoreader_collection.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { Collection } from "./collection"; 3 | import { App } from "./app"; 4 | import { join as pathJoin, resolve } from 'path'; 5 | import { writeFile, readFile, removeFile, fileExists, got } from './utils'; 6 | import { Summary, Abstract } from "./content"; 7 | import * as http from 'http'; 8 | import { parse as url_parse } from 'url'; 9 | import { AddressInfo } from 'net'; 10 | import { IncomingMessage, ServerResponse } from 'http'; 11 | import he = require('he'); 12 | 13 | interface Token { 14 | auth_code: string; 15 | access_token: string; 16 | refresh_token: string; 17 | expire: number; 18 | } 19 | 20 | export class InoreaderCollection extends Collection { 21 | private feed_tree: FeedTree = []; 22 | private token?: Token; 23 | private dirty_abstracts = new Set(); 24 | 25 | get type(): string { 26 | return "inoreader"; 27 | } 28 | 29 | protected get cfg(): InoreaderAccount { 30 | return super.cfg as InoreaderAccount; 31 | } 32 | 33 | private get domain(): string { 34 | return App.cfg.get('inoreader-domain')!; 35 | } 36 | 37 | async init() { 38 | const list_path = pathJoin(this.dir, 'feed_list'); 39 | if (await fileExists(list_path)) { 40 | this.feed_tree = JSON.parse(await readFile(list_path)); 41 | } 42 | 43 | const code_path = pathJoin(this.dir, 'auth_code'); 44 | if (await fileExists(code_path)) { 45 | this.token = JSON.parse(await readFile(code_path)); 46 | } 47 | 48 | await super.init(); 49 | } 50 | 51 | getFeedList(): FeedTree { 52 | if (this.feed_tree.length > 0) { 53 | return this.feed_tree; 54 | } else { 55 | return super.getFeedList(); 56 | } 57 | } 58 | 59 | private async saveToken(token: Token) { 60 | await writeFile(pathJoin(this.dir, 'auth_code'), JSON.stringify(token)); 61 | } 62 | 63 | private async authorize(): Promise { 64 | const server = http.createServer().listen(0, '127.0.0.1'); 65 | const addr = await new Promise(resolve => { 66 | server.on('listening', () => { 67 | resolve(server.address() as AddressInfo); 68 | }); 69 | }); 70 | 71 | const client_id = this.cfg.appid; 72 | const redirect_uri = encodeURIComponent(`http://127.0.0.1:${addr.port}`); 73 | const url = `https://${this.domain}/oauth2/auth?client_id=${client_id}&redirect_uri=${redirect_uri}&response_type=code&scope=read+write&state=1`; 74 | await vscode.env.openExternal(vscode.Uri.parse(url)); 75 | 76 | const auth_code = await vscode.window.withProgress({ 77 | location: vscode.ProgressLocation.Notification, 78 | title: 'Authorizing...', 79 | cancellable: true 80 | }, async (_, token) => new Promise((resolve, reject) => { 81 | const timer = setTimeout(() => { 82 | reject('Authorization Timeout'); 83 | server.close(); 84 | }, 300000); 85 | 86 | token.onCancellationRequested(() => { 87 | reject('Cancelled'); 88 | server.close(); 89 | clearInterval(timer); 90 | }); 91 | 92 | server.on('request', (req: IncomingMessage, res: ServerResponse) => { 93 | const query = url_parse(req.url!, true).query; 94 | if (query.code) { 95 | resolve(query.code as string); 96 | res.end('

Authorization Succeeded

'); 97 | server.close(); 98 | clearInterval(timer); 99 | } 100 | }); 101 | 102 | })); 103 | 104 | const res = await got({ 105 | url: `https://${this.domain}/oauth2/token`, 106 | method: 'POST', 107 | form: { 108 | code: auth_code, 109 | redirect_uri: redirect_uri, 110 | client_id: this.cfg.appid, 111 | client_secret: this.cfg.appkey, 112 | grant_type: 'authorization_code', 113 | }, 114 | throwHttpErrors: false, 115 | }); 116 | const response = JSON.parse(res.body); 117 | if (!response.refresh_token || !response.access_token || !response.expires_in) { 118 | throw Error('Get Token Fail: ' + response.error_description); 119 | } 120 | return { 121 | auth_code: auth_code, 122 | refresh_token: response.refresh_token, 123 | access_token: response.access_token, 124 | expire: new Date().getTime() + response.expires_in * 1000, 125 | }; 126 | } 127 | 128 | private async refreshToken(token: Token) { 129 | const res = await got({ 130 | url: `https://${this.domain}/oauth2/token`, 131 | method: 'POST', 132 | form: { 133 | client_id: this.cfg.appid, 134 | client_secret: this.cfg.appkey, 135 | grant_type: "refresh_token", 136 | refresh_token: token.refresh_token, 137 | }, 138 | throwHttpErrors: false, 139 | }); 140 | const response = JSON.parse(res.body); 141 | if (!response.refresh_token || !response.access_token || !response.expires_in) { 142 | return undefined; 143 | } 144 | token.refresh_token = response.refresh_token; 145 | token.access_token = response.access_token; 146 | token.expire = new Date().getTime() + response.expires_in * 1000; 147 | return token; 148 | } 149 | 150 | private async getAccessToken() { 151 | if (!this.token) { 152 | this.token = await this.authorize(); 153 | this.saveToken(this.token); 154 | } 155 | if (new Date().getTime() > this.token.expire) { 156 | this.token = await this.refreshToken(this.token); 157 | if (!this.token) { 158 | this.token = await this.authorize(); 159 | } 160 | this.saveToken(this.token); 161 | } 162 | 163 | return this.token.access_token; 164 | } 165 | 166 | private async request(cmd: string, param?: {[key: string]: any}, is_json: boolean=true): Promise { 167 | const access_token = await this.getAccessToken(); 168 | 169 | const res = await got({ 170 | url: `https://${this.domain}/reader/api/0/${cmd}`, 171 | method: 'POST', 172 | headers: {'Authorization': `Bearer ${access_token}`}, 173 | form: param, 174 | throwHttpErrors: false, 175 | timeout: App.cfg.timeout * 1000, 176 | retry: App.cfg.retry, 177 | }); 178 | if (res.statusCode !== 200) { 179 | if (res.statusCode === 401) { 180 | this.token = undefined; 181 | return await this.request(cmd, param); 182 | } else { 183 | throw Error(res.body); 184 | } 185 | } 186 | return is_json ? JSON.parse(res.body) : res.body; 187 | } 188 | 189 | async addFeed(feed: string) { 190 | if (this.getSummary(feed) !== undefined) { 191 | vscode.window.showInformationMessage('Feed already exists'); 192 | return; 193 | } 194 | await vscode.window.withProgress({ 195 | location: vscode.ProgressLocation.Notification, 196 | title: "Updating RSS...", 197 | cancellable: false 198 | }, async () => { 199 | try { 200 | const res = await this.request('subscription/quickadd', { 201 | quickadd: 'feed/' + feed, 202 | }); 203 | if (res.numResults > 0) { 204 | await this._fetchAll(false); 205 | App.instance.refreshLists(); 206 | } 207 | } catch (error: any) { 208 | vscode.window.showErrorMessage('Add feed failed: ' + error.toString()); 209 | } 210 | }); 211 | } 212 | 213 | async delFeed(feed: string) { 214 | const summary = this.getSummary(feed); 215 | if (summary === undefined) { 216 | return; 217 | } 218 | await vscode.window.withProgress({ 219 | location: vscode.ProgressLocation.Notification, 220 | title: "Updating RSS...", 221 | cancellable: false 222 | }, async () => { 223 | try { 224 | await this.request('subscription/edit', { 225 | ac: 'unsubscribe', 226 | s: summary.custom_data, 227 | }, false); 228 | await this._fetchAll(false); 229 | App.instance.refreshLists(); 230 | } catch (error: any) { 231 | vscode.window.showErrorMessage('Remove feed failed: ' + error.toString()); 232 | } 233 | }); 234 | } 235 | 236 | async addToFavorites(id: string) { 237 | const abstract = this.getAbstract(id); 238 | if (!abstract) { 239 | return; 240 | } 241 | abstract.starred = true; 242 | this.updateAbstract(id, abstract); 243 | await this.commit(); 244 | 245 | this.request('edit-tag', { 246 | a: 'user/-/state/com.google/starred', 247 | i: id, 248 | }, false).catch(error => { 249 | vscode.window.showErrorMessage('Add favorite failed: ' + error.toString()); 250 | }); 251 | } 252 | 253 | async removeFromFavorites(id: string) { 254 | const abstract = this.getAbstract(id); 255 | if (!abstract) { 256 | return; 257 | } 258 | abstract.starred = false; 259 | this.updateAbstract(id, abstract); 260 | await this.commit(); 261 | 262 | this.request('edit-tag', { 263 | r: 'user/-/state/com.google/starred', 264 | i: id, 265 | }, false).catch(error => { 266 | vscode.window.showErrorMessage('Remove favorite failed: ' + error.toString()); 267 | }); 268 | } 269 | 270 | private async fetch(url: string, update: boolean) { 271 | const summary = this.getSummary(url); 272 | if (summary === undefined || summary.custom_data === undefined) { 273 | throw Error('Feed dose not exist'); 274 | } 275 | if (!update && summary.ok) { 276 | return; 277 | } 278 | 279 | const param: {[key: string]: any} = {}; 280 | param.n = App.cfg.get('inoreader-limit'); 281 | if (App.cfg.get('fetch-unread-only')) { 282 | param.xt = 'user/-/state/com.google/read'; 283 | } 284 | 285 | const res = await this.request( 286 | 'stream/contents/' + encodeURIComponent(summary.custom_data), 287 | param 288 | ); 289 | const items = res.items as any[]; 290 | const id2abs = new Map(); 291 | for (const item of items) { 292 | let read = false, starred = false; 293 | for (const tag of item.categories as string[]) { 294 | if (tag.endsWith('state/com.google/read')) { 295 | read = true; 296 | } else if (tag.endsWith('state/com.google/starred')) { 297 | starred = true; 298 | } 299 | } 300 | const id = item.id.split('/').pop(); 301 | 302 | const abs = new Abstract(id, he.decode(item.title), item.published * 1000, 303 | item.canonical[0]?.href, read, url, starred); 304 | this.updateAbstract(id, abs); 305 | this.updateContent(id, item.summary.content); 306 | id2abs.set(id, abs); 307 | } 308 | 309 | for (const id of summary.catelog) { 310 | const abs = this.getAbstract(id); 311 | if (abs !== undefined && !id2abs.has(id)) { 312 | if (!abs.read) { 313 | abs.read = true; 314 | this.updateAbstract(id, abs); 315 | } 316 | id2abs.set(id, abs); 317 | } 318 | } 319 | 320 | summary.catelog = [...id2abs.values()] 321 | .sort((a, b) => b.date - a.date) 322 | .map(a => a.id); 323 | summary.ok = true; 324 | this.updateSummary(url, summary); 325 | } 326 | 327 | private async _fetchAll(update: boolean) { 328 | const res = await this.request('subscription/list'); 329 | const list = res.subscriptions as any[]; 330 | 331 | const feeds = new Set(); 332 | const caties = new Map(); 333 | const no_caties: FeedTree = []; 334 | for (const feed of list) { 335 | let summary = this.getSummary(feed.url); 336 | if (summary) { 337 | summary.ok = true; 338 | summary.title = feed.title; 339 | summary.custom_data = feed.id; 340 | } else { 341 | summary = new Summary(feed.htmlUrl, feed.title, [], false, feed.id); 342 | } 343 | this.updateSummary(feed.url, summary); 344 | feeds.add(feed.url); 345 | 346 | for (const caty of feed.categories as {id: string, label: string}[]) { 347 | let category = caties.get(caty.id); 348 | if (!category) { 349 | category = { 350 | name: caty.label, 351 | list: [], 352 | custom_data: caty.id, 353 | }; 354 | caties.set(caty.id, category); 355 | } 356 | category.list.push(feed.url); 357 | } 358 | if (feed.categories.length <= 0) { 359 | no_caties.push(feed.url); 360 | } 361 | } 362 | 363 | this.feed_tree = []; 364 | for (const caty of caties.values()) { 365 | this.feed_tree.push(caty); 366 | } 367 | this.feed_tree.push(...no_caties); 368 | for (const feed of this.getFeeds()) { 369 | if (!feeds.has(feed)) { 370 | this.updateSummary(feed, undefined); 371 | } 372 | } 373 | 374 | await Promise.all(this.getFeeds().map(url => this.fetch(url, update))); 375 | await this.commit(); 376 | } 377 | 378 | async fetchAll(update: boolean) { 379 | try { 380 | await this._fetchAll(update); 381 | } catch (error: any) { 382 | vscode.window.showErrorMessage('Update feeds failed: ' + error.toString()); 383 | } 384 | } 385 | 386 | async fetchOne(url: string, update: boolean) { 387 | try { 388 | await this.fetch(url, update); 389 | await this.commit(); 390 | } catch (error: any) { 391 | vscode.window.showErrorMessage('Update feed failed: ' + error.toString()); 392 | } 393 | } 394 | 395 | updateAbstract(id: string, abstract?: Abstract) { 396 | this.dirty_abstracts.add(id); 397 | return super.updateAbstract(id, abstract); 398 | } 399 | 400 | private async syncReadStatus(list: string[], read: boolean) { 401 | if (list.length <= 0) { 402 | return; 403 | } 404 | const param = list.map(i => ['i', i]); 405 | param.push([read ? 'a' : 'r', 'user/-/state/com.google/read']); 406 | await this.request('edit-tag', param, false); 407 | } 408 | 409 | async commit() { 410 | const read_list: string[] = []; 411 | const unread_list: string[] = []; 412 | for (const id of this.dirty_abstracts) { 413 | const abstract = this.getAbstract(id); 414 | if (abstract) { 415 | if (abstract.read) { 416 | read_list.push(abstract.id); 417 | } else { 418 | unread_list.push(abstract.id); 419 | } 420 | } 421 | } 422 | this.dirty_abstracts.clear(); 423 | Promise.all([ 424 | this.syncReadStatus(read_list, true), 425 | this.syncReadStatus(unread_list, false), 426 | ]).catch(error => { 427 | vscode.window.showErrorMessage('Sync read status failed: ' + error.toString()); 428 | }); 429 | 430 | await writeFile(pathJoin(this.dir, 'feed_list'), JSON.stringify(this.feed_tree)); 431 | await super.commit(); 432 | } 433 | 434 | } 435 | -------------------------------------------------------------------------------- /src/local_collection.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { parseXML2 } from './parser'; 3 | import { Entry, Summary, Abstract } from './content'; 4 | import { App } from './app'; 5 | import { Collection } from './collection'; 6 | import { walkFeedTree, got } from './utils'; 7 | 8 | export class LocalCollection extends Collection { 9 | private etags = new Map(); 10 | 11 | get type() { 12 | return 'local'; 13 | } 14 | 15 | protected get cfg(): LocalAccount { 16 | return super.cfg as LocalAccount; 17 | } 18 | 19 | getFeedList(): FeedTree { 20 | return this.cfg.feeds; 21 | } 22 | 23 | private async addToTree(tree: FeedTree, feed: string) { 24 | const categories: Category[] = []; 25 | for (const item of tree) { 26 | if (typeof(item) !== 'string') { 27 | categories.push(item); 28 | } 29 | } 30 | if (categories.length > 0) { 31 | const choice = await vscode.window.showQuickPick([ 32 | '.', ...categories.map(c => c.name) 33 | ], {placeHolder: 'Select a category'}); 34 | if (choice === undefined) { 35 | return; 36 | } else if (choice === '.') { 37 | tree.push(feed); 38 | } else { 39 | const caty = categories.find(c => c.name === choice)!; 40 | await this.addToTree(caty.list, feed); 41 | } 42 | } else { 43 | tree.push(feed); 44 | } 45 | } 46 | 47 | async addFeed(feed: string) { 48 | await this.addToTree(this.cfg.feeds, feed); 49 | await this.updateCfg(); 50 | } 51 | 52 | async addFeeds(feeds: string[]) { 53 | this.cfg.feeds.push(...feeds); 54 | await this.updateCfg(); 55 | } 56 | 57 | private deleteFromTree(tree: FeedTree, feed: string) { 58 | for (const [i, item] of tree.entries()) { 59 | if (typeof(item) === 'string') { 60 | if (item === feed) { 61 | tree.splice(i, 1); 62 | break; 63 | } 64 | } else { 65 | this.deleteFromTree(item.list, feed); 66 | } 67 | } 68 | } 69 | 70 | async delFeed(feed: string) { 71 | this.deleteFromTree(this.cfg.feeds, feed); 72 | await this.updateCfg(); 73 | } 74 | 75 | async addToFavorites(id: string) { 76 | const abstract = this.getAbstract(id); 77 | if (abstract) { 78 | abstract.starred = true; 79 | this.updateAbstract(id, abstract); 80 | await this.commit(); 81 | } 82 | } 83 | 84 | async removeFromFavorites(id: string) { 85 | const abstract = this.getAbstract(id); 86 | if (abstract) { 87 | abstract.starred = false; 88 | this.updateAbstract(id, abstract); 89 | await this.commit(); 90 | } 91 | } 92 | 93 | private async fetch(url: string, update: boolean) { 94 | const summary = this.getSummary(url) || new Summary(url, url, [], false); 95 | if (!update && summary.ok) { 96 | return; 97 | } 98 | 99 | let entries: Entry[]; 100 | try { 101 | const cfg = App.cfg; 102 | const res = await got(url, { 103 | timeout: cfg.timeout * 1000, retry: cfg.retry, encoding: 'binary', 104 | headers: { 105 | 'If-None-Match': this.etags.get(url), 106 | 'Accept-Encoding': 'gzip, br', 107 | } 108 | }); 109 | if (res.statusCode === 304) { 110 | return; 111 | } 112 | let etag = res.headers['etag']; 113 | if (etag) { 114 | if (Array.isArray(etag)) { 115 | etag = etag[0]; 116 | } 117 | this.etags.set(url, etag); 118 | } 119 | const [e, s] = parseXML2(res.body); 120 | entries = e; 121 | summary.title = s.title; 122 | summary.link = s.link; 123 | summary.ok = true; 124 | } catch (error: any) { 125 | vscode.window.showErrorMessage(error.toString()); 126 | entries = []; 127 | summary.ok = false; 128 | } 129 | 130 | const id2abs = new Map(); 131 | for (const entry of entries) { 132 | await this.updateContent(entry.id, entry.content); 133 | const abstract = Abstract.fromEntry(entry, url); 134 | const old = this.getAbstract(abstract.id); 135 | if (old) { 136 | abstract.read = old.read; 137 | abstract.starred = old.starred; 138 | } 139 | this.updateAbstract(abstract.id, abstract); 140 | id2abs.set(abstract.id, abstract); 141 | } 142 | 143 | for (const id of summary.catelog) { 144 | const abstract = this.getAbstract(id); 145 | if (abstract !== undefined && !id2abs.has(abstract.id)) { 146 | id2abs.set(abstract.id, abstract); 147 | } 148 | } 149 | 150 | summary.catelog = [...id2abs.values()] 151 | .sort((a, b) => b.date - a.date) 152 | .map(a => a.id); 153 | this.updateSummary(url, summary); 154 | } 155 | 156 | async fetchOne(url: string, update: boolean) { 157 | await this.fetch(url, update); 158 | await this.commit(); 159 | } 160 | 161 | async fetchAll(update: boolean) { 162 | const feeds = [...walkFeedTree(this.cfg.feeds)]; 163 | await Promise.all(feeds.map(feed => this.fetch(feed, update))); 164 | const feed_set = new Set(feeds); 165 | for (const feed of this.getFeeds()) { 166 | if (!feed_set.has(feed)) { 167 | await this.removeSummary(feed); 168 | } 169 | } 170 | await this.commit(); 171 | } 172 | } 173 | 174 | -------------------------------------------------------------------------------- /src/migrate.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import * as vscode from 'vscode'; 3 | import { join as pathJoin, isAbsolute } from 'path'; 4 | import { Summary, Entry, Abstract, Storage } from './content'; 5 | import { writeFile, readDir, checkDir, moveFile, readFile, fileExists, isDirEmpty } from './utils'; 6 | import * as uuid from 'uuid'; 7 | import * as crypto from 'crypto'; 8 | 9 | export async function checkStoragePath(context: vscode.ExtensionContext): Promise { 10 | const old = context.globalState.get('root', context.globalStoragePath); 11 | const cfg = vscode.workspace.getConfiguration('rss'); 12 | const root = cfg.get('storage-path') || context.globalStoragePath; 13 | if (old !== root) { 14 | if (!isAbsolute(root)) { 15 | throw Error(`"${root}" is not an absolute path`); 16 | } 17 | if (!await fileExists(root) || await isDirEmpty(root)) { 18 | await vscode.window.withProgress({ 19 | location: vscode.ProgressLocation.Notification, 20 | title: 'Moving data...', 21 | cancellable: false 22 | }, async () => { 23 | await checkDir(old); 24 | try { 25 | await moveFile(old, root); 26 | } catch (e: any) { 27 | throw Error(`Move data failed: ${e.toString()}`); 28 | } 29 | }); 30 | } else { 31 | const s = await vscode.window.showInformationMessage( 32 | `Target directory "${root}" is not empty, use this directory?`, 33 | 'Yes', "Cancel" 34 | ); 35 | if (s !== 'Yes') { 36 | // revert the configuration 37 | await cfg.update('storage-path', old, true); 38 | await checkDir(old); 39 | return old; 40 | } 41 | } 42 | await context.globalState.update('root', root); 43 | } 44 | await checkDir(root); 45 | return root; 46 | } 47 | 48 | async function getVersion(ctx: vscode.ExtensionContext, root: string) { 49 | const path = pathJoin(root, 'version'); 50 | if (!await fileExists(path)) { 51 | // an issue left over from history 52 | await setVersion(root, ctx.globalState.get('version', '0.0.1')); 53 | } 54 | return (await readFile(path)).trim(); 55 | } 56 | 57 | async function setVersion(root: string, version: string) { 58 | await writeFile(pathJoin(root, 'version'), version); 59 | } 60 | 61 | export async function migrate(context: vscode.ExtensionContext, root: string) { 62 | const old = await getVersion(context, root); 63 | const idx = VERSIONS.indexOf(old); 64 | if (idx < 0) { 65 | throw Error(`Invalid version "${old}". Current version is "${VERSIONS[VERSIONS.length - 1]}"`); 66 | } 67 | 68 | for (let i = idx + 1; i < VERSIONS.length; ++i) { 69 | const v = VERSIONS[i]; 70 | if (v in alter) { 71 | await alter[v](context, root); 72 | } 73 | } 74 | await setVersion(root, VERSIONS[VERSIONS.length - 1]); 75 | } 76 | 77 | const VERSIONS = [ 78 | '0.0.1', '0.0.2', '0.0.3', '0.0.4', '0.0.5', '0.1.0', '0.2.0', '0.2.1', 79 | '0.2.2', '0.3.0', '0.3.1', '0.4.0', '0.4.1', '0.5.0', '0.6.0', '0.6.1', 80 | '0.7.0', '0.7.1', '0.7.2', '0.8.0', '0.8.1', '0.9.0', '0.9.1', '0.9.2', 81 | '0.9.3', '0.10.0', '0.10.1', '0.10.2', '0.10.3', '0.10.4', 82 | ]; 83 | 84 | const alter: {[v: string]: (context: vscode.ExtensionContext, root: string) => Promise} = { 85 | '0.3.1': async (context, root) => { 86 | await vscode.window.withProgress({ 87 | location: vscode.ProgressLocation.Notification, 88 | title: 'Migrating data for the new version...', 89 | cancellable: false 90 | }, async () => { 91 | const cfg = vscode.workspace.getConfiguration('rss'); 92 | await checkDir(root); 93 | const summaries: {[url: string]: Summary} = {}; 94 | const abstracts: {[link: string]: Abstract} = {}; 95 | 96 | for (const feed of cfg.get('feeds', [])) { 97 | const summary = context.globalState.get(feed); 98 | if (summary === undefined) { continue; } 99 | for (const link of summary.catelog) { 100 | const entry = context.globalState.get(link); 101 | if (entry === undefined) { continue; } 102 | await writeFile(path.join(root, encodeURIComponent(link)), entry.content); 103 | 104 | abstracts[link] = Abstract.fromEntry(entry, feed); 105 | await context.globalState.update(link, undefined); 106 | } 107 | summaries[feed] = summary; 108 | await context.globalState.update(feed, undefined); 109 | } 110 | 111 | await context.globalState.update('summaries', summaries); 112 | await context.globalState.update('abstracts', abstracts); 113 | }); 114 | }, 115 | 116 | '0.4.0': async (context, root) => { 117 | await checkDir(root); 118 | 119 | const cfg = vscode.workspace.getConfiguration('rss'); 120 | const key = uuid.v1(); 121 | await cfg.update('accounts', { 122 | [key]: { 123 | name: 'Default', 124 | type: 'local', 125 | feeds: cfg.get('feeds', []), 126 | } 127 | }, true); 128 | 129 | const summaries = context.globalState.get<{[url: string]: Summary}>('summaries', {}); 130 | const abstracts = context.globalState.get<{[link: string]: Abstract}>('abstracts', {}); 131 | 132 | await checkDir(pathJoin(root, key)); 133 | await checkDir(pathJoin(root, key, 'feeds')); 134 | for (const url in summaries) { 135 | const summary = summaries[url]; 136 | const json = Storage.fromSummary(url, summary, link => { 137 | const abstract = abstracts[link]; 138 | abstract.feed = url; 139 | return abstract; 140 | }).toJSON(); 141 | await writeFile(pathJoin(root, key, 'feeds', encodeURIComponent(url)), json); 142 | } 143 | 144 | await checkDir(pathJoin(root, key, 'articles')); 145 | const files = await readDir(root); 146 | for (const file of files) { 147 | if (file === key) { 148 | continue; 149 | } 150 | await moveFile(pathJoin(root, file), pathJoin(root, key, 'articles', file)); 151 | } 152 | 153 | await context.globalState.update('summaries', undefined); 154 | await context.globalState.update('abstracts', undefined); 155 | }, 156 | 157 | '0.7.0': async (context, root) => { 158 | await checkDir(root); 159 | 160 | const cfg = vscode.workspace.getConfiguration('rss'); 161 | for (const key in cfg.accounts) { 162 | const dir = pathJoin(root, key); 163 | await checkDir(dir); 164 | await checkDir(pathJoin(dir, 'feeds')); 165 | const feeds = await readDir(pathJoin(dir, 'feeds')); 166 | for (const feed of feeds) { 167 | const file_name = pathJoin(dir, 'feeds', feed); 168 | const json = await readFile(file_name); 169 | const storage = JSON.parse(json); 170 | for (const abstract of storage.abstracts) { 171 | if (cfg.accounts[key].type === 'local') { 172 | abstract.id = abstract.link; 173 | } else if (cfg.accounts[key].type === 'ttrss') { 174 | abstract.id = abstract.custom_data; 175 | const old = pathJoin(dir, 'articles', encodeURIComponent(abstract.link)); 176 | if (await fileExists(old)) { 177 | await moveFile(old, pathJoin(dir, 'articles', encodeURIComponent(abstract.id))); 178 | } 179 | } 180 | } 181 | await writeFile(file_name, JSON.stringify(storage)); 182 | } 183 | 184 | } 185 | }, 186 | 187 | '0.7.1': async (context, root) => { 188 | await checkDir(root); 189 | 190 | const cfg = vscode.workspace.getConfiguration('rss'); 191 | for (const key in cfg.accounts) { 192 | if (cfg.accounts[key].type === 'local') { 193 | const dir = pathJoin(root, key); 194 | await checkDir(dir); 195 | await checkDir(pathJoin(dir, 'feeds')); 196 | const feeds = await readDir(pathJoin(dir, 'feeds')); 197 | for (const feed of feeds) { 198 | const file_name = pathJoin(dir, 'feeds', feed); 199 | const json = await readFile(file_name); 200 | const storage = JSON.parse(json); 201 | for (const abstract of storage.abstracts) { 202 | const old = pathJoin(dir, 'articles', encodeURIComponent(abstract.id)); 203 | abstract.id = crypto.createHash("sha256") 204 | .update(storage.link + abstract.id) 205 | .digest('hex'); 206 | if (await fileExists(old)) { 207 | await moveFile(old, pathJoin(dir, 'articles', abstract.id)); 208 | } 209 | } 210 | await writeFile(file_name, JSON.stringify(storage)); 211 | } 212 | } 213 | } 214 | }, 215 | 216 | }; 217 | -------------------------------------------------------------------------------- /src/parser.ts: -------------------------------------------------------------------------------- 1 | import * as parser from "fast-xml-parser"; 2 | import * as he from 'he'; 3 | import * as cheerio from 'cheerio'; 4 | import * as iconv from 'iconv-lite'; 5 | import { URL } from "url"; 6 | import { isString, isArray, isNumber } from "util"; 7 | import { Entry, Summary } from "./content"; 8 | import * as crypto from 'crypto'; 9 | import { CheerioAPI, Cheerio, Element } from "cheerio"; 10 | 11 | function isStringified(s: any) { 12 | return isString(s) || isNumber(s); 13 | } 14 | 15 | function order(attr: any) { 16 | if (!attr) { 17 | return -2; 18 | } 19 | if (attr.rel === 'alternate') { 20 | return 1; 21 | } else if (!attr.rel) { 22 | return 0; 23 | } else { 24 | return -1; 25 | } 26 | } 27 | 28 | function parseLink(link: any) { 29 | if (isArray(link) && link.length > 0) { 30 | link = link.reduce((a, b) => order(a.__attr) > order(b.__attr) ? a : b); 31 | } 32 | 33 | let ans; 34 | if (isStringified(link)) { 35 | ans = link; 36 | } else if (isStringified(link.__attr?.href)) { 37 | ans = link.__attr.href; 38 | } else if (isStringified(link.__text)) { 39 | ans = link.__text; 40 | } else if ('__cdata' in link) { 41 | if (isStringified(link.__cdata)) { 42 | ans = link.__cdata; 43 | } else if(isArray(link.__cdata)) { 44 | ans = link.__cdata.join(''); 45 | } 46 | } 47 | return ans; 48 | } 49 | 50 | function dom2html(name: string, node: any) { 51 | if (isStringified(node)) { 52 | return `<${name}>${node}`; 53 | } 54 | 55 | let html = '<' + name; 56 | if ('__attr' in node) { 57 | for (const key in node.__attr) { 58 | const value = node.__attr[key]; 59 | html += ` ${key}="${value}"`; 60 | } 61 | } 62 | html += '>'; 63 | 64 | if (isStringified(node.__text)) { 65 | html += node.__text; 66 | } 67 | for (const key in node) { 68 | if (key.startsWith('__')) {continue;} 69 | const value = node[key]; 70 | if (isArray(value)) { 71 | for (const item of value) { 72 | html += dom2html(key, item); 73 | } 74 | } else { 75 | html += dom2html(key, value); 76 | } 77 | } 78 | html += ``; 79 | return html; 80 | } 81 | 82 | function extractText(content: any) { 83 | let ans; 84 | if (isStringified(content)) { 85 | ans = content; 86 | } else if (isStringified(content.__text)) { 87 | ans = content.__text; 88 | } else if ('__cdata' in content) { 89 | if (isStringified(content.__cdata)) { 90 | ans = content.__cdata; 91 | } else if(isArray(content.__cdata)) { 92 | ans = content.__cdata.join(''); 93 | } 94 | } else if (content.__attr?.type === 'html') { 95 | // XXX: temporary solution. convert dom object to html string. 96 | ans = dom2html('html', content); 97 | } 98 | return ans; 99 | } 100 | 101 | function parseEntry(dom: any, baseURL: string, exclude: Set): Entry | undefined { 102 | let link; 103 | if (dom.link) { 104 | link = parseLink(dom.link); 105 | } else if (dom.source) { 106 | link = dom.source; 107 | } 108 | if (isStringified(link)) { 109 | link = new URL(link, baseURL).href; 110 | } else { 111 | link = undefined; 112 | } 113 | 114 | let id; 115 | if (dom.id) { 116 | id = extractText(dom.id); 117 | } else if (dom.guid) { 118 | id = extractText(dom.guid); 119 | } else { 120 | id = link; 121 | } 122 | if (!isStringified(id)) { 123 | throw new Error("Feed Format Error: Entry Missing ID"); 124 | } 125 | id = crypto.createHash("sha256").update(baseURL + id).digest('hex'); 126 | 127 | if (exclude.has(id)) { 128 | return undefined; 129 | } 130 | 131 | let title; 132 | if ('title' in dom) { 133 | title = extractText(dom.title); 134 | } 135 | if (!isStringified(title)) { 136 | throw new Error("Feed Format Error: Entry Missing Title"); 137 | } 138 | title = he.decode(title); 139 | 140 | let content; 141 | if ('content' in dom) { 142 | content = extractText(dom.content); 143 | } else if ("content:encoded" in dom) { 144 | content = extractText(dom["content:encoded"]); 145 | } else if ('description' in dom) { 146 | content = extractText(dom.description); 147 | } else if ('summary' in dom) { 148 | content = extractText(dom.summary); 149 | } else { 150 | content = title; 151 | } 152 | if (!isStringified(content)) { 153 | throw new Error("Feed Format Error: Entry Missing Content"); 154 | } 155 | content = he.decode(content); 156 | const $ = cheerio.load(content); 157 | $('a').each((_, ele) => { 158 | const $ele = $(ele); 159 | const href = $ele.attr('href'); 160 | if (href) { 161 | try { 162 | $ele.attr('href', new URL(href, baseURL).href); 163 | } catch {} 164 | } 165 | }); 166 | $('img').each((_, ele) => { 167 | const $ele = $(ele); 168 | const src = $ele.attr('src'); 169 | if (src) { 170 | try { 171 | $ele.attr('src', new URL(src, baseURL).href); 172 | } catch {} 173 | } 174 | $ele.removeAttr('height'); 175 | }); 176 | $('script').remove(); 177 | content = $.html(); 178 | 179 | let date; 180 | if (dom.published) { 181 | date = dom.published; 182 | } else if (dom.pubDate) { 183 | date = dom.pubDate; 184 | } else if (dom.updated) { 185 | date = dom.updated; 186 | } else if (dom["dc:date"]) { 187 | date = dom["dc:date"]; 188 | } 189 | if (!isStringified(date)) { 190 | date = new Date().getTime(); 191 | } else { 192 | date = new Date(date).getTime(); 193 | } 194 | if (isNaN(date)) { 195 | throw new Error("Feed Format Error: Invalid Date"); 196 | } 197 | 198 | return new Entry(id, title, content, date, link, false); 199 | } 200 | 201 | export function parseXML(xml: string, exclude: Set): [Entry[], Summary] { 202 | const match = xml.match(/<\?xml.*encoding="(\S+)".*\?>/); 203 | xml = iconv.decode(Buffer.from(xml, 'binary'), match ? match[1]: 'utf-8'); 204 | const dom = parser.parse(xml, { 205 | attributeNamePrefix: "", 206 | attrNodeName: "__attr", 207 | textNodeName: "__text", 208 | cdataTagName: "__cdata", 209 | cdataPositionChar: "", 210 | ignoreAttributes: false, 211 | parseAttributeValue: true, 212 | }); 213 | let feed; 214 | if (dom.rss) { 215 | if (dom.rss.channel) { 216 | feed = dom.rss.channel; 217 | } else if (dom.rss.feed) { 218 | feed = dom.rss.feed; 219 | } 220 | } else if (dom.channel) { 221 | feed = dom.channel; 222 | } else if (dom.feed) { 223 | feed = dom.feed; 224 | } else if (dom["rdf:RDF"]) { 225 | feed = dom["rdf:RDF"]; 226 | } 227 | if (!feed) { 228 | throw new Error('Feed Format Error'); 229 | } 230 | 231 | let title; 232 | if ('title' in feed) { 233 | title = extractText(feed.title); 234 | } else if (feed.channel?.title !== undefined) { 235 | title = extractText(feed.channel.title); 236 | } 237 | if (!isStringified(title)) { 238 | throw new Error('Feed Format Error: Missing Title'); 239 | } 240 | title = he.decode(title); 241 | 242 | let link: any; 243 | if (feed.link) { 244 | link = parseLink(feed.link); 245 | } else if (feed.channel?.link) { 246 | link = parseLink(feed.channel.link); 247 | } 248 | if (!isStringified(link)) { 249 | throw new Error('Feed Format Error: Missing Link'); 250 | } 251 | if (!link.match(/^https?:\/\//)) { 252 | if (link.match(/^\/\//)) { 253 | link = 'http:' + link; 254 | } else { 255 | link = 'http://' + link; 256 | } 257 | } 258 | 259 | let items: any; 260 | if (feed.item) { 261 | items = feed.item; 262 | } else if (feed.entry) { 263 | items = feed.entry; 264 | } 265 | if (!items) { 266 | items = []; 267 | } else if (!isArray(items)) { 268 | items = [items]; 269 | } 270 | 271 | const entries: Entry[] = []; 272 | for (const item of items) { 273 | const entry = parseEntry(item, link, exclude); 274 | if (entry) { 275 | entries.push(entry); 276 | } 277 | } 278 | const summary = new Summary(link, title); 279 | 280 | return [entries, summary]; 281 | } 282 | 283 | function getLink($link: Cheerio): string { 284 | let target = ''; 285 | $link.each((_, ele) => { 286 | const $ele = cheerio.default(ele); 287 | if (!target || $ele.attr('rel') === 'alternate') { 288 | target = $ele.attr('href') || $ele.text(); 289 | } 290 | }); 291 | return target; 292 | } 293 | 294 | function resolveAttr($: CheerioAPI, base: string, selector: string, attr: string) { 295 | $(selector).each((_, ele) => { 296 | const $ele = $(ele); 297 | const url = $ele.attr(attr); 298 | if (url) { 299 | try { 300 | $ele.attr(attr, new URL(url, base).href); 301 | } catch {} 302 | } 303 | }); 304 | } 305 | 306 | function resolveRelativeLinks(content: string, base: string): string { 307 | const $ = cheerio.load(content); 308 | resolveAttr($, base, 'a', 'href'); 309 | resolveAttr($, base, 'img', 'src'); 310 | resolveAttr($, base, 'video', 'src'); 311 | resolveAttr($, base, 'audio', 'src'); 312 | $('script').remove(); 313 | return $.html(); 314 | } 315 | 316 | // https://www.rssboard.org/rss-2-0 317 | function parseRSS($dom: CheerioAPI): [Entry[], Summary] { 318 | const title = $dom('channel > title').text(); 319 | const base = getLink($dom('channel > link')); 320 | const summary = new Summary(base, title); 321 | const entries: Entry[] = []; 322 | $dom('channel > item').each((_, ele) => { 323 | const $ele = $dom(ele); 324 | let id = $ele.find('guid').text(); 325 | let title = $ele.find('title').text(); 326 | let description = $ele.find('description').text(); 327 | let content = $ele.find('content').text() || $ele.find('content\\:encoded').text(); 328 | let date: string | number = $ele.find('pubDate').text(); 329 | let link = getLink($ele.find('link')); 330 | 331 | id = id || link; 332 | title = title || description || content; 333 | content = content || description || title; 334 | date = date ? new Date(date).getTime() : new Date().getTime(); 335 | 336 | if (!id) { 337 | throw new Error('Feed Format Error: Entry Missing ID'); 338 | } 339 | id = crypto.createHash("sha256").update(base + id).digest('hex'); 340 | 341 | content = resolveRelativeLinks(content, base); 342 | entries.push(new Entry(id, title, content, date, link, false)); 343 | }); 344 | 345 | return [entries, summary]; 346 | } 347 | 348 | // https://validator.w3.org/feed/docs/rss1.html 349 | function parseRDF($dom: CheerioAPI): [Entry[], Summary] { 350 | const title = $dom('channel > title').text(); 351 | const base = getLink($dom('channel > link')); 352 | const summary = new Summary(base, title); 353 | const entries: Entry[] = []; 354 | $dom('rdf\\:RDF > item').each((_, ele) => { 355 | const $ele = $dom(ele); 356 | let title = $ele.find('title').text(); 357 | let content = $ele.find('description').text(); 358 | let date: string | number = $ele.find('dc\\:date').text(); 359 | let link = getLink($ele.find('link')); 360 | 361 | if (!link) { 362 | throw new Error('Feed Format Error: Entry Missing Link'); 363 | } 364 | 365 | title = title || content; 366 | content = content || title; 367 | date = date ? new Date(date).getTime() : new Date().getTime(); 368 | const id = crypto.createHash("sha256").update(base + link).digest('hex'); 369 | 370 | content = resolveRelativeLinks(content, base); 371 | entries.push(new Entry(id, title, content, date, link, false)); 372 | }); 373 | 374 | return [entries, summary]; 375 | } 376 | 377 | // https://tools.ietf.org/html/rfc4287 378 | function parseAtom($dom: CheerioAPI): [Entry[], Summary] { 379 | const title = $dom('feed > title').text(); 380 | const base = getLink($dom('feed > link')); 381 | const summary = new Summary(base, title); 382 | const entries: Entry[] = []; 383 | $dom('feed > entry').each((_, ele) => { 384 | const $ele = $dom(ele); 385 | let id = $ele.find('id').text(); 386 | let title = $ele.find('title').text(); 387 | let summary = $ele.find('summary').text(); 388 | let content = $ele.find('content').text(); 389 | let date: string | number = $ele.find('published').text(); 390 | let link = getLink($ele.find('link')); 391 | 392 | id = id || link; 393 | title = title || summary || content; 394 | content = content || summary || title; 395 | date = date ? new Date(date).getTime() : new Date().getTime(); 396 | 397 | if (!id) { 398 | throw new Error('Feed Format Error: Entry Missing ID'); 399 | } 400 | id = crypto.createHash("sha256").update(base + id).digest('hex'); 401 | 402 | content = resolveRelativeLinks(content, base); 403 | entries.push(new Entry(id, title, content, date, link, false)); 404 | }); 405 | 406 | return [entries, summary]; 407 | } 408 | 409 | export function parseXML2(xml: string): [Entry[], Summary] { 410 | const match = xml.match(/<\?xml.*encoding="(\S+)".*\?>/); 411 | xml = iconv.decode(Buffer.from(xml, 'binary'), match ? match[1]: 'utf-8'); 412 | const $dom = cheerio.load(xml, {xmlMode: true}); 413 | 414 | const root = $dom.root().children()[0].name; 415 | switch (root) { 416 | case 'rss': 417 | return parseRSS($dom); 418 | case 'rdf:RDF': 419 | return parseRDF($dom); 420 | case 'feed': 421 | return parseAtom($dom); 422 | default: 423 | throw new Error('Unsupported format: ' + root); 424 | } 425 | } 426 | 427 | export function parseOPML(opml: string): string[] { 428 | const $dom = cheerio.load(opml, {xmlMode: true}); 429 | const ans: string[] = []; 430 | $dom('outline').each((_, ele) => { 431 | const url = $dom(ele).attr('xmlUrl'); 432 | if (url) { 433 | ans.push(url); 434 | } 435 | }); 436 | return ans; 437 | } 438 | -------------------------------------------------------------------------------- /src/status_bar.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { App } from './app'; 3 | import { Abstract } from './content'; 4 | 5 | export class StatusBar { 6 | private status_bar_item = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left); 7 | private unread_list: [string, string][] = []; 8 | private index = 0; 9 | private timer: NodeJS.Timeout | undefined; 10 | private read_state: [string, Abstract] | undefined; 11 | 12 | public init() { 13 | App.instance.context.subscriptions.push( 14 | vscode.commands.registerCommand('rss.read-notification', async () => { 15 | if (!this.read_state) { 16 | return; 17 | } 18 | const [account, abstract] = this.read_state; 19 | App.instance.rss_select(account); 20 | await App.instance.rss_read(abstract); 21 | }) 22 | ); 23 | this.refresh(); 24 | } 25 | 26 | public refresh() { 27 | this.unread_list = Object.values(App.instance.collections) 28 | .map(c => c.getArticles('').map((a): [string, string] => [c.account, a.id])) 29 | .reduce((a, b) => a.concat(b)); 30 | 31 | if (this.timer) { 32 | clearInterval(this.timer); 33 | this.timer = undefined; 34 | } 35 | if (App.cfg.get('status-bar-notify')) { 36 | const interval = App.cfg.get('status-bar-update') || 5; 37 | this.timer = setInterval(() => this.show(), interval * 1000); 38 | this.show(); 39 | } else { 40 | this.status_bar_item.hide(); 41 | } 42 | } 43 | 44 | private show() { 45 | this.status_bar_item.hide(); 46 | this.read_state = undefined; 47 | if (this.unread_list.length <= 0) { 48 | return; 49 | } 50 | 51 | this.index %= this.unread_list.length; 52 | let i = this.index; 53 | do { 54 | const [account, id] = this.unread_list[i]; 55 | const collection = App.instance.collections[account]; 56 | if (collection) { 57 | const abs = collection.getAbstract(id); 58 | if (abs && !abs.read) { 59 | this.status_bar_item.show(); 60 | const max_len = App.cfg.get('status-bar-length'); 61 | let title = abs.title; 62 | if (max_len && title.length > max_len) { 63 | title = title.substr(0, max_len - 3) + '...'; 64 | } 65 | this.status_bar_item.text = '$(rss) ' + title; 66 | this.status_bar_item.tooltip = abs.title; 67 | this.status_bar_item.command = 'rss.read-notification', 68 | this.read_state = [account, abs]; 69 | this.index = (i + 1) % this.unread_list.length; 70 | break; 71 | } 72 | } 73 | 74 | i = (i + 1) % this.unread_list.length; 75 | } while (i !== this.index); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/test/runTest.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | 3 | import { runTests } from 'vscode-test'; 4 | 5 | async function main() { 6 | try { 7 | // The folder containing the Extension Manifest package.json 8 | // Passed to `--extensionDevelopmentPath` 9 | const extensionDevelopmentPath = path.resolve(__dirname, '../../'); 10 | 11 | // The path to test runner 12 | // Passed to --extensionTestsPath 13 | const extensionTestsPath = path.resolve(__dirname, './suite/index'); 14 | 15 | // Download VS Code, unzip it and run the integration test 16 | await runTests({ extensionDevelopmentPath, extensionTestsPath }); 17 | } catch (err) { 18 | console.error('Failed to run tests'); 19 | process.exit(1); 20 | } 21 | } 22 | 23 | main(); 24 | -------------------------------------------------------------------------------- /src/test/suite/index.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import * as Mocha from 'mocha'; 3 | import * as glob from 'glob'; 4 | 5 | export function run(): Promise { 6 | // Create the mocha test 7 | const mocha = new Mocha({ 8 | ui: 'tdd', 9 | color: true 10 | }); 11 | 12 | const testsRoot = path.resolve(__dirname, '..'); 13 | 14 | return new Promise((c, e) => { 15 | glob('**/**.test.js', { cwd: testsRoot }, (err, files) => { 16 | if (err) { 17 | return e(err); 18 | } 19 | 20 | // Add files to the test suite 21 | files.forEach(f => mocha.addFile(path.resolve(testsRoot, f))); 22 | 23 | try { 24 | // Run the mocha test 25 | mocha.run(failures => { 26 | if (failures > 0) { 27 | e(new Error(`${failures} tests failed.`)); 28 | } else { 29 | c(); 30 | } 31 | }); 32 | } catch (err) { 33 | console.error(err); 34 | e(err); 35 | } 36 | }); 37 | }); 38 | } 39 | -------------------------------------------------------------------------------- /src/test/suite/parser.test.ts: -------------------------------------------------------------------------------- 1 | import * as assert from 'assert'; 2 | import * as vscode from 'vscode'; 3 | import * as parser from '../../parser'; 4 | import * as crypto from 'crypto'; 5 | 6 | function sha256(s: string) { 7 | return crypto.createHash('sha256').update(s).digest('hex'); 8 | } 9 | 10 | suite('test parser', () => { 11 | test('basic', () => { 12 | const xml = ` 13 | 14 | 15 | 16 | https://luyuhuang.tech/feed.xml 17 | Luyu Huang's Tech Blog 18 | 19 | Title 1 20 | 21 | 2020-06-03T00:00:00+08:00 22 | Some Content 23 | 24 | 25 | `; 26 | const [entries, summary] = parser.parseXML2(xml); 27 | assert.equal(summary.title, "Luyu Huang's Tech Blog"); 28 | assert.equal(summary.link, 'https://luyuhuang.tech/'); 29 | assert.equal(entries.length, 1); 30 | assert.equal(entries[0].title, 'Title 1'); 31 | assert.equal(entries[0].link, 'https://luyuhuang.tech/2020/06/03/cloudflare-free-https.html'); 32 | assert.equal(entries[0].content, 'Some Content'); 33 | }); 34 | 35 | test('empty entry', () => { 36 | const xml = ` 37 | 38 | 39 | 40 | https://luyuhuang.tech/feed.xml 41 | Luyu Huang's Tech Blog 42 | 43 | `; 44 | const [entries, summary] = parser.parseXML2(xml); 45 | assert.equal(summary.title, "Luyu Huang's Tech Blog"); 46 | assert.equal(summary.link, 'https://luyuhuang.tech/'); 47 | assert.equal(entries.length, 0); 48 | }); 49 | 50 | test('multiple entries', () => { 51 | const xml = ` 52 | 53 | 54 | 55 | https://luyuhuang.tech/feed.xml 56 | Luyu Huang's Tech Blog 57 | 58 | 59 | Title 1 60 | 61 | 2020-06-03T00:00:00+08:00 62 | Some Content 63 | 64 | 65 | 66 | Title 2 67 | 68 | 2020-05-22T00:00:00+08:00 69 | Another Content 70 | 71 | 72 | 73 | `; 74 | const [entries, summary] = parser.parseXML2(xml); 75 | assert.equal(summary.title, "Luyu Huang's Tech Blog"); 76 | assert.equal(summary.link, 'https://luyuhuang.tech/'); 77 | assert.equal(entries.length, 2); 78 | assert.equal(entries[0].title, 'Title 1'); 79 | assert.equal(entries[0].link, 'https://luyuhuang.tech/2020/06/03/cloudflare-free-https.html'); 80 | assert.equal(entries[0].content, 'Some Content'); 81 | assert.equal(entries[1].title, 'Title 2'); 82 | assert.equal(entries[1].link, 'https://luyuhuang.tech/2020/05/22/nginx-beginners-guide.html'); 83 | assert.equal(entries[1].content, 'Another Content'); 84 | }); 85 | 86 | test('dual links', () => { 87 | const xml = ` 88 | 89 | 90 | 91 | 92 | https://luyuhuang.tech/feed.xml 93 | Luyu Huang's Tech Blog 94 | 95 | Title 1 96 | https://luyuhuang.tech/ 97 | https://luyuhuang.tech/2020/06/03/cloudflare-free-https.html 98 | 2020-06-03T00:00:00+08:00 99 | Some Content 100 | 101 | 102 | `; 103 | const [entries, summary] = parser.parseXML2(xml); 104 | assert.equal(summary.title, "Luyu Huang's Tech Blog"); 105 | assert.equal(summary.link, 'https://luyuhuang.tech/'); 106 | assert.equal(entries.length, 1); 107 | assert.equal(entries[0].title, 'Title 1'); 108 | assert.equal(entries[0].link, 'https://luyuhuang.tech/2020/06/03/cloudflare-free-https.html'); 109 | assert.equal(entries[0].content, 'Some Content'); 110 | }); 111 | 112 | test('cdata link', () => { 113 | const xml = ` 114 | 115 | 116 | Site Title 117 | http://world.huanqiu.com 118 | 119 | <![CDATA[Title 1]]> 120 | 121 | 122 | 123 | 2020-06-18 124 | 125 | 126 | `; 127 | const [entries, summary] = parser.parseXML2(xml); 128 | assert.equal(summary.title, "Site Title"); 129 | assert.equal(summary.link, 'http://world.huanqiu.com'); 130 | assert.equal(entries.length, 1); 131 | assert.equal(entries[0].title, 'Title 1'); 132 | assert.equal(entries[0].link, 'http://world.huanqiu.com/exclusive/2020-06/16558145.html'); 133 | assert.equal(entries[0].content, 'Content 1'); 134 | }); 135 | 136 | test('content encoded', () => { 137 | const xml = ` 138 | 139 | 140 | Site Title 141 | http://world.huanqiu.com 142 | 143 | <![CDATA[Title 1]]> 144 | 145 | 146 | 147 | 2020-06-18 148 | 149 | 150 | `; 151 | const [entries, summary] = parser.parseXML2(xml); 152 | assert.equal(summary.title, "Site Title"); 153 | assert.equal(summary.link, 'http://world.huanqiu.com'); 154 | assert.equal(entries.length, 1); 155 | assert.equal(entries[0].title, 'Title 1'); 156 | assert.equal(entries[0].link, 'http://world.huanqiu.com/exclusive/2020-06/16558145.html'); 157 | assert.equal(entries[0].content, 'Content 1'); 158 | }); 159 | 160 | test('id', () => { 161 | const xml = ` 162 | 163 | 164 | Site Title 165 | http://world.huanqiu.com 166 | 167 | <![CDATA[Title 1]]> 168 | http://world.huanqiu.com/exclusive/2020-06/16558145.html 169 | 41d2104c-3453-42d9-9aff-7c3447913a42 170 | 171 | 172 | 2020-06-18 173 | 174 | 175 | `; 176 | const [entries, summary] = parser.parseXML2(xml); 177 | assert.equal(summary.title, "Site Title"); 178 | assert.equal(summary.link, 'http://world.huanqiu.com'); 179 | assert.equal(entries.length, 1); 180 | assert.equal(entries[0].title, 'Title 1'); 181 | assert.equal(entries[0].link, 'http://world.huanqiu.com/exclusive/2020-06/16558145.html'); 182 | assert.equal(entries[0].id, sha256('http://world.huanqiu.com41d2104c-3453-42d9-9aff-7c3447913a42')); 183 | assert.equal(entries[0].content, 'Content 1'); 184 | }); 185 | 186 | test('use link as id', () => { 187 | const xml = ` 188 | 189 | 190 | Site Title 191 | http://world.huanqiu.com 192 | 193 | <![CDATA[Title 1]]> 194 | http://world.huanqiu.com/exclusive/2020-06/16558145.html 195 | 196 | 197 | 2020-06-18 198 | 199 | 200 | `; 201 | const [entries, summary] = parser.parseXML2(xml); 202 | assert.equal(summary.title, "Site Title"); 203 | assert.equal(summary.link, 'http://world.huanqiu.com'); 204 | assert.equal(entries.length, 1); 205 | assert.equal(entries[0].title, 'Title 1'); 206 | assert.equal(entries[0].link, 'http://world.huanqiu.com/exclusive/2020-06/16558145.html'); 207 | assert.equal(entries[0].id, sha256('http://world.huanqiu.comhttp://world.huanqiu.com/exclusive/2020-06/16558145.html')); 208 | assert.equal(entries[0].content, 'Content 1'); 209 | }); 210 | 211 | test('atom date', () => { 212 | const xml = ` 213 | 214 | 215 | 216 | https://luyuhuang.tech/feed.xml 217 | Luyu Huang's Tech Blog 218 | 219 | Title 1 220 | 221 | 2020-06-03T00:00:00+08:00 222 | Some Content 223 | 224 | 225 | `; 226 | const [entries, summary] = parser.parseXML2(xml); 227 | assert.equal(entries[0].date, new Date('2020-06-03T00:00:00+08:00').getTime()); 228 | 229 | }); 230 | 231 | test('rss2 date', () => { 232 | const xml = ` 233 | 234 | 235 | Site Title 236 | http://world.huanqiu.com 237 | 238 | <![CDATA[Title 1]]> 239 | 240 | 241 | 242 | 2020-06-18 243 | 244 | 245 | `; 246 | const [entries, summary] = parser.parseXML2(xml); 247 | assert.equal(entries[0].date, new Date('2020-06-18').getTime()); 248 | }); 249 | 250 | test('missing date', () => { 251 | const xml = ` 252 | 253 | 254 | Site Title 255 | http://world.huanqiu.com 256 | 257 | <![CDATA[Title 1]]> 258 | 259 | 260 | 261 | 262 | 263 | `; 264 | const [entries, summary] = parser.parseXML2(xml); 265 | assert(new Date().getTime() - entries[0].date < 500); 266 | }); 267 | }); 268 | -------------------------------------------------------------------------------- /src/ttrss_collection.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import * as cheerio from 'cheerio'; 3 | import { join as pathJoin } from 'path'; 4 | import { Summary, Abstract } from './content'; 5 | import { App } from './app'; 6 | import { writeFile, readFile, removeFile, removeDir, fileExists, got } from './utils'; 7 | import { Collection } from './collection'; 8 | 9 | export class TTRSSCollection extends Collection { 10 | private session_id?: string; 11 | private dirty_abstracts = new Set(); 12 | private feed_tree: FeedTree = []; 13 | 14 | get type() { 15 | return 'ttrss'; 16 | } 17 | 18 | protected get cfg(): TTRSSAccount { 19 | return super.cfg as TTRSSAccount; 20 | } 21 | 22 | async init() { 23 | const path = pathJoin(this.dir, 'feed_list'); 24 | if (await fileExists(path)) { 25 | this.feed_tree = JSON.parse(await readFile(path)); 26 | } 27 | await super.init(); 28 | } 29 | 30 | getFeedList(): FeedTree { 31 | if (this.feed_tree.length > 0) { 32 | return this.feed_tree; 33 | } else { 34 | return super.getFeedList(); 35 | } 36 | } 37 | 38 | private async login() { 39 | const cfg = this.cfg; 40 | const res = await got({ 41 | url: cfg.server, 42 | method: 'POST', 43 | json: { 44 | op: "login", 45 | user: cfg.username, 46 | password: cfg.password, 47 | }, 48 | timeout: App.cfg.timeout * 1000, 49 | retry: App.cfg.retry, 50 | }); 51 | const response = JSON.parse(res.body); 52 | if (response.status !== 0) { 53 | throw Error(`Login failed: ${response.content.error}`); 54 | } 55 | this.session_id = response.content.session_id; 56 | } 57 | 58 | private async request(req: {[key: string]: any}): Promise { 59 | if (this.session_id === undefined) { 60 | await this.login(); 61 | } 62 | const res = await got({ 63 | url: this.cfg.server, 64 | method: 'POST', 65 | json: { 66 | sid: this.session_id, 67 | ...req 68 | }, 69 | timeout: App.cfg.timeout * 1000, 70 | retry: App.cfg.retry 71 | }); 72 | const response = JSON.parse(res.body); 73 | if (response.status !== 0) { 74 | if (response.content.error === 'NOT_LOGGED_IN') { 75 | this.session_id = undefined; 76 | return this.request(req); 77 | } else { 78 | throw Error(response.content.error); 79 | } 80 | } 81 | return response; 82 | } 83 | 84 | private async _addFeed(feed: string, category_id: number) { 85 | if (this.getSummary(feed) !== undefined) { 86 | vscode.window.showInformationMessage('Feed already exists'); 87 | return; 88 | } 89 | const response = await this.request({ 90 | op: 'subscribeToFeed', feed_url: feed, category_id 91 | }); 92 | await this.request({op: 'updateFeed', feed_id: response.content.status.feed_id}); 93 | await this._fetchAll(false); 94 | App.instance.refreshLists(); 95 | } 96 | 97 | private async selectCategory(id: number, tree: FeedTree): Promise { 98 | const categories: Category[] = []; 99 | for (const item of tree) { 100 | if (typeof(item) !== 'string') { 101 | categories.push(item); 102 | } 103 | } 104 | if (categories.length > 0) { 105 | const choice = await vscode.window.showQuickPick([ 106 | '.', ...categories.map(c => c.name) 107 | ], {placeHolder: 'Select a category'}); 108 | if (choice === undefined) { 109 | return undefined; 110 | } else if (choice === '.') { 111 | return id; 112 | } else { 113 | const caty = categories.find(c => c.name === choice)!; 114 | return this.selectCategory(caty.custom_data, caty.list); 115 | } 116 | } else { 117 | return id; 118 | } 119 | } 120 | 121 | async addFeed(feed: string) { 122 | const category_id = await this.selectCategory(0, this.feed_tree); 123 | if (category_id === undefined) { 124 | return; 125 | } 126 | await vscode.window.withProgress({ 127 | location: vscode.ProgressLocation.Notification, 128 | title: "Updating RSS...", 129 | cancellable: false 130 | }, async () => { 131 | try { 132 | await this._addFeed(feed, category_id); 133 | } catch (error: any) { 134 | vscode.window.showErrorMessage('Add feed failed: ' + error.toString()); 135 | } 136 | }); 137 | } 138 | 139 | async _delFeed(feed: string) { 140 | const summary = this.getSummary(feed); 141 | if (summary === undefined) { 142 | return; 143 | } 144 | await this.request({op: 'unsubscribeFeed', feed_id: summary.custom_data}); 145 | await this._fetchAll(false); 146 | App.instance.refreshLists(); 147 | } 148 | 149 | async delFeed(feed: string) { 150 | await vscode.window.withProgress({ 151 | location: vscode.ProgressLocation.Notification, 152 | title: "Updating RSS...", 153 | cancellable: false 154 | }, async () => { 155 | try { 156 | await this._delFeed(feed); 157 | } catch (error: any) { 158 | vscode.window.showErrorMessage('Remove feed failed: ' + error.toString()); 159 | } 160 | }); 161 | } 162 | 163 | async addToFavorites(id: string) { 164 | const abstract = this.getAbstract(id); 165 | if (!abstract) { 166 | return; 167 | } 168 | abstract.starred = true; 169 | this.updateAbstract(id, abstract); 170 | await this.commit(); 171 | 172 | this.request({ 173 | op: "updateArticle", 174 | article_ids: `${abstract.custom_data}`, 175 | field: 0, 176 | mode: 1, 177 | }).catch(error => { 178 | vscode.window.showErrorMessage('Add favorite failed: ' + error.toString()); 179 | }); 180 | } 181 | 182 | async removeFromFavorites(id: string) { 183 | const abstract = this.getAbstract(id); 184 | if (!abstract) { 185 | return; 186 | } 187 | abstract.starred = false; 188 | this.updateAbstract(id, abstract); 189 | await this.commit(); 190 | 191 | this.request({ 192 | op: "updateArticle", 193 | article_ids: `${abstract.custom_data}`, 194 | field: 0, 195 | mode: 0, 196 | }).catch(error => { 197 | vscode.window.showErrorMessage('Remove favorite failed: ' + error.toString()); 198 | }); 199 | } 200 | 201 | private async fetch(url: string, update: boolean) { 202 | const summary = this.getSummary(url); 203 | if (summary === undefined || summary.custom_data === undefined) { 204 | throw Error('Feed dose not exist'); 205 | } 206 | if (!update && summary.ok) { 207 | return; 208 | } 209 | 210 | const response = await this.request({ 211 | op: 'getHeadlines', 212 | feed_id: summary.custom_data, 213 | view_mode: App.cfg.get('fetch-unread-only') ? 'unread': 'all_articles', 214 | }); 215 | const headlines = response.content as any[]; 216 | const abstracts: Abstract[] = []; 217 | const ids = new Set(); 218 | for (const h of headlines) { 219 | const abstract = new Abstract(h.id, h.title, h.updated * 1000, h.link, 220 | !h.unread, url, h.marked, h.id); 221 | abstracts.push(abstract); 222 | ids.add(abstract.id); 223 | this.updateAbstract(abstract.id, abstract); 224 | } 225 | 226 | for (const id of summary.catelog) { 227 | if (!ids.has(id)) { 228 | const abstract = this.getAbstract(id); 229 | if (abstract) { 230 | if (!abstract.read) { 231 | abstract.read = true; 232 | this.updateAbstract(abstract.id, abstract); 233 | } 234 | abstracts.push(abstract); 235 | } 236 | } 237 | } 238 | 239 | abstracts.sort((a, b) => b.date - a.date); 240 | summary.catelog = abstracts.map(a => a.id); 241 | this.updateSummary(url, summary); 242 | } 243 | 244 | async getContent(id: string) { 245 | if (!await fileExists(pathJoin(this.dir, 'articles', id.toString()))) { 246 | return await vscode.window.withProgress({ 247 | location: vscode.ProgressLocation.Notification, 248 | title: "Fetching content...", 249 | cancellable: false 250 | }, async () => { 251 | try { 252 | const abstract = this.getAbstract(id)!; 253 | const response = await this.request({ 254 | op: 'getArticle', article_id: abstract.custom_data 255 | }); 256 | const content = response.content[0].content; 257 | const $ = cheerio.load(content); 258 | $('script').remove(); 259 | const html = $.html(); 260 | await this.updateContent(id, html); 261 | return html; 262 | } catch (error: any) { 263 | vscode.window.showErrorMessage('Fetch content failed: ' + error.toString()); 264 | throw error; 265 | } 266 | }); 267 | } else { 268 | return await super.getContent(id); 269 | } 270 | } 271 | 272 | private async _fetchAll(update: boolean) { 273 | const res1 = await this.request({op: 'getFeedTree'}); 274 | const res2 = await this.request({op: 'getFeeds', cat_id: -3}); 275 | const list: any[] = res2.content; 276 | const feed_map = new Map(list.map( 277 | (feed: any): [number, string] => [feed.id, feed.feed_url] 278 | )); 279 | const feeds = new Set(feed_map.values()); 280 | 281 | const walk = (node: any[]) => { 282 | const list: FeedTree = []; 283 | for (const item of node) { 284 | if (item.type === 'category') { 285 | if (item.bare_id < 0) { 286 | continue; 287 | } 288 | const sub = walk(item.items); 289 | if (item.bare_id === 0) { 290 | list.push(...sub); 291 | } else { 292 | list.push({ 293 | name: item.name, 294 | list: sub, 295 | custom_data: item.bare_id 296 | }); 297 | } 298 | } else { 299 | const feed = feed_map.get(item.bare_id); 300 | if (feed === undefined) { 301 | continue; 302 | } 303 | list.push(feed); 304 | let summary = this.getSummary(feed); 305 | if (summary) { 306 | summary.ok = item.error.length <= 0; 307 | summary.title = item.name; 308 | summary.custom_data = item.bare_id; 309 | } else { 310 | summary = new Summary(feed, item.name, [], true, item.bare_id); 311 | } 312 | this.updateSummary(feed, summary); 313 | } 314 | } 315 | return list; 316 | }; 317 | this.feed_tree = walk(res1.content.categories.items); 318 | 319 | for (const feed of this.getFeeds()) { 320 | if (!feeds.has(feed)) { 321 | this.updateSummary(feed, undefined); 322 | } 323 | } 324 | await Promise.all(this.getFeeds().map(url => this.fetch(url, update))); 325 | await this.commit(); 326 | } 327 | 328 | async fetchOne(url: string, update: boolean) { 329 | try { 330 | if (update) { 331 | const summary = this.getSummary(url); 332 | if (summary === undefined || summary.custom_data === undefined) { 333 | throw Error('Feed dose not exist'); 334 | } 335 | await this.request({op: 'updateFeed', feed_id: summary.custom_data}); 336 | } 337 | await this.fetch(url, update); 338 | await this.commit(); 339 | } catch (error: any) { 340 | vscode.window.showErrorMessage('Update feed failed: ' + error.toString()); 341 | } 342 | } 343 | 344 | async fetchAll(update: boolean) { 345 | try { 346 | await this._fetchAll(update); 347 | } catch (error: any) { 348 | vscode.window.showErrorMessage('Update feeds failed: ' + error.toString()); 349 | } 350 | } 351 | 352 | updateAbstract(id: string, abstract?: Abstract) { 353 | this.dirty_abstracts.add(id); 354 | return super.updateAbstract(id, abstract); 355 | } 356 | 357 | private async syncReadStatus(list: number[], read: boolean) { 358 | if (list.length <= 0) { 359 | return; 360 | } 361 | await this.request({ 362 | op: 'updateArticle', 363 | article_ids: list.join(','), 364 | mode: Number(!read), 365 | field: 2, 366 | }); 367 | } 368 | 369 | async commit() { 370 | const read_list: number[] = []; 371 | const unread_list: number[] = []; 372 | for (const id of this.dirty_abstracts) { 373 | const abstract = this.getAbstract(id); 374 | if (abstract) { 375 | if (abstract.read) { 376 | read_list.push(abstract.custom_data); 377 | } else { 378 | unread_list.push(abstract.custom_data); 379 | } 380 | } 381 | } 382 | this.dirty_abstracts.clear(); 383 | Promise.all([ 384 | this.syncReadStatus(read_list, true), 385 | this.syncReadStatus(unread_list, false), 386 | ]).catch(error => { 387 | vscode.window.showErrorMessage('Sync read status failed: ' + error.toString()); 388 | }); 389 | 390 | await writeFile(pathJoin(this.dir, 'feed_list'), JSON.stringify(this.feed_tree)); 391 | await super.commit(); 392 | } 393 | 394 | } 395 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | import * as fse from 'fs-extra'; 3 | import * as tls from 'tls'; 4 | import got_ from 'got'; 5 | 6 | export const got = got_.extend({https: {certificateAuthority: [...tls.rootCertificates]}}); 7 | 8 | export function checkDir(path: string) { 9 | return new Promise(resolve => fs.mkdir(path, resolve)); 10 | } 11 | 12 | export function writeFile(path: string, data: string) { 13 | return new Promise((resolve, reject) => { 14 | fs.writeFile(path, data, {encoding: 'utf-8'}, err => { 15 | if (err) { 16 | reject(err); 17 | } else { 18 | resolve(); 19 | } 20 | }); 21 | }); 22 | } 23 | 24 | export function readFile(path: string) { 25 | return new Promise((resolve, reject) => { 26 | fs.readFile(path, (err, data) => { 27 | if (err) { 28 | reject(err); 29 | } else { 30 | resolve(data.toString('utf-8')); 31 | } 32 | }); 33 | }); 34 | } 35 | 36 | export function moveFile(oldPath: string, newPath: string) { 37 | return fse.move(oldPath, newPath); 38 | } 39 | 40 | export function readDir(path: string) { 41 | return new Promise((resolve, reject) => { 42 | fs.readdir(path, (err, files) => { 43 | if (err) { 44 | reject(err); 45 | } else { 46 | resolve(files); 47 | } 48 | }); 49 | }); 50 | } 51 | 52 | export function removeFile(path: string) { 53 | return new Promise(resolve => { 54 | fs.unlink(path, resolve); 55 | }); 56 | } 57 | 58 | export function removeDir(path: string) { 59 | return fse.remove(path); 60 | } 61 | 62 | export function fileExists(path: string): Promise { 63 | return new Promise(resolve => { 64 | fs.exists(path, resolve); 65 | }); 66 | } 67 | 68 | export function isDirEmpty(path: string): Promise { 69 | return new Promise((resolve, reject) => { 70 | fs.readdir(path, (err, files) => { 71 | if (err) { 72 | reject(err); 73 | } else { 74 | resolve(files.length === 0); 75 | } 76 | }); 77 | }); 78 | } 79 | 80 | export function TTRSSApiURL(server_url: string) { 81 | return server_url.endsWith('/') ? server_url + 'api/' : server_url + '/api/'; 82 | } 83 | 84 | export function *walkFeedTree(tree: FeedTree): Generator { 85 | for (const item of tree) { 86 | if (typeof(item) === 'string') { 87 | yield item; 88 | } else { 89 | for (const feed of walkFeedTree(item.list)) { 90 | yield feed; 91 | } 92 | } 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es6", 5 | "outDir": "out", 6 | "lib": [ 7 | "es6" 8 | ], 9 | "sourceMap": true, 10 | "rootDir": "src", 11 | "strict": true /* enable all strict type-checking options */ 12 | /* Additional Checks */ 13 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 14 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 15 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 16 | }, 17 | "exclude": [ 18 | "node_modules", 19 | ".vscode-test" 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const path = require('path'); 4 | 5 | module.exports = { 6 | target: 'node', 7 | entry: './src/extension.ts', 8 | output: { 9 | path: path.resolve(__dirname, 'out'), 10 | filename: 'extension.js', 11 | libraryTarget: 'commonjs2', 12 | devtoolModuleFilenameTemplate: '../[resource-path]', 13 | }, 14 | 15 | devtool: 'source-map', 16 | externals: { 17 | vscode: 'commonjs vscode', 18 | }, 19 | resolve: { 20 | extensions: ['.ts', '.js'], 21 | }, 22 | 23 | module: { 24 | rules: [ 25 | { 26 | test: /\.ts$/, 27 | exclude: /node_modules/, 28 | use: [ 29 | { 30 | loader: 'ts-loader' 31 | } 32 | ] 33 | } 34 | ] 35 | } 36 | }; 37 | --------------------------------------------------------------------------------