├── .eslintrc.js ├── .github └── workflows │ ├── ci.yml │ ├── publish.yml │ └── stale.yml ├── .gitignore ├── .mocharc.json ├── .nycrc.json ├── .stylelintrc.json ├── LICENSE ├── README.md ├── __mocks__ ├── example.org.xml ├── invalid.xml ├── obsidian.ts └── wallabag.xml ├── babel.config.js ├── esbuild.config.mjs ├── jest.config.js ├── manifest.json ├── package.json ├── src ├── actions │ └── Action.ts ├── consts.ts ├── functions.ts ├── l10n │ ├── README.md │ ├── locale.ts │ └── locales │ │ ├── da.ts │ │ ├── de.ts │ │ ├── en.ts │ │ ├── es.ts │ │ ├── fr.ts │ │ ├── it.ts │ │ ├── pt.ts │ │ ├── ru.ts │ │ ├── test.ts │ │ └── zh.ts ├── main.ts ├── modals │ ├── ArticleSuggestModal.ts │ ├── BaseModal.ts │ ├── CleanupModal.ts │ ├── FeedModal.ts │ ├── FilteredFolderModal.ts │ ├── ImportModal.ts │ ├── ItemModal.ts │ ├── MessageModal.ts │ ├── TagModal.ts │ └── TextInputPrompt.ts ├── parser │ ├── opmlExport.ts │ ├── opmlParser.ts │ └── rssParser.ts ├── providers │ ├── Feed.ts │ ├── FeedProvider.ts │ ├── Folder.ts │ ├── Item.ts │ ├── Providers.ts │ ├── local │ │ ├── LocalFeed.ts │ │ ├── LocalFeedItem.ts │ │ ├── LocalFeedProvider.ts │ │ ├── LocalFeedSettings.ts │ │ └── LocalFolder.ts │ └── nextcloud │ │ ├── NextCloudFeed.ts │ │ ├── NextCloudFeedSettings.ts │ │ ├── NextCloudFolder.ts │ │ ├── NextCloudItem.ts │ │ └── NextcloudFeedProvider.ts ├── settings │ ├── AdvancedSettings.ts │ ├── FileCreationSettings.ts │ ├── FilterSettings.ts │ ├── FolderSuggestor.ts │ ├── HotkeySettings.ts │ ├── MiscSettings.ts │ ├── ProviderSettings.ts │ ├── ProviderValidation.ts │ ├── SettingsSection.ts │ ├── SettingsTab.ts │ ├── settings.ts │ └── suggest.ts ├── stores.ts ├── style │ └── main.scss └── view │ ├── ArraySuggest.ts │ ├── FeedFolderSuggest.ts │ ├── FeedView.svelte │ ├── FolderView.svelte │ ├── HtmlTooltip.svelte │ ├── IconComponent.svelte │ ├── ItemTitle.svelte │ ├── ItemView.svelte │ ├── MainView.svelte │ ├── MarkdownContent.svelte │ ├── OldFolderView.svelte │ ├── TopRowButtons.svelte │ └── ViewLoader.ts ├── test ├── RssParser.test.ts ├── importData.xml ├── locale.test.ts ├── subfolders.xml ├── subscriptions.xml └── tsconfig.json ├── tsconfig.json └── versions.json /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: "@typescript-eslint/parser", 4 | plugins: ["@typescript-eslint"], 5 | extends: ["eslint:recommended", "plugin:@typescript-eslint/recommended"], 6 | rules: { 7 | "@typescript-eslint/no-unused-vars": [ 8 | 2, 9 | { args: "all", argsIgnorePattern: "^_" }, 10 | ], 11 | "no-useless-escape": "off", 12 | "@typescript-eslint/no-explicit-any": "off", 13 | "@typescript-eslint/ban-ts-comment": "off", 14 | }, 15 | }; 16 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | branches: [master] 8 | 9 | jobs: 10 | lint-and-test: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v2 14 | - name: Install modules 15 | run: yarn 16 | - name: Lint 17 | run: yarn run lint 18 | - name: Lint CSS 19 | run: yarn run lint-css 20 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Build plugin 2 | 3 | on: 4 | push: 5 | # Sequence of patterns matched against refs/tags 6 | tags: 7 | - "*" # Push events to matching any tag format, i.e. 1.0, 20.15.10 8 | 9 | env: 10 | PLUGIN_NAME: rss-reader 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@v2 18 | - name: Use Node.js 19 | uses: actions/setup-node@v1 20 | with: 21 | node-version: "14.x" # You might need to adjust this value to your own version 22 | - name: Build 23 | id: build 24 | run: | 25 | npm install -g yarn 26 | yarn 27 | yarn run build 28 | mkdir ${{ env.PLUGIN_NAME }} 29 | cp main.js manifest.json ${{ env.PLUGIN_NAME }} 30 | zip -r ${{ env.PLUGIN_NAME }}.zip ${{ env.PLUGIN_NAME }} 31 | ls 32 | echo "::set-output name=tag_name::$(git tag --sort version:refname | tail -n 1)" 33 | - name: Create Release 34 | id: create_release 35 | uses: actions/create-release@v1 36 | env: 37 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 38 | VERSION: ${{ github.ref }} 39 | with: 40 | tag_name: ${{ github.ref }} 41 | release_name: ${{ github.ref }} 42 | draft: false 43 | prerelease: false 44 | - name: Upload zip file 45 | id: upload-zip 46 | uses: actions/upload-release-asset@v1 47 | env: 48 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 49 | with: 50 | upload_url: ${{ steps.create_release.outputs.upload_url }} 51 | asset_path: ./${{ env.PLUGIN_NAME }}.zip 52 | asset_name: ${{ env.PLUGIN_NAME }}-${{ steps.build.outputs.tag_name }}.zip 53 | asset_content_type: application/zip 54 | - name: Upload main.js 55 | id: upload-main 56 | uses: actions/upload-release-asset@v1 57 | env: 58 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 59 | with: 60 | upload_url: ${{ steps.create_release.outputs.upload_url }} 61 | asset_path: ./main.js 62 | asset_name: main.js 63 | asset_content_type: text/javascript 64 | - name: Upload manifest.json 65 | id: upload-manifest 66 | uses: actions/upload-release-asset@v1 67 | env: 68 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 69 | with: 70 | upload_url: ${{ steps.create_release.outputs.upload_url }} 71 | asset_path: ./manifest.json 72 | asset_name: manifest.json 73 | asset_content_type: application/json 74 | - name: Upload main.scss 75 | id: upload-styles 76 | uses: actions/upload-release-asset@v1 77 | env: 78 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 79 | with: 80 | upload_url: ${{ steps.create_release.outputs.upload_url }} 81 | asset_path: ./main.scss 82 | asset_name: main.scss 83 | asset_content_type: text/css 84 | -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | # This workflow warns and then closes issues and PRs that have had no activity for a specified amount of time. 2 | # 3 | # You can adjust the behavior by modifying this file. 4 | # For more information, see: 5 | # https://github.com/actions/stale 6 | name: Mark stale issues and pull requests 7 | 8 | on: 9 | schedule: 10 | - cron: '32 6 * * *' 11 | 12 | jobs: 13 | stale: 14 | 15 | runs-on: ubuntu-latest 16 | permissions: 17 | issues: write 18 | pull-requests: write 19 | 20 | steps: 21 | - uses: actions/stale@v3 22 | with: 23 | repo-token: ${{ secrets.GITHUB_TOKEN }} 24 | days-before-issue-stale: 30 25 | days-before-issue-close: 14 26 | stale-issue-label: "stale" 27 | stale-issue-message: "This issue is stale because it has been open for 30 days with no activity." 28 | close-issue-message: "This issue was closed because it has been inactive for 14 days since being marked as stale." 29 | any-of-issue-labels: "question" 30 | days-before-pr-stale: -1 31 | days-before-pr-close: -1 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Intellij 2 | *.iml 3 | .idea 4 | 5 | # npm 6 | node_modules 7 | package-lock.json 8 | 9 | # build 10 | main.js 11 | *.js.map 12 | build/ 13 | 14 | # obsidian 15 | data.json 16 | .nyc_output 17 | coverage 18 | -------------------------------------------------------------------------------- /.mocharc.json: -------------------------------------------------------------------------------- 1 | { 2 | "require": [ 3 | "./registerTests.js" 4 | ], 5 | "reporter": "dot" 6 | } 7 | -------------------------------------------------------------------------------- /.nycrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@istanbuljs/nyc-config-typescript", 3 | "include": [ 4 | "src/**/*.ts" 5 | ], 6 | "reporter": [ 7 | "text-summary", 8 | "html", 9 | "lcov", 10 | "cobertura" 11 | ], 12 | "report-dir": "./coverage", 13 | "all": true 14 | } 15 | -------------------------------------------------------------------------------- /.stylelintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["stylelint-config-recommended"], 3 | "rules": { 4 | "font-family-no-missing-generic-family-keyword": null, 5 | "no-descending-specificity": null 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## RSS Reader 2 | Plugin for [Obsidian](https://obsidian.md) 3 | 4 | ![GitHub package.json version](https://img.shields.io/github/package-json/v/joethei/obsidian-rss) 5 | ![GitHub manifest.json dynamic (path)](https://img.shields.io/github/manifest-json/minAppVersion/joethei/obsidian-rss?label=lowest%20supported%20app%20version) 6 | ![GitHub](https://img.shields.io/github/license/joethei/obsidian-rss) 7 | [![libera manifesto](https://img.shields.io/badge/libera-manifesto-lightgrey.svg)](https://liberamanifesto.com) 8 | --- 9 | ![](https://i.joethei.space/obsidian-rss.png) 10 | 11 | 12 | ## Features 13 | - Reading RSS feeds from within obsidian 14 | - Sorting feeds into folders 15 | - staring articles 16 | - creating new notes from articles 17 | - pasting article into current note 18 | - creating custom filters 19 | - tagging articles 20 | - support for audio and video feeds 21 | - reading articles with Text to speech (if the [TTS plugin](https://github.com/joethei/obsidian-tts) is installed) 22 | - multi language support(see [#43](https://github.com/joethei/obsidian-rss/issues/43) for translation instructions) 23 | - and more on the [Roadmap](https://github.com/joethei/obsidian-rss/projects/1) 24 | 25 | ![Demo GIF](https://i.joethei.space/QQATWu36eC.gif) 26 | 27 | ## Getting Started 28 | 29 | After installing the plugin: 30 | 31 | - Go to the plugin configuration and add a feed (under the *Content* section). 32 | - In Obsidian, expand the right hand pane and click the RSS tab. 33 | 34 | ## Finding the RSS feed for a website 35 | 36 | - Search for the RSS logo or a link on the website 37 | - Use an browser addon ([Firefox](https://addons.mozilla.org/en-US/firefox/addon/awesome-rss/), [Chrome based](https://chrome.google.com/webstore/detail/get-rss-feed-url/kfghpdldaipanmkhfpdcjglncmilendn)) 38 | - Search the websites sourcecode for `rss` 39 | 40 | ## Tips 41 | - get fulltext content for some truncated RSS feeds with [morss.it](https://morss.it/) 42 | - get feeds from some social media sites with [RSS Box](https://rssbox.herokuapp.com/) 43 | - Filter content from feeds with [SiftRSS](https://siftrss.com/) 44 | - Get an RSS feed for a site that does not support RSS with [RSS-proxy](https://github.com/damoeb/rss-proxy/) or [RSS Hub](https://github.com/DIYgod/RSSHub) 45 | 46 | ## Template variables 47 | - `{{title}}` title of article 48 | - `{{link}}` link to article 49 | - `{{author}}` author of article 50 | - `{{published}}` publishing date, you can also specify a custom date format like this: `{{published:YYYYMMDD}}` 51 | - `{{created}}` date of note creation, you can also specify a custom date format like this: `{{created:YYYYMMDD}}` 52 | - `{{content}}` the actual content 53 | - `{{description}}` short description 54 | - `{{folder}}` the folder the feed is in 55 | - `{{feed}}` the name of the feed 56 | - `{{filename}}` the filename, only available in the new file template 57 | - `{{tags}}` - tags, seperated by comma, you can also specify a seperator like this: `{{tags:;}}` 58 | - `{{#tags}}` - tags with #, seperated by comma, with #, you can also specify a seperator like this: `{{#tags:;}}` 59 | - `{{media}}` link to media 60 | - `{{highlights}}` - list of highlights, you can also specify a custom style, this example creates a [admonition](https://github.com/valentine195/obsidian-admonition) for each highlight: 61 | ![](https://i.joethei.space/obsidian-rss-highlight-syntax.png) 62 | 63 | ## ⚠ Security 64 | - This plugin contacts the servers that host the RSS feeds you have specified. 65 | - RSS feeds can contain arbitrary data, this data will get sanitized before being displayed. 66 | - Many Obsidian plugins use codeblocks to add some functionality. This plugin sanitizes these codeblocks at read/note creation time. This is to block rss feeds from executing arbitrary plugin code. 67 | - Some plugins allow for different kinds of inline syntax's, these are treated individually (Currently only _Dataview_ and _Templater_). 68 | 69 | 70 | ## Styling 71 | If you want to style the plugin differently you can use the following css classes 72 | 73 | - rss-read 74 | - rss-not-read 75 | - rss-filters 76 | - rss-folders 77 | - rss-folder 78 | - rss-feed 79 | - rss-feed-title 80 | - rss-feed-items 81 | - rss-feed-item 82 | - rss-tag 83 | - rss-tooltip 84 | - rss-modal 85 | - rss-title 86 | - rss-subtitle 87 | - rss-content 88 | 89 | For help with styling you can also check out the `#appearance` channel on the [Obsidian Members Group Discord](https://obsidian.md/community) 90 | 91 | ### Installing the plugin 92 | - `Settings > Community plugins > Community Plugins > Browse` and search for `RSS Reader` 93 | 94 | -------------------------------------------------------------------------------- /__mocks__/example.org.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Example Domain 5 | 6 | 7 | 8 | 9 | 36 | 37 | 38 | 39 |
40 |

Example Domain

41 |

This domain is for use in illustrative examples in documents. You may use this 42 | domain in literature without prior coordination or asking for permission.

43 |

More information...

44 |
45 | 46 | 47 | -------------------------------------------------------------------------------- /__mocks__/invalid.xml: -------------------------------------------------------------------------------- 1 | < 2 | -------------------------------------------------------------------------------- /__mocks__/obsidian.ts: -------------------------------------------------------------------------------- 1 | import "isomorphic-fetch"; 2 | import "fs"; 3 | import * as fs from "fs"; 4 | import * as path from "path"; 5 | 6 | export interface RequestParam { 7 | url: string; 8 | method?: string; 9 | contentType?: string; 10 | body?: string; 11 | headers?: Record; 12 | } 13 | 14 | export async function request(request: RequestParam) : Promise { 15 | if(!request.url.startsWith("http")) { 16 | const filePath = path.join(__dirname, request.url); 17 | return fs.readFileSync(filePath, 'utf-8'); 18 | } 19 | 20 | const result = await fetch(request.url,{ 21 | headers: request.headers, 22 | method: request.method, 23 | body: request.body 24 | }); 25 | return result.text(); 26 | } 27 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: ['@babel/preset-env'] 3 | } 4 | -------------------------------------------------------------------------------- /esbuild.config.mjs: -------------------------------------------------------------------------------- 1 | import esbuild from "esbuild"; 2 | import fs from 'fs'; 3 | import process from "process"; 4 | import builtins from 'builtin-modules'; 5 | import sass from "sass"; 6 | import autoprefixer from "autoprefixer"; 7 | import postcss from "postcss"; 8 | import cssnano from "cssnano"; 9 | import defaultPreset from "cssnano-preset-default"; 10 | import sveltePlugin from "esbuild-svelte"; 11 | import sveltePreprocess from "svelte-preprocess"; 12 | 13 | const banner = 14 | `/* 15 | THIS IS A GENERATED/BUNDLED FILE BY ESBUILD 16 | if you want to view the source, please visit the github repository of this plugin 17 | https://github.com/joethei/obisidian-rss 18 | */ 19 | `; 20 | 21 | const prod = (process.argv[2] === 'production'); 22 | 23 | const copyMinifiedCSS = { 24 | name: 'minify-css', 25 | setup: (build) => { 26 | build.onEnd(async () => { 27 | const {css} = sass.compile('src/style/main.scss'); 28 | let result; 29 | if (prod) { 30 | const content = `${banner}\n${css}`; 31 | const preset = defaultPreset({discardComments: false}); 32 | result = await postcss([cssnano({ 33 | preset: preset, 34 | plugins: [ 35 | autoprefixer, 36 | ] 37 | })]).process(content); 38 | } else { 39 | const content = `${banner}\n${css}`; 40 | result = await postcss([autoprefixer]).process(content); 41 | } 42 | 43 | fs.writeFileSync('build/styles.css', result.css, {encoding: 'utf-8'}); 44 | }) 45 | } 46 | } 47 | 48 | const copyManifest = { 49 | name: 'copy-manifest', 50 | setup: (build) => { 51 | build.onEnd(() => { 52 | fs.copyFileSync('manifest.json', 'build/manifest.json'); 53 | }); 54 | }, 55 | }; 56 | 57 | esbuild.build({ 58 | banner: { 59 | js: banner, 60 | }, 61 | entryPoints: ['src/main.ts'], 62 | bundle: true, 63 | external: ['obsidian', 'electron', ...builtins], 64 | plugins: [sveltePlugin({ 65 | preprocess: sveltePreprocess() 66 | }), copyManifest, copyMinifiedCSS], 67 | format: 'cjs', 68 | watch: !prod, 69 | target: 'es2016', 70 | logLevel: "info", 71 | sourcemap: prod ? false : 'inline', 72 | treeShaking: true, 73 | outfile: 'build/main.js', 74 | }).catch(() => process.exit(1)); 75 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | transform: {"\\.ts$": ['ts-jest']}, 3 | collectCoverage: true, 4 | testEnvironment: "jsdom", 5 | moduleDirectories: ["node_modules", "src", "test"], 6 | coverageReporters: ["lcov", "text", "teamcity"], 7 | testResultsProcessor: "jest-teamcity-reporter", 8 | testMatch: ["**/test/**/*.test.ts"] 9 | }; 10 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "rss-reader", 3 | "name": "RSS Reader", 4 | "version": "1.2.2", 5 | "minAppVersion": "0.13.33", 6 | "description": "Read RSS Feeds from within obsidian", 7 | "author": "Johannes Theiner", 8 | "authorUrl": "https://github.com/joethei", 9 | "isDesktopOnly": false 10 | } 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rss-reader", 3 | "version": "1.3.0", 4 | "description": "Read RSS Feeds from inside obsidian", 5 | "main": "main.js", 6 | "scripts": { 7 | "dev": "node esbuild.config.mjs", 8 | "build": "node esbuild.config.mjs production", 9 | "lint": "eslint . --ext .ts", 10 | "lint-css": "stylelint src/style/main.scss", 11 | "test": "jest --coverage", 12 | "test:watch": "jest --watch --coverage" 13 | }, 14 | "keywords": [], 15 | "author": "Johannes Theiner", 16 | "license": "GPL-3.0", 17 | "devDependencies": { 18 | "@types/lodash.groupby": "^4.6.6", 19 | "@types/lodash.keyby": "^4.6.6", 20 | "@types/lodash.mergewith": "^4.6.6", 21 | "@types/lodash.sortby": "^4.7.6", 22 | "@types/lodash.values": "^4.3.6", 23 | "@types/mocha": "^9.0.0", 24 | "@types/node": "^14.14.37", 25 | "@typescript-eslint/eslint-plugin": "^4.33.0", 26 | "@typescript-eslint/parser": "^4.33.0", 27 | "builtin-modules": "^3.3.0", 28 | "esbuild": "0.13.15", 29 | "esbuild-svelte": "^0.6.0", 30 | "eslint": "^7.32.0", 31 | "isomorphic-fetch": "3.0.0", 32 | "jest": "27.5.1", 33 | "jest-teamcity-reporter": "0.9.0", 34 | "jsdom": "^19.0.0", 35 | "process": "^0.11.10", 36 | "stylelint": "^14.1.0", 37 | "ts-jest": "27.1.4", 38 | "tslib": "^2.3.1", 39 | "tslint": "^6.1.3", 40 | "typescript": "^4.2.4", 41 | "sass": "1.53.0", 42 | "stylelint-scss": "4.3.0", 43 | "stylelint-config-standard": "26.0.0", 44 | "stylelint-config-standard-scss": "5.0.0", 45 | "postcss": "8.4.14", 46 | "autoprefixer": "10.4.7", 47 | "cssnano": "5.1.12", 48 | "cssnano-preset-default": "5.2.12", 49 | "svelte-preprocess": "^4.9.8" 50 | }, 51 | "dependencies": { 52 | "@popperjs/core": "^2.10.2", 53 | "@types/nunjucks": "^3.2.1", 54 | "@vanakat/plugin-api": "0.1.0", 55 | "jsdom-global": "^3.0.2", 56 | "lodash.groupby": "^4.6.0", 57 | "lodash.keyby": "^4.6.0", 58 | "lodash.mergewith": "^4.6.2", 59 | "lodash.sortby": "^4.7.0", 60 | "lodash.values": "^4.3.0", 61 | "nunjucks": "3.2.3", 62 | "obsidian": "0.16.3", 63 | "svelte": "^3.43.1", 64 | "ts-md5": "^1.2.10", 65 | "ts-node": "^10.4.0" 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/actions/Action.ts: -------------------------------------------------------------------------------- 1 | import {copy, createNewNote, openInBrowser, pasteToNote} from "../functions"; 2 | import RssReaderPlugin from "../main"; 3 | import {htmlToMarkdown, Notice} from "obsidian"; 4 | import {TagModal} from "../modals/TagModal"; 5 | import t from "../l10n/locale"; 6 | import {Item} from "../providers/Item"; 7 | 8 | export default class Action { 9 | 10 | static CREATE_NOTE = new Action(t("create_note"), "create-new", (plugin, item) : Promise => { 11 | return createNewNote(plugin, item); 12 | }); 13 | 14 | static PASTE = new Action(t("paste_to_note"), "paste", (plugin, item) : Promise => { 15 | return pasteToNote(plugin, item); 16 | }); 17 | 18 | static COPY = new Action(t("copy_to_clipboard"), "documents", ((_, item) : Promise => { 19 | return copy(htmlToMarkdown(item.body())); 20 | })); 21 | 22 | static OPEN = new Action(t("open_browser"), "open-elsewhere-glyph", ((_, item) : Promise => { 23 | openInBrowser(item); 24 | return Promise.resolve(); 25 | })); 26 | 27 | static TAGS = new Action(t("edit_tags"), "tag-glyph", (((plugin, item) => { 28 | const modal = new TagModal(plugin, item.tags()); 29 | 30 | modal.onClose = async () => { 31 | item.setTags(modal.tags); 32 | const items = plugin.settings.items; 33 | await plugin.writeFeedContent(() => { 34 | return items; 35 | }); 36 | }; 37 | 38 | modal.open(); 39 | return Promise.resolve(); 40 | }))); 41 | 42 | static READ = new Action(t("mark_as_read_unread"), "eye", ((async (plugin, item) : Promise => { 43 | if (item.read()) { 44 | item.markRead(false); 45 | new Notice(t("marked_as_unread")); 46 | } else { 47 | item.markRead(true); 48 | new Notice(t("marked_as_read")); 49 | } 50 | /*const items = plugin.settings.items; 51 | await plugin.writeFeedContent(() => { 52 | return items; 53 | });*/ 54 | return Promise.resolve(); 55 | }))); 56 | 57 | static FAVORITE = new Action(t("mark_as_favorite_remove"), "star", ((async (plugin, item) : Promise => { 58 | if (item.starred()) { 59 | item.markStarred(false); 60 | new Notice(t("removed_from_favorites")); 61 | } else { 62 | item.markStarred(true); 63 | new Notice(t("added_to_favorites")); 64 | } 65 | /*const items = plugin.settings.items; 66 | await plugin.writeFeedContent(() => { 67 | return items; 68 | }); 69 | */ 70 | return Promise.resolve(); 71 | }))); 72 | 73 | static actions = Array.of(Action.FAVORITE, Action.READ, Action.TAGS, Action.CREATE_NOTE, Action.PASTE, Action.COPY, Action.OPEN); 74 | 75 | readonly name: string; 76 | readonly icon: string; 77 | readonly processor: (plugin: RssReaderPlugin, value: Item) => Promise; 78 | 79 | constructor(name: string, icon: string, processor: (plugin: RssReaderPlugin, item: Item) => Promise) { 80 | this.name = name; 81 | this.icon = icon; 82 | this.processor = processor; 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/consts.ts: -------------------------------------------------------------------------------- 1 | export const VIEW_ID = "RSS_FEED"; 2 | export const FILE_NAME_REGEX = /["\/<>:|?]/gm; 3 | export const TAG_REGEX = /([\p{Letter}\p{Emoji_Presentation}\p{Number}\/_-]+)/u; 4 | export const NUMBER_REGEX = /^[0-9]*$/gm; 5 | 6 | //taken from: https://stackoverflow.com/a/43467144/5589264 7 | export function isValidHttpUrl(string: string) : boolean { 8 | let url; 9 | 10 | try { 11 | url = new URL(string); 12 | } catch (_) { 13 | return false; 14 | } 15 | 16 | return url.protocol === "http:" || url.protocol === "https:"; 17 | } 18 | -------------------------------------------------------------------------------- /src/l10n/README.md: -------------------------------------------------------------------------------- 1 | ## Localization 2 | 3 | The plugin has full localization support, and will attempt to load the current Obsidian locale. If one does not exist, it will fall back to English. 4 | 5 | ### Adding a new Locale 6 | 7 | New locales can be added by creating a pull request. Two things should be done in this pull request: 8 | 9 | 1. Create the locale in the `locales` folder by copying the `en.ts` file. This file should be given a name matching the [ISO 639-1](https://www.loc.gov/standards/iso639-2/php/English_list.php) code for that language. 10 | 2. Create the translation by editing the value of each property. 11 | 3. Add the import in `locales.ts`. 12 | 4. Add the language to the `localeMap` variable. 13 | 14 | #### Wildcards 15 | 16 | Some strings in the locale have wildcards in them, such as `%1`. This is used by the plugin to insert dynamic data into the translated string. 17 | 18 | For example: 19 | 20 | `Loading RSS Reader v%1`: The plugin will insert the version number for `%1`. 21 | 22 | This allows control of plural syntaxes or placement of file names in the sentence. 23 | -------------------------------------------------------------------------------- /src/l10n/locale.ts: -------------------------------------------------------------------------------- 1 | //taken from https://github.com/valentine195/obsidian-leaflet-plugin/blob/master/src/l10n/locale.ts 2 | import en from "./locales/en"; 3 | import de from "./locales/de"; 4 | import zh from "./locales/zh"; 5 | import fr from "./locales/fr"; 6 | import pt from "./locales/pt"; 7 | import it from "./locales/it"; 8 | import es from "./locales/es"; 9 | import da from "./locales/da"; 10 | import test from "./locales/test"; 11 | 12 | /* istanbul ignore next */ 13 | const locale = (window.moment) ? window.moment.locale() : "test"; 14 | 15 | const localeMap: { [k: string]: Partial } = { 16 | en, 17 | de, 18 | "zh-cn": zh, 19 | fr, 20 | es, 21 | test, 22 | pt, 23 | it, 24 | da 25 | }; 26 | 27 | const userLocale = localeMap[locale]; 28 | 29 | export default function t(str: keyof typeof en, ...inserts: string[]): string { 30 | let localeStr = (userLocale && userLocale[str]) ?? en[str]; 31 | 32 | for (let i = 0; i < inserts.length; i++) { 33 | localeStr = localeStr.replace(`%${i + 1}`, inserts[i]); 34 | } 35 | 36 | return localeStr; 37 | } 38 | -------------------------------------------------------------------------------- /src/l10n/locales/da.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | //these values are only used in testing, don't overwrite them 3 | testingValue: "", 4 | testingInserts: "", 5 | 6 | RSS_Reader: "RSS Reader", 7 | RSS_Feeds: "RSS Feeds", 8 | 9 | //commands 10 | open: "Åben", 11 | refresh_feeds: "Opdater feeds", 12 | create_all: "Opret alle", 13 | 14 | //folder actions 15 | mark_all_as_read: "Marker samtlige som læst", 16 | add_tags_to_all: "Tilføj tags til samtlige", 17 | 18 | filtered_folders: "Filtrerede mapper", 19 | folders: "Mapper", 20 | folder: "Mappe", 21 | feeds: "Feeds", 22 | 23 | //article actions 24 | create_note: "opret ny note", 25 | paste_to_note: "indsæt til nuværende note", 26 | copy_to_clipboard: "kopier til udklipsholder", 27 | open_browser: "åbn i browser", 28 | edit_tags: "rediger tags", 29 | mark_as_read: "marker som læst", 30 | mark_as_unread: "marker som ulæst", 31 | mark_as_favorite: "marker som favorit", 32 | remove_from_favorites: "fjern fra favoritter", 33 | read_article_tts: "læs artikel med TTS (tekst-til-tale)", 34 | next: "næste", 35 | previous: "forrige", 36 | 37 | mark_as_read_unread: "marker som læst/ulæst", 38 | mark_as_favorite_remove: "marker som/fjern fra favoritter", 39 | 40 | //action notifications 41 | marked_as_read: "markeret som læst", 42 | marked_as_unread: "markeret som ulæst", 43 | removed_from_favorites: "fjernet fra favoritter", 44 | added_to_favorites: "markeret som favorit", 45 | 46 | read: "læst", 47 | unread: "ulæst", 48 | favorites: "Favoritter", 49 | favorite: "Favorit", 50 | tags: "Tags", 51 | tag: "Tag", 52 | 53 | //base modal 54 | save: "Gem", 55 | cancel: "Annuller", 56 | delete: "Slet", 57 | edit: "Rediger", 58 | reset: "gendan standard", 59 | fix_errors: "Ret fejl før du gemmer.", 60 | 61 | add_new: "Tilføj ny", 62 | 63 | //feed settings 64 | add_new_feed: "Tilføj nyt feed", 65 | feed_already_configured: "du har allerede et RSS feed konfigureret med det samme url", 66 | no_folder: "Ingen mappe", 67 | 68 | //feed creation modal 69 | name: "Navn", 70 | name_help: "Hvad vil du gerne have at dette RSS feed skal vises som?", 71 | url_help: "Hvilket URL passer til dette RSS feed?", 72 | folder_help: "Hvilken kategori vil du angive dette RSS feed som?", 73 | 74 | invalid_name: "Venligst angiv et navn", 75 | invalid_url: "URL er ikke gyldigt", 76 | invalid_feed: "RSS feed har ingen elementer", 77 | 78 | //filter types 79 | filter_tags: "Alle artikler med tags", 80 | filter_unread: "Alle ulæste artikler (fra mapper)", 81 | filter_read: "Alle læste artikler (fra mapper)", 82 | filter_favorites: "Favoritter (fra mapper)", 83 | 84 | //sort order 85 | sort_date_newest: 'Udgivelsesdato (ny til gammel)', 86 | sort_date_oldest: 'Udgivelsesdato (gammel til ny)', 87 | sort_alphabet_normal: 'Navn (A til Å)', 88 | sort_alphabet_inverted: 'Navn (Å til A)', 89 | sort: 'Sorter efter', 90 | 91 | //filter creation modal 92 | filter_name_help: 'Hvad vil du have, at dette filter skal vises som?', 93 | filter_type: 'Type', 94 | filter_type_help: 'Filtreringstype', 95 | filter: 'Filter', 96 | filter_help: 'Filtrere ved hjælp af mapper/tags, opdelt efter ,', 97 | only_favorites: 'Vis kun favoritter', 98 | show_read: "Vis læste", 99 | show_unread: "Vis ulæste", 100 | filter_folder_help: "Vis kun artikler fra følgende mapper", 101 | filter_feed_help: "Vis kun artikler fra følgende feeds", 102 | filter_tags_help: "Vis kun artikler med følgende tags", 103 | 104 | from_folders: "fra mapper: ", 105 | from_feeds: "fra RSS feeds: ", 106 | with_tags: "inklusive tags: ", 107 | 108 | no_feed_with_name: "Der er intet RSS feed med dette navn", 109 | invalid_tag: "Dette er ikke et gyldigt tag", 110 | 111 | note_exists: "der findes allerede en note med dette navn", 112 | invalid_filename: "filnavnet er ikke gyldigt", 113 | 114 | specify_name: "Angiv et filnavn", 115 | cannot_contain: "kan ikke indeholde:", 116 | created_note: "Oprettet note fra artikel", 117 | inserted_article: "insat artikel i note", 118 | no_file_active: "ingen fil er aktiv", 119 | 120 | 121 | //settings 122 | settings: "Indstillinger", 123 | file_creation: "Fil oprettelse", 124 | template_new: "skabelon til ny fil", 125 | template_new_help: "Når du opretter en note fra en artikel, bliver dette behandlet.", 126 | template_paste: "indsæt artikelskabelon", 127 | template_paste_help: "Når man indsætter/kopierer en artikel, bliver dette behandlet.", 128 | available_variables: "Tilgængelige variabler:", 129 | file_location: "Default location for new notes", 130 | file_location_help: "Standardplacering for nye noter", 131 | file_location_default: "I standard mappe", 132 | file_location_custom: "I nedenstående mappe", 133 | file_location_folder: "Mappe til at oprette nye artikler i", 134 | file_location_folder_help: "nye artikler vil vises i denne mappe", 135 | 136 | date_format: "Dato format", 137 | syntax_reference: "Syntaksreference", 138 | syntax_looks: "Den valgte syntaks ser således ud: ", 139 | 140 | ask_filename: "Spørg efter filnavn", 141 | ask_filename_help: "Deaktiver for at anvende nedenstående skabelon (ugyldige symboler bliver fjernet)", 142 | refresh_time: "Opdateringstid", 143 | refresh_time_help: "Hvor ofte skal RSS feeds opdateres på, angivet i minutter. Anvend 0 for at deaktivere automatisk opdatering", 144 | specify_positive_number: "angiv venligst et positivt tal", 145 | multi_device_usage: "Anvendelse af flere enheder", 146 | multi_device_usage_help: "Hold status for artikler synkroniseret, når du anvender flere enheder på samme tid\n(Kræver genstart)", 147 | 148 | add_new_filter: "Tilføj ny filtreret mappe", 149 | filter_exists: "der er allerede et filter konfigureret med det navn", 150 | hotkeys: "Genvejstaster", 151 | hotkeys_reading: "når du læser en artikel", 152 | press_key: "tryk på en tast", 153 | customize_hotkey: "tilpas genvejstasten", 154 | 155 | refreshed_feeds: "RSS feeds er opdateret", 156 | 157 | //import modal 158 | import: "Import", 159 | import_opml: "Importere fra OPML", 160 | imported_x_feeds: "Importerede %1 feeds", 161 | choose_file: "Vælg fil", 162 | choose_file_help: "Vælg en fil der skal importeres", 163 | export_opml: "Eksportere som OPML", 164 | 165 | default_filename: "Skabelon til filnavn", 166 | default_filename_help: "Alle variabler fra skabelonen er tilgængelige", 167 | 168 | //cleanup modal 169 | cleanup: "Ryd op i artikler", 170 | cleanup_help: "Fjerner elementer, der passer til kriterierne specificeret nedenfor.", 171 | cleanup_help2: "Bemærk at artikler der stadig eksisterer i RSS feedet, vil vise sig når feedet bliver opdateret igen", 172 | perform_cleanup: "Udfør oprydning", 173 | all: "alle", 174 | from_feed: "fra feed", 175 | older_than: "ældre end X dage", 176 | older_than_help: "hold tom for alle vil blive ignoreret hvis der ikke er nogen udgivelsesdato forbundet med indtastningen", 177 | advanced: "Avanceret", 178 | remove_wrong_feed: "Fjern alle artikler, der er i det forkerte feed", 179 | remove_wrong_feed_help: "Dette kan være sket på grund af en fejl i versionerne før 0.8", 180 | scanning_items: "Gennemgår artikler (%1 / %2)", 181 | 182 | created_export: "Oprettet OPML-fil i din Vault", 183 | add: "Tilføj", 184 | from_archive: "Hent gamle artikler fra archive.org", 185 | reading_archive: "Læser data fra arkiv", 186 | scanning_duplicates: "Scanner for dubletter", 187 | do_not_close: "Luk ikke dette vindue", 188 | 189 | display_style: "Visning", 190 | list: "Liste", 191 | cards: "Kort", 192 | 193 | customize_terms: "Tilpas begreber", 194 | content: "Indhold", 195 | highlight: "Marker", 196 | highlight_remove: "fjern markering", 197 | 198 | filter_folder_ignore_help: "ignorer følgende mapper", 199 | filter_feed_ignore_help: "ignorer følgende feeds", 200 | filter_tags_ignore_help: "ignorer følgende tags", 201 | 202 | loading: "Indlæser", 203 | 204 | //template settings 205 | article_title: "Titel", 206 | article_link: "Link til artikel", 207 | article_author: "Artiklens forfatter", 208 | article_published: "Udgivelsesdato", 209 | article_description: "Kort artikelbeskrivelse", 210 | article_content: "Indhold i artiklen", 211 | article_tags: "Tags opdelt med komma", 212 | article_media: "Link til video/lydfil", 213 | feed_folder: "Feed mappe", 214 | feed_title: "Feed titel", 215 | highlights: "Markeringer", 216 | note_created: "Oprettet", 217 | filename: "Filnavn", 218 | 219 | misc: "Diverse", 220 | 221 | display_media: "Inkluder medier", 222 | base_folder: "Standard mappe", 223 | 224 | provider: "Udbyder" 225 | } 226 | -------------------------------------------------------------------------------- /src/l10n/locales/de.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | RSS_Reader: "RSS Reader", 3 | RSS_Feeds: "RSS Feeds", 4 | 5 | //commands 6 | open: "Öffnen", 7 | refresh_feeds: "Feeds neu laden", 8 | create_all: "Alle erstellen", 9 | 10 | //folder actions 11 | mark_all_as_read: "Alle als gelesen markieren", 12 | add_tags_to_all: "Tags zu allen hinzufügen", 13 | 14 | filtered_folders: "Gefilterte Ordner", 15 | folders: "Ordner", 16 | folder: "Ordner", 17 | feeds: "Feeds", 18 | 19 | //article actions 20 | create_note: "Neue Notiz erstellen", 21 | paste_to_note: "In aktuelle Notiz einfügen", 22 | copy_to_clipboard: "In die Zwischenablage kopieren", 23 | open_browser: "Im Webbrowser öffnen", 24 | edit_tags: "Tags bearbeiten", 25 | mark_as_read: "Als gelesen markieren", 26 | mark_as_unread: "Als ungelesen markieren", 27 | mark_as_favorite: "As Favorit markieren", 28 | remove_from_favorites: "Aus den Favoriten entfernen", 29 | read_article_tts: "Vorlesen", 30 | next: "nächster", 31 | previous: "vorheriger", 32 | 33 | mark_as_read_unread: "Als gelesen/ungelesen markieren", 34 | mark_as_favorite_remove: "Als Favorit markieren/Aus den Favoriten entfernen", 35 | 36 | //action notifications 37 | marked_as_read: "Als gelesen markiert", 38 | marked_as_unread: "Als ungelesen markiert", 39 | removed_from_favorites: "Von den Favoriten entfernt", 40 | added_to_favorites: "Als Favorit markiert", 41 | 42 | read: "gelesen", 43 | unread: "ungelesen", 44 | favorites: "Favoriten", 45 | favorite: "Favorit", 46 | tags: "Tags", 47 | tag: "Tag", 48 | 49 | //base modal 50 | save: "Speichern", 51 | cancel: "Abbrechen", 52 | delete: "Löschen", 53 | edit: "Bearbeiten", 54 | reset: "zurücksetzen", 55 | fix_errors: "Bitte behebe die Fehler vor dem speichern.", 56 | 57 | add_new: "neu hinzufügen", 58 | 59 | //feed settings 60 | add_new_feed: "neuen Feed hinzufügen", 61 | feed_already_configured: "Es existiert bereits ein Feed mit dieser URL", 62 | no_folder: "Kein Ordner", 63 | 64 | //feed creation modal 65 | name: "Name", 66 | name_help: "Unter welchem Namen soll dieser Feed angezeigt werden?", 67 | url_help: "Wie lautet die URL zu diesem Feed?", 68 | folder_help: "Als was kategorisierst du diesen Feed?", 69 | 70 | invalid_name: "Du must einen gültigen Namen vergeben", 71 | invalid_url: "diese URL ist nicht gültig", 72 | invalid_feed: "Dieser Feed hat keine Einträge", 73 | 74 | //filter types 75 | filter_tags: "Alle Artikel mit Tags", 76 | filter_unread: "Alle ungelesenen Artikel(aus Ordnern)", 77 | filter_read: "Alle gelesenen Artikel(aus Ordnern)", 78 | filter_favorites: "Favoriten(aus Ordnern)", 79 | 80 | //sort order 81 | sort_date_newest: 'Veröffentlichungsdatum (neu - alt)', 82 | sort_date_oldest: 'Veröffentlichungsdatum (alt - neu)', 83 | sort_alphabet_normal: 'Name (A - Z)', 84 | sort_alphabet_inverted: 'Name (Z - A)', 85 | sort: 'Ordnen nach', 86 | 87 | //filter creation modal 88 | filter_name_help: 'Wie soll der Filter angezeigt werden?', 89 | filter_type: 'Typ', 90 | filter_type_help: 'Typ des Filters', 91 | filter: 'Filter', 92 | filter_help: 'Order/Tags die gefiltert werden sollen, getrennt durch ,', 93 | only_favorites: 'Zeige nur Favoriten', 94 | show_read: "Zeige gelesene", 95 | show_unread: "Zeige ungelesene", 96 | filter_folder_help: "Zeige nur Artikel aus den folgenden Ordnern", 97 | filter_feed_help: "Zeige nur Artikel aus den folgenden Feeds", 98 | filter_tags_help: "Zeige nur Artikel mit den folgenden Tags", 99 | 100 | from_folders: "Aus Ordnern: ", 101 | from_feeds: "Aus Feeds: ", 102 | with_tags: "Mit Tags: ", 103 | 104 | no_feed_with_name: "Es existiert kein Feed mit diesem Namen", 105 | invalid_tag: "Dieser Tag ist nicht gültig", 106 | 107 | note_exists: "Es existiert bereits eine Notiz mit diesem Namen", 108 | invalid_filename: "Der Dateiname ist nicht gültig", 109 | 110 | specify_name: "Bitte einen Dateinamen angeben", 111 | cannot_contain: "kann nicht enhalten:", 112 | created_note: "Notiz erstellt", 113 | inserted_article: "in Notiz eingefügt", 114 | no_file_active: "Keine Datei geöffnet", 115 | 116 | 117 | //settings 118 | settings: "Einstellungen", 119 | file_creation: "Dateierstellung", 120 | template_new: "Vorlage für neue Dateien", 121 | template_new_help: "Beim erstellen einer Notiz wird dies verarbeitet.", 122 | template_paste: "Vorlage beim Einfügen in eine Datei", 123 | template_paste_help: "Beim einfügen/in die Zwischenablage kopieren wird dies verarbeitet.", 124 | available_variables: "Mögliche Variablen sind:", 125 | file_location: "Speicherort für neue Notizen", 126 | file_location_help: "Wo sollen neue Notizen gespeichert werden?", 127 | file_location_default: "In Standardordner", 128 | file_location_custom: "Eigenen Ordner festlegen", 129 | file_location_folder: "Ordner für neue Notizen", 130 | file_location_folder_help: "Speichert neue Notizen an diesem Ort", 131 | 132 | date_format: "Datumsformat", 133 | syntax_reference: "Syntax Referenz", 134 | syntax_looks: "So wird es aussehen: ", 135 | 136 | ask_filename: "Nach Dateiname fragen", 137 | ask_filename_help: "Deaktivieren um die Vorlage automatisch anzuwenden(ohne ungültige Zeichen)", 138 | refresh_time: "Aktualisierungsintervall", 139 | refresh_time_help: "Wie häufig soll auf neue Einträge überprüft werden(in Minuten), 0 zu deaktivieren", 140 | specify_positive_number: "Bitte eine positive Zahl angeben", 141 | multi_device_usage: "Mit mehreren Geräten nutzen", 142 | multi_device_usage_help: "Syncronisiere Lesestatus & Tags zwischen mehreren gleichzeitig genutzten Geräten\n(Benötigt einen Neustart der App)", 143 | 144 | add_new_filter: "Neuen gefilterten Ordner erstellen", 145 | filter_exists: "Es exisitiert bereits ein Feed mit diesem Namen", 146 | hotkeys: "Tastenkürzel", 147 | hotkeys_reading: "in der Leseansicht", 148 | press_key: "drücke eine Taste", 149 | customize_hotkey: "dieses Tastenkürzel anpassen", 150 | 151 | refreshed_feeds: "Feeds aktualisiert", 152 | 153 | //import modal 154 | import: "Importieren", 155 | import_opml: "Aus OPML importieren", 156 | imported_x_feeds: "%1 Feeds importiert", 157 | choose_file: "Datei auswählen", 158 | choose_file_help: "Wähle eine Datei aus der importiert werden soll", 159 | export_opml: "Als OPML exportieren", 160 | 161 | default_filename: "Vorlage für Dateinamen", 162 | default_filename_help: "Alle Variablen aus der einfügen Vorlage können verwendet werden", 163 | 164 | //cleanup modal 165 | cleanup: "Artikel aufräumen", 166 | cleanup_help: "Entfernt alle Artikel auf die folgende Kriterien zutreffen", 167 | cleanup_help2: "Alle Artikel die noch im Feed vorhanden sind werden beim nächsten aktualisieren wieder erscheinen", 168 | perform_cleanup: "ausführen", 169 | all: "Alle", 170 | from_feed: "von Feed", 171 | older_than: "älter als X Tage", 172 | older_than_help: "Leerlassen für alle, wird ignoriert wenn Artikel kein Veröffentlichungsdatum hat", 173 | advanced: "Erweitert", 174 | remove_wrong_feed: "Alle Artikel entfernen die im falschen Feed gelandet sind", 175 | remove_wrong_feed_help: "Aufgrund eines Fehlers in Versionen vor 0.8 könnte dies passiert sein", 176 | scanning_items: "Verarbeite Artikel (%1 / %2)", 177 | 178 | created_export: "OPML Export in Vault erstellt", 179 | add: "Hinzufügen", 180 | from_archive: "Alte Artikel von archive.org lesen", 181 | reading_archive: "Daten werden aus Archiv geladen", 182 | scanning_duplicates: "Entferne Duplikate", 183 | do_not_close: "Bitte dieses Fenster nicht schliesen", 184 | 185 | display_style: "Anzeige", 186 | list: "List", 187 | cards: "Karten", 188 | 189 | customize_terms: "Begriffe anpassen", 190 | content: "Inhalt", 191 | highlight: "Markieren", 192 | highlight_remove: "Markierung entfernen", 193 | 194 | filter_folder_ignore_help: "diese Ordner ignorieren", 195 | filter_feed_ignore_help: "diese Feeds ignorieren", 196 | filter_tags_ignore_help: "diese Tags ignorieren", 197 | 198 | loading: "Lädt", 199 | 200 | //template settings 201 | article_title: "Titel", 202 | article_link: "Link zum Artikel", 203 | article_author: "Autor", 204 | article_published: "Veröffentlichungsdatum", 205 | article_description: "Kurze Beschreibung des Artikels", 206 | article_content: "Inhalt des Artikels", 207 | article_tags: "Tags getrennt durch Komma", 208 | article_media: "Link zu Video/Audio Datei", 209 | feed_folder: "Ordner des Feeds", 210 | feed_title: "Feed Titel", 211 | highlights: "Highlights", 212 | note_created: "Erstelldatum der Notiz", 213 | filename: "Dateiname" 214 | } 215 | -------------------------------------------------------------------------------- /src/l10n/locales/en.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | //these values are only used in testing, don't overwrite them 3 | testingValue: "", 4 | testingInserts: "", 5 | 6 | RSS_Reader: "RSS Reader", 7 | RSS_Feeds: "RSS Feeds", 8 | 9 | //commands 10 | open: "Open", 11 | refresh_feeds: "Refresh feeds", 12 | create_all: "Create all", 13 | 14 | //folder actions 15 | mark_all_as_read: "Mark all as read", 16 | add_tags_to_all: "Add tags to all entries", 17 | 18 | filtered_folders: "Filtered Folders", 19 | folders: "Folders", 20 | folder: "Folder", 21 | feeds: "Feeds", 22 | 23 | //article actions 24 | create_note: "create new note", 25 | paste_to_note: "paste to current note", 26 | copy_to_clipboard: "copy to clipboard", 27 | open_browser: "open in browser", 28 | edit_tags: "edit tags", 29 | mark_as_read: "Mark as read", 30 | mark_as_unread: "Mark as unread", 31 | mark_as_favorite: "mark as favorite", 32 | remove_from_favorites: "remove from favorites", 33 | read_article_tts: "read article with TTS", 34 | next: "next", 35 | previous: "previous", 36 | 37 | mark_as_read_unread: "mark as read/unread", 38 | mark_as_favorite_remove: "mark as favorite/remove from favorites", 39 | 40 | //action notifications 41 | marked_as_read: "marked item as read", 42 | marked_as_unread: "marked item as unread", 43 | removed_from_favorites: "removed item from favorites", 44 | added_to_favorites: "marked item as favorite", 45 | 46 | read: "read", 47 | unread: "unread", 48 | favorites: "Favorites", 49 | favorite: "Favorite", 50 | tags: "Tags", 51 | tag: "Tag", 52 | 53 | //base modal 54 | save: "Save", 55 | cancel: "Cancel", 56 | delete: "Delete", 57 | edit: "Edit", 58 | reset: "restore default", 59 | fix_errors: "Please fix errors before saving.", 60 | 61 | add_new: "Add new", 62 | 63 | //feed settings 64 | add_new_feed: "Add new feed", 65 | feed_already_configured: "you already have a feed configured with that url", 66 | no_folder: "No folder", 67 | 68 | //feed creation modal 69 | name: "Name", 70 | name_help: "What do you want this feed to show up as?", 71 | url_help: "What is the URL to the feed?", 72 | folder_help: "What do you categorize this feed as?", 73 | 74 | invalid_name: "you need to specify a name", 75 | invalid_url: "this url is not valid", 76 | invalid_feed: "This feed does not have any entries", 77 | 78 | //filter types 79 | filter_tags: "All articles with tags", 80 | filter_unread: "All unread articles(from folders)", 81 | filter_read: "All read articles(from folders)", 82 | filter_favorites: "Favorites(from folders)", 83 | 84 | //sort order 85 | sort_date_newest: 'Publication date (new to old)', 86 | sort_date_oldest: 'Publication date (old to new)', 87 | sort_alphabet_normal: 'Name (A to Z)', 88 | sort_alphabet_inverted: 'Name (Z to A)', 89 | sort: 'Order by', 90 | 91 | //filter creation modal 92 | filter_name_help: 'What do you want this filter to show up as?', 93 | filter_type: 'Type', 94 | filter_type_help: 'Type of filter', 95 | filter: 'Filter', 96 | filter_help: 'Folders/Tags to filter on, split by ,', 97 | only_favorites: 'Show only favorites', 98 | show_read: "Show read", 99 | show_unread: "Show unread", 100 | filter_folder_help: "Only show articles from the following folders", 101 | filter_feed_help: "Only show articles from the following feeds", 102 | filter_tags_help: "Only show articles with the following tags", 103 | 104 | from_folders: "from folders: ", 105 | from_feeds: "from feeds: ", 106 | with_tags: "with tags: ", 107 | 108 | no_feed_with_name: "There is no feed with this name", 109 | invalid_tag: "This is not a valid tag", 110 | 111 | note_exists: "there is already a note with that name", 112 | invalid_filename: "that filename is not valid", 113 | 114 | specify_name: "Please specify a filename", 115 | cannot_contain: "cannot contain:", 116 | created_note: "Created note from article", 117 | inserted_article: "inserted article into note", 118 | no_file_active: "no file active", 119 | 120 | 121 | //settings 122 | settings: "Settings", 123 | file_creation: "File creation", 124 | template_new: "new file template", 125 | template_new_help: "When creating a note from an article, this gets processed.", 126 | template_paste: "paste article template", 127 | template_paste_help: "When pasting/copying an article this gets processed.", 128 | available_variables: "Available variables are:", 129 | file_location: "Default location for new notes", 130 | file_location_help: "Where newly created notes are placed", 131 | file_location_default: "In the default folder", 132 | file_location_custom: "In the folder specified below", 133 | file_location_folder: "Folder to create new articles in", 134 | file_location_folder_help: "newly created articles will appear in this folder", 135 | 136 | date_format: "Date format", 137 | syntax_reference: "Syntax Reference", 138 | syntax_looks: "Your current syntax looks like this: ", 139 | 140 | ask_filename: "Ask for filename", 141 | ask_filename_help: "Disable to apply the template below automatically(with invalid symbols removed)", 142 | refresh_time: "Refresh time", 143 | refresh_time_help: "How often should the feeds be refreshed, in minutes, use 0 to disable", 144 | specify_positive_number: "please specify a positive number", 145 | multi_device_usage: "Multi device usage", 146 | multi_device_usage_help: "Keep article status synced when using multiple devices at the same time\n(Requires a restart to become effective)", 147 | 148 | add_new_filter: "Add new filtered folder", 149 | filter_exists: "you already have a filter configured with that name", 150 | hotkeys: "Hotkeys", 151 | hotkeys_reading: "when reading an article", 152 | press_key: "press a key", 153 | customize_hotkey: "customize this hotkey", 154 | 155 | refreshed_feeds: "Feeds refreshed", 156 | 157 | //import modal 158 | import: "Import", 159 | import_opml: "Import from OPML", 160 | imported_x_feeds: "Imported %1 feeds", 161 | choose_file: "Choose file", 162 | choose_file_help: "Choose file to import", 163 | export_opml: "Export as OPML", 164 | 165 | default_filename: "Template for filename", 166 | default_filename_help: "All variables from the paste template are available", 167 | 168 | //cleanup modal 169 | cleanup: "Cleanup articles", 170 | cleanup_help: "Removes entries which fit the criteria specified below.", 171 | cleanup_help2: "Keep in mind that articles that still exist in the feed will reappear on the next refresh", 172 | perform_cleanup: "Perform cleanup", 173 | all: "all", 174 | from_feed: "from feed", 175 | older_than: "older than X Days", 176 | older_than_help: "keep empty for all, will be ignored if there is no publishing date associated with entry", 177 | advanced: "Advanced", 178 | remove_wrong_feed: "Remove all articles that are in the incorrect feed", 179 | remove_wrong_feed_help: "This might have happened due to a bug in versions pre 0.8", 180 | scanning_items: "Scanning Articles (%1 / %2)", 181 | 182 | created_export: "Created OPML file in your Vaults root folder", 183 | add: "Add", 184 | from_archive: "Get old articles from archive.org", 185 | reading_archive: "Reading data from archive", 186 | scanning_duplicates: "Scanning for duplicates", 187 | do_not_close: "Please do not close this window", 188 | 189 | display_style: "Display Style", 190 | list: "List", 191 | cards: "Cards", 192 | 193 | customize_terms: "Customize Terms", 194 | content: "Content", 195 | highlight: "Highlight", 196 | highlight_remove: "remove highlight", 197 | 198 | filter_folder_ignore_help: "ignore the following folders", 199 | filter_feed_ignore_help: "ignore the following feeds", 200 | filter_tags_ignore_help: "ignore the following tags", 201 | 202 | loading: "Loading", 203 | 204 | //template settings 205 | article_title: "Title", 206 | article_link: "Link to article", 207 | article_author: "Author of article", 208 | article_published: "Date published", 209 | article_description: "Short article description", 210 | article_content: "article content", 211 | article_tags: "Tags split by comma", 212 | article_media: "Link to video/audio file", 213 | feed_folder: "Folder of feed", 214 | feed_title: "Title of feed", 215 | highlights: "Highlights", 216 | note_created: "Note creation date", 217 | filename: "Filename", 218 | 219 | misc: "Misc", 220 | 221 | display_media: "Include Media", 222 | base_folder: "Base folder", 223 | 224 | provider: "Provider" 225 | } 226 | -------------------------------------------------------------------------------- /src/l10n/locales/fr.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | //these values are only used in testing, don't overwrite them 3 | testingValue: "", 4 | testingInserts: "", 5 | 6 | RSS_Reader: "Lecteur RSS", 7 | RSS_Feeds: "Fils RSS", 8 | 9 | //commands 10 | open: "Ouvrir", 11 | refresh_feeds: "Rafraîchir les fils", 12 | create_all: "Créer tous les articles", 13 | 14 | //folder actions 15 | mark_all_as_read: "Marquer tous les articles comme lus", 16 | add_tags_to_all: "Ajouter des mots-clés à tous les articles", 17 | 18 | filtered_folders: "Dossiers filtrés", 19 | folders: "Dossiers", 20 | folder: "Dossier", 21 | feeds: "Fils", 22 | 23 | //article actions 24 | create_note: "créer une nouvelle note", 25 | paste_to_note: "coller dans la note actuelle", 26 | copy_to_clipboard: "coller dans le presse-papier", 27 | open_browser: "ouvrir dans le navigateur", 28 | edit_tags: "éditer les mots-clés", 29 | mark_as_read: "Marqeur comme lu", 30 | mark_as_unread: "Marquer comme non lu", 31 | mark_as_favorite: "marquer comme favori", 32 | remove_from_favorites: "retirer des favoris", 33 | read_article_tts: "lire les articles avec TTS", 34 | next: "prochain", 35 | previous: "précédent", 36 | 37 | mark_as_read_unread: "marquer comme lu/non lu", 38 | mark_as_favorite_remove: "marquer comme favori/retirer des favoris", 39 | 40 | //action notifications 41 | marked_as_read: "article marqué comme lu", 42 | marked_as_unread: "article marqué comme non lu", 43 | removed_from_favorites: "article retiré des favoris", 44 | added_to_favorites: "article marqué comme favori", 45 | 46 | read: "lu", 47 | unread: "non lu", 48 | favorites: "Favoris", 49 | favorite: "Favori", 50 | tags: "Mots-clés", 51 | tag: "Mot-clé", 52 | 53 | //base modal 54 | save: "Sauvegarder", 55 | cancel: "Annuler", 56 | delete: "Supprimer", 57 | edit: "Éditer", 58 | reset: "Restaurer les valeurs par défaut", 59 | fix_errors: "Veuillez corriger les erreurs avant de sauvegarder.", 60 | 61 | add_new: "Ajouter un nouveau", 62 | 63 | //feed settings 64 | add_new_feed: "Ajouter un nouveau fil", 65 | feed_already_configured: "Vous avez déjà un fil configuré avec cette URL", 66 | no_folder: "Aucun dossier", 67 | 68 | //feed creation modal 69 | name: "Nom", 70 | name_help: "Comment voulez-vous que ce fil apparaisse?", 71 | url_help: "Quelle est l'URL du fil?", 72 | folder_help: "Comment catégorisez-vous ce fil?", 73 | 74 | invalid_name: "Vous devez spécifier un nom", 75 | invalid_url: "Cet URL n'est pas valide", 76 | invalid_feed: "Ce fil n'a aucune entrée", 77 | 78 | //filter types 79 | filter_tags: "Tous les articles avec des mots-clés", 80 | filter_unread: "Tous les articles non lus (à partir des dossiers)", 81 | filter_read: "Tous les articles lus (à partir des dossiers)", 82 | filter_favorites: "Favoris (à partir des dossiers)", 83 | 84 | //sort order 85 | sort_date_newest: 'Par date (du plus récent au plus ancien)', 86 | sort_date_oldest: 'Par date (du plus ancien au plus récent)', 87 | sort_alphabet_normal: 'Par ordre alphabétique (de A à Z)', 88 | sort_alphabet_inverted: 'Par ordre alphabétique (de Z à A)', 89 | sort: 'Trier par', 90 | 91 | //filter creation modal 92 | filter_name_help: 'Comment voulez-vous que ce filtre apparaisse?', 93 | filter_type: 'Type', 94 | filter_type_help: 'Type de filtre', 95 | filter: 'Filtre', 96 | filter_help: 'Dossiers/Mots-clés à filtrer, séparés par ,', 97 | only_favorites: 'Montrer seulement les favoris', 98 | show_read: "Montrer les articles lus", 99 | show_unread: "Montrer les articles non lus", 100 | filter_folder_help: "Montrer seulement les articles dans ces dossiers", 101 | filter_feed_help: "Montrer seulement les articles dans ces fils", 102 | filter_tags_help: "Montrer seulement les articles avec ces mots-clés", 103 | 104 | from_folders: "des dossiers: ", 105 | from_feeds: "des fils: ", 106 | with_tags: "avec les mots-clés: ", 107 | 108 | no_feed_with_name: "Il n'y a pas de fil avec ce nom", 109 | invalid_tag: "Ce mot-clé n'est pas valide", 110 | 111 | note_exists: "Cette note existe déjà", 112 | invalid_filename: "Ce nom de fichier n'est pas valide", 113 | 114 | specify_name: "Veuillez spécifier un nom", 115 | cannot_contain: "ne peut pas contenir:", 116 | created_note: "Note créée à partir de l'article", 117 | inserted_article: "Article inséré dans la note", 118 | no_file_active: "Aucun fichier actif", 119 | 120 | 121 | //settings 122 | settings: "Réglages", 123 | file_creation: "Création de fichier", 124 | template_new: "Nouveau modèle de fichier", 125 | template_new_help: "Ceci est effectué en créant une note à partir d'un article", 126 | template_paste: "Coller le modèle d'article", 127 | template_paste_help: "Ceci est effectué en collant un modèle d'article dans une note", 128 | available_variables: "Les varibles disponibles sont:", 129 | file_location: "Emplacements par défaut pour les nouvelles notes", 130 | file_location_help: "Là où sont placées les nouvelles notes", 131 | file_location_default: "Dans le dossier par défaut", 132 | file_location_custom: "Dans le dossier spécifié plus bas", 133 | file_location_folder: "Dossiers dans lesquels créer de novuveaux articles", 134 | file_location_folder_help: "Les nouvelles notes seront créées dans ce dossier", 135 | 136 | date_format: "Format de date", 137 | syntax_reference: "Référence de syntaxe", 138 | syntax_looks: "Votre syntaxe actuelle ressemble à ceci:", 139 | 140 | ask_filename: "Demander le nom de fichier", 141 | ask_filename_help: "Désactiver pour appliquer le modèle automatiquement (avec les symboles invalides retirés)", 142 | refresh_time: "Temps de rafraîchissement", 143 | refresh_time_help: "À quelle fréquence le fil sera rechargé en minutes, utiliser 0 pour désactiver", 144 | specify_positive_number: "Veuillez spécifier un nombre positif", 145 | multi_device_usage: "Utilsation sur plusieurs appareils", 146 | multi_device_usage_help: "Garder le statut des articles synchronisé entre plusieurs appareils\n(Nécessite un redémerrage pour prendre effet)", 147 | 148 | add_new_filter: "Ajouter un nouveau dossier filtré", 149 | filter_exists: "Un filtre avec ce nom existe déjà", 150 | hotkeys: "Raccourcis", 151 | hotkeys_reading: "en lisant un article", 152 | press_key: "Appuyez sur une touche", 153 | customize_hotkey: "Personnaliser ce raccourci", 154 | 155 | refreshed_feeds: "Fils rafraîchis", 156 | 157 | //import modal 158 | import: "Importer", 159 | import_opml: "Importer un fichier OPML", 160 | imported_x_feeds: "%1 fils importés", 161 | choose_file: "Choisir un fichier", 162 | choose_file_help: "Choisir un fichier à importer", 163 | export_opml: "Exporter un fichier OPML", 164 | 165 | default_filename: "Modèle pour le nom de fichier", 166 | default_filename_help: "Toutes les variables du modèle collé sont disponibles", 167 | 168 | //cleanup modal 169 | cleanup: "Nettoyer les articles", 170 | cleanup_help: "Retire les entrées qui correspondent au critère suivant:", 171 | cleanup_help2: "Prenez-note que les articles qui existent toujours dans le fil réparraîtront au prochain rafraîchissement", 172 | perform_cleanup: "Nettoyer", 173 | all: "tout", 174 | from_feed: "à partir du fil", 175 | older_than: "plus vieux que X Jours", 176 | older_than_help: "garde vide pour tous, sera ignoré s'il n'existe pas de date de publication associée à l'article", 177 | advanced: "Avancé", 178 | remove_wrong_feed: "Retirer tous les articles qui sont dans un fil incorrect", 179 | remove_wrong_feed_help: "Ceci peut être arrivé en raison d'un bug dans les version pré 0.8", 180 | scanning_items: "Articles en cours de scannage (%1 / %2)", 181 | 182 | created_export: "Fichier OPML créé dans le dossier source", 183 | add: "Ajouter", 184 | from_archive: "Obtenir des anciens articles à partir de archive.org", 185 | reading_archive: "Lecture de l'archive en cours", 186 | scanning_duplicates: "Scan des doublons en cours", 187 | do_not_close: "Ne pas fermer cette fenêtre", 188 | 189 | display_style: "Style d'affichage", 190 | list: "Liste", 191 | cards: "Cartes", 192 | 193 | customize_terms: "Personnaliser les termes", 194 | content: "Contenu", 195 | highlight: "Surligner", 196 | highlight_remove: "Retirer le surlignement", 197 | 198 | filter_folder_ignore_help: "Ignorer les dossiers suivants", 199 | filter_feed_ignore_help: "Ignorer les fils suivants", 200 | filter_tags_ignore_help: "Ignorer les mots-clés suivants", 201 | 202 | loading: "En cours de hargement", 203 | 204 | //template settings 205 | article_title: "Titre", 206 | article_link: "Lien vers l'article", 207 | article_author: "Auteur de l'article", 208 | article_published: "Date de publication", 209 | article_description: "Courte descrition de l'article", 210 | article_content: "Contenu de l'article", 211 | article_tags: "Mots-clés séparés par des virgules", 212 | article_media: "Lien vers la vidéo/ le fichier audio", 213 | feed_folder: "Dossier du fil", 214 | feed_title: "Titre du fil", 215 | highlights: "Surlignements", 216 | note_created: "Date de création de la note", 217 | filename: "Nom du fichier", 218 | 219 | display_media: "Inclure les médias", 220 | base_folder: "Dossier source", 221 | } 222 | -------------------------------------------------------------------------------- /src/l10n/locales/pt.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | //these values are only used in testing, don't overwrite them 3 | testingValue: "", 4 | testingInserts: "", 5 | 6 | RSS_Reader: "Leitor RSS", 7 | RSS_Feeds: "Feed RSS", 8 | 9 | //commands 10 | open: "Abrir", 11 | refresh_feeds: "Atualizar feed", 12 | create_all: "Criar todos", 13 | 14 | //folder actions 15 | mark_all_as_read: "Marcar todos como lidos", 16 | add_tags_to_all: "Adicionar tags para todos os artigos", 17 | 18 | filtered_folders: "Pastas filtradas", 19 | folders: "Pastas", 20 | folder: "Pasta", 21 | feeds: "Feeds", 22 | 23 | //article actions 24 | create_note: "criar uma nova nota", 25 | paste_to_note: "colar na nota atual", 26 | copy_to_clipboard: "copiar para área de transferência", 27 | open_browser: "abrir no navegador", 28 | edit_tags: "editar tags", 29 | mark_as_read: "marcar como lido", 30 | mark_as_unread: "marcar como não lido", 31 | mark_as_favorite: "marcar como favorito", 32 | remove_from_favorites: "remover dos favoritos", 33 | read_article_tts: "ler artigo com TTS", 34 | next: "próximo", 35 | previous: "anterior", 36 | 37 | mark_as_read_unread: "marcar como lido/não lido", 38 | mark_as_favorite_remove: "marcar como favorito/remover dos favoritos", 39 | 40 | //action notifications 41 | marked_as_read: "item marcado como lido", 42 | marked_as_unread: "item marcado como não lido", 43 | removed_from_favorites: "item removido dos favoritos", 44 | added_to_favorites: "item marcado como favorito", 45 | 46 | read: "lido", 47 | unread: "não lido", 48 | favorites: "Favoritos", 49 | favorite: "Favorito", 50 | tags: "Tags", 51 | tag: "Tag", 52 | 53 | //base modal 54 | save: "Salvar", 55 | cancel: "Cancelar", 56 | delete: "Remover", 57 | edit: "Editar", 58 | reset: "restaurar padrão", 59 | fix_errors: "Por favor corrija erros antes de salvar", 60 | 61 | add_new: "Adicionar novo", 62 | 63 | //feed settings 64 | add_new_feed: "Adicionar novo feed", 65 | feed_already_configured: "você já possui um feeed configurado com esta URL", 66 | no_folder: "Nenhuma pasta", 67 | 68 | //feed creation modal 69 | name: "Nome", 70 | name_help: "Com qual nome você quer que este feed seja mostrado?", 71 | url_help: "Qual é o URL deste feed?", 72 | folder_help: "Como você categoriza este feed?", 73 | 74 | invalid_name: "você precisa especificar um nome", 75 | invalid_url: "esta url não é válido", 76 | invalid_feed: "Este feed não possui nenhum artigo", 77 | 78 | //filter types 79 | filter_tags: "Todos os artigos com tags", 80 | filter_unread: "Todos os artigos não lidos (de pastas)", 81 | filter_read: "Todos os artigos lidos (de pastas)", 82 | filter_favorites: "Favoritos(de pastas)", 83 | 84 | //sort order 85 | sort_date_newest: 'Data de publicação (novo para antigo)', 86 | sort_date_oldest: 'Data de publicação (antigo para novo)', 87 | sort_alphabet_normal: 'Nome (A à Z)', 88 | sort_alphabet_inverted: 'Nome (Z à A)', 89 | sort: 'Ordenar por', 90 | 91 | //filter creation modal 92 | filter_name_help: 'Com qual nome você quer que este filtro seja mostrado?', 93 | filter_type: 'Tipo', 94 | filter_type_help: 'Tipo de filtro', 95 | filter: 'Filtro', 96 | filter_help: 'Pastas/Tags para que seja filtrado, dividido, ', 97 | only_favorites: 'Mostrar apenas favoritos', 98 | show_read: "Mostrar lidos", 99 | show_unread: "Mostrar não lidos", 100 | filter_folder_help: "Apenas mostrar artigos das seguintes pastas", 101 | filter_feed_help: "Apenas mostrar artigos dos seguintes feeds", 102 | filter_tags_help: "Apenas mostrar artigos com as seguintes tags", 103 | 104 | from_folders: "das pastas: ", 105 | from_feeds: "dos feeds: ", 106 | with_tags: "com as tags: ", 107 | 108 | no_feed_with_name: "Não existe um feed com este nome", 109 | invalid_tag: "Esta não é uma tag válida", 110 | 111 | note_exists: "já existe uma nota com este nome", 112 | invalid_filename: "este nome de arquivo não é válido", 113 | 114 | specify_name: "Por favor especifique um nome de arquivo", 115 | cannot_contain: "Não pode conter:", 116 | created_note: "Nota criada de artigo", 117 | inserted_article: "artigo inserido em nota", 118 | no_file_active: "nenhum arquivo ativo", 119 | 120 | 121 | //settings 122 | settings: "Configurações", 123 | file_creation: "Criação de arquivo", 124 | template_new: "Novo modelo de arquivo", 125 | template_new_help: "Quando é criado uma nota de um artigo, este modelo é processado.", 126 | template_paste: "Colar modelo de artigo", 127 | template_paste_help: "Quando é colado/copiado um artigo, este modelo é processado.", 128 | available_variables: "Variáveis disponíveis são:", 129 | file_location: "Local padrão para novas notas", 130 | file_location_help: "Onde notas recentemente criadas são colocadas", 131 | file_location_default: "Em uma pasta padrão", 132 | file_location_custom: "Na pasta especificada abaixo", 133 | file_location_folder: "Pasta onde será criada novos artigos", 134 | file_location_folder_help: "artigos recentemente criados irão aparecer nesta pasta", 135 | 136 | date_format: "Formato de data", 137 | syntax_reference: "Referência de sintaxe", 138 | syntax_looks: "Sua sintaxe atual é exibida como: ", 139 | 140 | ask_filename: "Perguntar por nome de arquivo", 141 | ask_filename_help: "Desativar para aplicar o modelo abaixo automaticamente (com símbolos inválidos removidos)", 142 | refresh_time: "Tempo de atualização", 143 | refresh_time_help: "Quão frequente os feeds devem ser atualizados, em minutos. Use 0 para desativar", 144 | specify_positive_number: "por favor especifique um número positivo", 145 | multi_device_usage: "Uso em múltiplos dispositivos", 146 | multi_device_usage_help: "Mantenha os status de artigos sincronizados usando múltiplos dispositivos ao mesmo tempo\n (Necessário reiniciar para ser efetivo)", 147 | 148 | add_new_filter: "Adicionar nova pasta filtrada", 149 | filter_exists: "você já possui um filtro configurado com este nome", 150 | hotkeys: "atalho", 151 | hotkeys_reading: "Lendo um artigo", 152 | press_key: "Pressione uma tecla", 153 | customize_hotkey: "Customizar este atalho", 154 | 155 | refreshed_feeds: "Feeds atualizados", 156 | 157 | //import modal 158 | import: "Importar", 159 | import_opml: "Importar de OPML", 160 | imported_x_feeds: "Importado %1 feeds", 161 | choose_file: "Escolha arquivo", 162 | choose_file_help: "Escolha arquivo para importar", 163 | export_opml: "Exportar como OPML", 164 | 165 | default_filename: "Modelo para arquivo", 166 | default_filename_help: "Todas as váriaveis do modelo colado está disponíveis", 167 | 168 | //cleanup modal 169 | cleanup: "Limpeza de artigos", 170 | cleanup_help: "Remova artigso que se encaixem em critério especificado abaixo.", 171 | cleanup_help2: "Tenha em mente que artigos que ainda existam no feed irão reaparecer na próxima atualização", 172 | perform_cleanup: "Realizar limpeza", 173 | all: "todos", 174 | from_feed: "de feed", 175 | older_than: "anteriores à X Days", 176 | older_than_help: "matem vazio para todos, será ignorado se nenhuma data de publicação estiver associado ao artigo", 177 | advanced: "Avançado", 178 | remove_wrong_feed: "Remover todos os artigos que estiverem em feed incorreto", 179 | remove_wrong_feed_help: "Isso pode ter acontecido devido a bug in versões pré 0.8", 180 | scanning_items: "Escaneando artigos (%1 / %2)", 181 | 182 | created_export: "Arquivo OPML criado em sua pasta raiz do Cofre", 183 | add: "Adicionar", 184 | from_archive: "Obtenha artigos antigos do archive.org", 185 | reading_archive: "Lendo dados do arquivo", 186 | scanning_duplicates: "Escaneando por duplicatas", 187 | do_not_close: "Por favor não feche esta janela", 188 | 189 | display_style: "Estilo de visualizaçãoDisplay Style", 190 | list: "Lista", 191 | cards: "Cartões", 192 | 193 | customize_terms: "Customizar Termos", 194 | content: "Conteúdo", 195 | highlight: "Destaque", 196 | highlight_remove: "remover destaque", 197 | 198 | filter_folder_ignore_help: "ignorar as seguintes pastas", 199 | filter_feed_ignore_help: "ignorar os seguintes feeds", 200 | filter_tags_ignore_help: "ignorar as seguintes tags", 201 | 202 | loading: "Carregando", 203 | 204 | //template settings 205 | article_title: "Título", 206 | article_link: "Link para artigo", 207 | article_author: "Autor de artigo", 208 | article_published: "Data publicada", 209 | article_description: "Descrição curta de artigo", 210 | article_content: "conteúdo de artigo", 211 | article_tags: "Tags divididas por vírgula", 212 | article_media: "Link para arquivo de vídeo/áudio", 213 | feed_folder: "Pasta de feed", 214 | feed_title: "Título de feed", 215 | highlights: "Destaques", 216 | note_created: "Data de criação da nota", 217 | filename: "Nome do arquivo", 218 | 219 | misc: "Outros", 220 | 221 | display_media: "Incluir mídia", 222 | base_folder: "Pasta base", 223 | 224 | provider: "Provedor" 225 | } 226 | -------------------------------------------------------------------------------- /src/l10n/locales/ru.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | //these values are only used in testing, don't overwrite them 3 | testingValue: "", 4 | testingInserts: "", 5 | 6 | RSS_Reader: "RSS Reader", 7 | RSS_Feeds: "RSS Feeds", 8 | 9 | //commands 10 | open: "Открыть", 11 | refresh_feeds: "Обновить каналы", 12 | create_all: "Создать все", 13 | 14 | //folder actions 15 | mark_all_as_read: "Пометить все прочитанным", 16 | add_tags_to_all: "Добавить теги ко всем записям", 17 | 18 | filtered_folders: "Отфильтрованная папка", 19 | folders: "Папки", 20 | folder: "Папка", 21 | feeds: "Записи", 22 | 23 | //article actions 24 | create_note: "создать новую заметку", 25 | paste_to_note: "вставить в текущую заметку", 26 | copy_to_clipboard: "скопировать в буфер обмена", 27 | open_browser: "открыть в браузере", 28 | edit_tags: "изменить теги", 29 | mark_as_read: "Пометить прочитанным", 30 | mark_as_unread: "Пометить непрочитанным", 31 | mark_as_favorite: "пометить избранным", 32 | remove_from_favorites: "удалить из избранного", 33 | read_article_tts: "озвучить с помощью TTS", 34 | next: "следующая", 35 | previous: "предыдущая", 36 | 37 | mark_as_read_unread: "пометить как прочитано/непрочитано", 38 | mark_as_favorite_remove: "отметить как избранное/удалить из избранного", 39 | 40 | //action notifications 41 | marked_as_read: "пометил пункт как прочитанный", 42 | marked_as_unread: "пометил пункт как не прочитанный", 43 | removed_from_favorites: "удалил элемент из избранного", 44 | added_to_favorites: "пометил элемент как избранное", 45 | 46 | read: "прочитано", 47 | unread: "не прочитано", 48 | favorites: "Избранное", 49 | favorite: "Избранное", 50 | tags: "Теги", 51 | tag: "Тег", 52 | 53 | //base modal 54 | save: "Сохранить", 55 | cancel: "Отменить", 56 | delete: "Удалить", 57 | edit: "Редактировать", 58 | reset: "востановить по умолчанию", 59 | fix_errors: "Пожайлуста исправьте ошибки перед сохрананением.", 60 | 61 | add_new: "Добавить новую", 62 | 63 | //feed settings 64 | add_new_feed: "Добавить новую ленту", 65 | feed_already_configured: "у вас уже есть канал, настроенный на этот адрес url", 66 | no_folder: "Нет папки", 67 | 68 | //feed creation modal 69 | name: "Имя", 70 | name_help: "Как вы хотите, чтобы эта лента отображалась?", 71 | url_help: "Какой URL-адрес у этой ленты?", 72 | folder_help: "К какой категории вы относите эту ленту?", 73 | 74 | invalid_name: "необходимо указать имя", 75 | invalid_url: "Этот адрес(URL) недействителен", 76 | invalid_feed: "В этой ленте нет записей", 77 | 78 | //filter types 79 | filter_tags: "Все статьи с тегами", 80 | filter_unread: "Все непрочитанные статьи (из папок)", 81 | filter_read: "Все прочитанные статьи (из папок)", 82 | filter_favorites: "Избранное (из папок)", 83 | 84 | //sort order 85 | sort_date_newest: 'Дата публикации (от новой до старой)', 86 | sort_date_oldest: 'Дата публикации (от старой до новой)', 87 | sort_alphabet_normal: 'Имя (от А до Я)', 88 | sort_alphabet_inverted: 'Имя (от Я до А)', 89 | sort: 'Порядок', 90 | 91 | //filter creation modal 92 | filter_name_help: 'Как вы хотите, чтобы этот фильтр отображался?', 93 | filter_type: 'Тип', 94 | filter_type_help: 'Тип фильтра', 95 | filter: 'Фильтр', 96 | filter_help: 'Папки/теги для фильтрации, разделения по ,', 97 | only_favorites: 'Показывать только избранное', 98 | show_read: "Показать прочитанное", 99 | show_unread: "Показать не прочитанное", 100 | filter_folder_help: "Показывайте только статьи из следующих папок", 101 | filter_feed_help: "Показывайте только статьи из следующих лент", 102 | filter_tags_help: "Показывайте только статьи из следующих тег", 103 | 104 | from_folders: "из папки: ", 105 | from_feeds: "из ленты: ", 106 | with_tags: "с тегом: ", 107 | 108 | no_feed_with_name: "Не существует ленты с таким названием", 109 | invalid_tag: "Это недопустимый тег", 110 | 111 | note_exists: "уже есть заметка с таким названием", 112 | invalid_filename: "имя файла недействительно", 113 | 114 | specify_name: "Пожалуйста, укажите имя файла", 115 | cannot_contain: "не может содержать:", 116 | created_note: "Созданная заметка из статьи", 117 | inserted_article: "вставил статью в заметку", 118 | no_file_active: "файл не активен", 119 | 120 | 121 | //settings 122 | settings: "Настройки", 123 | file_creation: "Файл создан", 124 | template_new: "новый шаблон файла", 125 | template_new_help: "При создании заметки из статьи это обрабатывается.", 126 | template_paste: "вставить шаблон статьи", 127 | template_paste_help: "При вставке/копировании статьи это обрабатывается.", 128 | available_variables: "Доступны следующие переменные:", 129 | file_location: "Место по умолчанию для новых заметок", 130 | file_location_help: "Куда помещаются вновь созданные заметки", 131 | file_location_default: "В папке по умолчанию", 132 | file_location_custom: "В папке, указанной ниже", 133 | file_location_folder: "Папка для создания новых статей", 134 | file_location_folder_help: "Вновь созданные статьи будут появляться в этой папке", 135 | 136 | date_format: "Формат даты", 137 | syntax_reference: "Справочник по синтаксису", 138 | syntax_looks: "Ваш текущий синтаксис выглядит следующим образом: ", 139 | 140 | ask_filename: "Задайте имя файла", 141 | ask_filename_help: "Отключите автоматическое применение приведенного ниже шаблона (с удалением недопустимых символов)", 142 | refresh_time: "Время обновления", 143 | refresh_time_help: "Как часто должна обновляться лента, в минутах, используйте 0, чтобы отключить.", 144 | specify_positive_number: "пожалуйста, укажите положительное число", 145 | multi_device_usage: "Использование нескольких устройств", 146 | multi_device_usage_help: "Сохраняйте статус статьи синхронизированным при одновременном использовании нескольких устройств\n(Требуется перезагрузка для вступления в силу)", 147 | 148 | add_new_filter: "Добавление новой отфильтрованной папки", 149 | filter_exists: "у вас уже есть фильтр с таким именем", 150 | hotkeys: "Горячие клавиши", 151 | hotkeys_reading: "при чтении статьи", 152 | press_key: "нажать клавишу", 153 | customize_hotkey: "настройка этой горячей клавиши", 154 | 155 | refreshed_feeds: "Обновление каналов", 156 | 157 | //import modal 158 | import: "Импорт", 159 | import_opml: "Импорт из OPML", 160 | imported_x_feeds: "Импортирована %1 лента", 161 | choose_file: "Выбереите файл", 162 | choose_file_help: "Выберите файл для импорта", 163 | export_opml: "Экспортировать как OPML", 164 | 165 | default_filename: "Шаблон для файлов", 166 | default_filename_help: "Все переменные из шаблона вставки доступны", 167 | 168 | //cleanup modal 169 | cleanup: "Очистка статей", 170 | cleanup_help: "Удаляет записи, соответствующие критериям, указанным ниже.", 171 | cleanup_help2: "Имейте в виду, что статьи, которые все еще существуют в ленте, появятся при следующем обновлении.", 172 | perform_cleanup: "Выполните очистку", 173 | all: "все", 174 | from_feed: "из ленты", 175 | older_than: "старше чем X Дней", 176 | older_than_help: "оставить пустым для всех, будет игнорироваться, если нет даты публикации, связанной с записью", 177 | advanced: "Расширенный", 178 | remove_wrong_feed: "Удалите все статьи, которые находятся в неправильной ленте", 179 | remove_wrong_feed_help: "Это могло произойти из-за ошибки в версиях до 0.8.", 180 | scanning_items: "Сканирование статей (%1 / %2)", 181 | 182 | created_export: "Созданный файл OPML в корневой папке хранилища", 183 | add: "Добавить", 184 | from_archive: "Получайте старые статьи с сайта archive.org", 185 | reading_archive: "Чтение данных из архива", 186 | scanning_duplicates: "Сканирование для поиска дубликатов", 187 | do_not_close: "Пожалуйста, не закрывайте это окно", 188 | 189 | display_style: "Стиль отображения", 190 | list: "Список", 191 | cards: "Карточки", 192 | 193 | customize_terms: "Персонализация условий", 194 | content: "Контент", 195 | highlight: "Выделение", 196 | highlight_remove: "убрать выделение", 197 | 198 | filter_folder_ignore_help: "не обращайте внимания на следующие папки", 199 | filter_feed_ignore_help: "не обращайте внимания на следующие ленты", 200 | filter_tags_ignore_help: "не обращайте внимания на следующие теги", 201 | 202 | loading: "Загрузка", 203 | 204 | //template settings 205 | article_title: "Заголовок", 206 | article_link: "Ссылка на заголовок", 207 | article_author: "Автор заголовка", 208 | article_published: "Дата публикации", 209 | article_description: "Краткое описание статьи", 210 | article_content: "содержание статьи", 211 | article_tags: "Теги, разделенные запятой", 212 | article_media: "Ссылка на видео/аудиофайл", 213 | feed_folder: "Папка с лентами", 214 | feed_title: "Заголовок ленты", 215 | highlights: "Выделение", 216 | note_created: "Дата создания заметки", 217 | filename: "Имя файла", 218 | 219 | misc: "Разное", 220 | 221 | display_media: "Включить медиа", 222 | base_folder: "Базовая папка", 223 | 224 | provider: "Поставщик" 225 | } 226 | -------------------------------------------------------------------------------- /src/l10n/locales/test.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | "testingValue": "Hello World", 3 | testingInserts: "Hello %1 %2", 4 | } 5 | -------------------------------------------------------------------------------- /src/l10n/locales/zh.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | RSS_Reader: "RSS Reader", 3 | RSS_Feeds: "订阅源", 4 | 5 | //commands 6 | open: "打开", 7 | refresh_feeds: "更新订阅", 8 | create_all: "创建全部", 9 | 10 | //folder actions 11 | mark_all_as_read: "全部标记为已读", 12 | add_tags_to_all: "为所有条目添加标签", 13 | 14 | filtered_folders: "筛选分类", 15 | folders: "分类", 16 | folder: "分类", 17 | feeds: "订阅源", 18 | 19 | //article actions 20 | create_note: "新建笔记", 21 | paste_to_note: "粘贴到当前笔记", 22 | copy_to_clipboard: "复制到剪切板", 23 | open_browser: "用浏览器打开", 24 | edit_tags: "编辑标签", 25 | mark_as_read: "标记为已读", 26 | mark_as_unread: "标记为未读", 27 | mark_as_favorite: "添加到收藏夹", 28 | remove_from_favorites: "从收藏夹中删除", 29 | read_article_tts: "语音(TTS)阅读文章", 30 | next: "下一篇", 31 | previous: "上一篇", 32 | 33 | mark_as_read_unread: "标记为已读/未读", 34 | mark_as_favorite_remove: "添加到收藏夹/从收藏夹中删除", 35 | 36 | //action notifications 37 | marked_as_read: "已标记为已读", 38 | marked_as_unread: "已标记为未读", 39 | removed_from_favorites: "已从收藏夹中删除", 40 | added_to_favorites: "已添加到收藏夹", 41 | 42 | read: "已读", 43 | unread: "未读", 44 | favorites: "收藏夹", 45 | favorite: "收藏", 46 | tags: "标签", 47 | tag: "标签", 48 | 49 | //base modal 50 | save: "保存", 51 | cancel: "取消", 52 | delete: "删除", 53 | edit: "编辑", 54 | reset: "恢复默认值", 55 | fix_errors: "请在保存前修复错误。", 56 | 57 | add_new: "添加", 58 | 59 | //feed settings 60 | add_new_feed: "添加新订阅源", 61 | feed_already_configured: "您已经添加了该 URL 地址的订阅源", 62 | no_folder: "未分类", 63 | 64 | //feed creation modal 65 | name: "名称", 66 | name_help: "设置订阅源名称", 67 | url_help: "输入订阅源的 URL 地址", 68 | folder_help: "设置订阅源分类", 69 | 70 | invalid_name: "请输入订阅源名称", 71 | invalid_url: "请输入有效的订阅源 URL 地址", 72 | invalid_feed: "此订阅源没有任何内容", 73 | 74 | //filter types 75 | filter_tags: "已打标签的文章", 76 | filter_unread: "全部未读文章(来自分类)", 77 | filter_read: "全部已读文章(来自分类)", 78 | filter_favorites: "收藏夹(来自分类)", 79 | 80 | //sort order 81 | sort_date_newest: '发布日期 (新 → 旧)', 82 | sort_date_oldest: '发布日期 (旧 to 新)', 83 | sort_alphabet_normal: '名称 (A → Z)', 84 | sort_alphabet_inverted: '名称 (Z → A)', 85 | sort: '排序', 86 | 87 | //filter creation modal 88 | filter_name_help: '设置筛选器名称', 89 | filter_type: '类型', 90 | filter_type_help: '筛选器类型', 91 | filter: '筛选器', 92 | filter_help: '要筛选的分类/标签,', 93 | only_favorites: '仅显示已收藏', 94 | show_read: "显示已读", 95 | show_unread: "显示未读", 96 | filter_folder_help: "仅显示以下分类中的文章", 97 | filter_feed_help: "仅显示以下订阅源中的文章", 98 | filter_tags_help: "仅显示以下标签中的文章", 99 | 100 | from_folders: "来自分类: ", 101 | from_feeds: "来自订阅源: ", 102 | with_tags: "来自标签: ", 103 | 104 | no_feed_with_name: "没有找到该名称订阅源", 105 | invalid_tag: "此标签无效", 106 | 107 | note_exists: "已存在同名笔记", 108 | invalid_filename: "文件名无效", 109 | 110 | specify_name: "请输入文件名", 111 | cannot_contain: "不能包含: ", 112 | created_note: "已将该文章复制为笔记", 113 | inserted_article: "已将该文章复制到当前笔记", 114 | no_file_active: "没有文件处于活动状态", 115 | 116 | 117 | //settings 118 | settings: "设置", 119 | file_creation: "新建笔记", 120 | template_new: "笔记模板", 121 | template_new_help: "使用订阅文章创建笔记时,会根据已设置的模板变量进行处理。", 122 | template_paste: "复制/粘贴模板", 123 | template_paste_help: "将订阅文章复制/粘贴为笔记时,会根据已设置的模板变量进行处理。", 124 | available_variables: "可用模板变量: ", 125 | file_location: "保存位置", 126 | file_location_help: "请选择要保存新建笔记的位置", 127 | file_location_default: "默认目录", 128 | file_location_custom: "自定义目录", 129 | file_location_folder: "请选择要保存新建笔记的目录", 130 | file_location_folder_help: "新创建的笔记将保存在该目录中", 131 | 132 | date_format: "日期格式", 133 | syntax_reference: "日期格式语法参考", 134 | syntax_looks: "当前日期格式: ", 135 | 136 | ask_filename: "确认文件名", 137 | ask_filename_help: "禁用则自动使用下面的文件名模板创建文件(自动删除无效的文件名字符)", 138 | refresh_time: "更新频率", 139 | refresh_time_help: "多久更新一次订阅源(单位: 分钟),设置为0则禁用。", 140 | specify_positive_number: "请输入正数", 141 | multi_device_usage: "多设备使用", 142 | multi_device_usage_help: "同时使用多个设备时保持文章状态同步\n(需要重新启动才能生效)", 143 | 144 | add_new_filter: "添加新筛选器", 145 | filter_exists: "已存在同名筛选器", 146 | hotkeys: "快捷键", 147 | hotkeys_reading: "阅读文章时", 148 | press_key: "按下快捷键", 149 | customize_hotkey: "分配快捷键", 150 | 151 | refreshed_feeds: "已更新 RSS 订阅源", 152 | 153 | //import modal 154 | import: "导入", 155 | import_opml: "通过 OPML 导入", 156 | imported_x_feeds: "已导入 %1 条订阅源", 157 | choose_file: "选择文件", 158 | choose_file_help: "请选择要导入的文件", 159 | export_opml: "导出 OPML 文件", 160 | 161 | default_filename: "文件名模板", 162 | default_filename_help: "上面创建笔记的所有模板变量都可用", 163 | 164 | //cleanup modal 165 | cleanup: "清除文章", 166 | cleanup_help: "清除符合以下规则的文章", 167 | cleanup_help2: "注意,订阅源中仍存在的文章将在下次刷新时重新出现", 168 | perform_cleanup: "清除文章", 169 | all: "全部", 170 | from_feed: "来自订阅源", 171 | older_than: "多少天之前发布的文章", 172 | older_than_help: "如果没有符合的文章,则忽略该条规则(为空则保留所有日期的文章)", 173 | advanced: "高级设置", 174 | remove_wrong_feed: "清除所有不正确订阅源中的文章", 175 | remove_wrong_feed_help: "这可能是由于0.8之前版本中的错误造成的", 176 | scanning_items: "扫描文章 (%1 / %2))", 177 | 178 | created_export: "已在笔记仓库根目录创建 OPML 文件", 179 | add: "添加", 180 | from_archive: "从互联网档案馆(archive.org)获取旧文章", 181 | reading_archive: "正在从存档中读取数据", 182 | scanning_duplicates: "扫描重复文章", 183 | do_not_close: "请勿关闭此窗口", 184 | 185 | display_style: "显示风格", 186 | list: "列表", 187 | cards: "卡片", 188 | 189 | customize_terms: "自定义术语", 190 | content: "内容设置", 191 | highlight: "高亮", 192 | highlight_remove: "删除高亮", 193 | 194 | filter_folder_ignore_help: "忽略以下分类", 195 | filter_feed_ignore_help: "忽略以下订阅源", 196 | filter_tags_ignore_help: "忽略以下标签", 197 | 198 | loading: "正在加载", 199 | 200 | //template settings 201 | article_title: "标题", 202 | article_link: "文章链接", 203 | article_author: "文章作者", 204 | article_published: "发布日期", 205 | article_description: "文章摘要", 206 | article_content: "文章正文", 207 | article_tags: "标签以逗号分隔", 208 | article_media: "视频/音频链接", 209 | feed_folder: "订阅源文件夹", 210 | feed_title: "订阅源名称", 211 | highlights: "高亮", 212 | note_created: "笔记创建时间", 213 | filename: "文件名", 214 | 215 | display_media: "包含媒体", 216 | base_folder: "源文件夹" 217 | } 218 | -------------------------------------------------------------------------------- /src/modals/ArticleSuggestModal.ts: -------------------------------------------------------------------------------- 1 | import {moment, SuggestModal} from "obsidian"; 2 | import RssReaderPlugin from "../main"; 3 | import {ItemModal} from "./ItemModal"; 4 | import {Item} from "../providers/Item"; 5 | 6 | export class ArticleSuggestModal extends SuggestModal { 7 | protected readonly plugin: RssReaderPlugin; 8 | protected readonly items: Item[]; 9 | 10 | constructor(plugin: RssReaderPlugin, items: Item[]) { 11 | super(plugin.app); 12 | this.plugin = plugin; 13 | this.items = items; 14 | } 15 | 16 | getItems(): Item[] { 17 | return this.items; 18 | } 19 | 20 | onChooseSuggestion(item: Item, _: MouseEvent | KeyboardEvent): void { 21 | this.close(); 22 | new ItemModal(this.plugin, item, this.items, false).open(); 23 | } 24 | 25 | getSuggestions(query: string): Item[] { 26 | return this.items.filter((item) => { 27 | return item.title().toLowerCase().includes(query.toLowerCase()) || item.body().toLowerCase().includes(query.toLowerCase()); 28 | }); 29 | } 30 | 31 | renderSuggestion(item: Item, el: HTMLElement) : void { 32 | el.createEl("div", { text: item.title() }); 33 | el.createEl("small", { text: moment(item.pubDate()).format(this.plugin.settings.dateFormat) + " " + item.author() }); 34 | } 35 | 36 | } 37 | -------------------------------------------------------------------------------- /src/modals/BaseModal.ts: -------------------------------------------------------------------------------- 1 | import {AbstractTextComponent, Modal} from "obsidian"; 2 | 3 | export class BaseModal extends Modal { 4 | 5 | //taken from github.com/valentine195/obsidian-admonition 6 | setValidationError(input: AbstractTextComponent, message?: string) : void { 7 | input.inputEl.addClass("is-invalid"); 8 | if (message) { 9 | input.inputEl.parentElement.addClasses([ 10 | "has-invalid-message", 11 | "unset-align-items" 12 | ]); 13 | input.inputEl.parentElement.addClass( 14 | ".unset-align-items" 15 | ); 16 | let mDiv = input.inputEl.parentElement.querySelector( 17 | ".invalid-feedback" 18 | ) as HTMLDivElement; 19 | 20 | if (!mDiv) { 21 | mDiv = createDiv({ cls: "invalid-feedback" }); 22 | } 23 | mDiv.innerText = message; 24 | mDiv.insertAfter(input.inputEl); 25 | } 26 | } 27 | 28 | removeValidationError(input: AbstractTextComponent) : void { 29 | input.inputEl.removeClass("is-invalid"); 30 | input.inputEl.parentElement.removeClasses([ 31 | "has-invalid-message", 32 | "unset-align-items" 33 | ]); 34 | input.inputEl.parentElement.parentElement.removeClass( 35 | ".unset-align-items" 36 | ); 37 | 38 | if ( 39 | input.inputEl.parentElement.querySelector(".invalid-feedback") 40 | ) { 41 | input.inputEl.parentElement.removeChild( 42 | input.inputEl.parentElement.querySelector( 43 | ".invalid-feedback" 44 | ) 45 | ); 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/modals/CleanupModal.ts: -------------------------------------------------------------------------------- 1 | import {Notice, Setting, TextComponent, moment} from "obsidian"; 2 | import RssReaderPlugin from "../main"; 3 | import t from "../l10n/locale"; 4 | import {ArraySuggest} from "../view/ArraySuggest"; 5 | import {get} from "svelte/store"; 6 | import {folderStore} from "../stores"; 7 | import {BaseModal} from "./BaseModal"; 8 | import {RssFeedContent} from "../parser/rssParser"; 9 | import sortBy from "lodash.sortby"; 10 | import groupBy from "lodash.groupby"; 11 | 12 | export class CleanupModal extends BaseModal { 13 | 14 | plugin: RssReaderPlugin; 15 | 16 | constructor(plugin: RssReaderPlugin) { 17 | super(plugin.app); 18 | this.plugin = plugin; 19 | } 20 | 21 | unread: boolean; 22 | read: boolean; 23 | favorite: boolean; 24 | tag = ""; 25 | older_than: number; 26 | feed = "wallabag.xml-option-id"; 27 | wrong_feed: boolean; 28 | 29 | 30 | async onOpen() : Promise { 31 | const {contentEl} = this; 32 | 33 | contentEl.empty(); 34 | 35 | contentEl.createEl("h1", {text: t("cleanup")}); 36 | contentEl.createEl("p", {text: t("cleanup_help")}); 37 | contentEl.createEl("p", {text: t("cleanup_help2")}); 38 | 39 | new Setting(contentEl) 40 | .setName(t("unread")) 41 | .addToggle(toggle => { 42 | toggle.onChange((value) => { 43 | this.unread = value; 44 | }) 45 | }); 46 | 47 | new Setting(contentEl) 48 | .setName(t("read")) 49 | .addToggle(toggle => { 50 | toggle.onChange((value) => { 51 | this.read = value; 52 | }) 53 | }); 54 | 55 | new Setting(contentEl) 56 | .setName(t("favorite")) 57 | .addToggle(toggle => { 58 | toggle.onChange((value) => { 59 | this.favorite = value; 60 | }) 61 | }); 62 | 63 | new Setting(contentEl) 64 | .setName(t("tag")) 65 | .addSearch(search => { 66 | const tags: string[] = []; 67 | for (const feed of this.plugin.settings.items) { 68 | for(const item of feed.items) { 69 | if(item !== undefined) 70 | tags.push(...item.tags); 71 | } 72 | } 73 | new ArraySuggest(this.app, search.inputEl, new Set(tags)); 74 | search 75 | .onChange(async (value: string) => { 76 | this.tag = value; 77 | }); 78 | }); 79 | 80 | let older_than_setting: TextComponent; 81 | new Setting(contentEl) 82 | .setName(t("older_than")) 83 | .setDesc(t("older_than_help")) 84 | .addText(text => { 85 | older_than_setting = text; 86 | text.setPlaceholder("5") 87 | .onChange(value => { 88 | this.removeValidationError(text); 89 | if (Number(value)) { 90 | this.older_than = Number(value); 91 | } 92 | }); 93 | 94 | }).controlEl.addClass("rss-setting-input"); 95 | //we don't want decimal numbers. 96 | older_than_setting.inputEl.setAttr("onkeypress", "return event.charCode >= 48 && event.charCode <= 57"); 97 | 98 | new Setting(contentEl) 99 | .setName(t("from_feed")) 100 | .addDropdown(dropdown => { 101 | dropdown.addOption("wallabag.xml-option-id", t("all")); 102 | 103 | const sorted = sortBy(groupBy(this.plugin.settings.feeds, "folder"), function (o) { 104 | return o[0].folder; 105 | }); 106 | for (const [, feeds] of Object.entries(sorted)) { 107 | for (const id in feeds) { 108 | const feed = feeds[id]; 109 | dropdown.addOption(feed.folder + "-" + feed.name, feed.folder + " - " + feed.name); 110 | } 111 | dropdown.setValue(this.feed); 112 | } 113 | dropdown.onChange(value => { 114 | this.feed = value; 115 | }); 116 | }); 117 | 118 | const details = contentEl.createEl("details"); 119 | const summary = details.createEl("summary"); 120 | summary.setText(t("advanced")); 121 | const advanced = details.createDiv("advanced"); 122 | 123 | new Setting(advanced) 124 | .setName(t("remove_wrong_feed")) 125 | .setDesc(t("remove_wrong_feed_help")) 126 | .addToggle(toggle => { 127 | toggle.onChange((value) => { 128 | this.wrong_feed = value; 129 | }) 130 | }); 131 | 132 | new Setting(contentEl).addButton((button) => { 133 | button 134 | .setIcon("feather-trash") 135 | .setTooltip(t("perform_cleanup")) 136 | .onClick(async () => { 137 | 138 | let items: RssFeedContent[] = this.plugin.settings.items; 139 | 140 | let date = moment(); 141 | if (this.older_than) { 142 | date = moment().subtract(this.older_than, 'days'); 143 | } 144 | 145 | let count = 0; 146 | const itemsCount = items.reduce((count, current) => count + current.items.length, 0); 147 | const notice = new Notice(t("scanning_items", "0", itemsCount.toString())); 148 | 149 | for(const feed of items) { 150 | for (const item of feed.items) { 151 | if (item !== undefined) { 152 | let toRemove = 0; 153 | if (item.pubDate === undefined || moment(item.pubDate).isBefore(date)) { 154 | if (this.feed === "wallabag.xml-option-id" || this.feed === (item.folder + "-" + item.feed)) { 155 | if ((this.read && item.read) || (!this.read && !item.read) || (this.read && !item.read)) { 156 | toRemove++; 157 | } 158 | if ((this.unread && !item.read) || (!this.unread && item.read)) { 159 | toRemove++; 160 | } 161 | if ((this.favorite && item.favorite) || (!this.favorite && !item.favorite) || (this.favorite && !item.favorite)) { 162 | toRemove++; 163 | } 164 | if (this.tag === "" || item.tags.includes(this.tag)) { 165 | toRemove++; 166 | } 167 | } 168 | 169 | } 170 | if(toRemove == 4) { 171 | feed.items = feed.items.filter(value => value.hash !== item.hash); 172 | } 173 | } 174 | 175 | count++; 176 | notice.setMessage(t("scanning_items", count.toString(), itemsCount.toString())); 177 | } 178 | } 179 | 180 | if (this.wrong_feed) { 181 | console.log("removing invalid feeds"); 182 | const feeds = this.plugin.settings.feeds.map((feed) => { 183 | return feed.name; 184 | }); 185 | items = items.filter((item) => { 186 | return feeds.includes(item.name); 187 | }); 188 | 189 | const folders = get(folderStore); 190 | items = items.filter((item) => { 191 | return folders.has(item.folder); 192 | }); 193 | 194 | //removing wallabag.xml items that do not fit 195 | items.forEach((feed) => { 196 | feed.items = feed.items.filter((item) => { 197 | return feed.name === item.feed && feed.folder === item.folder; 198 | }); 199 | }); 200 | } 201 | 202 | await this.plugin.writeFeedContent(() => { 203 | return items; 204 | }); 205 | this.close(); 206 | 207 | }); 208 | }).addExtraButton((button) => { 209 | button 210 | .setIcon("cross") 211 | .setTooltip(t("cancel")) 212 | .onClick(() => { 213 | this.close(); 214 | }) 215 | }); 216 | } 217 | } 218 | -------------------------------------------------------------------------------- /src/modals/FeedModal.ts: -------------------------------------------------------------------------------- 1 | import {Notice, SearchComponent, Setting, TextComponent} from "obsidian"; 2 | import RssReaderPlugin from "../main"; 3 | import {RssFeed} from "../settings/settings"; 4 | import {getFeedItems} from "../parser/rssParser"; 5 | import {isValidHttpUrl} from "../consts"; 6 | import {BaseModal} from "./BaseModal"; 7 | import t from "../l10n/locale"; 8 | import {FeedFolderSuggest} from "../view/FeedFolderSuggest"; 9 | 10 | export class FeedModal extends BaseModal { 11 | name: string; 12 | url: string; 13 | folder: string; 14 | 15 | saved = false; 16 | 17 | constructor(plugin: RssReaderPlugin, feed?: RssFeed) { 18 | super(plugin.app); 19 | 20 | if(feed) { 21 | this.name = feed.name; 22 | this.url = feed.url; 23 | this.folder = feed.folder; 24 | } 25 | } 26 | 27 | async display() : Promise { 28 | const { contentEl } = this; 29 | 30 | contentEl.empty(); 31 | 32 | let nameText: TextComponent; 33 | const name = new Setting(contentEl) 34 | .setName(t("name")) 35 | .setDesc(t("name_help")) 36 | .addText((text) => { 37 | nameText = text; 38 | text.setValue(this.name) 39 | .onChange((value) => { 40 | this.removeValidationError(text); 41 | this.name = value; 42 | }); 43 | }); 44 | name.controlEl.addClass("rss-setting-input"); 45 | 46 | let urlText: TextComponent; 47 | const url = new Setting(contentEl) 48 | .setName("URL") 49 | .setDesc(t("url_help")) 50 | .addText((text) => { 51 | urlText = text; 52 | text.setValue(this.url) 53 | .onChange(async(value) => { 54 | this.removeValidationError(text); 55 | this.url = value; 56 | 57 | }); 58 | }); 59 | url.controlEl.addClass("rss-setting-input"); 60 | 61 | new Setting(contentEl) 62 | .setName(t("folder")) 63 | .setDesc(t("folder_help")) 64 | .addSearch(async (search: SearchComponent) => { 65 | new FeedFolderSuggest(this.app, search.inputEl); 66 | search 67 | .setValue(this.folder) 68 | .setPlaceholder(t("no_folder")) 69 | .onChange(async (value: string) => { 70 | this.folder = value; 71 | }); 72 | }); 73 | 74 | const footerEl = contentEl.createDiv(); 75 | const footerButtons = new Setting(footerEl); 76 | footerButtons.addButton((b) => { 77 | b.setTooltip(t("save")) 78 | .setIcon("checkmark") 79 | .onClick(async () => { 80 | let error = false; 81 | if(!nameText.getValue().length) { 82 | this.setValidationError(nameText, t("invalid_name")); 83 | error = true; 84 | } 85 | 86 | if(!urlText.getValue().length) { 87 | this.setValidationError(urlText, t("invalid_url")); 88 | error = true; 89 | } 90 | if(!isValidHttpUrl(urlText.getValue())) { 91 | this.setValidationError(urlText, t("invalid_url")); 92 | error = true; 93 | }else { 94 | const items = await getFeedItems({name: "test", url: urlText.getValue(), folder: ""}); 95 | if(items.items.length == 0) { 96 | this.setValidationError(urlText, t("invalid_feed")); 97 | error = true; 98 | } 99 | } 100 | 101 | if(error) { 102 | new Notice(t("fix_errors")); 103 | return; 104 | } 105 | this.saved = true; 106 | this.close(); 107 | }); 108 | return b; 109 | }); 110 | footerButtons.addExtraButton((b) => { 111 | b.setIcon("cross") 112 | .setTooltip(t("cancel")) 113 | .onClick(() => { 114 | this.saved = false; 115 | this.close(); 116 | }); 117 | return b; 118 | }); 119 | } 120 | 121 | async onOpen() : Promise { 122 | await this.display(); 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/modals/ImportModal.ts: -------------------------------------------------------------------------------- 1 | import {Modal, Notice, Setting} from "obsidian"; 2 | import {loadFeedsFromString} from "../parser/opmlParser"; 3 | import RssReaderPlugin from "../main"; 4 | import t from "../l10n/locale"; 5 | import {FeedFolderSuggest} from "../view/FeedFolderSuggest"; 6 | 7 | //adapted from javalent's code here: https://discord.com/channels/686053708261228577/840286264964022302/918146537220112455 8 | export class ImportModal extends Modal { 9 | 10 | plugin: RssReaderPlugin; 11 | 12 | constructor(plugin: RssReaderPlugin) { 13 | super(plugin.app); 14 | this.plugin = plugin; 15 | } 16 | 17 | importData = ""; 18 | defaultFolder = ""; 19 | 20 | async onOpen() : Promise { 21 | const setting = new Setting(this.contentEl).setName(t("choose_file")).setDesc(t("choose_file_help")); 22 | const input = setting.controlEl.createEl("input", { 23 | attr: { 24 | type: "file", 25 | accept: ".xml,.opml", 26 | } 27 | }); 28 | 29 | input.onchange = async () => { 30 | const {files} = input; 31 | if (!files.length) return; 32 | for (const id in files) { 33 | const file = files[id]; 34 | const reader = new FileReader(); 35 | reader.onload = () => { 36 | this.importData = reader.result as string; 37 | } 38 | reader.readAsText(file); 39 | } 40 | } 41 | 42 | new Setting(this.contentEl) 43 | .setName(t("base_folder")) 44 | .addSearch(search => { 45 | new FeedFolderSuggest(this.app, search.inputEl); 46 | search.setValue(this.defaultFolder) 47 | .onChange(value => { 48 | this.defaultFolder = value; 49 | }) 50 | }); 51 | 52 | new Setting(this.contentEl).addButton((button) => { 53 | button 54 | .setIcon("import-glyph") 55 | .setTooltip(t("import")) 56 | .onClick(async () => { 57 | if (this.importData) { 58 | const feeds = await loadFeedsFromString(this.importData, this.defaultFolder); 59 | await this.plugin.writeFeeds(() => (this.plugin.settings.feeds.concat(feeds))); 60 | new Notice(t("imported_x_feeds", String(feeds.length))); 61 | this.close(); 62 | } else { 63 | new Notice(t("fix_errors")); 64 | } 65 | }); 66 | }).addExtraButton((button) => { 67 | button 68 | .setIcon("cross") 69 | .setTooltip(t("cancel")) 70 | .onClick(() => { 71 | this.close(); 72 | }) 73 | }); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/modals/MessageModal.ts: -------------------------------------------------------------------------------- 1 | import {Modal} from "obsidian"; 2 | import RssReaderPlugin from "../main"; 3 | import t from "../l10n/locale"; 4 | 5 | export class MessageModal extends Modal { 6 | 7 | message: string; 8 | 9 | constructor(plugin: RssReaderPlugin, message: string) { 10 | super(plugin.app); 11 | this.message = message; 12 | } 13 | 14 | onOpen() : void { 15 | this.display(); 16 | } 17 | 18 | display() : void { 19 | const {contentEl} = this; 20 | 21 | contentEl.empty(); 22 | 23 | contentEl.createEl("h1", {text: this.message}); 24 | contentEl.createEl("p", {text: t("do_not_close")}); 25 | } 26 | 27 | setMessage(message: string) : void { 28 | this.message = message; 29 | this.display(); 30 | } 31 | 32 | } 33 | -------------------------------------------------------------------------------- /src/modals/TagModal.ts: -------------------------------------------------------------------------------- 1 | import {BaseModal} from "./BaseModal"; 2 | import RssReaderPlugin from "../main"; 3 | import {SearchComponent, Setting} from "obsidian"; 4 | import {NUMBER_REGEX, TAG_REGEX} from "../consts"; 5 | import {ArraySuggest} from "../view/ArraySuggest"; 6 | import t from "../l10n/locale"; 7 | import {get} from "svelte/store"; 8 | import {tagsStore} from "../stores"; 9 | 10 | export class TagModal extends BaseModal { 11 | plugin: RssReaderPlugin; 12 | tags: string[]; 13 | 14 | constructor(plugin: RssReaderPlugin, tags: string[]) { 15 | super(plugin.app); 16 | this.plugin = plugin; 17 | this.tags = tags; 18 | } 19 | 20 | display(): void { 21 | const {contentEl} = this; 22 | contentEl.empty(); 23 | 24 | contentEl.createEl("h1", {text: t("edit_tags")}); 25 | 26 | const tagDiv = contentEl.createDiv("tags"); 27 | 28 | for (const tag in this.tags) { 29 | new Setting(tagDiv) 30 | .addSearch(async (search: SearchComponent) => { 31 | new ArraySuggest(this.app, search.inputEl, get(tagsStore)); 32 | search 33 | .setValue(this.tags[tag]) 34 | .onChange(async (value: string) => { 35 | this.removeValidationError(search); 36 | if (!value.match(TAG_REGEX) || value.match(NUMBER_REGEX) || value.contains(" ") || value.contains('#')) { 37 | this.setValidationError(search, t("invalid_tag")); 38 | return; 39 | } 40 | this.tags = this.tags.filter(e => e !== this.tags[tag]); 41 | this.tags.push(value); 42 | }); 43 | }) 44 | .addExtraButton((button) => { 45 | button 46 | .setTooltip(t("delete")) 47 | .setIcon("trash") 48 | .onClick(() => { 49 | this.tags = this.tags.filter(e => e !== this.tags[tag]); 50 | this.display(); 51 | }); 52 | 53 | }); 54 | } 55 | 56 | let tagValue = ""; 57 | let tagComponent: SearchComponent; 58 | const newTag = new Setting(tagDiv) 59 | .addSearch(async (search: SearchComponent) => { 60 | tagComponent = search; 61 | new ArraySuggest(this.app, search.inputEl, get(tagsStore)); 62 | search 63 | .onChange(async (value: string) => { 64 | if (!value.match(TAG_REGEX) || value.match(NUMBER_REGEX) || value.contains(" ") || value.contains('#')) { 65 | this.setValidationError(search, t("invalid_tag")); 66 | return; 67 | } 68 | tagValue = value; 69 | }); 70 | }).addExtraButton(button => { 71 | button 72 | .setTooltip(t("add")) 73 | .setIcon("plus") 74 | .onClick(() => { 75 | if (!tagValue.match(TAG_REGEX) || tagValue.match(NUMBER_REGEX) || tagValue.contains(" ") || tagValue.contains('#')) { 76 | this.setValidationError(tagComponent, t("invalid_tag")); 77 | return; 78 | } 79 | this.tags.push(tagValue); 80 | this.display(); 81 | }); 82 | }); 83 | newTag.controlEl.addClass("rss-setting-input"); 84 | 85 | const buttonEl = contentEl.createSpan("actionButtons"); 86 | 87 | new Setting(buttonEl).addExtraButton((btn) => 88 | btn 89 | .setTooltip(t("save")) 90 | .setIcon("checkmark") 91 | .onClick(async () => { 92 | this.close(); 93 | })); 94 | } 95 | 96 | onClose(): void { 97 | const {contentEl} = this; 98 | contentEl.empty(); 99 | } 100 | 101 | async onOpen(): Promise { 102 | await this.display(); 103 | } 104 | 105 | } 106 | -------------------------------------------------------------------------------- /src/modals/TextInputPrompt.ts: -------------------------------------------------------------------------------- 1 | import {App, Setting, TextComponent} from "obsidian"; 2 | import {BaseModal} from "./BaseModal"; 3 | import t from "../l10n/locale"; 4 | 5 | //slightly modified version from https://github.com/zsviczian/obsidian-excalidraw-plugin 6 | export class TextInputPrompt extends BaseModal { 7 | private resolve: (value: TextComponent) => void; 8 | private textComponent: TextComponent; 9 | private buttonText: string; 10 | 11 | constructor(app: App, private promptText: string, private hint: string, private defaultValue: string, private placeholder: string, buttonText: string = t("save")) { 12 | super(app); 13 | this.buttonText = buttonText; 14 | } 15 | 16 | onOpen(): void { 17 | this.titleEl.setText(this.promptText); 18 | this.createForm(); 19 | } 20 | 21 | onClose(): void { 22 | this.contentEl.empty(); 23 | } 24 | 25 | createForm(): void { 26 | const div = this.contentEl.createDiv(); 27 | 28 | const text = new Setting(div).setName(this.promptText).setDesc(this.hint).addText((textComponent) => { 29 | textComponent 30 | .setValue(this.defaultValue) 31 | .setPlaceholder(this.placeholder) 32 | .onChange(() => { 33 | this.removeValidationError(textComponent); 34 | }) 35 | .inputEl.setAttribute("size", "50"); 36 | this.textComponent = textComponent; 37 | }); 38 | text.controlEl.addClass("rss-setting-input"); 39 | 40 | new Setting(div).addButton((b) => { 41 | b 42 | .setButtonText(this.buttonText) 43 | .onClick(async () => { 44 | this.resolve(this.textComponent); 45 | }); 46 | return b; 47 | }); 48 | } 49 | 50 | async openAndGetValue(resolve: (value: TextComponent) => void): Promise { 51 | this.resolve = resolve; 52 | await this.open(); 53 | } 54 | } 55 | 56 | 57 | 58 | -------------------------------------------------------------------------------- /src/parser/opmlExport.ts: -------------------------------------------------------------------------------- 1 | import {RssFeed} from "../settings/settings"; 2 | import groupBy from "lodash.groupby"; 3 | 4 | export function generateOPML(feeds: RssFeed[]) : string { 5 | const doc = document.implementation.createDocument("", "opml"); 6 | 7 | const head = doc.createElement("head"); 8 | const title = doc.createElement("title"); 9 | head.appendChild(title); 10 | title.setText("Obsidian RSS Export"); 11 | 12 | doc.documentElement.appendChild(head); 13 | const body = doc.createElement("body"); 14 | 15 | doc.documentElement.appendChild(body); 16 | 17 | const sorted = groupBy(feeds, "folder"); 18 | for(const id of Object.keys(sorted)) { 19 | const folder = sorted[id]; 20 | const outline = doc.createElement("outline"); 21 | body.appendChild(outline); 22 | outline.setAttribute("title", folder[0].folder); 23 | for (const feed of folder) { 24 | const exportFeed = doc.createElement("outline"); 25 | exportFeed.setAttribute("title", feed.name) 26 | exportFeed.setAttribute("xmlUrl", feed.url); 27 | outline.append(exportFeed); 28 | } 29 | } 30 | 31 | return new XMLSerializer().serializeToString(doc.documentElement); 32 | } 33 | -------------------------------------------------------------------------------- /src/parser/opmlParser.ts: -------------------------------------------------------------------------------- 1 | import {RssFeed} from "../settings/settings"; 2 | 3 | export async function loadFeedsFromString(importData: string, defaultFolder: string): Promise { 4 | const rawData = new window.DOMParser().parseFromString(importData, "text/xml"); 5 | const feeds: RssFeed[] = []; 6 | 7 | 8 | const outlines = rawData.getElementsByTagName('outline'); 9 | 10 | for (let i = 0, max = outlines.length; i < max; i++) { 11 | 12 | const current = outlines[i]; 13 | if (!current.hasChildNodes()) { 14 | 15 | const title = current.getAttribute("title"); 16 | const xmlUrl = current.getAttribute('xmlUrl'); 17 | 18 | if(current.parentElement.hasAttribute("title")) { 19 | feeds.push({ 20 | name: title, 21 | url: xmlUrl, 22 | folder: defaultFolder + ((defaultFolder) ? "/" : "") + current.parentElement.getAttribute("title"), 23 | }); 24 | }else { 25 | feeds.push({ 26 | name: title, 27 | url: xmlUrl, 28 | folder: defaultFolder + "" 29 | }); 30 | } 31 | } 32 | } 33 | 34 | return feeds; 35 | } 36 | -------------------------------------------------------------------------------- /src/parser/rssParser.ts: -------------------------------------------------------------------------------- 1 | import {request} from "obsidian"; 2 | import {RssFeed} from "../settings/settings"; 3 | import {Md5} from "ts-md5"; 4 | 5 | /** 6 | * parser for .rss files, build from scratch 7 | * because I could not find a parser that 8 | * - works on mobile 9 | * - is up-to-date 10 | * - works for multiple different interpretations of the rss spec 11 | */ 12 | 13 | export interface RssFeedContent { 14 | subtitle: string, 15 | title: string, 16 | name: string, 17 | link: string, 18 | image: string, 19 | folder: string, 20 | description: string, 21 | language: string, 22 | hash: string, 23 | items: RssFeedItem[] 24 | } 25 | 26 | export interface RssFeedItem { 27 | title: string, 28 | description: string, 29 | content: string, 30 | category: string, 31 | link: string, 32 | creator: string, 33 | language: string, 34 | enclosure: string, 35 | enclosureType: string, 36 | image: string, 37 | pubDate: string, 38 | folder: string, 39 | feed: string, 40 | favorite: boolean, 41 | read: boolean, 42 | created: boolean, 43 | tags: string[], 44 | hash: string, 45 | id: string, 46 | highlights: string[], 47 | } 48 | 49 | /** 50 | * return the node with the specified name 51 | * : to get namespaced element 52 | * . to get nested element 53 | * @param element 54 | * @param name 55 | */ 56 | function getElementByName(element: Element | Document, name: string): ChildNode { 57 | let value: ChildNode; 58 | if (typeof element.getElementsByTagName !== 'function' && typeof element.getElementsByTagNameNS !== 'function') { 59 | //the required methods do not exist on element, aborting 60 | return; 61 | } 62 | 63 | if (name.includes(":")) { 64 | const [namespace, tag] = name.split(":"); 65 | const namespaceUri = element.lookupNamespaceURI(namespace); 66 | const byNamespace = element.getElementsByTagNameNS(namespaceUri, tag); 67 | if (byNamespace.length > 0) { 68 | value = byNamespace[0].childNodes[0]; 69 | } else { 70 | //there is no element in that namespace, probably because no namespace has been defined 71 | const tmp = element.getElementsByTagName(name); 72 | if (tmp.length > 0) { 73 | if (tmp[0].childNodes.length === 0) { 74 | value = tmp[0]; 75 | } else { 76 | const node = tmp[0].childNodes[0]; 77 | if (node !== undefined) { 78 | value = node; 79 | } 80 | } 81 | } 82 | } 83 | 84 | } else if (name.includes(".")) { 85 | const [prefix, tag] = name.split("."); 86 | if (element.getElementsByTagName(prefix).length > 0) { 87 | const nodes = Array.from(element.getElementsByTagName(prefix)[0].childNodes); 88 | nodes.forEach((node) => { 89 | if (node.nodeName == tag) { 90 | value = node; 91 | } 92 | }); 93 | } 94 | 95 | } else if (element.getElementsByTagName(name).length > 0) { 96 | if (element.getElementsByTagName(name)[0].childNodes.length == 0) { 97 | value = element.getElementsByTagName(name)[0]; 98 | } else { 99 | const node = element.getElementsByTagName(name)[0].childNodes[0]; 100 | if (node !== undefined) 101 | value = node; 102 | } 103 | } 104 | //if(name === "content") console.log(value); 105 | 106 | return value; 107 | } 108 | 109 | /** 110 | * # to get attribute 111 | * Always returns the last found value for names 112 | * @param element 113 | * @param names possible names 114 | */ 115 | function getContent(element: Element | Document, names: string[]): string { 116 | let value: string; 117 | for (const name of names) { 118 | if (name.includes("#")) { 119 | const [elementName, attr] = name.split("#"); 120 | const data = getElementByName(element, elementName); 121 | if (data) { 122 | if (data.nodeName === elementName) { 123 | //@ts-ignore 124 | const tmp = data.getAttribute(attr); 125 | if (tmp.length > 0) { 126 | value = tmp; 127 | } 128 | } 129 | } 130 | } else { 131 | const data = getElementByName(element, name); 132 | if (data) { 133 | //@ts-ignore 134 | if(data.wholeText && data.wholeText.length > 0) { 135 | //@ts-ignore 136 | value = data.wholeText; 137 | } 138 | 139 | //@ts-ignore 140 | if (!value && data.nodeValue && data.nodeValue.length > 0) { 141 | value = data.nodeValue; 142 | } 143 | //@ts-ignore 144 | if (!value && data.innerHTML && data.innerHTML.length > 0) { 145 | //@ts-ignore 146 | value = data.innerHTML; 147 | } 148 | } 149 | } 150 | } 151 | if (value === undefined) { 152 | return ""; 153 | } 154 | return value; 155 | } 156 | 157 | function buildItem(element: Element): RssFeedItem { 158 | return { 159 | title: getContent(element, ["title"]), 160 | description: getContent(element, ["content", "content:encoded", "itunes:summary", "description", "summary", "media:description"]), 161 | content: getContent(element, ["itunes:summary", "description", "summary", "media:description", "content", "content:encoded", "ns0:encoded"]), 162 | category: getContent(element, ["category"]), 163 | link: getContent(element, ["link", "link#href"]), 164 | creator: getContent(element, ["creator", "dc:creator", "author", "author.name"]), 165 | pubDate: getContent(element, ["pubDate", "published", "updated", "dc:date"]), 166 | enclosure: getContent(element, ["enclosure#url", "yt:videoId"]), 167 | enclosureType: getContent(element, ["enclosure#type"]), 168 | image: getContent(element, ["enclosure#url", "media:content#url", "itunes:image#href", "media:thumbnail#url"]), 169 | id: getContent(element, ["id"]), 170 | language: null, 171 | folder: null, 172 | feed: null, 173 | read: null, 174 | favorite: null, 175 | created: null, 176 | tags: [], 177 | hash: null, 178 | highlights: [] 179 | } 180 | } 181 | 182 | function getAllItems(doc: Document): Element[] { 183 | const items: Element[] = []; 184 | 185 | if (doc.getElementsByTagName("item")) { 186 | for (const elementsByTagNameKey in doc.getElementsByTagName("item")) { 187 | const entry = doc.getElementsByTagName("item")[elementsByTagNameKey]; 188 | items.push(entry); 189 | 190 | } 191 | } 192 | if (doc.getElementsByTagName("entry")) { 193 | for (const elementsByTagNameKey in doc.getElementsByTagName("entry")) { 194 | const entry = doc.getElementsByTagName("entry")[elementsByTagNameKey]; 195 | items.push(entry); 196 | } 197 | } 198 | return items; 199 | } 200 | 201 | async function requestFeed(feed: RssFeed) : Promise { 202 | return await request({url: feed.url}); 203 | } 204 | 205 | export async function getFeedItems(feed: RssFeed): Promise { 206 | let data; 207 | try { 208 | const rawData = await requestFeed(feed); 209 | data = new window.DOMParser().parseFromString(rawData, "text/xml"); 210 | } catch (e) { 211 | console.error(e); 212 | return Promise.resolve(undefined); 213 | } 214 | 215 | 216 | const items: RssFeedItem[] = []; 217 | const rawItems = getAllItems(data); 218 | 219 | const language = getContent(data, ["language"]).substr(0, 2); 220 | 221 | rawItems.forEach((rawItem) => { 222 | const item = buildItem(rawItem); 223 | if (item.title !== undefined && item.title.length !== 0) { 224 | item.folder = feed.folder; 225 | item.feed = feed.name; 226 | item.read = false; 227 | item.favorite = false; 228 | item.created = false; 229 | item.language = language; 230 | item.hash = new Md5().appendStr(item.title).appendStr(item.folder).appendStr(item.link).end(); 231 | 232 | if (!item.image && feed.url.includes("youtube.com/feeds")) { 233 | item.image = "https://i3.ytimg.com/vi/" + item.id.split(":")[2] + "/hqdefault.jpg"; 234 | } 235 | 236 | items.push(item); 237 | } 238 | }) 239 | const image = getContent(data, ["image", "image.url", "icon"]); 240 | 241 | const content: RssFeedContent = { 242 | title: getContent(data, ["title"]), 243 | subtitle: getContent(data, ["subtitle"]), 244 | link: getContent(data, ["link"]), 245 | //we don't want any leading or trailing slashes in image urls(i.e. reddit does that) 246 | image: image ? image.replace(/^\/|\/$/g, '') : null, 247 | description: getContent(data, ["description"]), 248 | items: items, 249 | folder: feed.folder, 250 | name: feed.name, 251 | language: language, 252 | hash: "", 253 | }; 254 | 255 | return Promise.resolve(content); 256 | } 257 | -------------------------------------------------------------------------------- /src/providers/Feed.ts: -------------------------------------------------------------------------------- 1 | import {Item} from "./Item"; 2 | 3 | export enum FeedOrder { 4 | DEFAULT, 5 | OLDEST_FIRST, 6 | NEWEST_FIRST, 7 | } 8 | 9 | 10 | export interface Feed { 11 | id(): number; 12 | url(): string; 13 | title(): string; 14 | favicon(): string; 15 | unreadCount(): number; 16 | ordering(): FeedOrder; 17 | link(): string; 18 | folderId(): number; 19 | folderName(): string; 20 | items(): Item[]; 21 | } 22 | -------------------------------------------------------------------------------- /src/providers/FeedProvider.ts: -------------------------------------------------------------------------------- 1 | import {Feed} from "./Feed"; 2 | import {Folder} from "./Folder"; 3 | import {Item} from "./Item"; 4 | import {SettingsSection} from "../settings/SettingsSection"; 5 | 6 | export interface FeedProvider { 7 | 8 | id(): string; 9 | 10 | name(): string; 11 | 12 | isValid(): Promise; 13 | 14 | warnings() : string[]; 15 | 16 | folders(): Promise; 17 | 18 | filteredFolders() : Promise; 19 | 20 | feeds(): Promise; 21 | 22 | items() : Promise; 23 | 24 | settings(containerEl: HTMLDivElement) : SettingsSection; 25 | } 26 | -------------------------------------------------------------------------------- /src/providers/Folder.ts: -------------------------------------------------------------------------------- 1 | import {Feed} from "./Feed"; 2 | 3 | export interface Folder { 4 | id(): number; 5 | name(): string; 6 | feeds(): Feed[]; 7 | } 8 | -------------------------------------------------------------------------------- /src/providers/Item.ts: -------------------------------------------------------------------------------- 1 | export interface Item { 2 | 3 | id(): string | number; 4 | guid(): string; 5 | guidHash(): string; 6 | url(): string; 7 | title(): string; 8 | author(): string; 9 | pubDate(): string; 10 | body(): string; 11 | description(): string; 12 | feedId(): number; 13 | read(): boolean; 14 | starred(): boolean; 15 | rtl(): boolean; 16 | mediaThumbnail(): string; 17 | mediaDescription(): string; 18 | enclosureMime(): string; 19 | enclosureLink(): string; 20 | markStarred(starred: boolean): void; 21 | markRead(read: boolean): void; 22 | tags(): string[]; 23 | setTags(tags: string[]): void; 24 | created(): boolean; 25 | markCreated(created: boolean): void; 26 | language(): string | undefined; 27 | highlights(): string[]; 28 | folder(): string; 29 | feed(): string; 30 | } 31 | -------------------------------------------------------------------------------- /src/providers/Providers.ts: -------------------------------------------------------------------------------- 1 | import {FeedProvider} from "./FeedProvider"; 2 | import RssReaderPlugin from "../main"; 3 | 4 | export class Providers { 5 | private plugin: RssReaderPlugin; 6 | private providers: FeedProvider[] = []; 7 | 8 | constructor(plugin: RssReaderPlugin) { 9 | this.plugin = plugin; 10 | } 11 | 12 | getAll(): FeedProvider[] { 13 | return this.providers; 14 | } 15 | 16 | getCurrent(): FeedProvider { 17 | return this.getById(this.plugin.settings.provider); 18 | } 19 | 20 | getById(id: string): FeedProvider { 21 | return this.providers.filter(provider => provider.id() === id).first(); 22 | } 23 | 24 | register(provider: FeedProvider): void { 25 | this.providers.push(provider); 26 | } 27 | 28 | } 29 | -------------------------------------------------------------------------------- /src/providers/local/LocalFeed.ts: -------------------------------------------------------------------------------- 1 | import {Feed, FeedOrder} from "../Feed"; 2 | import {Item} from "../Item"; 3 | import {RssFeedContent} from "../../parser/rssParser"; 4 | import {LocalFeedItem} from "./LocalFeedItem"; 5 | 6 | export class LocalFeed implements Feed { 7 | 8 | private readonly parsed: RssFeedContent; 9 | 10 | constructor(parsed: RssFeedContent) { 11 | this.parsed = parsed; 12 | } 13 | 14 | 15 | favicon(): string { 16 | return this.parsed.image; 17 | } 18 | 19 | folderId(): number { 20 | return this.parsed.folder.length; 21 | } 22 | 23 | folderName(): string { 24 | return this.parsed.folder; 25 | } 26 | 27 | id(): number { 28 | return 0; 29 | } 30 | 31 | items(): Item[] { 32 | const result: Item[] = []; 33 | for (const item of this.parsed.items) { 34 | result.push(new LocalFeedItem(item)); 35 | } 36 | return result; 37 | } 38 | 39 | link(): string { 40 | return this.parsed.link; 41 | } 42 | 43 | ordering(): FeedOrder { 44 | return FeedOrder.DEFAULT; 45 | } 46 | 47 | title(): string { 48 | return this.parsed.title; 49 | } 50 | 51 | unreadCount(): number { 52 | return 0; 53 | } 54 | 55 | url(): string { 56 | return this.parsed.link; 57 | } 58 | 59 | } 60 | -------------------------------------------------------------------------------- /src/providers/local/LocalFeedItem.ts: -------------------------------------------------------------------------------- 1 | import {Item} from "../Item"; 2 | import {RssFeedItem} from "../../parser/rssParser"; 3 | 4 | export class LocalFeedItem implements Item { 5 | 6 | private readonly item: RssFeedItem; 7 | 8 | constructor(item: RssFeedItem) { 9 | this.item = item; 10 | } 11 | 12 | author(): string { 13 | return this.item.creator; 14 | } 15 | 16 | body(): string { 17 | return this.item.content; 18 | } 19 | 20 | created(): boolean { 21 | return false; 22 | } 23 | 24 | description(): string { 25 | return this.item.description; 26 | } 27 | 28 | enclosureLink(): string { 29 | return ""; 30 | } 31 | 32 | enclosureMime(): string { 33 | return ""; 34 | } 35 | 36 | feed(): string { 37 | return ""; 38 | } 39 | 40 | feedId(): number { 41 | return 0; 42 | } 43 | 44 | folder(): string { 45 | return ""; 46 | } 47 | 48 | guid(): string { 49 | return ""; 50 | } 51 | 52 | guidHash(): string { 53 | return ""; 54 | } 55 | 56 | highlights(): string[] { 57 | return []; 58 | } 59 | 60 | id(): string | number { 61 | return undefined; 62 | } 63 | 64 | language(): string | undefined { 65 | return this.item.language; 66 | } 67 | 68 | markCreated(created: boolean): void { 69 | 70 | } 71 | 72 | markRead(read: boolean): void { 73 | } 74 | 75 | markStarred(starred: boolean): void { 76 | } 77 | 78 | mediaDescription(): string { 79 | return ""; 80 | } 81 | 82 | mediaThumbnail(): string { 83 | return ""; 84 | } 85 | 86 | pubDate(): string { 87 | return this.item.pubDate; 88 | } 89 | 90 | read(): boolean { 91 | return false; 92 | } 93 | 94 | rtl(): boolean { 95 | return false; 96 | } 97 | 98 | setTags(tags: string[]): void { 99 | } 100 | 101 | starred(): boolean { 102 | return false; 103 | } 104 | 105 | tags(): string[] { 106 | return []; 107 | } 108 | 109 | title(): string { 110 | return this.item.title; 111 | } 112 | 113 | url(): string { 114 | return this.item.link; 115 | } 116 | 117 | } 118 | -------------------------------------------------------------------------------- /src/providers/local/LocalFeedProvider.ts: -------------------------------------------------------------------------------- 1 | import {FeedProvider} from "../FeedProvider"; 2 | import {Feed} from "../Feed"; 3 | import {Folder} from "../Folder"; 4 | import {Item} from "../Item"; 5 | import RssReaderPlugin from "../../main"; 6 | import {SettingsSection} from "../../settings/SettingsSection"; 7 | import {LocalFeedSettings} from "./LocalFeedSettings"; 8 | import {LocalFeed} from "./LocalFeed"; 9 | import groupBy from "lodash.groupby"; 10 | import {LocalFolder} from "./LocalFolder"; 11 | import {getFeedItems} from "../../parser/rssParser"; 12 | 13 | export class LocalFeedProvider implements FeedProvider { 14 | private readonly plugin: RssReaderPlugin; 15 | 16 | constructor(plugin: RssReaderPlugin) { 17 | this.plugin = plugin; 18 | } 19 | 20 | async isValid(): Promise { 21 | return true; 22 | } 23 | 24 | id(): string { 25 | return "local"; 26 | } 27 | 28 | name(): string { 29 | return "Local"; 30 | } 31 | 32 | async feeds(): Promise { 33 | const result: Feed[] = []; 34 | const feeds = this.plugin.settings.feeds; 35 | for (const feed of feeds) { 36 | const content = await getFeedItems(feed); 37 | result.push(new LocalFeed(content)); 38 | } 39 | 40 | return result; 41 | } 42 | 43 | async feedFromUrl(url: string): Promise { 44 | const feed = { 45 | name: '', 46 | url, 47 | folder: '', 48 | } 49 | const content = await getFeedItems(feed); 50 | return new LocalFeed(content); 51 | } 52 | 53 | async filteredFolders(): Promise { 54 | return []; 55 | } 56 | 57 | 58 | async folders(): Promise { 59 | const result: Folder[] = []; 60 | const feeds = await this.feeds(); 61 | const grouped = groupBy(feeds, item => item.folderName()); 62 | 63 | for (const key of Object.keys(grouped)) { 64 | const folderContent = grouped[key]; 65 | result.push(new LocalFolder(key, folderContent)); 66 | } 67 | return result; 68 | } 69 | 70 | async items(): Promise { 71 | return []; 72 | } 73 | 74 | warnings(): string[] { 75 | return []; 76 | } 77 | 78 | settings(containerEl: HTMLDivElement): SettingsSection { 79 | return new LocalFeedSettings(this.plugin, containerEl); 80 | } 81 | 82 | } 83 | -------------------------------------------------------------------------------- /src/providers/local/LocalFolder.ts: -------------------------------------------------------------------------------- 1 | import {Folder} from "../Folder"; 2 | import {Feed} from "../Feed"; 3 | 4 | export class LocalFolder implements Folder { 5 | 6 | private readonly _name: string; 7 | private readonly _feeds: Feed[]; 8 | 9 | constructor(name: string, feeds: Feed[]) { 10 | this._name = name; 11 | this._feeds = feeds; 12 | } 13 | 14 | feeds(): Feed[] { 15 | return this._feeds; 16 | } 17 | 18 | id(): number { 19 | return 0; 20 | } 21 | 22 | name(): string { 23 | return this._name; 24 | } 25 | 26 | } 27 | -------------------------------------------------------------------------------- /src/providers/nextcloud/NextCloudFeed.ts: -------------------------------------------------------------------------------- 1 | import {Feed, FeedOrder} from "../Feed"; 2 | import {NextcloudFeedProvider} from "./NextcloudFeedProvider"; 3 | import {Item} from "../Item"; 4 | 5 | export class NextCloudFeed implements Feed { 6 | 7 | private readonly provider: NextcloudFeedProvider; 8 | private readonly json: any; 9 | private readonly _items: Item[]; 10 | 11 | constructor(provider: NextcloudFeedProvider, json: any, items: Item[]) { 12 | this.provider = provider; 13 | this.json = json; 14 | this._items = items; 15 | } 16 | 17 | favicon(): string { 18 | return this.json.faviconLink; 19 | } 20 | 21 | id(): number { 22 | return this.json.id; 23 | } 24 | 25 | link(): string { 26 | return this.json.link; 27 | } 28 | 29 | ordering(): FeedOrder { 30 | return this.json.ordering; 31 | } 32 | 33 | title(): string { 34 | return this.json.title; 35 | } 36 | 37 | unreadCount(): number { 38 | return this.json.unreadCount; 39 | } 40 | 41 | url(): string { 42 | return this.json.url; 43 | } 44 | 45 | folderId(): number { 46 | return this.json.folderId; 47 | } 48 | 49 | folderName(): string { 50 | return this.json.folderName; 51 | } 52 | 53 | items(): Item[] { 54 | return this._items; 55 | } 56 | 57 | } 58 | -------------------------------------------------------------------------------- /src/providers/nextcloud/NextCloudFeedSettings.ts: -------------------------------------------------------------------------------- 1 | import {SettingsSection} from "../../settings/SettingsSection"; 2 | import {Setting} from "obsidian"; 3 | import RssReaderPlugin from "../../main"; 4 | import {NextcloudFeedProvider} from "./NextcloudFeedProvider"; 5 | import {ProviderValidation} from "../../settings/ProviderValidation"; 6 | 7 | export class NextCloudFeedSettings extends SettingsSection { 8 | private readonly provider: NextcloudFeedProvider; 9 | private readonly validation: ProviderValidation; 10 | 11 | constructor(plugin: RssReaderPlugin, containerEl: HTMLDivElement, provider: NextcloudFeedProvider) { 12 | super(plugin, containerEl, false); 13 | this.provider = provider; 14 | 15 | this.validation = new ProviderValidation(this.provider, this.contentEl); 16 | } 17 | 18 | getName(): string { 19 | return ""; 20 | } 21 | 22 | async display() { 23 | this.contentEl.empty(); 24 | 25 | const authData = this.provider.getAuthData(); 26 | 27 | new Setting(this.contentEl) 28 | .setName("Server") 29 | .addText(text => { 30 | text 31 | .setPlaceholder("https://your-nextcloud.server.zyx") 32 | .setValue(authData.server) 33 | .onChange(value => { 34 | localStorage.setItem(this.provider.server_key, value); 35 | }); 36 | }); 37 | 38 | new Setting(this.contentEl) 39 | .setName("Username") 40 | .addText(text => { 41 | text 42 | .setPlaceholder("your-username") 43 | .setValue(authData.username) 44 | .onChange(value => { 45 | localStorage.setItem(this.provider.user_key, value); 46 | }); 47 | }); 48 | 49 | new Setting(this.contentEl) 50 | .setName("Password") 51 | .addText(text => { 52 | text 53 | .setPlaceholder("your password") 54 | .setValue(authData.password) 55 | .onChange(value => { 56 | localStorage.setItem(this.provider.password_key, value); 57 | }); 58 | text.inputEl.type = "password"; 59 | }); 60 | 61 | await this.validation.display(); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/providers/nextcloud/NextCloudFolder.ts: -------------------------------------------------------------------------------- 1 | import {Folder} from "../Folder"; 2 | import {Feed} from "../Feed"; 3 | 4 | export class NextCloudFolder implements Folder { 5 | private readonly json: any; 6 | private readonly _feeds: Feed[]; 7 | 8 | constructor(json: any, feeds: Feed[]) { 9 | this.json = json; 10 | this._feeds = feeds; 11 | } 12 | 13 | id(): number { 14 | return this.json.id; 15 | } 16 | 17 | name(): string { 18 | return this.json.name; 19 | } 20 | 21 | feeds(): Feed[] { 22 | return this._feeds; 23 | } 24 | 25 | } 26 | -------------------------------------------------------------------------------- /src/providers/nextcloud/NextCloudItem.ts: -------------------------------------------------------------------------------- 1 | import {Item} from "../Item"; 2 | import {NextcloudFeedProvider} from "./NextcloudFeedProvider"; 3 | 4 | export class NextCloudItem implements Item{ 5 | 6 | private readonly provider: NextcloudFeedProvider; 7 | private readonly json: any; 8 | 9 | constructor(provider: NextcloudFeedProvider, json: any) { 10 | this.provider = provider; 11 | this.json = json; 12 | } 13 | 14 | public author(): string { 15 | return this.json.author; 16 | } 17 | 18 | public body(): string { 19 | return this.json.body; 20 | } 21 | 22 | public enclosureLink(): string { 23 | return this.json.enclosureLink; 24 | } 25 | 26 | public enclosureMime(): string { 27 | return this.json.enclosureMime; 28 | } 29 | 30 | public feedId(): number { 31 | return this.json.feedId; 32 | } 33 | 34 | public id(): number { 35 | return this.json.id; 36 | } 37 | 38 | public guid(): string { 39 | return this.json.guid; 40 | } 41 | 42 | public guidHash(): string { 43 | return this.json.guidHash; 44 | } 45 | 46 | public mediaDescription(): string { 47 | return this.json.mediaDescription; 48 | } 49 | 50 | public mediaThumbnail(): string { 51 | return this.json.mediaThumbnail; 52 | } 53 | 54 | public pubDate(): string { 55 | return this.json.pubDate; 56 | } 57 | 58 | public read(): boolean { 59 | return !this.json.unread; 60 | } 61 | 62 | public rtl(): boolean { 63 | return this.json.rtl; 64 | } 65 | 66 | public starred(): boolean { 67 | return this.json.starred; 68 | } 69 | 70 | public title(): string { 71 | return this.json.title; 72 | } 73 | 74 | public url(): string { 75 | return this.json.url; 76 | } 77 | 78 | tags(): string[] { 79 | return []; 80 | } 81 | 82 | setTags(tags: string[]) { 83 | 84 | } 85 | 86 | created(): boolean { 87 | return false; 88 | } 89 | 90 | markCreated(created: boolean) { 91 | 92 | } 93 | 94 | language(): string | undefined { 95 | return undefined; 96 | } 97 | 98 | highlights(): string[] { 99 | return []; 100 | } 101 | 102 | description(): string { 103 | return ""; 104 | } 105 | 106 | folder(): string { 107 | return ""; 108 | } 109 | 110 | feed(): string { 111 | return ""; 112 | } 113 | 114 | public async markStarred(starred: boolean) { 115 | this.json.starred = starred; 116 | if(starred) { 117 | await this.provider.putData(`items/${this.feedId()}/${this.guidHash()}/star`); 118 | }else { 119 | await this.provider.putData(`items/${this.feedId()}/${this.guidHash()}/unstar`); 120 | } 121 | } 122 | 123 | public async markRead(read: boolean) { 124 | this.json.unread = !read; 125 | if(read) { 126 | await this.provider.putData(`items/${this.id()}/read`); 127 | }else { 128 | await this.provider.putData(`items/${this.id()}/unread`); 129 | } 130 | } 131 | 132 | } 133 | -------------------------------------------------------------------------------- /src/providers/nextcloud/NextcloudFeedProvider.ts: -------------------------------------------------------------------------------- 1 | import {FeedProvider} from "../FeedProvider"; 2 | import {Folder} from "../Folder"; 3 | import {Feed} from "../Feed"; 4 | import RssReaderPlugin from "../../main"; 5 | import { requestUrl, RequestUrlResponse} from "obsidian"; 6 | import {Item} from "../Item"; 7 | import {NextCloudItem} from "./NextCloudItem"; 8 | import {NextCloudFeed} from "./NextCloudFeed"; 9 | import {NextCloudFolder} from "./NextCloudFolder"; 10 | import {NextCloudFeedSettings} from "./NextCloudFeedSettings"; 11 | import {SettingsSection} from "../../settings/SettingsSection"; 12 | 13 | interface NextcloudAuthData { 14 | server: string; 15 | username: string; 16 | password: string; 17 | header: string; 18 | } 19 | 20 | export class NextcloudFeedProvider implements FeedProvider { 21 | private readonly plugin: RssReaderPlugin; 22 | 23 | public readonly server_key = "rss-nc-server"; 24 | public readonly user_key = "rss-nc-user"; 25 | public readonly password_key = "rss-nc-password"; 26 | private readonly path = "/index.php/apps/news/api/v1-2/"; 27 | 28 | private _warnings: string[] = []; 29 | 30 | constructor(plugin: RssReaderPlugin) { 31 | this.plugin = plugin; 32 | } 33 | 34 | getAuthData(): NextcloudAuthData { 35 | const username = localStorage.getItem(this.user_key); 36 | const password = localStorage.getItem(this.password_key); 37 | 38 | const header = username + ":" + password; 39 | 40 | return { 41 | server: localStorage.getItem(this.server_key), 42 | username: username, 43 | password: password, 44 | header: btoa(header) 45 | } 46 | } 47 | 48 | getRequestUrl(endpoint: string): string { 49 | const authData = this.getAuthData(); 50 | return authData.server + this.path + endpoint; 51 | } 52 | 53 | 54 | async requestData(endpoint: string): Promise { 55 | const authData = this.getAuthData(); 56 | return requestUrl({ 57 | url: this.getRequestUrl(endpoint), 58 | headers: { 59 | "Content-Type": "application/json", 60 | "Authorization": "Basic " + authData.header, 61 | } 62 | }); 63 | } 64 | 65 | async putData(endpoint: string, body?: any) : Promise { 66 | const authData = this.getAuthData(); 67 | return requestUrl({ 68 | url: this.getRequestUrl(endpoint), 69 | method: "PUT", 70 | headers: { 71 | "Content-Type": "application/json", 72 | "Authorization": "Basic " + authData.header, 73 | }, 74 | body 75 | }); 76 | } 77 | 78 | id(): string { 79 | return "nextcloud"; 80 | } 81 | 82 | 83 | async isValid(): Promise { 84 | this._warnings = []; 85 | try { 86 | const data = await this.requestData("status"); 87 | if (data.status !== 200) { 88 | this._warnings.push("Server responded with status code: " + data.status); 89 | return false; 90 | } 91 | 92 | if (data.json.warnings.improperlyConfiguredCron) { 93 | this._warnings.push("The NextCloud News App updater is improperly configured and you will lose updates.\n" + 94 | "See " + this.getAuthData().server + "/index.php/apps/news for instructions on how to fix it."); 95 | return false; 96 | } 97 | 98 | if (data.json.warnings.incorrectDbCharset) { 99 | this._warnings.push("Your NextCloud database is not properly configured, feed updates with unicode characters might fail"); 100 | return false; 101 | } 102 | return true; 103 | } catch (e) { 104 | console.log(e); 105 | this._warnings.push("Could not connect to server"); 106 | } 107 | 108 | return false; 109 | } 110 | 111 | warnings(): string[] { 112 | return this._warnings; 113 | } 114 | 115 | 116 | name(): string { 117 | return "NextCloud News"; 118 | } 119 | 120 | async feeds(): Promise { 121 | const data = await this.requestData("feeds"); 122 | const feeds: NextCloudFeed[] = []; 123 | const items = await this.items(); 124 | for(const feed of data.json.feeds) { 125 | const feedItems = items.filter(item => item.feedId() === feed.id); 126 | feeds.push(new NextCloudFeed(this, feed, feedItems)); 127 | } 128 | return feeds; 129 | } 130 | 131 | async filteredFolders(): Promise { 132 | return []; 133 | } 134 | 135 | async folders(): Promise { 136 | const data = await this.requestData("folders"); 137 | const folders: NextCloudFolder[] = []; 138 | const feeds = await this.feeds(); 139 | for(const folder of data.json.folders) { 140 | const folderFeeds = feeds.filter(feed => feed.folderId() === folder.id); 141 | folders.push(new NextCloudFolder(this, folder, folderFeeds)); 142 | } 143 | return folders; 144 | } 145 | 146 | async items(): Promise { 147 | const data = await this.requestData("items"); 148 | const items: NextCloudItem[] = []; 149 | for (const item of data.json.items) { 150 | items.push(new NextCloudItem(this, item)); 151 | } 152 | return items; 153 | } 154 | 155 | settings(containerEl: HTMLDivElement): SettingsSection { 156 | return new NextCloudFeedSettings(this.plugin, containerEl, this); 157 | } 158 | 159 | 160 | } 161 | -------------------------------------------------------------------------------- /src/settings/AdvancedSettings.ts: -------------------------------------------------------------------------------- 1 | import {SettingsSection} from "./SettingsSection"; 2 | import t from "../l10n/locale"; 3 | import {Setting} from "obsidian"; 4 | 5 | export class AdvancedSettings extends SettingsSection { 6 | 7 | getName(): string { 8 | return t('advanced'); 9 | } 10 | 11 | display() { 12 | this.contentEl.createEl("h4", {text: t("customize_terms")}); 13 | this.contentEl.createSpan({text: "Change a few selected terms here. You can help translating the plugin "}); 14 | this.contentEl.createEl("a", {text: "here", href: "https://github.com/joethei/obsidian-rss/tree/master/src/l10n"}); 15 | 16 | new Setting(this.contentEl) 17 | .setName(t("folders")) 18 | .addText(text => { 19 | text 20 | .setPlaceholder(t("folders")) 21 | .setValue(this.plugin.settings.renamedText.folders) 22 | .onChange(async value => { 23 | await this.plugin.writeSettings(() => ({ 24 | renamedText: { 25 | ...this.plugin.settings.renamedText, 26 | folders: value 27 | } 28 | })); 29 | }); 30 | }); 31 | 32 | new Setting(this.contentEl) 33 | .setName(t("filtered_folders")) 34 | .addText(text => { 35 | text 36 | .setPlaceholder(t("filtered_folders")) 37 | .setValue(this.plugin.settings.renamedText.filtered_folders) 38 | .onChange(async value => { 39 | await this.plugin.writeSettings(() => ({ 40 | renamedText: { 41 | ...this.plugin.settings.renamedText, 42 | filtered_folders: value 43 | } 44 | })); 45 | }); 46 | }); 47 | 48 | new Setting(this.contentEl) 49 | .setName(t("no_folder")) 50 | .addText(text => { 51 | text 52 | .setPlaceholder(t("no_folder")) 53 | .setValue(this.plugin.settings.renamedText.no_folder) 54 | .onChange(async value => { 55 | await this.plugin.writeSettings(() => ({ 56 | renamedText: { 57 | ...this.plugin.settings.renamedText, 58 | no_folder: value 59 | } 60 | })); 61 | }); 62 | }); 63 | 64 | this.contentEl.createEl("hr", {cls: "rss-divider"}); 65 | 66 | new Setting(this.contentEl) 67 | .setName(t("display_media")) 68 | .addToggle(toggle => { 69 | toggle 70 | .setValue(this.plugin.settings.displayMedia) 71 | .onChange(async value => { 72 | await this.plugin.writeSettings(() => ({ 73 | displayMedia: value 74 | })); 75 | }); 76 | }); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/settings/FileCreationSettings.ts: -------------------------------------------------------------------------------- 1 | import {SettingsSection} from "./SettingsSection"; 2 | import t from "../l10n/locale"; 3 | import { 4 | DropdownComponent, 5 | MomentFormatComponent, Notice, 6 | SearchComponent, 7 | Setting, 8 | TextAreaComponent, 9 | ToggleComponent 10 | } from "obsidian"; 11 | import {DEFAULT_SETTINGS} from "./settings"; 12 | import {FolderSuggest} from "./FolderSuggestor"; 13 | 14 | export class FileCreationSettings extends SettingsSection { 15 | 16 | getName(): string { 17 | return t('file_creation'); 18 | } 19 | 20 | display(): void { 21 | this.contentEl.empty(); 22 | 23 | const templateDesc = new DocumentFragment(); 24 | templateDesc.createDiv().innerHTML = t("template_new_help") + "
" + t("available_variables") + `
` + 25 | `{{title}} → ${t("article_title")}
` + 26 | `{{link}} → ${t("article_link")}
` + 27 | `{{author}} → ${t("article_author")}
` + 28 | `{{published}} → ${t("article_published")}
` + 29 | `{{created}} → ${t("note_created")}
` + 30 | `{{description}} → ${t("article_description")}
` + 31 | `{{content}} → ${t("article_content")}
` + 32 | `{{folder}} → ${t("feed_folder")}
` + 33 | `{{feed}} → ${t("feed_title")}
` + 34 | `{{filename}} → ${t("filename")}
` + 35 | `{{tags}} → ${t("article_tags")}
` + 36 | `{{media}} → ${t("article_media")}
` + 37 | `{{highlights}} → ${t("highlights")}`; 38 | 39 | new Setting(this.contentEl) 40 | .setName(t("template_new")) 41 | .setDesc(templateDesc) 42 | .addTextArea((textArea: TextAreaComponent) => { 43 | textArea 44 | .setValue(this.plugin.settings.template) 45 | .setPlaceholder(DEFAULT_SETTINGS.template) 46 | .onChange(async (value) => { 47 | await this.plugin.writeSettings(() => ({ 48 | template: value 49 | })); 50 | }); 51 | textArea.inputEl.setAttr("rows", 15); 52 | textArea.inputEl.setAttr("cols", 50); 53 | }); 54 | 55 | const pasteTemplateDesc = new DocumentFragment(); 56 | pasteTemplateDesc.createDiv().innerHTML = t("template_new_help") + "
" + t("available_variables") + `
` + 57 | `{{title}} → ${t("article_title")}
` + 58 | `{{link}} → ${t("article_link")}
` + 59 | `{{author}} → ${t("article_author")}
` + 60 | `{{published}} → ${t("article_published")}
` + 61 | `{{created}} → ${t("note_created")}
` + 62 | `{{description}} → ${t("article_description")}
` + 63 | `{{content}} → ${t("article_content")}
` + 64 | `{{folder}} → ${t("feed_folder")}
` + 65 | `{{feed}} → ${t("feed_title")}
` + 66 | `{{tags}} → ${t("article_tags")}
` + 67 | `{{media}} → ${t("article_media")}
` + 68 | `{{highlights}} → ${t("highlights")}`; 69 | 70 | new Setting(this.contentEl) 71 | .setName(t("template_paste")) 72 | .setDesc(pasteTemplateDesc) 73 | .addTextArea((textArea: TextAreaComponent) => { 74 | textArea 75 | .setValue(this.plugin.settings.pasteTemplate) 76 | .setPlaceholder(DEFAULT_SETTINGS.pasteTemplate) 77 | .onChange(async (value) => { 78 | await this.plugin.writeSettings(() => ({ 79 | pasteTemplate: value 80 | })); 81 | }); 82 | textArea.inputEl.setAttr("rows", 15); 83 | textArea.inputEl.setAttr("cols", 50); 84 | }); 85 | 86 | new Setting(this.contentEl) 87 | .setName(t("file_location")) 88 | .setDesc(t("file_location_help")) 89 | .addDropdown(async (dropdown: DropdownComponent) => { 90 | dropdown 91 | .addOption("default", t("file_location_default")) 92 | .addOption("custom", t("file_location_custom")) 93 | .setValue(this.plugin.settings.saveLocation) 94 | .onChange(async (value: string) => { 95 | await this.plugin.writeSettings(() => ( 96 | {saveLocation: value} 97 | )); 98 | this.display(); 99 | }); 100 | }); 101 | 102 | if (this.plugin.settings.saveLocation == "custom") { 103 | new Setting(this.contentEl) 104 | .setName(t("file_location_folder")) 105 | .setDesc(t("file_location_folder_help")) 106 | .addSearch(async (search: SearchComponent) => { 107 | new FolderSuggest(this.plugin.app, search.inputEl); 108 | search 109 | .setValue(this.plugin.settings.saveLocationFolder) 110 | .setPlaceholder(DEFAULT_SETTINGS.saveLocationFolder) 111 | .onChange(async (value: string) => { 112 | await this.plugin.writeSettings(() => ( 113 | {saveLocationFolder: value} 114 | )); 115 | }); 116 | }); 117 | } 118 | 119 | let dateFormatSampleEl: MomentFormatComponent; 120 | const dateFormat = new Setting(this.contentEl) 121 | .setName(t("date_format")) 122 | .addMomentFormat((format: MomentFormatComponent) => { 123 | dateFormatSampleEl = format 124 | .setDefaultFormat(DEFAULT_SETTINGS.dateFormat) 125 | .setPlaceholder(DEFAULT_SETTINGS.dateFormat) 126 | .setValue(this.plugin.settings.dateFormat) 127 | .onChange(async (value) => { 128 | await this.plugin.writeSettings(() => ( 129 | {dateFormat: value} 130 | )); 131 | }); 132 | }); 133 | const referenceLink = dateFormat.descEl.createEl("a"); 134 | referenceLink.setAttr("href", "https://momentjs.com/docs/#/displaying/format/"); 135 | referenceLink.setText(t("syntax_reference")); 136 | const text = dateFormat.descEl.createDiv("text"); 137 | text.setText(t("syntax_looks")); 138 | const sampleEl = text.createSpan("sample"); 139 | dateFormatSampleEl.setSampleEl(sampleEl); 140 | dateFormat.addExtraButton((button) => { 141 | button 142 | .setIcon('reset') 143 | .setTooltip(t("reset")) 144 | .onClick(async () => { 145 | await this.plugin.writeSettings(() => ({ 146 | dateFormat: DEFAULT_SETTINGS.dateFormat 147 | })); 148 | this.display(); 149 | }); 150 | }); 151 | 152 | new Setting(this.contentEl) 153 | .setName(t("ask_filename")) 154 | .setDesc(t("ask_filename_help")) 155 | .addToggle((toggle: ToggleComponent) => { 156 | toggle 157 | .setValue(this.plugin.settings.askForFilename) 158 | .onChange(async (value) => { 159 | await this.plugin.writeSettings(() => ({ 160 | askForFilename: value 161 | })); 162 | }); 163 | }); 164 | 165 | new Setting(this.contentEl) 166 | .setName(t("default_filename")) 167 | .setDesc(t("default_filename_help")) 168 | .addText((text) => { 169 | text 170 | .setPlaceholder(DEFAULT_SETTINGS.defaultFilename) 171 | .setValue(this.plugin.settings.defaultFilename) 172 | .onChange(async (value) => { 173 | if (value.length > 0) { 174 | await this.plugin.writeSettings(() => ({ 175 | defaultFilename: value 176 | })); 177 | } else { 178 | new Notice(t("fix_errors")); 179 | } 180 | }); 181 | }); 182 | } 183 | 184 | } 185 | -------------------------------------------------------------------------------- /src/settings/FilterSettings.ts: -------------------------------------------------------------------------------- 1 | import t from "../l10n/locale"; 2 | import {ButtonComponent, Notice, Setting} from "obsidian"; 3 | import {FilteredFolderModal} from "../modals/FilteredFolderModal"; 4 | import {SettingsSection} from "./SettingsSection"; 5 | 6 | export class FilterSettings extends SettingsSection { 7 | getName(): string { 8 | return t('filtered_folders'); 9 | } 10 | 11 | display() { 12 | 13 | this.contentEl.empty(); 14 | 15 | new Setting(this.contentEl) 16 | .setName(t("add_new")) 17 | .setDesc(t("add_new_filter")) 18 | .addButton((button: ButtonComponent): ButtonComponent => { 19 | return button 20 | .setTooltip(t("add_new_filter")) 21 | .setIcon("plus") 22 | .onClick(async () => { 23 | const modal = new FilteredFolderModal(this.plugin); 24 | 25 | modal.onClose = async () => { 26 | if (modal.saved) { 27 | if (this.plugin.settings.filtered.some(folder => folder.name === modal.name)) { 28 | new Notice(t("filter_exists")); 29 | return; 30 | } 31 | await this.plugin.writeFiltered(() => ( 32 | this.plugin.settings.filtered.concat({ 33 | name: modal.name, 34 | sortOrder: modal.sortOrder, 35 | filterFeeds: modal.filterFeeds, 36 | filterFolders: modal.filterFolders, 37 | filterTags: modal.filterTags, 38 | favorites: modal.favorites, 39 | ignoreFolders: modal.ignoreFolders, 40 | ignoreFeeds: modal.ignoreFeeds, 41 | ignoreTags: modal.ignoreTags, 42 | read: modal.read, 43 | unread: modal.unread, 44 | } 45 | ))); 46 | this.display(); 47 | } 48 | }; 49 | 50 | modal.open(); 51 | }); 52 | }); 53 | 54 | const filterContainer = this.contentEl.createDiv( 55 | "filter-container" 56 | ); 57 | const filtersDiv = filterContainer.createDiv("filters"); 58 | for (const id in this.plugin.settings.filtered.sort((a, b) => a.name.localeCompare(b.name))) { 59 | const filter = this.plugin.settings.filtered[id]; 60 | if (filter === undefined) { 61 | continue; 62 | } 63 | const setting = new Setting(filtersDiv); 64 | 65 | setting.setName(filter.name); 66 | 67 | const description: string[] = []; 68 | if (filter.read) 69 | description.push(t("read")); 70 | if (filter.unread) 71 | description.push(t("unread")); 72 | if (filter.favorites) 73 | description.push(t("favorites")); 74 | 75 | let message = ""; 76 | if (filter.filterFolders !== undefined && filter.filterFolders.length > 0) { 77 | const folders = filter.filterFolders.join(","); 78 | message += "; " + t("from_folders") + folders; 79 | } 80 | if (filter.filterFeeds !== undefined && filter.filterFeeds.length > 0) { 81 | const feeds = filter.filterFeeds.join(","); 82 | message += "; " + t("from_feeds") + feeds; 83 | } 84 | if (filter.filterTags !== undefined && filter.filterTags.length > 0) { 85 | const tags = filter.filterTags.join(","); 86 | message += "; " + t("with_tags") + tags; 87 | } 88 | 89 | setting.setDesc(description.join(",") + message); 90 | 91 | 92 | setting 93 | .addExtraButton((b) => { 94 | b.setIcon("edit") 95 | .setTooltip(t("edit")) 96 | .onClick(() => { 97 | const modal = new FilteredFolderModal(this.plugin, filter); 98 | const oldFilter = filter; 99 | 100 | modal.onClose = async () => { 101 | if (modal.saved) { 102 | const filters = this.plugin.settings.filtered; 103 | filters.remove(oldFilter); 104 | filters.push({ 105 | name: modal.name, 106 | sortOrder: modal.sortOrder, 107 | filterFeeds: modal.filterFeeds, 108 | filterFolders: modal.filterFolders, 109 | filterTags: modal.filterTags, 110 | ignoreFolders: modal.ignoreFolders, 111 | ignoreFeeds: modal.ignoreFeeds, 112 | ignoreTags: modal.ignoreTags, 113 | favorites: modal.favorites, 114 | read: modal.read, 115 | unread: modal.unread, 116 | }); 117 | await this.plugin.writeFiltered(() => (filters)); 118 | this.display(); 119 | } 120 | }; 121 | 122 | modal.open(); 123 | }); 124 | }) 125 | .addExtraButton((b) => { 126 | b.setIcon("lucide-trash") 127 | .setTooltip(t("delete")) 128 | .onClick(async () => { 129 | const filters = this.plugin.settings.filtered; 130 | filters.remove(filter); 131 | await this.plugin.writeFiltered(() => (filters)); 132 | this.display(); 133 | }); 134 | }); 135 | } 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /src/settings/FolderSuggestor.ts: -------------------------------------------------------------------------------- 1 | // Credits go to Liam's Periodic Notes Plugin: https://github.com/liamcain/obsidian-periodic-notes 2 | 3 | import { TAbstractFile, TFolder } from "obsidian"; 4 | import { TextInputSuggest } from "./suggest"; 5 | 6 | export class FolderSuggest extends TextInputSuggest { 7 | getSuggestions(inputStr: string): TFolder[] { 8 | const abstractFiles = this.app.vault.getAllLoadedFiles(); 9 | const folders: TFolder[] = []; 10 | const lowerCaseInputStr = inputStr.toLowerCase(); 11 | 12 | abstractFiles.forEach((folder: TAbstractFile) => { 13 | if ( 14 | folder instanceof TFolder && 15 | folder.path.toLowerCase().contains(lowerCaseInputStr) 16 | ) { 17 | folders.push(folder); 18 | } 19 | }); 20 | 21 | return folders; 22 | } 23 | 24 | renderSuggestion(file: TFolder, el: HTMLElement): void { 25 | el.setText(file.path); 26 | } 27 | 28 | selectSuggestion(file: TFolder): void { 29 | this.inputEl.value = file.path; 30 | this.inputEl.trigger("input"); 31 | this.close(); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/settings/MiscSettings.ts: -------------------------------------------------------------------------------- 1 | import {SettingsSection} from "./SettingsSection"; 2 | import t from "../l10n/locale"; 3 | import {Notice, Setting, TextComponent, ToggleComponent} from "obsidian"; 4 | import {DEFAULT_SETTINGS} from "./settings"; 5 | 6 | export class MiscSettings extends SettingsSection { 7 | 8 | getName(): string { 9 | return t('misc'); 10 | } 11 | 12 | display() { 13 | this.contentEl.empty(); 14 | 15 | const refresh = new Setting(this.contentEl) 16 | .setName(t("refresh_time")) 17 | .setDesc(t("refresh_time_help")) 18 | .addText((text: TextComponent) => { 19 | text 20 | .setPlaceholder(String(DEFAULT_SETTINGS.updateTime)) 21 | .setValue(String(this.plugin.settings.updateTime)) 22 | .onChange(async (value) => { 23 | if (value.length === 0) { 24 | new Notice(t("specify_positive_number")); 25 | return; 26 | } 27 | if (Number(value) < 0) { 28 | new Notice(t("specify_positive_number")); 29 | return; 30 | } 31 | 32 | await this.plugin.writeSettings(() => ( 33 | {updateTime: Number(value)} 34 | )); 35 | }); 36 | text.inputEl.setAttr("type", "number"); 37 | text.inputEl.setAttr("min", "1"); 38 | //we don't want decimal numbers. 39 | text.inputEl.setAttr("onkeypress", "return event.charCode >= 48 && event.charCode <= 57"); 40 | }); 41 | refresh.addExtraButton((button) => { 42 | button 43 | .setIcon('reset') 44 | .setTooltip('restore default') 45 | .onClick(async () => { 46 | await this.plugin.writeSettings(() => ({ 47 | updateTime: DEFAULT_SETTINGS.updateTime 48 | })); 49 | this.display(); 50 | }); 51 | }); 52 | 53 | new Setting(this.contentEl) 54 | .setName(t("multi_device_usage")) 55 | .setDesc(t("multi_device_usage_help")) 56 | .addToggle((toggle: ToggleComponent) => { 57 | return toggle 58 | .setValue(this.plugin.settings.autoSync) 59 | .onChange(async (value) => { 60 | await this.plugin.writeSettings(() => ({ 61 | autoSync: value 62 | })); 63 | }); 64 | }); 65 | 66 | new Setting(this.contentEl) 67 | .setName(t("display_style")) 68 | .addDropdown(dropdown => { 69 | return dropdown 70 | .addOption("list", t("list")) 71 | .addOption("cards", t("cards")) 72 | .setValue(this.plugin.settings.displayStyle) 73 | .onChange(async (value) => { 74 | await this.plugin.writeSettings(() => ({ 75 | displayStyle: value 76 | })); 77 | }); 78 | }); 79 | } 80 | 81 | } 82 | -------------------------------------------------------------------------------- /src/settings/ProviderSettings.ts: -------------------------------------------------------------------------------- 1 | import {SettingsSection} from "./SettingsSection"; 2 | import t from "../l10n/locale"; 3 | 4 | export class ProviderSettings extends SettingsSection { 5 | 6 | getName(): string { 7 | return t('content'); 8 | } 9 | 10 | display(): void { 11 | this.contentEl.empty(); 12 | 13 | /* new Setting(this.contentEl) 14 | .setName(t("provider")) 15 | .addDropdown(dropdown => { 16 | for (const feedProvider of this.plugin.providers.getAll()) { 17 | dropdown.addOption(feedProvider.id(), feedProvider.name()); 18 | } 19 | dropdown 20 | .setValue(this.plugin.settings.provider) 21 | .onChange(async (value) => { 22 | this.plugin.settings.provider = value; 23 | await this.plugin.saveSettings(); 24 | this.display(); 25 | }) 26 | });*/ 27 | 28 | const providerEl = this.contentEl.createDiv(); 29 | 30 | const provider = this.plugin.providers.getCurrent(); 31 | provider.settings(providerEl).display(); 32 | } 33 | 34 | } 35 | -------------------------------------------------------------------------------- /src/settings/ProviderValidation.ts: -------------------------------------------------------------------------------- 1 | import {Setting} from "obsidian"; 2 | import {FeedProvider} from "../providers/FeedProvider"; 3 | 4 | export class ProviderValidation { 5 | private readonly provider: FeedProvider; 6 | private readonly containerEl: HTMLDivElement; 7 | 8 | constructor(provider: FeedProvider, containerEl: HTMLDivElement) { 9 | this.provider = provider; 10 | this.containerEl = containerEl; 11 | } 12 | 13 | public async display() { 14 | const isValid = await this.provider.isValid(); 15 | 16 | new Setting(this.containerEl) 17 | .setName("Validate") 18 | .setDesc("Ensure that the service is configured properly") 19 | .addButton(button => { 20 | button 21 | .setButtonText("Test") 22 | .setIcon(isValid ? "check" : "x") 23 | .setClass(isValid ? "rss-test-valid" : "rss-test-invalid") 24 | .onClick(async () => { 25 | await this.display(); 26 | }) 27 | } 28 | ) 29 | 30 | 31 | if (!isValid) { 32 | this.containerEl.createEl("h4", {text: "Errors"}); 33 | const list = this.containerEl.createEl("ul"); 34 | for (const warning of this.provider.warnings()) { 35 | list.createEl("li", {text: warning}); 36 | } 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/settings/SettingsSection.ts: -------------------------------------------------------------------------------- 1 | import RssReaderPlugin from "../main"; 2 | 3 | export abstract class SettingsSection { 4 | protected readonly plugin: RssReaderPlugin; 5 | protected readonly containerEl: HTMLDivElement; 6 | protected readonly contentEl: HTMLDivElement; 7 | 8 | constructor(plugin: RssReaderPlugin, containerEl: HTMLDivElement, divider = true) { 9 | this.plugin = plugin; 10 | this.containerEl = containerEl; 11 | 12 | this.containerEl.createEl('h3', {text: this.getName()}); 13 | this.contentEl = this.containerEl.createDiv('settings-section'); 14 | if(divider) 15 | this.containerEl.createEl("hr", {cls: "rss-divider"}); 16 | } 17 | 18 | public abstract getName() : string; 19 | 20 | public abstract display(): void; 21 | } 22 | -------------------------------------------------------------------------------- /src/settings/SettingsTab.ts: -------------------------------------------------------------------------------- 1 | import { 2 | App, 3 | PluginSettingTab, 4 | } from "obsidian"; 5 | import RssReaderPlugin from "../main"; 6 | import {HotkeySettings} from "./HotkeySettings"; 7 | import {ProviderSettings} from "./ProviderSettings"; 8 | import {FileCreationSettings} from "./FileCreationSettings"; 9 | import {AdvancedSettings} from "./AdvancedSettings"; 10 | import {MiscSettings} from "./MiscSettings"; 11 | 12 | export class RSSReaderSettingsTab extends PluginSettingTab { 13 | plugin: RssReaderPlugin; 14 | 15 | constructor(app: App, plugin: RssReaderPlugin) { 16 | super(app, plugin); 17 | this.plugin = plugin; 18 | } 19 | 20 | display(): void { 21 | const {containerEl} = this; 22 | 23 | containerEl.empty(); 24 | 25 | new ProviderSettings(this.plugin, containerEl.createDiv('content')).display(); 26 | new FileCreationSettings(this.plugin, containerEl.createDiv('file-creation')).display(); 27 | new MiscSettings(this.plugin, containerEl.createDiv('misc')).display(); 28 | new HotkeySettings(this.plugin, containerEl.createDiv('hotkeys')).display(); 29 | new AdvancedSettings(this.plugin, this.containerEl.createDiv('advanced'), false).display(); 30 | 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/settings/settings.ts: -------------------------------------------------------------------------------- 1 | import {FilteredFolder} from "../modals/FilteredFolderModal"; 2 | import {RssFeedContent} from "../parser/rssParser"; 3 | 4 | export interface RssFeed { 5 | name: string, 6 | url: string, 7 | folder: string 8 | } 9 | 10 | export interface RssReaderSettings { 11 | feeds: RssFeed[], 12 | template: string, 13 | pasteTemplate: string, 14 | updateTime: number, 15 | saveLocation: string, 16 | saveLocationFolder: string, 17 | filtered: FilteredFolder[], 18 | items: RssFeedContent[], 19 | dateFormat: string, 20 | askForFilename: boolean, 21 | defaultFilename: string, 22 | autoSync: boolean, 23 | displayStyle: string, 24 | hotkeys: { 25 | create: string, 26 | paste: string, 27 | copy: string, 28 | favorite: string, 29 | read: string, 30 | tags: string, 31 | open: string, 32 | tts: string, 33 | next: string, 34 | previous: string, 35 | }, 36 | folded: string[], 37 | renamedText: { 38 | filtered_folders: string, 39 | folders: string, 40 | no_folder: string, 41 | }, 42 | displayMedia: boolean, 43 | provider: string, 44 | } 45 | 46 | export const DEFAULT_SETTINGS: RssReaderSettings = Object.freeze({ 47 | feeds: [], 48 | updateTime: 60, 49 | filtered: [{ 50 | name: "Favorites", 51 | read: true, 52 | unread: true, 53 | filterTags: [], 54 | filterFolders: [], 55 | filterFeeds: [], 56 | ignoreTags: [], 57 | ignoreFeeds: [], 58 | ignoreFolders: [], 59 | favorites: true, 60 | sortOrder: "ALPHABET_NORMAL" 61 | }], 62 | saveLocation: 'default', 63 | displayStyle: 'cards', 64 | saveLocationFolder: '', 65 | items: [], 66 | dateFormat: "YYYY-MM-DDTHH:mm:SS", 67 | template: "---\n" + 68 | "link: {{link}}\n" + 69 | "author: {{author}}\n" + 70 | "published: {{published}}\n" + 71 | "tags: [{{tags:,}}]\n" + 72 | "---\n" + 73 | "# Highlights\n" + 74 | "{{highlights}}\n\n" + 75 | "---\n" + 76 | "# {{title}}\n" + 77 | "{{content}}", 78 | pasteTemplate: "## {{title}}\n" + 79 | "{{content}}", 80 | askForFilename: false, 81 | defaultFilename: "{{title}}", 82 | autoSync: false, 83 | hotkeys: { 84 | create: "n", 85 | paste: "v", 86 | copy: "c", 87 | favorite: "f", 88 | read: "r", 89 | tags: "t", 90 | open: "o", 91 | tts: "s", 92 | previous: "ArrowLeft", 93 | next: "ArrowRight" 94 | }, 95 | folded: [], 96 | renamedText: { 97 | filtered_folders: "", 98 | folders: "", 99 | no_folder: "" 100 | }, 101 | displayMedia: true, 102 | provider: "local" 103 | }); 104 | 105 | -------------------------------------------------------------------------------- /src/settings/suggest.ts: -------------------------------------------------------------------------------- 1 | // Credits go to Liam's Periodic Notes Plugin: https://github.com/liamcain/obsidian-periodic-notes 2 | 3 | import { App, ISuggestOwner, Scope } from "obsidian"; 4 | import { createPopper, Instance as PopperInstance } from "@popperjs/core"; 5 | 6 | const wrapAround = (value: number, size: number): number => { 7 | return ((value % size) + size) % size; 8 | }; 9 | 10 | class Suggest { 11 | private owner: ISuggestOwner; 12 | private values: T[]; 13 | private suggestions: HTMLDivElement[]; 14 | private selectedItem: number; 15 | private containerEl: HTMLElement; 16 | 17 | constructor( 18 | owner: ISuggestOwner, 19 | containerEl: HTMLElement, 20 | scope: Scope 21 | ) { 22 | this.owner = owner; 23 | this.containerEl = containerEl; 24 | 25 | containerEl.on( 26 | "click", 27 | ".suggestion-item", 28 | this.onSuggestionClick.bind(this) 29 | ); 30 | containerEl.on( 31 | "mousemove", 32 | ".suggestion-item", 33 | this.onSuggestionMouseover.bind(this) 34 | ); 35 | 36 | scope.register([], "ArrowUp", (event) => { 37 | if (!event.isComposing) { 38 | this.setSelectedItem(this.selectedItem - 1, true); 39 | return false; 40 | } 41 | }); 42 | 43 | scope.register([], "ArrowDown", (event) => { 44 | if (!event.isComposing) { 45 | this.setSelectedItem(this.selectedItem + 1, true); 46 | return false; 47 | } 48 | }); 49 | 50 | scope.register([], "Enter", (event) => { 51 | if (!event.isComposing) { 52 | this.useSelectedItem(event); 53 | return false; 54 | } 55 | }); 56 | } 57 | 58 | onSuggestionClick(event: MouseEvent, el: HTMLDivElement): void { 59 | event.preventDefault(); 60 | 61 | const item = this.suggestions.indexOf(el); 62 | this.setSelectedItem(item, false); 63 | this.useSelectedItem(event); 64 | } 65 | 66 | onSuggestionMouseover(_event: MouseEvent, el: HTMLDivElement): void { 67 | const item = this.suggestions.indexOf(el); 68 | this.setSelectedItem(item, false); 69 | } 70 | 71 | setSuggestions(values: T[]) { 72 | this.containerEl.empty(); 73 | const suggestionEls: HTMLDivElement[] = []; 74 | 75 | values.forEach((value) => { 76 | const suggestionEl = this.containerEl.createDiv("suggestion-item"); 77 | this.owner.renderSuggestion(value, suggestionEl); 78 | suggestionEls.push(suggestionEl); 79 | }); 80 | 81 | this.values = values; 82 | this.suggestions = suggestionEls; 83 | this.setSelectedItem(0, false); 84 | } 85 | 86 | useSelectedItem(event: MouseEvent | KeyboardEvent) { 87 | const currentValue = this.values[this.selectedItem]; 88 | if (currentValue) { 89 | this.owner.selectSuggestion(currentValue, event); 90 | } 91 | } 92 | 93 | setSelectedItem(selectedIndex: number, scrollIntoView: boolean) { 94 | const normalizedIndex = wrapAround( 95 | selectedIndex, 96 | this.suggestions.length 97 | ); 98 | const prevSelectedSuggestion = this.suggestions[this.selectedItem]; 99 | const selectedSuggestion = this.suggestions[normalizedIndex]; 100 | 101 | prevSelectedSuggestion?.removeClass("is-selected"); 102 | selectedSuggestion?.addClass("is-selected"); 103 | 104 | this.selectedItem = normalizedIndex; 105 | 106 | if (scrollIntoView) { 107 | selectedSuggestion.scrollIntoView(false); 108 | } 109 | } 110 | } 111 | 112 | export abstract class TextInputSuggest implements ISuggestOwner { 113 | protected app: App; 114 | protected inputEl: HTMLInputElement | HTMLTextAreaElement; 115 | 116 | private popper: PopperInstance; 117 | private readonly scope: Scope; 118 | private readonly suggestEl: HTMLElement; 119 | private suggest: Suggest; 120 | 121 | constructor(app: App, inputEl: HTMLInputElement | HTMLTextAreaElement) { 122 | this.app = app; 123 | this.inputEl = inputEl; 124 | this.scope = new Scope(); 125 | 126 | this.suggestEl = createDiv("suggestion-container"); 127 | const suggestion = this.suggestEl.createDiv("suggestion"); 128 | this.suggest = new Suggest(this, suggestion, this.scope); 129 | 130 | this.scope.register([], "Escape", this.close.bind(this)); 131 | 132 | this.inputEl.addEventListener("input", this.onInputChanged.bind(this)); 133 | this.inputEl.addEventListener("focus", this.onInputChanged.bind(this)); 134 | this.inputEl.addEventListener("blur", this.close.bind(this)); 135 | this.suggestEl.on( 136 | "mousedown", 137 | ".suggestion-container", 138 | (event: MouseEvent) => { 139 | event.preventDefault(); 140 | } 141 | ); 142 | } 143 | 144 | onInputChanged(): void { 145 | const inputStr = this.inputEl.value; 146 | const suggestions = this.getSuggestions(inputStr); 147 | 148 | if (!suggestions) { 149 | this.close(); 150 | return; 151 | } 152 | 153 | if (suggestions.length > 0) { 154 | this.suggest.setSuggestions(suggestions); 155 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 156 | this.open((this.app).dom.appContainerEl, this.inputEl); 157 | } else { 158 | this.close(); 159 | } 160 | } 161 | 162 | open(container: HTMLElement, inputEl: HTMLElement): void { 163 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 164 | (this.app).keymap.pushScope(this.scope); 165 | 166 | container.appendChild(this.suggestEl); 167 | this.popper = createPopper(inputEl, this.suggestEl, { 168 | placement: "bottom-start", 169 | modifiers: [ 170 | { 171 | name: "sameWidth", 172 | enabled: true, 173 | fn: ({ state, instance }) => { 174 | // Note: positioning needs to be calculated twice - 175 | // first pass - positioning it according to the width of the popper 176 | // second pass - position it with the width bound to the reference element 177 | // we need to early exit to avoid an infinite loop 178 | const targetWidth = `${state.rects.reference.width}px`; 179 | if (state.styles.popper.width === targetWidth) { 180 | return; 181 | } 182 | state.styles.popper.width = targetWidth; 183 | instance.update(); 184 | }, 185 | phase: "beforeWrite", 186 | requires: ["computeStyles"], 187 | }, 188 | ], 189 | }); 190 | } 191 | 192 | close(): void { 193 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 194 | (this.app).keymap.popScope(this.scope); 195 | 196 | this.suggest.setSuggestions([]); 197 | if (this.popper) this.popper.destroy(); 198 | this.suggestEl.detach(); 199 | } 200 | 201 | abstract getSuggestions(inputStr: string): T[]; 202 | abstract renderSuggestion(item: T, el: HTMLElement): void; 203 | abstract selectSuggestion(item: T): void; 204 | } 205 | -------------------------------------------------------------------------------- /src/stores.ts: -------------------------------------------------------------------------------- 1 | import {writable} from "svelte/store"; 2 | import {DEFAULT_SETTINGS, RssFeed, RssReaderSettings} from "./settings/settings"; 3 | import {RssFeedContent, RssFeedItem} from "./parser/rssParser"; 4 | import Array from "obsidian"; 5 | import {FilteredFolder} from "./modals/FilteredFolderModal"; 6 | 7 | export interface FeedItems { 8 | items: RssFeedItem[]; 9 | } 10 | export interface FilteredFolderContent { 11 | filter: FilteredFolder; 12 | items: FeedItems; 13 | } 14 | 15 | export const configuredFeedsStore = writable>([]); 16 | export const filteredStore = writable>([]); 17 | export const settingsStore = writable(DEFAULT_SETTINGS); 18 | 19 | export const feedsStore = writable([]); 20 | export const sortedFeedsStore = writable<_.Dictionary>(); 21 | export const filteredItemsStore = writable>(); 22 | 23 | export const foldedState = writable>(); 24 | export const tagsStore = writable>(); 25 | export const folderStore = writable>(); 26 | -------------------------------------------------------------------------------- /src/style/main.scss: -------------------------------------------------------------------------------- 1 | .rss-read a { 2 | color: darkslategrey; 3 | } 4 | 5 | .has-invalid-message { 6 | display: grid; 7 | grid-template-columns: 1fr 1fr; 8 | grid-template-rows: 1fr 1fr; 9 | grid-template-areas: 10 | "text image" 11 | "inv inv"; 12 | } 13 | 14 | .rss-setting-input input { 15 | grid-column: span 2; 16 | } 17 | 18 | input.is-invalid { 19 | border-color: #dc3545 !important; 20 | background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%23dc3545'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23dc3545' stroke='none'/%3e%3c/svg%3e"); 21 | background-repeat: no-repeat; 22 | background-position: right calc(0.375em + 0.1875rem) center; 23 | background-size: calc(0.75em + 0.375rem) calc(0.75em + 0.375rem); 24 | } 25 | 26 | .unset-align-items { 27 | align-items: unset; 28 | } 29 | 30 | .invalid-feedback { 31 | display: block; 32 | grid-area: inv; 33 | width: 100%; 34 | margin-top: 0.25rem; 35 | font-size: 0.875em; 36 | color: #dc3545; 37 | } 38 | 39 | .rss-scrollable-content { 40 | overflow: auto; 41 | } 42 | 43 | .rss-content { 44 | height: 60vh; 45 | } 46 | 47 | 48 | .rss-tooltip { 49 | position: relative; 50 | } 51 | 52 | .rss-tooltip .tooltiptext { 53 | visibility: hidden; 54 | background-color: var(--interactive-hover); 55 | color: var(--text-normal); 56 | text-align: center; 57 | padding: 5px 0; 58 | border-radius: 6px; 59 | 60 | position: absolute; 61 | z-index: var(--layer-tooltip); 62 | } 63 | 64 | .rss-tooltip:hover .tooltiptext { 65 | visibility: visible; 66 | } 67 | 68 | .rss-content img { 69 | max-width: 100%; 70 | } 71 | 72 | .rss-subtitle { 73 | display: inline; 74 | } 75 | 76 | /*action buttons on mobile should not take one line each*/ 77 | .is-mobile button.rss-button { 78 | width: auto; 79 | } 80 | 81 | .rss-selectable { 82 | user-select: text; 83 | } 84 | 85 | .rss-card { 86 | padding-top: 10px; 87 | width: 100%; 88 | height: 100%; 89 | 90 | box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2); 91 | transition: 0.3s; 92 | border-radius: 5px; 93 | background: var(--background-primary); 94 | } 95 | 96 | .rss-card-items { 97 | display: flex; 98 | padding-top: 10px; 99 | } 100 | 101 | .rss-card-items .rss-item-text { 102 | padding-left: 5px; 103 | text-overflow: ellipsis; 104 | overflow: hidden; 105 | max-height: 10em; 106 | } 107 | 108 | .rss-item-title { 109 | padding-left: 5px; 110 | } 111 | 112 | .rss-feed-item { 113 | width: 100%; 114 | } 115 | 116 | .rss-view { 117 | background: var(--background-secondary); 118 | } 119 | 120 | .rss-modal { 121 | width: 1000px; 122 | } 123 | 124 | img.feed-favicon { 125 | height: 1em; 126 | } 127 | 128 | /*making sure both highlight styles look consistent*/ 129 | .rss-modal li mark { 130 | background-color: var(--text-highlight-bg); 131 | } 132 | 133 | 134 | .rss-modal mark li { 135 | background-color: var(--text-highlight-bg); 136 | } 137 | 138 | .rss-modal mark { 139 | background-color: var(--text-highlight-bg); 140 | } 141 | 142 | .rss-content .frontmatter { 143 | display: none; 144 | } 145 | 146 | .rss-content .frontmatter-container { 147 | display: none; 148 | } 149 | 150 | .rss-item-count { 151 | margin-left: auto; 152 | margin-right: 0; 153 | } 154 | 155 | .rss-divider { 156 | border-top: 5px solid var(--background-modifier-border); 157 | } 158 | 159 | .modal.mod-settings button:not(.mod-cta):not(.mod-warning).rss-test-valid { 160 | background-color: var(--background-modifier-success); 161 | } 162 | 163 | .modal.mod-settings button:not(.mod-cta):not(.mod-warning).rss-test-invalid { 164 | background-color: var(--background-modifier-error); 165 | } 166 | 167 | .feed ul{ 168 | margin-block-start: 0; 169 | } 170 | -------------------------------------------------------------------------------- /src/view/ArraySuggest.ts: -------------------------------------------------------------------------------- 1 | import {TextInputSuggest} from "../settings/suggest"; 2 | import {App} from "obsidian"; 3 | 4 | export class ArraySuggest extends TextInputSuggest { 5 | 6 | content: Set; 7 | 8 | constructor(app : App, input: HTMLInputElement, content: Set) { 9 | super(app, input); 10 | this.content = content; 11 | } 12 | 13 | getSuggestions(inputStr: string): string[] { 14 | const lowerCaseInputStr = inputStr.toLowerCase(); 15 | return [...this.content].filter((content) => content.contains(lowerCaseInputStr)); 16 | } 17 | 18 | renderSuggestion(content: string, el: HTMLElement): void { 19 | el.setText(content); 20 | } 21 | 22 | selectSuggestion(content: string): void { 23 | this.inputEl.value = content; 24 | this.inputEl.trigger("input"); 25 | this.close(); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/view/FeedFolderSuggest.ts: -------------------------------------------------------------------------------- 1 | import {TextInputSuggest} from "../settings/suggest"; 2 | import {get} from "svelte/store"; 3 | import {folderStore} from "../stores"; 4 | 5 | export class FeedFolderSuggest extends TextInputSuggest { 6 | 7 | getSuggestions(inputStr: string): string[] { 8 | const folders = get(folderStore); 9 | const lowerCaseInputStr = inputStr.toLowerCase(); 10 | return [...folders].filter(folder => folder.contains(lowerCaseInputStr)); 11 | } 12 | 13 | renderSuggestion(folder: string, el: HTMLElement): void { 14 | el.setText(folder); 15 | } 16 | 17 | selectSuggestion(folder: string): void { 18 | this.inputEl.value = folder; 19 | this.inputEl.trigger("input"); 20 | this.close(); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/view/FeedView.svelte: -------------------------------------------------------------------------------- 1 | 79 | 80 | {#if !feed} 81 |

...loading

82 | {:else} 83 | 84 |
85 |
toggleFold(feed.name)} 86 | on:contextmenu={openMenu}> 87 |
88 | {#if folded.contains(feed.title())} 89 | 90 | {:else} 91 | 92 | {/if} 93 | 94 | {feed.title()} 95 | {#if (feed.favicon())} 96 | {feed.title} 97 | {/if} 98 | 99 |
100 | { items.filter(item => !item.read).length } 101 |
102 | 103 |
104 | {#if !folded.contains(feed.title())} 105 |
106 | {#each items as item} 107 |
108 |
109 | 110 |
111 |
112 | {/each} 113 |
114 | 115 | {/if} 116 |
117 | 118 |
119 | 120 | {/if} 121 | -------------------------------------------------------------------------------- /src/view/FolderView.svelte: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joethei/obsidian-rss/4cbdc7155c6a6ffddab814cde9d72f317da6f8db/src/view/FolderView.svelte -------------------------------------------------------------------------------- /src/view/HtmlTooltip.svelte: -------------------------------------------------------------------------------- 1 | 13 | 14 | {#if content.length > 0} 15 |

16 | {/if} 17 | -------------------------------------------------------------------------------- /src/view/IconComponent.svelte: -------------------------------------------------------------------------------- 1 | 2 | 13 | 14 | {#if iconName.length > 0} 15 | 16 | {/if} 17 | -------------------------------------------------------------------------------- /src/view/ItemTitle.svelte: -------------------------------------------------------------------------------- 1 | 47 | 48 | {#if (item.starred())} 49 | 50 | {/if} 51 | {#if (item.created())} 52 | 53 | {/if} 54 | { 55 | new ItemModal(plugin, item, items).open(); 56 | }} 57 | on:contextmenu={openMenu} 58 | > 59 | {item.title()} 60 | 61 | -------------------------------------------------------------------------------- /src/view/ItemView.svelte: -------------------------------------------------------------------------------- 1 | 20 | 21 | {#if item} 22 |
23 | {#if $settingsStore.displayStyle === "list"} 24 | 28 | 29 | {#if item.tags.length > 0} 30 | 31 | {#each item.tags as tag} 32 |  {tag} 33 | {/each} 34 | 35 | {/if} 36 | {#if (hover)} 37 | {#if (item.mediaDescription() !== item.body())} 38 | 39 | {/if} 40 | {/if} 41 | 42 | {:else if $settingsStore.displayStyle === "cards"} 43 |
44 | 45 | 49 | {#if item.tags().length > 0} 50 | 51 | {#each item.tags() as tag} 52 |  {tag} 53 | {/each} 54 | 55 | {/if} 56 | 57 | 58 |
59 |
60 | {#if item.mediaThumbnail() && !item.mediaThumbnail().includes(".mp3")} 61 | Article 62 | {/if} 63 |
64 |
65 | {#if item.description()} 66 | 67 | {/if} 68 |
69 |
70 |
71 | {/if} 72 |
73 | {/if} 74 | -------------------------------------------------------------------------------- /src/view/MainView.svelte: -------------------------------------------------------------------------------- 1 | 21 | 22 | {#each folders as folder} 23 |

{folder.name()}

24 | {#each feeds.filter(feed => feed.folderId() === folder.id()) as feed} 25 | 26 | {/each} 27 | 28 | {/each} 29 | -------------------------------------------------------------------------------- /src/view/MarkdownContent.svelte: -------------------------------------------------------------------------------- 1 | 15 | 16 | {#if content.length > 0} 17 |

18 | {/if} 19 | -------------------------------------------------------------------------------- /src/view/TopRowButtons.svelte: -------------------------------------------------------------------------------- 1 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /src/view/ViewLoader.ts: -------------------------------------------------------------------------------- 1 | import {setIcon, View, WorkspaceLeaf} from "obsidian"; 2 | import RssReaderPlugin from "../main"; 3 | import {VIEW_ID} from "../consts"; 4 | import t from "../l10n/locale"; 5 | import {ItemModal} from "../modals/ItemModal"; 6 | 7 | export default class ViewLoader extends View { 8 | private readonly plugin: RssReaderPlugin; 9 | 10 | private navigationEl: HTMLElement; 11 | private navigationButtonsEl: HTMLElement; 12 | private contentContainer: HTMLDivElement; 13 | 14 | constructor(leaf: WorkspaceLeaf, plugin: RssReaderPlugin) { 15 | super(leaf); 16 | this.plugin = plugin; 17 | 18 | this.navigationEl = this.containerEl.createDiv('nav-header'); 19 | this.navigationButtonsEl = this.navigationEl.createDiv('nav-buttons-container'); 20 | 21 | this.contentContainer = this.containerEl.createDiv({cls: 'content rss-scrollable-content'}); 22 | } 23 | 24 | getDisplayText(): string { 25 | return t("RSS_Feeds"); 26 | } 27 | 28 | getViewType(): string { 29 | return VIEW_ID; 30 | } 31 | 32 | getIcon(): string { 33 | return "rss"; 34 | } 35 | 36 | protected async onOpen(): Promise { 37 | const buttonEl = this.navigationButtonsEl.createDiv('clickable-buttons nav-action-button'); 38 | buttonEl.addEventListener('click', async() => { 39 | await this.displayData(); 40 | }); 41 | setIcon(buttonEl,'refresh-cw'); 42 | buttonEl.setAttr('aria-label', t('refresh_feeds')); 43 | 44 | await this.displayData(); 45 | } 46 | 47 | private async displayData() { 48 | 49 | this.contentContainer.empty(); 50 | 51 | const folders = await this.plugin.providers.getCurrent().folders(); 52 | 53 | for (const folder of folders) { 54 | const folderDiv = this.contentContainer.createDiv('rss-folder'); 55 | const folderCollapseIcon = folderDiv.createSpan(); 56 | setIcon(folderCollapseIcon, 'right-triangle'); 57 | folderDiv.createSpan({text: folder.name()}); 58 | 59 | for (const feed of folder.feeds()) { 60 | const feedDiv = folderDiv.createDiv('feed'); 61 | const feedTitleDiv = feedDiv.createSpan('rss-feed'); 62 | 63 | const feedCollapse = feedTitleDiv.createSpan(); 64 | setIcon(feedCollapse, 'right-triangle'); 65 | 66 | if(feed.favicon()) { 67 | feedTitleDiv.createEl('img', {cls: 'feed-favicon', attr: {src: feed.favicon()}}); 68 | 69 | } 70 | feedTitleDiv.createSpan({text: feed.title()}); 71 | 72 | const feedList = feedDiv.createEl('ul'); 73 | 74 | for (const item of feed.items()) { 75 | const itemDiv = feedList.createEl('li'); 76 | 77 | if(item.starred()) 78 | setIcon(itemDiv.createSpan(), 'star'); 79 | if(item.created()) 80 | setIcon(itemDiv.createSpan(), 'document'); 81 | 82 | if(item.read()) 83 | itemDiv.addClass('rss-read'); 84 | 85 | itemDiv.createSpan({text: item.title()}); 86 | 87 | itemDiv.onClickEvent(() => { 88 | new ItemModal(this.plugin, item, null, true).open(); 89 | }); 90 | } 91 | } 92 | } 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /test/RssParser.test.ts: -------------------------------------------------------------------------------- 1 | import {RssFeed} from "../src/settings/settings"; 2 | import {getFeedItems} from "../src/parser/rssParser"; 3 | 4 | describe('invalid', () => { 5 | test('not xml', async () => { 6 | const feed: RssFeed = { 7 | name: "Invalid", 8 | url: "./invalid.xml", 9 | folder: "" 10 | }; 11 | const result = await getFeedItems(feed); 12 | expect(result).toBeUndefined(); 13 | 14 | }); 15 | test('Not a RSS feed', async () => { 16 | const feed: RssFeed = { 17 | name: "Invalid", 18 | url: "./example.org.xml", 19 | folder: "" 20 | }; 21 | const result = await getFeedItems(feed); 22 | expect(result.items.length).toEqual(0); 23 | expect(result.name).toEqual(feed.name); 24 | expect(result.folder).toEqual(feed.folder); 25 | expect(result.image).toBeNull(); 26 | 27 | }); 28 | }); 29 | 30 | describe('Wallabag', () => { 31 | test('live', async () => { 32 | const feed: RssFeed = { 33 | name: "Wallabag", 34 | url: "https://wallabag.joethei.de/feed/testUser/vPKtC7bLgxvUmkF/all", 35 | folder: "" 36 | }; 37 | const result = await getFeedItems(feed); 38 | expect(result.items.length).toEqual(3); 39 | expect(result.title).toEqual("wallabag — all feed"); 40 | expect(result.image).toEqual("https://wallabag.joethei.de/favicon.ico"); 41 | expect(result.items[0].title).toEqual("Using Obsidian For Writing Fiction & Notes » Eleanor Konik"); 42 | 43 | }); 44 | test('fake', async () => { 45 | const feed: RssFeed = { 46 | name: "Wallabag", 47 | url: "./wallabag.xml", 48 | folder: "" 49 | }; 50 | 51 | const result = await getFeedItems(feed); 52 | expect(result.items.length).toEqual(3); 53 | }) 54 | }); 55 | -------------------------------------------------------------------------------- /test/importData.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Nachrichten aus der Politik 4 | 5 | 6 | 8 | 11 | 14 | 17 | 20 | 23 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /test/locale.test.ts: -------------------------------------------------------------------------------- 1 | import t from "../src/l10n/locale"; 2 | 3 | describe('translation', function () { 4 | test('', function () { 5 | expect(t("testingValue")).toEqual("Hello World"); 6 | }); 7 | test('fallback to default if no value in selected language', function () { 8 | expect(t("save")).toEqual("Save"); 9 | }); 10 | test('inserts', function () { 11 | expect(t('testingInserts', "World", "!")).toEqual("Hello World !"); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /test/subfolders.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Obsidian RSS Export 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 22 | 24 | 26 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /test/subscriptions.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /test/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "baseUrl": "./", 5 | "module": "commonjs", 6 | "experimentalDecorators": true, 7 | "strictPropertyInitialization": false, 8 | "isolatedModules": false, 9 | "strict": false, 10 | "noImplicitAny": false, 11 | "types": [ 12 | "jest" 13 | ], 14 | "typeRoots" : [ 15 | "../node_modules/@types" 16 | ] 17 | }, 18 | "exclude": [ 19 | "../node_modules" 20 | ], 21 | "include": [ 22 | "./**/*.ts" 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "experimentalDecorators": true, 4 | "baseUrl": ".", 5 | "inlineSourceMap": true, 6 | "inlineSources": true, 7 | "module": "ESNext", 8 | "target": "es2018", 9 | "allowJs": true, 10 | "noImplicitAny": true, 11 | "moduleResolution": "node", 12 | "importHelpers": true, 13 | "isolatedModules": false, 14 | "types": [ 15 | "node", 16 | "svelte", 17 | "jest" 18 | ], 19 | "lib": [ 20 | "dom", 21 | "es5", 22 | "scripthost", 23 | "es2015" 24 | ], 25 | "allowSyntheticDefaultImports": true 26 | }, 27 | } 28 | -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "0.1.0": "0.9.12", 3 | "0.2.0": "0.9.12", 4 | "0.3.0": "0.9.12", 5 | "0.3.1": "0.9.12", 6 | "0.4.0": "0.9.12", 7 | "0.5.0": "0.9.12", 8 | "0.6.0": "0.9.12", 9 | "0.6.1": "0.9.12", 10 | "0.6.2": "0.9.12", 11 | "0.6.3": "0.9.12", 12 | "0.6.4": "0.9.12", 13 | "0.6.5": "0.9.12", 14 | "0.6.6": "0.9.12", 15 | "0.7.0": "0.9.12", 16 | "0.7.1": "0.9.12", 17 | "0.8.0": "0.12.19", 18 | "0.9.0": "0.12.19", 19 | "0.9.1": "0.12.19", 20 | "0.9.2": "0.13.14", 21 | "0.9.3": "0.12.17", 22 | "1.0.0": "0.12.17", 23 | "1.0.1": "0.12.17", 24 | "1.0.2": "0.12.17", 25 | "1.0.3": "0.12.17", 26 | "1.0.4": "0.12.17", 27 | "1.0.5": "0.12.17", 28 | "1.1.0": "0.12.17", 29 | "1.2.0": "0.13.33", 30 | "1.2.1": "0.13.33", 31 | "1.2.2": "0.13.33" 32 | } 33 | --------------------------------------------------------------------------------