Repository: yang991178/fluent-reader Branch: master Commit: feb64294682e Files: 131 Total size: 1.1 MB Directory structure: gitextract_ma5b55aq/ ├── .gitattributes ├── .github/ │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.md │ │ └── feature_request.md │ └── workflows/ │ ├── release-linux.yml │ └── release-main.yml ├── .gitignore ├── .prettierignore ├── .prettierrc.yml ├── .vscode/ │ └── launch.json ├── LICENSE ├── README.md ├── build/ │ ├── entitlements.mas.inherit.plist │ ├── entitlements.mas.loginhelper.plist │ ├── entitlements.mas.plist │ ├── icon.icns │ └── resignAndPackage.sh ├── dist/ │ ├── article/ │ │ ├── article.css │ │ ├── article.html │ │ ├── article.js │ │ └── mercury.web.js │ ├── fontlist │ ├── fonts.vbs │ ├── index.css │ └── styles/ │ ├── cards.css │ ├── dark.css │ ├── feeds.css │ ├── global.css │ ├── main.css │ └── scroll.css ├── docs/ │ ├── index.html │ └── styles.css ├── electron-builder-mas.yml ├── electron-builder.yml ├── package.json ├── src/ │ ├── bridges/ │ │ ├── settings.ts │ │ └── utils.ts │ ├── components/ │ │ ├── article.tsx │ │ ├── cards/ │ │ │ ├── card.tsx │ │ │ ├── compact-card.tsx │ │ │ ├── default-card.tsx │ │ │ ├── highlights.tsx │ │ │ ├── info.tsx │ │ │ ├── list-card.tsx │ │ │ └── magazine-card.tsx │ │ ├── context-menu.tsx │ │ ├── feeds/ │ │ │ ├── cards-feed.tsx │ │ │ ├── feed.tsx │ │ │ └── list-feed.tsx │ │ ├── log-menu.tsx │ │ ├── menu.tsx │ │ ├── nav.tsx │ │ ├── page.tsx │ │ ├── root.tsx │ │ ├── settings/ │ │ │ ├── about.tsx │ │ │ ├── app.tsx │ │ │ ├── groups.tsx │ │ │ ├── rules.tsx │ │ │ ├── service.tsx │ │ │ ├── services/ │ │ │ │ ├── feedbin.tsx │ │ │ │ ├── fever.tsx │ │ │ │ ├── greader.tsx │ │ │ │ ├── inoreader.tsx │ │ │ │ ├── lite-exporter.tsx │ │ │ │ ├── miniflux.tsx │ │ │ │ └── nextcloud.tsx │ │ │ └── sources.tsx │ │ ├── settings.tsx │ │ └── utils/ │ │ ├── ResizeObserver.d.ts │ │ ├── article-search.tsx │ │ ├── danger-button.tsx │ │ └── time.tsx │ ├── containers/ │ │ ├── article-container.tsx │ │ ├── feed-container.tsx │ │ ├── menu-container.tsx │ │ ├── page-container.tsx │ │ ├── settings/ │ │ │ ├── app-container.tsx │ │ │ ├── groups-container.tsx │ │ │ ├── rules-container.tsx │ │ │ ├── service-container.tsx │ │ │ └── sources-container.tsx │ │ └── settings-container.tsx │ ├── electron.ts │ ├── index.html │ ├── index.tsx │ ├── main/ │ │ ├── settings.ts │ │ ├── touchbar.ts │ │ ├── update-scripts.ts │ │ ├── utils.ts │ │ └── window.ts │ ├── preload.ts │ ├── schema-types.ts │ └── scripts/ │ ├── db.ts │ ├── i18n/ │ │ ├── README.md │ │ ├── _locales.ts │ │ ├── cs.json │ │ ├── de.json │ │ ├── en-US.json │ │ ├── es.json │ │ ├── fi-FI.json │ │ ├── fr-FR.json │ │ ├── it.json │ │ ├── ja.json │ │ ├── ko.json │ │ ├── nl.json │ │ ├── pt-BR.json │ │ ├── pt-PT.json │ │ ├── ru.json │ │ ├── sv.json │ │ ├── tr.json │ │ ├── uk.json │ │ ├── zh-CN.json │ │ └── zh-TW.json │ ├── models/ │ │ ├── app.ts │ │ ├── feed.ts │ │ ├── group.ts │ │ ├── item.ts │ │ ├── page.ts │ │ ├── rule.ts │ │ ├── service.ts │ │ ├── services/ │ │ │ ├── feedbin.ts │ │ │ ├── fever.ts │ │ │ ├── greader.ts │ │ │ ├── miniflux.ts │ │ │ └── nextcloud.ts │ │ └── source.ts │ ├── reducer.ts │ ├── settings.ts │ └── utils.ts ├── tsconfig.json └── webpack.config.js ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitattributes ================================================ dist/article/article.js text eol=lf dist/article/mercury.web.js text eol=lf ================================================ FILE: .github/FUNDING.yml ================================================ # These are supported funding model platforms github: ["yang991178"] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] patreon: # Replace with a single Patreon username open_collective: # "fluent-reader" ko_fi: # Replace with a single Ko-fi username tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry liberapay: # Replace with a single Liberapay username issuehunt: # Replace with a single IssueHunt username otechie: # Replace with a single Otechie username custom: [ "https://www.paypal.me/yang991178", "https://hyliu.me/fluent-reader/imgs/alipay.jpg", ] ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.md ================================================ --- name: Bug report about: Create a report to help us improve title: '' labels: '' assignees: '' --- **Describe the bug** A clear and concise description of what the bug is. **To Reproduce** Steps to reproduce the behavior: 1. Go to '...' 2. Click on '....' 3. Scroll down to '....' 4. See error **Expected behavior** A clear and concise description of what you expected to happen. **Screenshots** If applicable, add screenshots to help explain your problem. **Platform (please complete the following information):** - OS: [e.g. Windows 10 2004] - Version [e.g. 0.6.1] **Additional context** Add any other context about the problem here. ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.md ================================================ --- name: Feature request about: Suggest an idea for this project title: '' labels: '' assignees: '' --- **Is your feature request related to a problem? Please describe.** A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] **Describe the solution you'd like** A clear and concise description of what you want to happen. **Describe alternatives you've considered** A clear and concise description of any alternative solutions or features you've considered. **Additional context** Add any other context or screenshots about the feature request here. ================================================ FILE: .github/workflows/release-linux.yml ================================================ name: CI/CD Release Linux on: release: types: - published jobs: release-linux: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - name: Build and package the app run: | npm install npm run build npm run package-linux - name: Get app version id: package-version uses: martinbeentjes/npm-get-version-action@master - name: Get release id: get_release uses: bruceadams/get-release@v1.2.0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Upload AppImage to release assets uses: actions/upload-release-asset@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: ${{ steps.get_release.outputs.upload_url }} asset_path: ./bin/linux/x64/Fluent Reader-${{ steps.package-version.outputs.current-version }}.AppImage asset_name: Fluent.Reader.${{ steps.package-version.outputs.current-version }}.AppImage asset_content_type: application/octet-stream ================================================ FILE: .github/workflows/release-main.yml ================================================ name: CI/CD Release on: push: tags: - "v*" jobs: release: runs-on: windows-latest steps: - uses: actions/checkout@v2 - name: Build and package the app run: | npm install npm run build npm run package-win-ci - name: Get app version id: package-version run: | PACKAGE_VERSION=$(cat package.json | grep version | head -1 | awk -F: '{ print $2 }' | sed 's/[",]//g' | tr -d '[[:space:]]') echo ::set-output name=current-version::$PACKAGE_VERSION shell: bash - name: Create release id: create_release uses: actions/create-release@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: tag_name: ${{ github.ref }} release_name: Fluent Reader v${{ steps.package-version.outputs.current-version }} draft: true prerelease: false - name: Upload x64 exe to release assets uses: actions/upload-release-asset@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: ${{ steps.create_release.outputs.upload_url }} asset_path: ./bin/win32/x64/Fluent Reader Setup ${{ steps.package-version.outputs.current-version }}.exe asset_name: Fluent.Reader.Setup.${{ steps.package-version.outputs.current-version }}.x64.exe asset_content_type: application/vnd.microsoft.portable-executable - name: Upload x86 exe to release assets uses: actions/upload-release-asset@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: ${{ steps.create_release.outputs.upload_url }} asset_path: ./bin/win32/ia32/Fluent Reader Setup ${{ steps.package-version.outputs.current-version }}.exe asset_name: Fluent.Reader.Setup.${{ steps.package-version.outputs.current-version }}.x86.exe asset_content_type: application/vnd.microsoft.portable-executable - name: Upload x64 zip to release assets uses: actions/upload-release-asset@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: ${{ steps.create_release.outputs.upload_url }} asset_path: ./bin/win32/x64/Fluent Reader-${{ steps.package-version.outputs.current-version }}-win.zip asset_name: Fluent.Reader.Unpacked.${{ steps.package-version.outputs.current-version }}.x64.zip asset_content_type: application/zip - name: Upload x86 zip to release assets uses: actions/upload-release-asset@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: ${{ steps.create_release.outputs.upload_url }} asset_path: ./bin/win32/ia32/Fluent Reader-${{ steps.package-version.outputs.current-version }}-ia32-win.zip asset_name: Fluent.Reader.Unpacked.${{ steps.package-version.outputs.current-version }}.x86.zip asset_content_type: application/zip ================================================ FILE: .gitignore ================================================ node_modules dist/*.js dist/*.js.map dist/*.html dist/*.LICENSE.txt bin/* .DS_Store *.provisionprofile *.lock ================================================ FILE: .prettierignore ================================================ node_modules dist/**/*.js dist/**/*.js.map bin/* .DS_Store *.provisionprofile *.lock *.html *.md *.json !src/**/*.json ================================================ FILE: .prettierrc.yml ================================================ tabWidth: 4 semi: false jsxBracketSameLine: true arrowParens: "avoid" quoteProps: "consistent" ================================================ FILE: .vscode/launch.json ================================================ { "version": "0.2.0", "configurations": [ { "name": "Debug Main Process", "type": "node", "request": "launch", "cwd": "${workspaceRoot}", "runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron", "windows": { "runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron.cmd" }, "program": "${workspaceRoot}/dist/electron.js", "args" : ["."], "outputCapture": "std", "sourceMaps": true } ] } ================================================ FILE: LICENSE ================================================ BSD 3-Clause License Copyright (c) 2020, Haoyuan Liu All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ================================================ FILE: README.md ================================================

Fluent Reader

A modern desktop RSS reader


## Download For Windows 10 users, the recommended way of installation is through [Microsoft Store](https://www.microsoft.com/store/apps/9P71FC94LRH8?cid=github). This enables auto-update and experimental ARM64 support. macOS users can also get Fluent Reader from the [Mac App Store](https://apps.apple.com/app/id1520907427). If you are using Linux or an older version of Windows, you can [get Fluent Reader from GitHub releases](https://github.com/yang991178/fluent-reader/releases). ### Mobile App The repo of the mobile version of this app [can be found here](https://github.com/yang991178/fluent-reader-lite). ## Features

- A modern UI inspired by Fluent Design System with full dark mode support. - Read locally or sync with self-hosted services compatible with Fever or Google Reader API. - Sync with RSS Services including Inoreader, Feedbin, The Old Reader, BazQux Reader, and more. - Importing or exporting OPML files, full application data backup & restoration. - Read the full content with the built-in article view or load webpages by default. - Search for articles with regular expressions or filter by read status. - Organize your subscriptions with folder-like groupings. - Single-key [keyboard shortcuts](https://github.com/yang991178/fluent-reader/wiki/Support#keyboard-shortcuts). - Hide, mark as read, or star articles automatically as they arrive with regular expression rules. - Fetch articles in the background and send push notifications. Support for other RSS services are [under fundraising](https://github.com/yang991178/fluent-reader/issues/23). ## Development ### Contribute Help make Fluent Reader better by reporting bugs or opening feature requests through [GitHub issues](https://github.com/yang991178/fluent-reader/issues). You can also help internationalize the app by providing [translations into additional languages](https://github.com/yang991178/fluent-reader/tree/master/src/scripts/i18n). Refer to the repo of [react-intl-universal](https://github.com/alibaba/react-intl-universal) to get started on internationalization. If you enjoy using this app, consider supporting its development by donating through [GitHub Sponsors](https://github.com/sponsors/yang991178), [Paypal](https://www.paypal.me/yang991178), or [Alipay](https://hyliu.me/fluent-reader/imgs/alipay.jpg). ### Build from source ```bash # Install dependencies npm install # Compile ts & dependencies npm run build # Start the application npm run electron # Generate certificate for signature electron-builder create-self-signed-cert # Package the app for Windows npm run package-win ``` ### Developed with - [Electron](https://github.com/electron/electron) - [React](https://github.com/facebook/react) - [Redux](https://github.com/reduxjs/redux) - [Fluent UI](https://github.com/microsoft/fluentui) - [Lovefield](https://github.com/google/lovefield) - [Mercury Parser](https://github.com/postlight/mercury-parser) ### License BSD ================================================ FILE: build/entitlements.mas.inherit.plist ================================================ com.apple.security.app-sandbox com.apple.security.inherit com.apple.security.cs.allow-jit com.apple.security.cs.allow-unsigned-executable-memory ================================================ FILE: build/entitlements.mas.loginhelper.plist ================================================ com.apple.security.app-sandbox ================================================ FILE: build/entitlements.mas.plist ================================================ com.apple.security.app-sandbox com.apple.security.application-groups EM8VE646TZ.DevHYLiu.FluentReader com.apple.security.network.client com.apple.security.files.user-selected.read-write com.apple.security.files.user-selected.read-only com.apple.security.cs.allow-jit com.apple.security.cs.allow-unsigned-executable-memory com.apple.security.cs.allow-dyld-environment-variables ================================================ FILE: build/resignAndPackage.sh ================================================ # Name of your app. APP="Fluent Reader" # Your Certificate name. CERT="Jieyu Yan (EM8VE646TZ)" # The path of your app to sign. APP_PATH="bin/darwin/universal/mas-universal/Fluent Reader.app" # The path to the location you want to put the signed package. RESULT_PATH="bin/$APP-mac_store.pkg" # The name of certificates you requested. APP_KEY="Apple Distribution: $CERT" INSTALLER_KEY="3rd Party Mac Developer Installer: $CERT" # The path of your plist files. PARENT_PLIST="build/entitlements.mas.plist" CHILD_PLIST="build/entitlements.mas.inherit.plist" LOGINHELPER_PLIST="build/entitlements.mas.loginhelper.plist" FRAMEWORKS_PATH="$APP_PATH/Contents/Frameworks" # Build universal binary for font-list # FONTLIST_PATH="node_modules/font-list/libs/darwin/fontlist.m" # clang -arch arm64 -arch x86_64 "$FONTLIST_PATH" -fmodules -o "dist/fontlist" # Build the MAS app CSC_IDENTITY_AUTO_DISCOVERY=false npx electron-builder -c electron-builder-mas.yml --mac mas:universal # Add ElectronTeamID to Info.plist sed -i '' -e 's/<\/dict>/ElectronTeamID<\/key>EM8VE646TZ<\/string><\/dict>/g' "bin/darwin/universal/mas-universal/Fluent Reader.app/Contents/Info.plist" printf "......................\nresignAndPackage start\n\n" codesign --deep --force --verify --verbose=4 --timestamp --options runtime --entitlements "$CHILD_PLIST" -s "$APP_KEY" "$APP_PATH/Contents/Resources/app.asar.unpacked/dist/fontlist" codesign -s "$APP_KEY" -f --entitlements "$CHILD_PLIST" "$FRAMEWORKS_PATH/Electron Framework.framework/Versions/A/Electron Framework" codesign -s "$APP_KEY" -f --entitlements "$CHILD_PLIST" "$FRAMEWORKS_PATH/Electron Framework.framework/Versions/A/Libraries/libEGL.dylib" codesign -s "$APP_KEY" -f --entitlements "$CHILD_PLIST" "$FRAMEWORKS_PATH/Electron Framework.framework/Versions/A/Libraries/libGLESv2.dylib" codesign -s "$APP_KEY" -f --entitlements "$CHILD_PLIST" "$FRAMEWORKS_PATH/Electron Framework.framework/Versions/A/Libraries/libswiftshader_libEGL.dylib" codesign -s "$APP_KEY" -f --entitlements "$CHILD_PLIST" "$FRAMEWORKS_PATH/Electron Framework.framework/Versions/A/Libraries/libswiftshader_libGLESv2.dylib" codesign -s "$APP_KEY" -f --entitlements "$CHILD_PLIST" "$FRAMEWORKS_PATH/Electron Framework.framework/Versions/A/Libraries/libvk_swiftshader.dylib" codesign -s "$APP_KEY" -f --entitlements "$CHILD_PLIST" "$FRAMEWORKS_PATH/Electron Framework.framework/Versions/A/Libraries/libffmpeg.dylib" codesign -s "$APP_KEY" -f --entitlements "$CHILD_PLIST" "$FRAMEWORKS_PATH/Electron Framework.framework/Libraries/libffmpeg.dylib" codesign -s "$APP_KEY" -f --entitlements "$CHILD_PLIST" "$FRAMEWORKS_PATH/Electron Framework.framework" codesign -s "$APP_KEY" -f --entitlements "$CHILD_PLIST" "$FRAMEWORKS_PATH/$APP Helper.app/Contents/MacOS/$APP Helper" codesign -s "$APP_KEY" -f --entitlements "$CHILD_PLIST" "$FRAMEWORKS_PATH/$APP Helper.app/" codesign -s "$APP_KEY" -f --entitlements "$CHILD_PLIST" "$FRAMEWORKS_PATH/$APP Helper (GPU).app/Contents/MacOS/$APP Helper (GPU)" codesign -s "$APP_KEY" -f --entitlements "$CHILD_PLIST" "$FRAMEWORKS_PATH/$APP Helper (GPU).app/" codesign -s "$APP_KEY" -f --entitlements "$CHILD_PLIST" "$FRAMEWORKS_PATH/$APP Helper (Renderer).app/Contents/MacOS/$APP Helper (Renderer)" codesign -s "$APP_KEY" -f --entitlements "$CHILD_PLIST" "$FRAMEWORKS_PATH/$APP Helper (Renderer).app/" codesign -s "$APP_KEY" -f --entitlements "$CHILD_PLIST" "$FRAMEWORKS_PATH/$APP Helper (Plugin).app/Contents/MacOS/$APP Helper (Plugin)" codesign -s "$APP_KEY" -f --entitlements "$CHILD_PLIST" "$FRAMEWORKS_PATH/$APP Helper (Plugin).app/" codesign -s "$APP_KEY" -f --entitlements "$LOGINHELPER_PLIST" "$APP_PATH/Contents/Library/LoginItems/$APP Login Helper.app/Contents/MacOS/$APP Login Helper" codesign -s "$APP_KEY" -f --entitlements "$LOGINHELPER_PLIST" "$APP_PATH/Contents/Library/LoginItems/$APP Login Helper.app/" codesign -s "$APP_KEY" -f --entitlements "$CHILD_PLIST" "$APP_PATH/Contents/MacOS/$APP" codesign -s "$APP_KEY" -f --entitlements "$PARENT_PLIST" "$APP_PATH" productbuild --component "$APP_PATH" /Applications --sign "$INSTALLER_KEY" "$RESULT_PATH" printf "\nresignAndPackage end\n......................\n" ================================================ FILE: dist/article/article.css ================================================ @import "../styles/scroll.css"; html, body { margin: 0; font-family: "Segoe UI", "Source Han Sans Regular", sans-serif; } body { padding: 12px 96px 32px; overflow: hidden scroll; } body.rtl { direction: rtl; } body.vertical { padding: 32px; padding-right: 96px; writing-mode: vertical-rl; overflow: scroll hidden; } :root { --gray: #484644; --primary: #0078d4; --primary-alt: #004578; } @media (prefers-color-scheme: dark) { :root { color: #f8f8f8; --gray: #a19f9d; --primary: #4ba0e1; --primary-alt: #65aee6; } } h1, h2, h3, h4, h5, h6, b, strong { font-weight: 600; } a { color: var(--primary); text-decoration: none; } a:hover, a:active { color: var(--primary-alt); text-decoration: underline; } @keyframes fadeIn { 0% { opacity: 0; transform: translateY(10px); } 100% { opacity: 1; transform: translateY(0); } } #main { max-width: 700px; margin: 0 auto; display: none; } body.vertical #main { max-width: unset; max-height: 700px; margin: auto 0; } #main.show { display: block; animation-name: fadeIn; animation-duration: 0.367s; animation-timing-function: cubic-bezier(0.1, 0.9, 0.2, 1); animation-fill-mode: both; } #main > p.title { font-size: 1.25rem; line-height: 1.75rem; font-weight: 600; margin-block-end: 0; } #main > p.date { color: var(--gray); font-size: 0.875rem; } article { line-height: 1.6; } body.vertical article { line-height: 1.5; } body.vertical article p { text-indent: 2rem; } article * { max-width: 100%; } article img { height: auto; } body.vertical article img { max-height: 75%; } article figure { margin: 16px 0; text-align: center; } article figure figcaption { font-size: 0.875rem; color: var(--gray); -webkit-user-modify: read-only; } article iframe { width: 100%; } article code { font-family: Monaco, Consolas, monospace; font-size: 0.875rem; line-height: 1; } article pre { word-break: normal; overflow-wrap: normal; white-space: pre-wrap; } article blockquote { border-left: 2px solid var(--gray); margin: 1em 0; padding: 0 40px; } ================================================ FILE: dist/article/article.html ================================================ Article
================================================ FILE: dist/article/article.js ================================================ function get(name) { if (name = (new RegExp('[?&]' + encodeURIComponent(name) + '=([^&]*)')).exec(location.search)) return decodeURIComponent(name[1]); } let dir = get("d") if (dir === "1") { document.body.classList.add("rtl") } else if (dir === "2") { document.body.classList.add("vertical") document.body.addEventListener("wheel", (evt) => { document.scrollingElement.scrollLeft -= evt.deltaY; }); } async function getArticle(url) { let article = get("a") if (get("m") === "1") { return (await Mercury.parse(url, {html: article})).content || "" } else { return article } } document.documentElement.style.fontSize = get("s") + "px" let font = get("f") if (font) document.body.style.fontFamily = `"${font}"` let url = get("u") getArticle(url).then(article => { let domParser = new DOMParser() let dom = domParser.parseFromString(get("h"), "text/html") dom.getElementsByTagName("article")[0].innerHTML = article let baseEl = dom.createElement('base') baseEl.setAttribute('href', url.split("/").slice(0, 3).join("/")) dom.head.append(baseEl) for (let s of dom.getElementsByTagName("script")) { s.parentNode.removeChild(s) } for (let e of dom.querySelectorAll("*[src]")) { e.src = e.src } for (let e of dom.querySelectorAll("*[href]")) { e.href = e.href } let main = document.getElementById("main") main.innerHTML = dom.body.innerHTML main.classList.add("show") }) ================================================ FILE: dist/article/mercury.web.js ================================================ var Mercury=function(){"use strict";function $n(){throw new Error("Dynamic requires are not currently supported by rollup-plugin-commonjs")}function e(e,t){return e(t={exports:{}},t.exports),t.exports}var t=e(function(O){!function(e){var u,t=Object.prototype,c=t.hasOwnProperty,n="function"==typeof Symbol?Symbol:{},a=n.iterator||"@@iterator",r=n.asyncIterator||"@@asyncIterator",i=n.toStringTag||"@@toStringTag",o=e.regeneratorRuntime;if(o)O.exports=o;else{(o=e.regeneratorRuntime=O.exports).wrap=y;var f="suspendedStart",h="suspendedYield",d="executing",p="completed",m={},s={};s[a]=function(){return this};var l=Object.getPrototypeOf,g=l&&l(l(C([])));g&&g!==t&&c.call(g,a)&&(s=g);var v=A.prototype=b.prototype=Object.create(s);w.prototype=v.constructor=A,A.constructor=w,A[i]=w.displayName="GeneratorFunction",o.isGeneratorFunction=function(e){var t="function"==typeof e&&e.constructor;return!!t&&(t===w||"GeneratorFunction"===(t.displayName||t.name))},o.mark=function(e){return Object.setPrototypeOf?Object.setPrototypeOf(e,A):(e.__proto__=A,i in e||(e[i]="GeneratorFunction")),e.prototype=Object.create(v),e},o.awrap=function(e){return{__await:e}},x(k.prototype),k.prototype[r]=function(){return this},o.AsyncIterator=k,o.async=function(e,t,n,r){var a=new k(y(e,t,n,r));return o.isGeneratorFunction(t)?a:a.next().then(function(e){return e.done?e.value:a.next()})},x(v),v[i]="Generator",v[a]=function(){return this},v.toString=function(){return"[object Generator]"},o.keys=function(n){var r=[];for(var e in n)r.push(e);return r.reverse(),function e(){for(;r.length;){var t=r.pop();if(t in n)return e.value=t,e.done=!1,e}return e.done=!0,e}},o.values=C,T.prototype={constructor:T,reset:function(e){if(this.prev=0,this.next=0,this.sent=this._sent=u,this.done=!1,this.delegate=null,this.method="next",this.arg=u,this.tryEntries.forEach(M),!e)for(var t in this)"t"===t.charAt(0)&&c.call(this,t)&&!isNaN(+t.slice(1))&&(this[t]=u)},stop:function(){this.done=!0;var e=this.tryEntries[0].completion;if("throw"===e.type)throw e.arg;return this.rval},dispatchException:function(n){if(this.done)throw n;var r=this;function e(e,t){return i.type="throw",i.arg=n,r.next=e,t&&(r.method="next",r.arg=u),!!t}for(var t=this.tryEntries.length-1;0<=t;--t){var a=this.tryEntries[t],i=a.completion;if("root"===a.tryLoc)return e("end");if(a.tryLoc<=this.prev){var o=c.call(a,"catchLoc"),s=c.call(a,"finallyLoc");if(o&&s){if(this.preva;)m(r,n=t[a++])&&(~le(i,n)||i.push(n));return i},de="constructor,hasOwnProperty,isPrototypeOf,propertyIsEnumerable,toLocaleString,toString,valueOf".split(","),pe=Object.keys||function(e){return he(e,de)},me={f:Object.getOwnPropertySymbols},ge=Array.isArray||function(e){return"Array"==s(e)},ve=v?Object.defineProperties:function(e,t){D(e);for(var n,r=pe(t),a=r.length,i=0;idocument.F=Object<\/script>"),e.close(),xe=e.F;n--;)delete xe[Ae][de[n]];return xe()},ke=Object.create||function(e,t){var n;return null!==e?(we[Ae]=D(e),n=new we,we[Ae]=null,n[be]=e):n=xe(),void 0===t?n:ve(n,t)},Ee=de.concat("length","prototype"),Se={f:Object.getOwnPropertyNames||function(e){return he(e,Ee)}},Me=Se.f,Te={}.toString,Ce="object"==typeof window&&window&&Object.getOwnPropertyNames?Object.getOwnPropertyNames(window):[],De={f:function(e){return Ce&&"[object Window]"==Te.call(e)?function(e){try{return Me(e)}catch(e){return Ce.slice()}}(e):Me(c(e))}},Oe=U.KEY,je=E.f,Ne=j.f,ze=De.f,Pe=y.Symbol,Le=y.JSON,Re=Le&&Le.stringify,Ye="prototype",We=V("_hidden"),qe=V("toPrimitive"),Ie={}.propertyIsEnumerable,He=$("symbol-registry"),Fe=$("symbols"),Be=$("op-symbols"),Ge=Object[Ye],Ue="function"==typeof Pe,$e=y.QObject,Ve=!$e||!$e[Ye]||!$e[Ye].findChild,Je=v&&g(function(){return 7!=ke(Ne({},"a",{get:function(){return Ne(this,"a",{value:7}).a}})).a})?function(e,t,n){var r=je(Ge,t);r&&delete Ge[t],Ne(e,t,n),r&&e!==Ge&&Ne(Ge,t,r)}:Ne,Ke=function(e){var t=Fe[e]=ke(Pe[Ye]);return t._k=e,t},Xe=Ue&&"symbol"==typeof Pe.iterator?function(e){return"symbol"==typeof e}:function(e){return e instanceof Pe},Ze=function(e,t,n){return e===Ge&&Ze(Be,t,n),D(e),t=f(t,!0),D(n),m(Fe,t)?(n.enumerable?(m(e,We)&&e[We][t]&&(e[We][t]=!1),n=ke(n,{enumerable:A(0,!1)})):(m(e,We)||Ne(e,We,A(1,{})),e[We][t]=!0),Je(e,t,n)):Ne(e,t,n)},Qe=function(e,t){D(e);for(var n,r=function(e){var t=pe(e),n=me.f;if(n)for(var r,a=n(e),i=d.f,o=0;a.length>o;)i.call(e,r=a[o++])&&t.push(r);return t}(t=c(t)),a=0,i=r.length;aa;)m(Fe,t=n[a++])||t==We||t==Oe||r.push(t);return r},rt=function(e){for(var t,n=e===Ge,r=ze(n?Be:c(e)),a=[],i=0;r.length>i;)!m(Fe,t=r[i++])||n&&!m(Ge,t)||a.push(Fe[t]);return a};Ue||(H((Pe=function(){if(this instanceof Pe)throw TypeError("Symbol is not a constructor!");var t=G(0it;)V(at[it++]);for(var ot=pe(V.store),st=0;ot.length>st;)ee(ot[st++]);L(L.S+L.F*!Ue,"Symbol",{for:function(e){return m(He,e+="")?He[e]:He[e]=Pe(e)},keyFor:function(e){if(!Xe(e))throw TypeError(e+" is not a symbol!");for(var t in He)if(He[t]===e)return t},useSetter:function(){Ve=!0},useSimple:function(){Ve=!1}}),L(L.S+L.F*!Ue,"Object",{create:function(e,t){return void 0===t?ke(e):Qe(ke(e),t)},defineProperty:Ze,defineProperties:Qe,getOwnPropertyDescriptor:tt,getOwnPropertyNames:nt,getOwnPropertySymbols:rt}),Le&&L(L.S+L.F*(!Ue||g(function(){var e=Pe();return"[null]"!=Re([e])||"{}"!=Re({a:e})||"{}"!=Re(Object(e))})),"JSON",{stringify:function(e){for(var t,n,r=[e],a=1;arguments.length>a;)r.push(arguments[a++]);if(n=t=r[1],(l(t)||void 0!==e)&&!Xe(e))return ge(t)||(t=function(e,t){if("function"==typeof n&&(t=n.call(this,e,t)),!Xe(t))return t}),r[1]=t,Re.apply(Le,r)}}),Pe[Ye][qe]||N(Pe[Ye],qe,Pe[Ye].valueOf),X(Pe,"Symbol"),X(Math,"Math",!0),X(y.JSON,"JSON",!0);var ut=M.Object.getOwnPropertySymbols,ct=function(e){return Object(u(e))};R("keys",function(){return function(e){return pe(ct(e))}});var lt=M.Object.keys;L(L.S+L.F*!v,"Object",{defineProperty:j.f});var ft=M.Object,ht=function(e,t,n){return ft.defineProperty(e,t,n)};var dt=function(e,t,n){return t in e?ht(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e};var pt=function(t){for(var e=1;e=t.length?{value:void 0,done:!0}:(e=Tt(t,n),this._i+=e.length,{value:e,done:!1})});var Ct=function(e,t){return{value:t,done:!!e}};Mt(Array,"Array",function(e,t){this._t=c(e),this._i=0,this._k=t},function(){var e=this._t,t=this._k,n=this._i++;return!e||n>=e.length?(this._t=void 0,Ct(1)):Ct(0,"keys"==t?n:"values"==t?e[n]:[n,e[n]])},"values");vt.Arguments=vt.Array;for(var Dt=V("toStringTag"),Ot="CSSRuleList,CSSStyleDeclaration,CSSValueList,ClientRectList,DOMRectList,DOMStringList,DOMTokenList,DataTransferItemList,FileList,HTMLAllCollection,HTMLCollection,HTMLFormElement,HTMLSelectElement,MediaList,MimeTypeArray,NamedNodeMap,NodeList,PaintRequestList,Plugin,PluginArray,SVGLengthList,SVGNumberList,SVGPathSegList,SVGPointList,SVGStringList,SVGTransformList,SourceBufferList,StyleSheetList,TextTrackCueList,TextTrackList,TouchList".split(","),jt=0;jtn;)t.push(arguments[n++]);return an[++rn]=function(){!function(e,t,n){var r=void 0===n;switch(t.length){case 0:return r?e():e.call(n);case 1:return r?e(t[0]):e.call(n,t[0]);case 2:return r?e(t[0],t[1]):e.call(n,t[0],t[1]);case 3:return r?e(t[0],t[1],t[2]):e.call(n,t[0],t[1],t[2]);case 4:return r?e(t[0],t[1],t[2],t[3]):e.call(n,t[0],t[1],t[2],t[3])}e.apply(n,t)}("function"==typeof e?e:Function(e),t)},Lt(rn),rn},en=function(e){delete an[e]},"process"==s(Zt)?Lt=function(e){Zt.nextTick(C(sn,e,1))}:nn&&nn.now?Lt=function(e){nn.now(C(sn,e,1))}:tn?(Yt=(Rt=new tn).port2,Rt.port1.onmessage=un,Lt=C(Yt.postMessage,Yt,1)):y.addEventListener&&"function"==typeof postMessage&&!y.importScripts?(Lt=function(e){y.postMessage(e+"","*")},y.addEventListener("message",un,!1)):Lt=on in w("script")?function(e){_e.appendChild(w("script"))[on]=function(){_e.removeChild(this),sn.call(e)}}:function(e){setTimeout(C(sn,e,1),0)});var cn={set:Qt,clear:en},ln=cn.set,fn=y.MutationObserver||y.WebKitMutationObserver,hn=y.process,dn=y.Promise,pn="process"==s(hn);function mn(e){var n,r;this.promise=new e(function(e,t){if(void 0!==n||void 0!==r)throw TypeError("Bad Promise constructor");n=e,r=t}),this.resolve=T(n),this.reject=T(r)}var gn={f:function(e){return new mn(e)}},vn=function(e){try{return{e:!1,v:e()}}catch(e){return{e:!0,v:e}}},yn=y.navigator,_n=yn&&yn.userAgent||"",bn=function(e,t){if(D(e),l(t)&&t.constructor===e)return t;var n=gn.f(e);return(0,n.resolve)(t),n.promise},wn=function(e,t,n){for(var r in t)n&&e[r]?e[r]=t[r]:N(e,r,t[r]);return e},An=V("species"),xn=function(e){var t="function"==typeof M[e]?M[e]:y[e];v&&t&&!t[An]&&j.f(t,An,{configurable:!0,get:function(){return this}})},kn=V("iterator"),En=!1;try{[7][kn]().return=function(){En=!0}}catch(e){}var Sn,Mn,Tn,Cn,Dn=function(e,t){if(!t&&!En)return!1;var n=!1;try{var r=[7],a=r[kn]();a.next=function(){return{done:n=!0}},r[kn]=function(){return a},e(r)}catch(e){}return n},On=cn.set,jn=function(){var n,r,a,e=function(){var e,t;for(pn&&(e=hn.domain)&&e.exit();n;){t=n.fn,n=n.next;try{t()}catch(e){throw n?a():r=void 0,e}}r=void 0,e&&e.enter()};if(pn)a=function(){hn.nextTick(e)};else if(!fn||y.navigator&&y.navigator.standalone)if(dn&&dn.resolve){var t=dn.resolve(void 0);a=function(){t.then(e)}}else a=function(){ln.call(y,e)};else{var i=!0,o=document.createTextNode("");new fn(e).observe(o,{characterData:!0}),a=function(){o.data=i=!i}}return function(e){var t={fn:e,next:void 0};r&&(r.next=t),n||(n=t,a()),r=t}}(),Nn="Promise",zn=y.TypeError,Pn=y.process,Ln=Pn&&Pn.versions,Rn=Ln&&Ln.v8||"",Yn=y[Nn],Wn="process"==It(Pn),qn=function(){},In=Mn=gn.f,Hn=!!function(){try{var e=Yn.resolve(1),t=(e.constructor={})[V("species")]=function(e){e(qn,qn)};return(Wn||"function"==typeof PromiseRejectionEvent)&&e.then(qn)instanceof t&&0!==Rn.indexOf("6.6")&&-1===_n.indexOf("Chrome/66")}catch(e){}}(),Fn=function(e){var t;return!(!l(e)||"function"!=typeof(t=e.then))&&t},Bn=function(l,n){if(!l._n){l._n=!0;var r=l._c;jn(function(){for(var u=l._v,c=1==l._s,e=0,t=function(e){var t,n,r,a=c?e.ok:e.fail,i=e.resolve,o=e.reject,s=e.domain;try{a?(c||(2==l._h&&Vn(l),l._h=1),!0===a?t=u:(s&&s.enter(),t=a(u),s&&(s.exit(),r=!0)),t===e.promise?o(zn("Promise-chain cycle")):(n=Fn(t))?n.call(t,i,o):i(t)):o(u)}catch(e){s&&!r&&s.exit(),o(e)}};r.length>e;)t(r[e++]);l._c=[],l._n=!1,n&&!l._h&&Gn(l)})}},Gn=function(i){On.call(y,function(){var e,t,n,r=i._v,a=Un(i);if(a&&(e=vn(function(){Wn?Pn.emit("unhandledRejection",r,i):(t=y.onunhandledrejection)?t({promise:i,reason:r}):(n=y.console)&&n.error&&n.error("Unhandled promise rejection",r)}),i._h=Wn||Un(i)?2:1),i._a=void 0,a&&e.e)throw e.v})},Un=function(e){return 1!==e._h&&0===(e._a||e._c).length},Vn=function(t){On.call(y,function(){var e;Wn?Pn.emit("rejectionHandled",t):(e=y.onrejectionhandled)&&e({promise:t,reason:t._v})})},Jn=function(e){var t=this;t._d||(t._d=!0,(t=t._w||t)._v=e,t._s=2,t._a||(t._a=t._c.slice()),Bn(t,!0))},Kn=function(e){var n,r=this;if(!r._d){r._d=!0,r=r._w||r;try{if(r===e)throw zn("Promise can't be resolved itself");(n=Fn(e))?jn(function(){var t={_w:r,_d:!1};try{n.call(e,C(Kn,t,1),C(Jn,t,1))}catch(e){Jn.call(t,e)}}):(r._v=e,r._s=1,Bn(r,!1))}catch(e){Jn.call({_w:r,_d:!1},e)}}};Hn||(Yn=function(e){Ht(this,Yn,Nn,"_h"),T(e),Sn.call(this);try{e(C(Kn,this,1),C(Jn,this,1))}catch(e){Jn.call(this,e)}},(Sn=function(e){this._c=[],this._a=void 0,this._s=0,this._d=!1,this._v=void 0,this._h=0,this._n=!1}).prototype=wn(Yn.prototype,{then:function(e,t){var n=In(Xt(this,Yn));return n.ok="function"!=typeof e||e,n.fail="function"==typeof t&&t,n.domain=Wn?Pn.domain:void 0,this._c.push(n),this._a&&this._a.push(n),this._s&&Bn(this,!1),n.promise},catch:function(e){return this.then(void 0,e)}}),Tn=function(){var e=new Sn;this.promise=e,this.resolve=C(Kn,e,1),this.reject=C(Jn,e,1)},gn.f=In=function(e){return e===Yn||e===Cn?new Tn(e):Mn(e)}),L(L.G+L.W+L.F*!Hn,{Promise:Yn}),X(Yn,Nn),xn(Nn),Cn=M[Nn],L(L.S+L.F*!Hn,Nn,{reject:function(e){var t=In(this);return(0,t.reject)(e),t.promise}}),L(L.S+!0*L.F,Nn,{resolve:function(e){return bn(this===Cn?Yn:this,e)}}),L(L.S+L.F*!(Hn&&Dn(function(e){Yn.all(e).catch(qn)})),Nn,{all:function(e){var o=this,t=In(o),s=t.resolve,u=t.reject,n=vn(function(){var r=[],a=0,i=1;Jt(e,!1,function(e){var t=a++,n=!1;r.push(void 0),i++,o.resolve(e).then(function(e){n||(n=!0,r[t]=e,--i||s(r))},u)}),--i||s(r)});return n.e&&u(n.v),t.promise},race:function(e){var t=this,n=In(t),r=n.reject,a=vn(function(){Jt(e,!1,function(e){t.resolve(e).then(n.resolve,r)})});return a.e&&r(a.v),n.promise}}),L(L.P+L.R,"Promise",{finally:function(t){var n=Xt(this,M.Promise||y.Promise),e="function"==typeof t;return this.then(e?function(e){return bn(n,t()).then(function(){return e})}:t,e?function(e){return bn(n,t()).then(function(){throw e})}:t)}}),L(L.S,"Promise",{try:function(e){var t=gn.f(this),n=vn(e);return(n.e?t.reject:t.resolve)(n.v),t.promise}});var Xn=M.Promise;function Zn(e,t,n,r,a,i,o){try{var s=e[i](o),u=s.value}catch(e){return void n(e)}s.done?t(u):Xn.resolve(u).then(r,a)}var Qn=function(s){return function(){var e=this,o=arguments;return new Xn(function(t,n){var r=s.apply(e,o);function a(e){Zn(r,t,n,a,i,"next",e)}function i(e){Zn(r,t,n,a,i,"throw",e)}a(void 0)})}},er="undefined"!=typeof global?global:"undefined"!=typeof self?self:"undefined"!=typeof window?window:{},tr=e(function(O,j){!function(e){var t=j&&!j.nodeType&&j,n=O&&!O.nodeType&&O,r="object"==typeof er&&er;r.global!==r&&r.window!==r&&r.self!==r||(e=r);var a,i,v=2147483647,y=36,_=1,b=26,o=38,s=700,w=72,A=128,x="-",u=/^xn--/,c=/[^\x20-\x7E]/,l=/[\x2E\u3002\uFF0E\uFF61]/g,f={overflow:"Overflow: input needs wider integers to process","not-basic":"Illegal input >= 0x80 (not a basic code point)","invalid-input":"Invalid input"},h=y-_,k=Math.floor,E=String.fromCharCode;function S(e){throw RangeError(f[e])}function d(e,t){for(var n=e.length,r=[];n--;)r[n]=t(e[n]);return r}function p(e,t){var n=e.split("@"),r="";return 1>>10&1023|55296),e=56320|1023&e),t+=E(e)}).join("")}function C(e,t){return e+22+75*(e<26)-((0!=t)<<5)}function D(e,t,n){var r=0;for(e=n?k(e/s):e>>1,e+=k(e/t);h*b>>1k((v-p)/o))&&S("overflow"),p+=u*o,!(u<(c=s<=g?_:g+b<=s?b:s-g));s+=y)o>k(v/(l=y-c))&&S("overflow"),o*=l;g=D(p-i,t=h.length+1,0==i),k(p/t)>v-m&&S("overflow"),m+=k(p/t),p%=t,h.splice(p++,0,m)}return T(h)}function g(e){var t,n,r,a,i,o,s,u,c,l,f,h,d,p,m,g=[];for(h=(e=M(e)).length,t=A,i=w,o=n=0;ok((v-n)/(d=r+1))&&S("overflow"),n+=(s-t)*d,t=s,o=0;ov&&S("overflow"),f==t){for(u=n,c=y;!(u<(l=c<=i?_:i+b<=c?b:c-i));c+=y)m=u-l,p=y-l,g.push(E(C(l+m%p,0))),u=k(m/p);g.push(E(C(u,0))),i=D(n,d,r==a),n=0,++r}++n,++t}return g.join("")}if(a={version:"1.3.2",ucs2:{decode:M,encode:T},decode:m,encode:g,toASCII:function(e){return p(e,function(e){return c.test(e)?"xn--"+g(e):e})},toUnicode:function(e){return p(e,function(e){return u.test(e)?m(e.slice(4).toLowerCase()):e})}},t&&n)if(O.exports==t)n.exports=a;else for(i in a)a.hasOwnProperty(i)&&(t[i]=a[i]);else e.punycode=a}(this)}),nr={isString:function(e){return"string"==typeof e},isObject:function(e){return"object"==typeof e&&null!==e},isNull:function(e){return null===e},isNullOrUndefined:function(e){return null==e}};var rr=function(e,t,n,r){t=t||"&",n=n||"=";var a={};if("string"!=typeof e||0===e.length)return a;var i=/\+/g;e=e.split(t);var o=1e3;r&&"number"==typeof r.maxKeys&&(o=r.maxKeys);var s,u,c=e.length;0",'"',"`"," ","\r","\n","\t"]),vr=["'"].concat(gr),yr=["%","/","?",";","#"].concat(vr),_r=["/","?","#"],br=/^[+a-z0-9A-Z_-]{0,63}$/,wr=/^([+a-z0-9A-Z_-]{0,63})(.*)$/,Ar={javascript:!0,"javascript:":!0},xr={javascript:!0,"javascript:":!0},kr={http:!0,https:!0,ftp:!0,gopher:!0,file:!0,"http:":!0,"https:":!0,"ftp:":!0,"gopher:":!0,"file:":!0};function Er(e,t,n){if(e&&nr.isObject(e)&&e instanceof hr)return e;var r=new hr;return r.parse(e,t,n),r}hr.prototype.parse=function(e,t,n){if(!nr.isString(e))throw new TypeError("Parameter 'url' must be a string, not "+typeof e);var r=e.indexOf("?"),a=-1!==r&&r+~]|"+R+")"+R+"*"),G=new RegExp(R+"|>"),U=new RegExp(q),$=new RegExp("^"+Y+"$"),V={ID:new RegExp("^#("+Y+")"),CLASS:new RegExp("^\\.("+Y+")"),TAG:new RegExp("^("+Y+"|[*])"),ATTR:new RegExp("^"+W),PSEUDO:new RegExp("^"+q),CHILD:new RegExp("^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\("+R+"*(even|odd|(([+-]|)(\\d*)n|)"+R+"*(?:([+-]|)"+R+"*(\\d+)|))"+R+"*\\)|)","i"),bool:new RegExp("^(?:"+L+")$","i"),needsContext:new RegExp("^"+R+"*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\("+R+"*((?:-\\d)?\\d*)"+R+"*\\)|)(?=[^-]|$)","i")},J=/HTML$/i,K=/^(?:input|select|textarea|button)$/i,X=/^h\d$/i,Z=/^[^{]+\{\s*\[native \w/,Q=/^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/,ee=/[+~]/,te=new RegExp("\\\\([\\da-f]{1,6}"+R+"?|("+R+")|.)","ig"),ne=function(e,t,n){var r="0x"+t-65536;return r!=r||n?t:r<0?String.fromCharCode(r+65536):String.fromCharCode(r>>10|55296,1023&r|56320)},re=/([\0-\x1f\x7f]|^-?\d)|^-$|[^\0-\x1f\x7f-\uFFFF\w-]/g,ae=function(e,t){return t?"\0"===e?"�":e.slice(0,-1)+"\\"+e.charCodeAt(e.length-1).toString(16)+" ":"\\"+e},ie=function(){A()},oe=be(function(e){return!0===e.disabled&&"fieldset"===e.nodeName.toLowerCase()},{dir:"parentNode",next:"legend"});try{N.apply(t=z.call(y.childNodes),y.childNodes),t[y.childNodes.length].nodeType}catch(e){N={apply:t.length?function(e,t){j.apply(e,z.call(t))}:function(e,t){for(var n=e.length,r=0;e[n++]=t[r++];);e.length=n-1}}}function se(e,t,n,r){var a,i,o,s,u,c,l,f=t&&t.ownerDocument,h=t?t.nodeType:9;if(n=n||[],"string"!=typeof e||!e||1!==h&&9!==h&&11!==h)return n;if(!r&&((t?t.ownerDocument||t:y)!==x&&A(t),t=t||x,k)){if(11!==h&&(u=Q.exec(e)))if(a=u[1]){if(9===h){if(!(o=t.getElementById(a)))return n;if(o.id===a)return n.push(o),n}else if(f&&(o=f.getElementById(a))&&v(t,o)&&o.id===a)return n.push(o),n}else{if(u[2])return N.apply(n,t.getElementsByTagName(e)),n;if((a=u[3])&&d.getElementsByClassName&&t.getElementsByClassName)return N.apply(n,t.getElementsByClassName(a)),n}if(d.qsa&&!T[e+" "]&&(!g||!g.test(e))&&(1!==h||"object"!==t.nodeName.toLowerCase())){if(l=e,f=t,1===h&&G.test(e)){for((s=t.getAttribute("id"))?s=s.replace(re,ae):t.setAttribute("id",s=E),i=(c=p(e)).length;i--;)c[i]="#"+s+" "+_e(c[i]);l=c.join(","),f=ee.test(e)&&ve(t.parentNode)||t}try{return N.apply(n,f.querySelectorAll(l)),n}catch(t){T(e,!0)}finally{s===E&&t.removeAttribute("id")}}}return m(e.replace(H,"$1"),t,n,r)}function ue(){var r=[];return function e(t,n){return r.push(t+" ")>b.cacheLength&&delete e[r.shift()],e[t+" "]=n}}function ce(e){return e[E]=!0,e}function le(e){var t=x.createElement("fieldset");try{return!!e(t)}catch(e){return!1}finally{t.parentNode&&t.parentNode.removeChild(t),t=null}}function fe(e,t){for(var n=e.split("|"),r=n.length;r--;)b.attrHandle[n[r]]=t}function he(e,t){var n=t&&e,r=n&&1===e.nodeType&&1===t.nodeType&&e.sourceIndex-t.sourceIndex;if(r)return r;if(n)for(;n=n.nextSibling;)if(n===t)return-1;return e?1:-1}function de(t){return function(e){return"input"===e.nodeName.toLowerCase()&&e.type===t}}function pe(n){return function(e){var t=e.nodeName.toLowerCase();return("input"===t||"button"===t)&&e.type===n}}function me(t){return function(e){return"form"in e?e.parentNode&&!1===e.disabled?"label"in e?"label"in e.parentNode?e.parentNode.disabled===t:e.disabled===t:e.isDisabled===t||e.isDisabled!==!t&&oe(e)===t:e.disabled===t:"label"in e&&e.disabled===t}}function ge(o){return ce(function(i){return i=+i,ce(function(e,t){for(var n,r=o([],e.length,i),a=r.length;a--;)e[n=r[a]]&&(e[n]=!(t[n]=e[n]))})})}function ve(e){return e&&void 0!==e.getElementsByTagName&&e}for(e in d=se.support={},a=se.isXML=function(e){var t=e.namespaceURI,n=(e.ownerDocument||e).documentElement;return!J.test(t||n&&n.nodeName||"HTML")},A=se.setDocument=function(e){var t,n,r=e?e.ownerDocument||e:y;return r!==x&&9===r.nodeType&&r.documentElement&&(o=(x=r).documentElement,k=!a(x),y!==x&&(n=x.defaultView)&&n.top!==n&&(n.addEventListener?n.addEventListener("unload",ie,!1):n.attachEvent&&n.attachEvent("onunload",ie)),d.attributes=le(function(e){return e.className="i",!e.getAttribute("className")}),d.getElementsByTagName=le(function(e){return e.appendChild(x.createComment("")),!e.getElementsByTagName("*").length}),d.getElementsByClassName=Z.test(x.getElementsByClassName),d.getById=le(function(e){return o.appendChild(e).id=E,!x.getElementsByName||!x.getElementsByName(E).length}),d.getById?(b.filter.ID=function(e){var t=e.replace(te,ne);return function(e){return e.getAttribute("id")===t}},b.find.ID=function(e,t){if(void 0!==t.getElementById&&k){var n=t.getElementById(e);return n?[n]:[]}}):(b.filter.ID=function(e){var n=e.replace(te,ne);return function(e){var t=void 0!==e.getAttributeNode&&e.getAttributeNode("id");return t&&t.value===n}},b.find.ID=function(e,t){if(void 0!==t.getElementById&&k){var n,r,a,i=t.getElementById(e);if(i){if((n=i.getAttributeNode("id"))&&n.value===e)return[i];for(a=t.getElementsByName(e),r=0;i=a[r++];)if((n=i.getAttributeNode("id"))&&n.value===e)return[i]}return[]}}),b.find.TAG=d.getElementsByTagName?function(e,t){return void 0!==t.getElementsByTagName?t.getElementsByTagName(e):d.qsa?t.querySelectorAll(e):void 0}:function(e,t){var n,r=[],a=0,i=t.getElementsByTagName(e);if("*"!==e)return i;for(;n=i[a++];)1===n.nodeType&&r.push(n);return r},b.find.CLASS=d.getElementsByClassName&&function(e,t){if(void 0!==t.getElementsByClassName&&k)return t.getElementsByClassName(e)},s=[],g=[],(d.qsa=Z.test(x.querySelectorAll))&&(le(function(e){o.appendChild(e).innerHTML="",e.querySelectorAll("[msallowcapture^='']").length&&g.push("[*^$]="+R+"*(?:''|\"\")"),e.querySelectorAll("[selected]").length||g.push("\\["+R+"*(?:value|"+L+")"),e.querySelectorAll("[id~="+E+"-]").length||g.push("~="),e.querySelectorAll(":checked").length||g.push(":checked"),e.querySelectorAll("a#"+E+"+*").length||g.push(".#.+[+~]")}),le(function(e){e.innerHTML="";var t=x.createElement("input");t.setAttribute("type","hidden"),e.appendChild(t).setAttribute("name","D"),e.querySelectorAll("[name=d]").length&&g.push("name"+R+"*[*^$|!~]?="),2!==e.querySelectorAll(":enabled").length&&g.push(":enabled",":disabled"),o.appendChild(e).disabled=!0,2!==e.querySelectorAll(":disabled").length&&g.push(":enabled",":disabled"),e.querySelectorAll("*,:x"),g.push(",.*:")})),(d.matchesSelector=Z.test(l=o.matches||o.webkitMatchesSelector||o.mozMatchesSelector||o.oMatchesSelector||o.msMatchesSelector))&&le(function(e){d.disconnectedMatch=l.call(e,"*"),l.call(e,"[s!='']:x"),s.push("!=",q)}),g=g.length&&new RegExp(g.join("|")),s=s.length&&new RegExp(s.join("|")),t=Z.test(o.compareDocumentPosition),v=t||Z.test(o.contains)?function(e,t){var n=9===e.nodeType?e.documentElement:e,r=t&&t.parentNode;return e===r||!(!r||1!==r.nodeType||!(n.contains?n.contains(r):e.compareDocumentPosition&&16&e.compareDocumentPosition(r)))}:function(e,t){if(t)for(;t=t.parentNode;)if(t===e)return!0;return!1},C=t?function(e,t){if(e===t)return c=!0,0;var n=!e.compareDocumentPosition-!t.compareDocumentPosition;return n||(1&(n=(e.ownerDocument||e)===(t.ownerDocument||t)?e.compareDocumentPosition(t):1)||!d.sortDetached&&t.compareDocumentPosition(e)===n?e===x||e.ownerDocument===y&&v(y,e)?-1:t===x||t.ownerDocument===y&&v(y,t)?1:u?P(u,e)-P(u,t):0:4&n?-1:1)}:function(e,t){if(e===t)return c=!0,0;var n,r=0,a=e.parentNode,i=t.parentNode,o=[e],s=[t];if(!a||!i)return e===x?-1:t===x?1:a?-1:i?1:u?P(u,e)-P(u,t):0;if(a===i)return he(e,t);for(n=e;n=n.parentNode;)o.unshift(n);for(n=t;n=n.parentNode;)s.unshift(n);for(;o[r]===s[r];)r++;return r?he(o[r],s[r]):o[r]===y?-1:s[r]===y?1:0}),x},se.matches=function(e,t){return se(e,null,null,t)},se.matchesSelector=function(e,t){if((e.ownerDocument||e)!==x&&A(e),d.matchesSelector&&k&&!T[t+" "]&&(!s||!s.test(t))&&(!g||!g.test(t)))try{var n=l.call(e,t);if(n||d.disconnectedMatch||e.document&&11!==e.document.nodeType)return n}catch(e){T(t,!0)}return 0":{dir:"parentNode",first:!0}," ":{dir:"parentNode"},"+":{dir:"previousSibling",first:!0},"~":{dir:"previousSibling"}},preFilter:{ATTR:function(e){return e[1]=e[1].replace(te,ne),e[3]=(e[3]||e[4]||e[5]||"").replace(te,ne),"~="===e[2]&&(e[3]=" "+e[3]+" "),e.slice(0,4)},CHILD:function(e){return e[1]=e[1].toLowerCase(),"nth"===e[1].slice(0,3)?(e[3]||se.error(e[0]),e[4]=+(e[4]?e[5]+(e[6]||1):2*("even"===e[3]||"odd"===e[3])),e[5]=+(e[7]+e[8]||"odd"===e[3])):e[3]&&se.error(e[0]),e},PSEUDO:function(e){var t,n=!e[6]&&e[2];return V.CHILD.test(e[0])?null:(e[3]?e[2]=e[4]||e[5]||"":n&&U.test(n)&&(t=p(n,!0))&&(t=n.indexOf(")",n.length-t)-n.length)&&(e[0]=e[0].slice(0,t),e[2]=n.slice(0,t)),e.slice(0,3))}},filter:{TAG:function(e){var t=e.replace(te,ne).toLowerCase();return"*"===e?function(){return!0}:function(e){return e.nodeName&&e.nodeName.toLowerCase()===t}},CLASS:function(e){var t=h[e+" "];return t||(t=new RegExp("(^|"+R+")"+e+"("+R+"|$)"))&&h(e,function(e){return t.test("string"==typeof e.className&&e.className||void 0!==e.getAttribute&&e.getAttribute("class")||"")})},ATTR:function(n,r,a){return function(e){var t=se.attr(e,n);return null==t?"!="===r:!r||(t+="","="===r?t===a:"!="===r?t!==a:"^="===r?a&&0===t.indexOf(a):"*="===r?a&&-1:\x20\t\r\n\f]*)[\x20\t\r\n\f]*\/?>(?:<\/\1>|)$/i;function C(e,n,r){return y(n)?E.grep(e,function(e,t){return!!n.call(e,t,e)!==r}):n.nodeType?E.grep(e,function(e){return e===n!==r}):"string"!=typeof n?E.grep(e,function(e){return-1)[^>]*|#([\w-]+))$/;(E.fn.init=function(e,t,n){var r,a;if(!e)return this;if(n=n||D,"string"!=typeof e)return e.nodeType?(this[0]=e,this.length=1,this):y(e)?void 0!==n.ready?n.ready(e):e(E):E.makeArray(e,this);if(!(r="<"===e[0]&&">"===e[e.length-1]&&3<=e.length?[null,e,null]:O.exec(e))||!r[1]&&t)return!t||t.jquery?(t||n).find(e):this.constructor(t).find(e);if(r[1]){if(t=t instanceof E?t[0]:t,E.merge(this,E.parseHTML(r[1],t&&t.nodeType?t.ownerDocument||t:k,!0)),T.test(r[1])&&E.isPlainObject(t))for(r in t)y(this[r])?this[r](t[r]):this.attr(r,t[r]);return this}return(a=k.getElementById(r[2]))&&(this[0]=a,this.length=1),this}).prototype=E.fn,D=E(k);var j=/^(?:parents|prev(?:Until|All))/,N={children:!0,contents:!0,next:!0,prev:!0};function z(e,t){for(;(e=e[t])&&1!==e.nodeType;);return e}E.fn.extend({has:function(e){var t=E(e,this),n=t.length;return this.filter(function(){for(var e=0;e\x20\t\r\n\f]*)/i,de=/^$|^module$|\/(?:java|ecma)script/i,pe={option:[1,""],thead:[1,"","
"],col:[2,"","
"],tr:[2,"","
"],td:[3,"","
"],_default:[0,"",""]};function me(e,t){var n;return n=void 0!==e.getElementsByTagName?e.getElementsByTagName(t||"*"):void 0!==e.querySelectorAll?e.querySelectorAll(t||"*"):[],void 0===t||t&&M(e,t)?E.merge([e],n):n}function ge(e,t){for(var n=0,r=e.length;nx",v.noCloneChecked=!!ve.cloneNode(!0).lastChild.defaultValue;var we=/^key/,Ae=/^(?:mouse|pointer|contextmenu|drag|drop)|click/,xe=/^([^.]*)(?:\.(.+)|)/;function ke(){return!0}function Ee(){return!1}function Se(e,t){return e===function(){try{return k.activeElement}catch(e){}}()==("focus"===t)}function Me(e,t,n,r,a,i){var o,s;if("object"==typeof t){for(s in"string"!=typeof n&&(r=r||n,n=void 0),t)Me(e,s,n,r,t[s],i);return e}if(null==r&&null==a?(a=n,r=n=void 0):null==a&&("string"==typeof n?(a=r,r=void 0):(a=r,r=n,n=void 0)),!1===a)a=Ee;else if(!a)return e;return 1===i&&(o=a,(a=function(e){return E().off(e),o.apply(this,arguments)}).guid=o.guid||(o.guid=E.guid++)),e.each(function(){E.event.add(this,t,a,r,n)})}function Te(e,a,i){i?(J.set(e,a,!1),E.event.add(e,a,{namespace:!1,handler:function(e){var t,n,r=J.get(this,a);if(1&e.isTrigger&&this[a]){if(r.length)(E.event.special[a]||{}).delegateType&&e.stopPropagation();else if(r=s.call(arguments),J.set(this,a,r),t=i(this,a),this[a](),r!==(n=J.get(this,a))||t?J.set(this,a,!1):n={},r!==n)return e.stopImmediatePropagation(),e.preventDefault(),n.value}else r.length&&(J.set(this,a,{value:E.event.trigger(E.extend(r[0],E.Event.prototype),r.slice(1),this)}),e.stopImmediatePropagation())}})):void 0===J.get(e,a)&&E.event.add(e,a,ke)}E.event={global:{},add:function(t,e,n,r,a){var i,o,s,u,c,l,f,h,d,p,m,g=J.get(t);if(g)for(n.handler&&(n=(i=n).handler,a=i.selector),a&&E.find.matchesSelector(re,a),n.guid||(n.guid=E.guid++),(u=g.events)||(u=g.events={}),(o=g.handle)||(o=g.handle=function(e){return void 0!==E&&E.event.triggered!==e.type?E.event.dispatch.apply(t,arguments):void 0}),c=(e=(e||"").match(P)||[""]).length;c--;)d=m=(s=xe.exec(e[c])||[])[1],p=(s[2]||"").split(".").sort(),d&&(f=E.event.special[d]||{},d=(a?f.delegateType:f.bindType)||d,f=E.event.special[d]||{},l=E.extend({type:d,origType:m,data:r,handler:n,guid:n.guid,selector:a,needsContext:a&&E.expr.match.needsContext.test(a),namespace:p.join(".")},i),(h=u[d])||((h=u[d]=[]).delegateCount=0,f.setup&&!1!==f.setup.call(t,r,p,o)||t.addEventListener&&t.addEventListener(d,o)),f.add&&(f.add.call(t,l),l.handler.guid||(l.handler.guid=n.guid)),a?h.splice(h.delegateCount++,0,l):h.push(l),E.event.global[d]=!0)},remove:function(e,t,n,r,a){var i,o,s,u,c,l,f,h,d,p,m,g=J.hasData(e)&&J.get(e);if(g&&(u=g.events)){for(c=(t=(t||"").match(P)||[""]).length;c--;)if(d=m=(s=xe.exec(t[c])||[])[1],p=(s[2]||"").split(".").sort(),d){for(f=E.event.special[d]||{},h=u[d=(r?f.delegateType:f.bindType)||d]||[],s=s[2]&&new RegExp("(^|\\.)"+p.join("\\.(?:.*\\.|)")+"(\\.|$)"),o=i=h.length;i--;)l=h[i],!a&&m!==l.origType||n&&n.guid!==l.guid||s&&!s.test(l.namespace)||r&&r!==l.selector&&("**"!==r||!l.selector)||(h.splice(i,1),l.selector&&h.delegateCount--,f.remove&&f.remove.call(e,l));o&&!h.length&&(f.teardown&&!1!==f.teardown.call(e,p,g.handle)||E.removeEvent(e,d,g.handle),delete u[d])}else for(d in u)E.event.remove(e,d+t[c],n,r,!0);E.isEmptyObject(u)&&J.remove(e,"handle events")}},dispatch:function(e){var t,n,r,a,i,o,s=E.event.fix(e),u=new Array(arguments.length),c=(J.get(this,"events")||{})[s.type]||[],l=E.event.special[s.type]||{};for(u[0]=s,t=1;t\x20\t\r\n\f]*)[^>]*)\/>/gi,De=/\s*$/g;function Ne(e,t){return M(e,"table")&&M(11!==t.nodeType?t:t.firstChild,"tr")&&E(e).children("tbody")[0]||e}function ze(e){return e.type=(null!==e.getAttribute("type"))+"/"+e.type,e}function Pe(e){return"true/"===(e.type||"").slice(0,5)?e.type=e.type.slice(5):e.removeAttribute("type"),e}function Le(e,t){var n,r,a,i,o,s,u,c;if(1===t.nodeType){if(J.hasData(e)&&(i=J.access(e),o=J.set(t,i),c=i.events))for(a in delete o.handle,o.events={},c)for(n=0,r=c[a].length;n")},clone:function(e,t,n){var r,a,i,o,s,u,c,l=e.cloneNode(!0),f=ae(e);if(!(v.noCloneChecked||1!==e.nodeType&&11!==e.nodeType||E.isXMLDoc(e)))for(o=me(l),r=0,a=(i=me(e)).length;r").attr(n.scriptAttrs||{}).prop({charset:n.scriptCharset,src:n.url}).on("load error",a=function(e){r.remove(),a=null,e&&t("error"===e.type?404:200,e.type)}),k.head.appendChild(r[0])},abort:function(){a&&a()}}});var Ut,$t=[],Vt=/(=)\?(?=&|$)|\?\?/;E.ajaxSetup({jsonp:"callback",jsonpCallback:function(){var e=$t.pop()||E.expando+"_"+kt++;return this[e]=!0,e}}),E.ajaxPrefilter("json jsonp",function(e,t,n){var r,a,i,o=!1!==e.jsonp&&(Vt.test(e.url)?"url":"string"==typeof e.data&&0===(e.contentType||"").indexOf("application/x-www-form-urlencoded")&&Vt.test(e.data)&&"data");if(o||"jsonp"===e.dataTypes[0])return r=e.jsonpCallback=y(e.jsonpCallback)?e.jsonpCallback():e.jsonpCallback,o?e[o]=e[o].replace(Vt,"$1"+r):!1!==e.jsonp&&(e.url+=(Et.test(e.url)?"&":"?")+e.jsonp+"="+r),e.converters["script json"]=function(){return i||E.error(r+" was not called"),i[0]},e.dataTypes[0]="json",a=x[r],x[r]=function(){i=arguments},n.always(function(){void 0===a?E(x).removeProp(r):x[r]=a,e[r]&&(e.jsonpCallback=t.jsonpCallback,$t.push(r)),i&&y(a)&&a(i[0]),i=a=void 0}),"script"}),v.createHTMLDocument=((Ut=k.implementation.createHTMLDocument("").body).innerHTML="
",2===Ut.childNodes.length),E.parseHTML=function(e,t,n){return"string"!=typeof e?[]:("boolean"==typeof t&&(n=t,t=!1),t||(v.createHTMLDocument?((r=(t=k.implementation.createHTMLDocument("")).createElement("base")).href=k.location.href,t.head.appendChild(r)):t=k),i=!n&&[],(a=T.exec(e))?[t.createElement(a[1])]:(a=be([e],t,i),i&&i.length&&E(i).remove(),E.merge([],a.childNodes)));var r,a,i},E.fn.load=function(e,t,n){var r,a,i,o=this,s=e.indexOf(" ");return-1").append(E.parseHTML(e)).find(r):e)}).always(n&&function(e,t){o.each(function(){n.apply(this,i||[e.responseText,t,e])})}),this},E.each(["ajaxStart","ajaxStop","ajaxComplete","ajaxError","ajaxSuccess","ajaxSend"],function(e,t){E.fn[t]=function(e){return this.on(t,e)}}),E.expr.pseudos.animated=function(t){return E.grep(E.timers,function(e){return t===e.elem}).length},E.offset={setOffset:function(e,t,n){var r,a,i,o,s,u,c=E.css(e,"position"),l=E(e),f={};"static"===c&&(e.style.position="relative"),s=l.offset(),i=E.css(e,"top"),u=E.css(e,"left"),a=("absolute"===c||"fixed"===c)&&-1<(i+u).indexOf("auto")?(o=(r=l.position()).top,r.left):(o=parseFloat(i)||0,parseFloat(u)||0),y(t)&&(t=t.call(e,n,E.extend({},s))),null!=t.top&&(f.top=t.top-s.top+o),null!=t.left&&(f.left=t.left-s.left+a),"using"in t?t.using.call(e,f):l.css(f)}},E.fn.extend({offset:function(t){if(arguments.length)return void 0===t?this:this.each(function(e){E.offset.setOffset(this,t,e)});var e,n,r=this[0];return r?r.getClientRects().length?(e=r.getBoundingClientRect(),n=r.ownerDocument.defaultView,{top:e.top+n.pageYOffset,left:e.left+n.pageXOffset}):{top:0,left:0}:void 0},position:function(){if(this[0]){var e,t,n,r=this[0],a={top:0,left:0};if("fixed"===E.css(r,"position"))t=r.getBoundingClientRect();else{for(t=this.offset(),n=r.ownerDocument,e=r.offsetParent||n.documentElement;e&&(e===n.body||e===n.documentElement)&&"static"===E.css(e,"position");)e=e.parentNode;e&&e!==r&&1===e.nodeType&&((a=E(e).offset()).top+=E.css(e,"borderTopWidth",!0),a.left+=E.css(e,"borderLeftWidth",!0))}return{top:t.top-a.top-E.css(r,"marginTop",!0),left:t.left-a.left-E.css(r,"marginLeft",!0)}}},offsetParent:function(){return this.map(function(){for(var e=this.offsetParent;e&&"static"===E.css(e,"position");)e=e.offsetParent;return e||re})}}),E.each({scrollLeft:"pageXOffset",scrollTop:"pageYOffset"},function(t,a){var i="pageYOffset"===a;E.fn[t]=function(e){return H(this,function(e,t,n){var r;if(_(e)?r=e:9===e.nodeType&&(r=e.defaultView),void 0===n)return r?r[a]:e[t];r?r.scrollTo(i?r.pageXOffset:n,i?n:r.pageYOffset):e[t]=n},t,e,arguments.length)}}),E.each(["top","left"],function(e,n){E.cssHooks[n]=Fe(v.pixelPosition,function(e,t){if(t)return t=He(e,n),We.test(t)?E(e).position()[n]+"px":t})}),E.each({Height:"height",Width:"width"},function(o,s){E.each({padding:"inner"+o,content:s,"":"outer"+o},function(r,i){E.fn[i]=function(e,t){var n=arguments.length&&(r||"boolean"!=typeof e),a=r||(!0===e||!0===t?"margin":"border");return H(this,function(e,t,n){var r;return _(e)?0===i.indexOf("outer")?e["inner"+o]:e.document.documentElement["client"+o]:9===e.nodeType?(r=e.documentElement,Math.max(e.body["scroll"+o],r["scroll"+o],e.body["offset"+o],r["offset"+o],r["client"+o])):void 0===n?E.css(e,t,a):E.style(e,t,n,a)},s,n?e:void 0,n)}})}),E.each("blur focus focusin focusout resize scroll click dblclick mousedown mouseup mousemove mouseover mouseout mouseenter mouseleave change select submit keydown keypress keyup contextmenu".split(" "),function(e,n){E.fn[n]=function(e,t){return 0").wrap("
")},Cr.root=function(){return Cr("*").first()},Cr.browser=!0;var Or=function(e){var t=e.get(0);return!(!t||!t.tagName)&&"container"===t.tagName.toLowerCase()};function jr(e,t){return Array(t+1).join(e)}Cr.html=function(e){if(e)return Or(e)||Or(e.children("container"))?e.children("container").html()||e.html():Cr("
").append(e.eq(0).clone()).html();var t=Dr(Cr("body",null,null,!1).clone()),n=Dr(Cr("head",null,null,!1).clone());return Sr&&0").append(Cr("".concat(n.html(),""))).append(Cr("".concat(t.html(),""))).wrap("").parent().html()},Cr.load=function(e){var t=2").html(e):Cr.cloneHtml(),Sr=Sr||Cr('");return c.parent().children().each(function(e,t){var n=f(t);if(Yi.test(t.tagName))return null;var r,a=Zi(n);if(a)if(n.get(0)===c.get(0))d.append(n);else{var i=0,o=$o(n);if(o<.05&&(i+=20),.5<=o&&(i-=20),n.attr("class")===c.attr("class")&&(i+=.2*l),h<=a+i)return d.append(n);if("p"===t.tagName){var s=n.text(),u=Uo(s);if(80t)&&void 0===e.parents().toArray().find(function(e){var t=ns(e),n=t.class,r=t.id,a="".concat(n," ").concat(r);return a.includes("comment")})}function Qo(e,t){var n=2".concat(e,"")).text();return""===n?e:n}function ts(e){return 100<=e.text().trim().length}function ns(e){var t=e.attribs,r=e.attributes;return t||!r?t:si(r).reduce(function(e,t){var n=r[t];return n.name&&n.value&&(e[n.name]=n.value),e},{})}function rs(e,t,n){return e.attribs?e.attribs[t]=n:e.attributes&&e.setAttribute(t,n),e}var as=new RegExp("https?://","i"),is=".(png|gif|jpe?g)",os=new RegExp("".concat(is),"i"),ss=new RegExp("".concat(is,"(\\?\\S+)?(\\s*[\\d.]+[wx])"),"i"),us=["script","style","form"].join(",");function cs(e,t){return"comment"===t.type}function ls(e){var t;return e(us).remove(),(t=e).root().find("*").contents().filter(cs).remove(),e=t}var fs,hs={create:(fs=Qn(S.mark(function e(t,n,r){var a,i,o=arguments;return S.wrap(function(e){for(;;)switch(e.prev=e.next){case 0:if(a=3").attr("src",n);e.replaceWith(r)}},clean:[]},date_published:{selectors:[[".PostByline__timestamp[datetime]","datetime"]]},lead_image_url:{selectors:[['meta[name="og:image"]',"value"]]},dek:{selectors:[]},next_page_url:{selectors:[]},excerpt:{selectors:[]}},Es={domain:"medium.com",title:{selectors:["h1",['meta[name="og:title"]',"value"]]},author:{selectors:[['meta[name="author"]',"value"]]},content:{selectors:["article"],transforms:{iframe:function(e){var t=/https:\/\/i.embed.ly\/.+url=https:\/\/i\.ytimg\.com\/vi\/(\w+)\//,n=decodeURIComponent(e.attr("data-thumbnail")),r=e.parents("figure");if(t.test(n)){var a=n.match(t),i=ja(a,2),o=(i[0],i[1]);e.attr("src","https://www.youtube.com/embed/".concat(o));var s=r.find("figcaption");r.empty().append([e,s])}else r.remove()},figure:function(e){if(!(0'))}},clean:[]}},Ms={domain:"genius.com",title:{selectors:["h1"]},author:{selectors:["h2 a"]},date_published:{selectors:[["meta[itemprop=page_data]","value",function(e){return JSON.parse(e).song.release_date}]]},dek:{selectors:[]},lead_image_url:{selectors:[["meta[itemprop=page_data]","value",function(e){return JSON.parse(e).song.album.cover_art_url}]]},content:{selectors:[".lyrics"],transforms:{},clean:[]}},Ts={domain:"wired.jp",title:{selectors:["h1.post-title"]},author:{selectors:['p[itemprop="author"]']},date_published:{selectors:[["time","datetime"]]},dek:{selectors:[".post-intro"]},lead_image_url:{selectors:[['meta[name="og:image"]',"value"]]},content:{selectors:["article.article-detail"],transforms:{"img[data-original]":function(e){var t=e.attr("data-original"),n=e.attr("src"),r=Mr.resolve(n,t);e.attr("src",r)}},clean:[".post-category","time","h1.post-title",".social-area-syncer"]}},Cs=Object.freeze({BloggerExtractor:As,NYMagExtractor:xs,WikipediaExtractor:{domain:"wikipedia.org",content:{selectors:["#mw-content-text"],defaultCleaner:!1,transforms:{".infobox img":function(e){var t=e.parents(".infobox");0===t.children("img").length&&t.prepend(e)},".infobox caption":"figcaption",".infobox":"figure"},clean:[".mw-editsection","figure tr, figure td, figure tbody","#toc",".navbox"]},author:"Wikipedia Contributors",title:{selectors:["h2.title"]},date_published:{selectors:["#footer-info-lastmod"]}},TwitterExtractor:{domain:"twitter.com",content:{transforms:{".permalink[role=main]":function(e,t){var n=e.find(".tweet"),r=t('
');r.append(n),e.replaceWith(r)},s:"span"},selectors:[".permalink[role=main]"],defaultCleaner:!1,clean:[".stream-item-footer","button",".tweet-details-fixer"]},author:{selectors:[".tweet.permalink-tweet .username"]},date_published:{selectors:[[".permalink-tweet ._timestamp[data-time-ms]","data-time-ms"]]}},NYTimesExtractor:{domain:"www.nytimes.com",title:{selectors:["h1.g-headline",'h1[itemprop="headline"]',"h1.headline"]},author:{selectors:[['meta[name="author"]',"value"],".g-byline",".byline"]},content:{selectors:["div.g-blocks","article#story"],transforms:{"img.g-lazy":function(e){var t=e.attr("src");t=t.replace("{{size}}",640),e.attr("src",t)}},clean:[".ad","header#story-header",".story-body-1 .lede.video",".visually-hidden","#newsletter-promo",".promo",".comments-button",".hidden",".comments",".supplemental",".nocontent",".story-footer-links"]},date_published:{selectors:[['meta[name="article:published"]',"value"]]},lead_image_url:{selectors:[['meta[name="og:image"]',"value"]]},dek:null,next_page_url:null,excerpt:null},TheAtlanticExtractor:{domain:"www.theatlantic.com",title:{selectors:["h1",".c-article-header__hed"]},author:{selectors:[['meta[name="author"]',"value"],".c-byline__author"]},content:{selectors:["article",".article-body"],transforms:[],clean:[".partner-box",".callout",".c-article-writer__image",".c-article-writer__content",".c-letters-cta__text",".c-footer__logo",".c-recirculation-link",".twitter-tweet"]},dek:{selectors:[['meta[name="description"]',"value"]]},date_published:{selectors:[['time[itemprop="datePublished"]',"datetime"]]},lead_image_url:{selectors:[['img[itemprop="url"]',"src"]]},next_page_url:null,excerpt:null},NewYorkerExtractor:{domain:"www.newyorker.com",title:{selectors:['h1[class^="ArticleHeader__hed"]',['meta[name="og:title"]',"value"]]},author:{selectors:['div[class^="ArticleContributors"] a[rel="author"]','article header div[class*="Byline__multipleContributors"]']},content:{selectors:['main[class^="Layout__content"]'],transforms:[],clean:['footer[class^="ArticleFooter__footer"]']},date_published:{selectors:[['meta[name="pubdate"]',"value"]],format:"YYYYMMDD",timezone:"America/New_York"},lead_image_url:{selectors:[['meta[name="og:image"]',"value"]]},dek:{selectors:['h2[class^="ArticleHeader__dek"]']},next_page_url:null,excerpt:null},WiredExtractor:{domain:"www.wired.com",title:{selectors:["h1.post-title"]},author:{selectors:['a[rel="author"]']},content:{selectors:["article.content"],transforms:[],clean:[".visually-hidden","figcaption img.photo"]},date_published:{selectors:[['meta[itemprop="datePublished"]',"value"]]},lead_image_url:{selectors:[['meta[name="og:image"]',"value"]]},dek:{selectors:[]},next_page_url:null,excerpt:null},MSNExtractor:{domain:"www.msn.com",title:{selectors:["h1"]},author:{selectors:["span.authorname-txt"]},content:{selectors:["div.richtext"],transforms:[],clean:["span.caption"]},date_published:{selectors:["span.time"]},lead_image_url:{selectors:[]},dek:{selectors:[]},next_page_url:null,excerpt:null},YahooExtractor:{domain:"www.yahoo.com",title:{selectors:["header.canvas-header"]},author:{selectors:["span.provider-name"]},content:{selectors:[".content-canvas"],transforms:[],clean:[".figure-caption"]},date_published:{selectors:[["time.date[datetime]","datetime"]]},lead_image_url:{selectors:[['meta[name="og:image"]',"value"]]},dek:{selectors:[]},next_page_url:null,excerpt:null},BuzzfeedExtractor:{domain:"www.buzzfeed.com",title:{selectors:['h1[id="post-title"]']},author:{selectors:['a[data-action="user/username"]',"byline__author"]},content:{selectors:[[".longform_custom_header_media","#buzz_sub_buzz"],"#buzz_sub_buzz"],defaultCleaner:!1,transforms:{h2:"b","div.longform_custom_header_media":function(e){return e.has("img")&&e.has(".longform_header_image_source")?"figure":null},"figure.longform_custom_header_media .longform_header_image_source":"figcaption"},clean:[".instapaper_ignore",".suplist_list_hide .buzz_superlist_item .buzz_superlist_number_inline",".share-box",".print"]},date_published:{selectors:[".buzz-datetime"]},lead_image_url:{selectors:[['meta[name="og:image"]',"value"]]},dek:{selectors:[]},next_page_url:null,excerpt:null},WikiaExtractor:{domain:"fandom.wikia.com",title:{selectors:["h1.entry-title"]},author:{selectors:[".author vcard",".fn"]},content:{selectors:[".grid-content",".entry-content"],transforms:[],clean:[]},date_published:{selectors:[['meta[name="article:published_time"]',"value"]]},lead_image_url:{selectors:[['meta[name="og:image"]',"value"]]},dek:{selectors:[]},next_page_url:null,excerpt:null},LittleThingsExtractor:{domain:"www.littlethings.com",title:{selectors:["h1.post-title"]},author:{selectors:[['meta[name="author"]',"value"]]},content:{selectors:[".mainContentIntro",".content-wrapper"],transforms:[],clean:[]},lead_image_url:{selectors:[['meta[name="og:image"]',"value"]]},next_page_url:null,excerpt:null},PoliticoExtractor:{domain:"www.politico.com",title:{selectors:[['meta[name="og:title"]',"value"]]},author:{selectors:[".story-main-content .byline .vcard"]},content:{selectors:[".story-main-content",".content-group",".story-core",".story-text"],transforms:[],clean:["figcaption"]},date_published:{selectors:[[".story-main-content .timestamp time[datetime]","datetime"]]},lead_image_url:{selectors:[['meta[name="og:image"]',"value"]]},dek:{selectors:[]},next_page_url:null,excerpt:null},DeadspinExtractor:{domain:"deadspin.com",supportedDomains:["jezebel.com","lifehacker.com","kotaku.com","gizmodo.com","jalopnik.com","kinja.com","avclub.com","clickhole.com","splinternews.com","theonion.com","theroot.com","thetakeout.com","theinventory.com"],title:{selectors:["h1.headline"]},author:{selectors:[".author"]},content:{selectors:[".post-content",".entry-content"],transforms:{'iframe.lazyload[data-recommend-id^="youtube://"]':function(e){var t=e.attr("id").split("youtube-")[1];e.attr("src","https://www.youtube.com/embed/".concat(t))}},clean:[".magnifier",".lightbox"]},date_published:{selectors:[["time.updated[datetime]","datetime"]]},lead_image_url:{selectors:[['meta[name="og:image"]',"value"]]},dek:{selectors:[]},next_page_url:{selectors:[]},excerpt:{selectors:[]}},BroadwayWorldExtractor:{domain:"www.broadwayworld.com",title:{selectors:["h1.article-title"]},author:{selectors:["span[itemprop=author]"]},content:{selectors:["div[itemprop=articlebody]"],transforms:{},clean:[]},date_published:{selectors:[["meta[itemprop=datePublished]","value"]]},lead_image_url:{selectors:[['meta[name="og:image"]',"value"]]},dek:{selectors:[]},next_page_url:{selectors:[]},excerpt:{selectors:[]}},ApartmentTherapyExtractor:ks,MediumExtractor:Es,WwwTmzComExtractor:{domain:"www.tmz.com",title:{selectors:[".post-title-breadcrumb","h1",".headline"]},author:"TMZ STAFF",date_published:{selectors:[".article-posted-date"],timezone:"America/Los_Angeles"},dek:{selectors:[]},lead_image_url:{selectors:[['meta[name="og:image"]',"value"]]},content:{selectors:[".article-content",".all-post-body"],transforms:{},clean:[".lightbox-link"]}},WwwWashingtonpostComExtractor:{domain:"www.washingtonpost.com",title:{selectors:["h1","#topper-headline-wrapper"]},author:{selectors:[".pb-author-name"]},date_published:{selectors:[['.author-timestamp[itemprop="datePublished"]',"content"]]},dek:{selectors:[]},lead_image_url:{selectors:[['meta[name="og:image"]',"value"]]},content:{selectors:[".article-body"],transforms:{"div.inline-content":function(e){return 0'))}},clean:[]}},WwwTheguardianComExtractor:{domain:"www.theguardian.com",title:{selectors:[".content__headline"]},author:{selectors:["p.byline"]},date_published:{selectors:[['meta[name="article:published_time"]',"value"]]},dek:{selectors:[".content__standfirst"]},lead_image_url:{selectors:[['meta[name="og:image"]',"value"]]},content:{selectors:[".content__article-body"],transforms:{},clean:[".hide-on-mobile",".inline-icon"]}},WwwSbnationComExtractor:{domain:"www.sbnation.com",title:{selectors:["h1.c-page-title"]},author:{selectors:[['meta[name="author"]',"value"]]},date_published:{selectors:[['meta[name="article:published_time"]',"value"]]},dek:{selectors:["h2.c-entry-summary.p-dek"]},lead_image_url:{selectors:[['meta[name="og:image"]',"value"]]},content:{selectors:["div.c-entry-content"],transforms:{},clean:[]}},WwwBloombergComExtractor:{domain:"www.bloomberg.com",title:{selectors:[".lede-headline","h1.article-title","h1.lede-text-only__hed"]},author:{selectors:[['meta[name="parsely-author"]',"value"],".byline-details__link",".bydek",".author"]},date_published:{selectors:[["time.published-at","datetime"],["time[datetime]","datetime"],['meta[name="date"]',"value"],['meta[name="parsely-pub-date"]',"value"]]},dek:{selectors:[]},lead_image_url:{selectors:[['meta[name="og:image"]',"value"]]},content:{selectors:[".article-body__content",["section.copy-block"],".body-copy"],transforms:{},clean:[".inline-newsletter",".page-ad"]}},WwwBustleComExtractor:{domain:"www.bustle.com",title:{selectors:["h1.post-page__title"]},author:{selectors:["div.content-meta__author"]},date_published:{selectors:[["time.content-meta__published-date[datetime]","datetime"]]},lead_image_url:{selectors:[['meta[name="og:image"]',"value"]]},content:{selectors:[".post-page__body"],transforms:{},clean:[]}},WwwNprOrgExtractor:{domain:"www.npr.org",title:{selectors:["h1",".storytitle"]},author:{selectors:["p.byline__name.byline__name--block"]},date_published:{selectors:[[".dateblock time[datetime]","datetime"],['meta[name="date"]',"value"]]},lead_image_url:{selectors:[['meta[name="og:image"]',"value"],['meta[name="twitter:image:src"]',"value"]]},content:{selectors:[".storytext"],transforms:{".bucketwrap.image":"figure",".bucketwrap.image .credit-caption":"figcaption"},clean:["div.enlarge_measure"]}},WwwRecodeNetExtractor:{domain:"www.recode.net",title:{selectors:["h1.c-page-title"]},author:{selectors:[['meta[name="author"]',"value"]]},date_published:{selectors:[['meta[name="article:published_time"]',"value"]]},dek:{selectors:["h2.c-entry-summary.p-dek"]},lead_image_url:{selectors:[['meta[name="og:image"]',"value"]]},content:{selectors:[["figure.e-image--hero",".c-entry-content"],".c-entry-content"],transforms:{},clean:[]}},QzComExtractor:{domain:"qz.com",title:{selectors:["header.item-header.content-width-responsive"]},author:{selectors:[['meta[name="author"]',"value"]]},date_published:{selectors:[".timestamp"]},lead_image_url:{selectors:[['meta[name="og:image"]',"value"]]},content:{selectors:[["figure.featured-image",".item-body"],".item-body"],transforms:{},clean:[".article-aside",".progressive-image-thumbnail"]}},WwwDmagazineComExtractor:{domain:"www.dmagazine.com",title:{selectors:["h1.story__title"]},author:{selectors:[".story__info .story__info__item:first-child"]},date_published:{selectors:[".story__info"],timezone:"America/Chicago"},dek:{selectors:[".story__subhead"]},lead_image_url:{selectors:[["article figure a:first-child","href"]]},content:{selectors:[".story__content"],transforms:{},clean:[]}},WwwReutersComExtractor:{domain:"www.reuters.com",title:{selectors:["h1.article-headline"]},author:{selectors:[".author"]},date_published:{selectors:[['meta[name="og:article:published_time"]',"value"]]},lead_image_url:{selectors:[['meta[name="og:image"]',"value"]]},content:{selectors:["#article-text"],transforms:{".article-subtitle":"h4"},clean:["#article-byline .author"]}},MashableComExtractor:{domain:"mashable.com",title:{selectors:["h1.title"]},author:{selectors:["span.author_name a"]},date_published:{selectors:[['meta[name="og:article:published_time"]',"value"]]},lead_image_url:{selectors:[['meta[name="og:image"]',"value"]]},content:{selectors:["section.article-content.blueprint"],transforms:{".image-credit":"figcaption"},clean:[]}},WwwChicagotribuneComExtractor:{domain:"www.chicagotribune.com",title:{selectors:["h1.trb_ar_hl_t"]},author:{selectors:["span.trb_ar_by_nm_au"]},date_published:{selectors:[['meta[itemprop="datePublished"]',"value"]]},lead_image_url:{selectors:[['meta[name="og:image"]',"value"]]},content:{selectors:["div.trb_ar_page"],transforms:{},clean:[]}},WwwVoxComExtractor:{domain:"www.vox.com",title:{selectors:["h1.c-page-title"]},author:{selectors:[['meta[name="author"]',"value"]]},date_published:{selectors:[['meta[name="article:published_time"]',"value"]]},dek:{selectors:[".p-dek"]},lead_image_url:{selectors:[['meta[name="og:image"]',"value"]]},content:{selectors:[["figure.e-image--hero",".c-entry-content"],".c-entry-content"],transforms:{"figure .e-image__image noscript":function(e){var t=e.html();e.parents(".e-image__image").find(".c-dynamic-image").replaceWith(t)},"figure .e-image__meta":"figcaption"},clean:[]}},NewsNationalgeographicComExtractor:{domain:"news.nationalgeographic.com",title:{selectors:["h1","h1.main-title"]},author:{selectors:[".byline-component__contributors b span"]},date_published:{selectors:[['meta[name="article:published_time"]',"value"]],format:"ddd MMM DD HH:mm:ss zz YYYY",timezone:"EST"},dek:{selectors:[".article__deck"]},lead_image_url:{selectors:[['meta[name="og:image"]',"value"]]},content:{selectors:[[".parsys.content",".__image-lead__"],".content"],transforms:{".parsys.content":function(e,t){var n=e.find(".image.parbase.section").find(".picturefill").first().data("platform-src");n&&e.prepend(t('')))}},clean:[".pull-quote.pull-quote--large"]}},WwwNationalgeographicComExtractor:{domain:"www.nationalgeographic.com",title:{selectors:["h1","h1.main-title"]},author:{selectors:[".byline-component__contributors b span"]},date_published:{selectors:[['meta[name="article:published_time"]',"value"]]},dek:{selectors:[".article__deck"]},lead_image_url:{selectors:[['meta[name="og:image"]',"value"]]},content:{selectors:[[".parsys.content",".__image-lead__"],".content"],transforms:{".parsys.content":function(e,t){var n=e.children().first();if(n.hasClass("imageGroup")){var r=n.find(".media--medium__container").children().first(),a=r.data("platform-image1-path"),i=r.data("platform-image2-path");i&&a&&e.prepend(t('
\n \n \n
')))}else{var o=e.find(".image.parbase.section").find(".picturefill").first().data("platform-src");o&&e.prepend(t('')))}}},clean:[".pull-quote.pull-quote--small"]}},WwwLatimesComExtractor:{domain:"www.latimes.com",title:{selectors:[".trb_ar_hl"]},author:{selectors:[['meta[name="author"]',"value"]]},date_published:{selectors:[['meta[itemprop="datePublished"]',"value"]]},lead_image_url:{selectors:[['meta[name="og:image"]',"value"]]},content:{selectors:[".trb_ar_main"],transforms:{".trb_ar_la":function(e){var t=e.find("figure");e.replaceWith(t)}},clean:[".trb_ar_by",".trb_ar_cr"]}},PagesixComExtractor:{domain:"pagesix.com",supportedDomains:["nypost.com"],title:{selectors:["h1 a"]},author:{selectors:[".byline"]},date_published:{selectors:[['meta[name="article:published_time"]',"value"]]},dek:{selectors:[['meta[name="description"]',"value"]]},lead_image_url:{selectors:[['meta[name="og:image"]',"value"]]},content:{selectors:[["#featured-image-wrapper",".entry-content"],".entry-content"],transforms:{"#featured-image-wrapper":"figure",".wp-caption-text":"figcaption"},clean:[".modal-trigger"]}},ThefederalistpapersOrgExtractor:{domain:"thefederalistpapers.org",title:{selectors:["h1.entry-title"]},author:{selectors:["main span.entry-author-name"]},date_published:{selectors:[['meta[name="article:published_time"]',"value"]]},lead_image_url:{selectors:[['meta[name="og:image"]',"value"]]},content:{selectors:[".entry-content"],transforms:{},clean:[["p[style]"]]}},WwwCbssportsComExtractor:{domain:"www.cbssports.com",title:{selectors:[".article-headline"]},author:{selectors:[".author-name"]},date_published:{selectors:[[".date-original-reading-time time","datetime"]],timezone:"UTC"},dek:{selectors:[".article-subline"]},lead_image_url:{selectors:[['meta[name="og:image"]',"value"]]},content:{selectors:[".article"],transforms:{},clean:[]}},WwwMsnbcComExtractor:Ss,WwwThepoliticalinsiderComExtractor:{domain:"www.thepoliticalinsider.com",title:{selectors:[['meta[name="sailthru.title"]',"value"]]},author:{selectors:[['meta[name="sailthru.author"]',"value"]]},date_published:{selectors:[['meta[name="sailthru.date"]',"value"]],timezone:"America/New_York"},dek:{selectors:[]},lead_image_url:{selectors:[['meta[name="og:image"]',"value"]]},content:{selectors:["div#article-body"],transforms:{},clean:[]}},WwwMentalflossComExtractor:{domain:"www.mentalfloss.com",title:{selectors:["h1.title",".title-group",".inner"]},author:{selectors:[".field-name-field-enhanced-authors"]},date_published:{selectors:[".date-display-single"],timezone:"America/New_York"},lead_image_url:{selectors:[['meta[name="og:image"]',"value"]]},content:{selectors:["div.field.field-name-body"],transforms:{},clean:[]}},AbcnewsGoComExtractor:{domain:"abcnews.go.com",title:{selectors:[".article-header h1"]},author:{selectors:[".authors"],clean:[".author-overlay",".by-text"]},date_published:{selectors:[".timestamp"],timezone:"America/New_York"},lead_image_url:{selectors:[['meta[name="og:image"]',"value"]]},content:{selectors:[".article-copy"],transforms:{},clean:[]}},WwwNydailynewsComExtractor:{domain:"www.nydailynews.com",title:{selectors:["h1#ra-headline"]},author:{selectors:[['meta[name="parsely-author"]',"value"]]},date_published:{selectors:[['meta[name="sailthru.date"]',"value"]]},lead_image_url:{selectors:[['meta[name="og:image"]',"value"]]},content:{selectors:["article#ra-body"],transforms:{},clean:["dl#ra-tags",".ra-related","a.ra-editor","dl#ra-share-bottom"]}},WwwCnbcComExtractor:{domain:"www.cnbc.com",title:{selectors:["h1.title","h1.ArticleHeader-headline"]},author:{selectors:[['meta[name="author"]',"value"]]},date_published:{selectors:[['meta[name="article:published_time"]',"value"]]},lead_image_url:{selectors:[['meta[name="og:image"]',"value"]]},content:{selectors:["div#article_body.content","div.story","div.ArticleBody-articleBody"],transforms:{},clean:[]}},WwwPopsugarComExtractor:{domain:"www.popsugar.com",title:{selectors:["h2.post-title","title-text"]},author:{selectors:[['meta[name="article:author"]',"value"]]},date_published:{selectors:[['meta[name="article:published_time"]',"value"]]},lead_image_url:{selectors:[['meta[name="og:image"]',"value"]]},content:{selectors:["#content"],transforms:{},clean:[".share-copy-title",".post-tags",".reactions"]}},ObserverComExtractor:{domain:"observer.com",title:{selectors:["h1.entry-title"]},author:{selectors:[".author",".vcard"]},date_published:{selectors:[['meta[name="article:published_time"]',"value"]]},dek:{selectors:["h2.dek"]},lead_image_url:{selectors:[['meta[name="og:image"]',"value"]]},content:{selectors:["div.entry-content"],transforms:{},clean:[]}},PeopleComExtractor:{domain:"people.com",title:{selectors:[['meta[name="og:title"]',"value"]]},author:{selectors:["a.author.url.fn"]},date_published:{selectors:[['meta[name="article:published_time"]',"value"]]},lead_image_url:{selectors:[['meta[name="og:image"]',"value"]]},content:{selectors:["div.article-body__inner"],transforms:{},clean:[]}},WwwUsmagazineComExtractor:{domain:"www.usmagazine.com",title:{selectors:["header h1"]},author:{selectors:["a.article-byline.tracked-offpage"]},date_published:{timezone:"America/New_York",selectors:["time.article-published-date"]},lead_image_url:{selectors:[['meta[name="og:image"]',"value"]]},content:{selectors:["div.article-body-inner"],transforms:{},clean:[".module-related"]}},WwwRollingstoneComExtractor:{domain:"www.rollingstone.com",title:{selectors:["h1.content-title"]},author:{selectors:["a.content-author.tracked-offpage"]},date_published:{selectors:["time.content-published-date"],timezone:"America/New_York"},dek:{selectors:[".content-description"]},lead_image_url:{selectors:[['meta[name="og:image"]',"value"]]},content:{selectors:[[".lead-container",".article-content"],".article-content"],transforms:{},clean:[".module-related"]}},twofortysevensportsComExtractor:{domain:"247sports.com",title:{selectors:["title","article header h1"]},author:{selectors:[".author"]},date_published:{selectors:[["time[data-published]","data-published"]]},lead_image_url:{selectors:[['meta[name="og:image"]',"value"]]},content:{selectors:["section.body.article"],transforms:{},clean:[]}},UproxxComExtractor:{domain:"uproxx.com",title:{selectors:["div.post-top h1"]},author:{selectors:[".post-top .authorname"]},date_published:{selectors:[['meta[name="article:published_time"]',"value"]]},lead_image_url:{selectors:[['meta[name="og:image"]',"value"]]},content:{selectors:[".post-body"],transforms:{"div.image":"figure","div.image .wp-media-credit":"figcaption"},clean:[]}},WwwEonlineComExtractor:{domain:"www.eonline.com",title:{selectors:["h1.article__title"]},author:{selectors:[".entry-meta__author a"]},date_published:{selectors:[['meta[itemprop="datePublished"]',"value"]]},lead_image_url:{selectors:[['meta[name="og:image"]',"value"]]},content:{selectors:[[".post-content section, .post-content div.post-content__image"]],transforms:{"div.post-content__image":"figure","div.post-content__image .image__credits":"figcaption"},clean:[]}},WwwMiamiheraldComExtractor:{domain:"www.miamiherald.com",title:{selectors:["h1.title"]},date_published:{selectors:["p.published-date"],timezone:"America/New_York"},lead_image_url:{selectors:[['meta[name="og:image"]',"value"]]},content:{selectors:["div.dateline-storybody"],transforms:{},clean:[]}},WwwRefinery29ComExtractor:{domain:"www.refinery29.com",title:{selectors:["h1.title"]},author:{selectors:[".contributor"]},date_published:{selectors:[['meta[name="sailthru.date"]',"value"]],timezone:"America/New_York"},lead_image_url:{selectors:[['meta[name="og:image"]',"value"]]},content:{selectors:[[".full-width-opener",".article-content"],".article-content",".body"],transforms:{"div.loading noscript":function(e){var t=e.html();e.parents(".loading").replaceWith(t)},".section-image":"figure",".section-image .content-caption":"figcaption",".section-text":"p"},clean:[".story-share"]}},WwwMacrumorsComExtractor:{domain:"www.macrumors.com",title:{selectors:["h1","h1.title"]},author:{selectors:[".author-url"]},date_published:{selectors:[".article .byline"],format:"dddd MMMM D, YYYY h:mm A zz",timezone:"America/Los_Angeles"},dek:{selectors:[['meta[name="description"]',"value"]]},lead_image_url:{selectors:[['meta[name="og:image"]',"value"]]},content:{selectors:[".article"],transforms:{},clean:[]}},WwwAndroidcentralComExtractor:{domain:"www.androidcentral.com",title:{selectors:["h1","h1.main-title"]},author:{selectors:[".meta-by"]},date_published:{selectors:[['meta[name="article:published_time"]',"value"]]},dek:{selectors:[['meta[name="og:description"]',"value"]]},lead_image_url:{selectors:[[".image-large","src"]]},content:{selectors:[".article-body"],transforms:{},clean:[".intro","blockquote"]}},WwwSiComExtractor:{domain:"www.si.com",title:{selectors:["h1","h1.headline"]},author:{selectors:[['meta[name="author"]',"value"]]},date_published:{selectors:[".timestamp"],timezone:"America/New_York"},dek:{selectors:[".quick-hit ul"]},lead_image_url:{selectors:[['meta[name="og:image"]',"value"]]},content:{selectors:[["p",".marquee_large_2x",".component.image"]],transforms:{noscript:function(e){var t=e.children();return 1===t.length&&"img"===t.get(0).tagName?"figure":null}},clean:[[".inline-thumb",".primary-message",".description",".instructions"]]}},WwwRawstoryComExtractor:{domain:"www.rawstory.com",title:{selectors:[".blog-title"]},author:{selectors:[".blog-author a:first-of-type"]},date_published:{selectors:[".blog-author a:last-of-type"],timezone:"EST"},lead_image_url:{selectors:[['meta[name="og:image"]',"value"]]},content:{selectors:[".blog-content"],transforms:{},clean:[]}},WwwCnetComExtractor:{domain:"www.cnet.com",title:{selectors:[['meta[name="og:title"]',"value"]]},author:{selectors:["a.author"]},date_published:{selectors:["time"],timezone:"America/Los_Angeles"},dek:{selectors:[".article-dek"]},lead_image_url:{selectors:[['meta[name="og:image"]',"value"]]},content:{selectors:[["img.__image-lead__",".article-main-body"],".article-main-body"],transforms:{"figure.image":function(e){var t=e.find("img");t.attr("width","100%"),t.attr("height","100%"),t.addClass("__image-lead__"),e.remove(".imgContainer").prepend(t)}},clean:[]}},WwwCinemablendComExtractor:{domain:"www.cinemablend.com",title:{selectors:[".story_title"]},author:{selectors:[".author"]},date_published:{selectors:[['meta[name="article:published_time"]',"value"]],timezone:"EST"},lead_image_url:{selectors:[['meta[name="og:image"]',"value"]]},content:{selectors:["div#wrap_left_content"],transforms:{},clean:[]}},WwwTodayComExtractor:{domain:"www.today.com",title:{selectors:["h1.entry-headline"]},author:{selectors:[['meta[name="author"]',"value"]]},date_published:{selectors:[['meta[name="DC.date.issued"]',"value"]]},lead_image_url:{selectors:[['meta[name="og:image"]',"value"]]},content:{selectors:[".entry-container"],transforms:{},clean:[".label-comment"]}},WwwHowtogeekComExtractor:{domain:"www.howtogeek.com",title:{selectors:["title"]},author:{selectors:["#authorinfobox a"]},date_published:{selectors:["#authorinfobox + div li"],timezone:"GMT"},lead_image_url:{selectors:[['meta[name="og:image"]',"value"]]},content:{selectors:[".thecontent"],transforms:{},clean:[]}},WwwAlComExtractor:{domain:"www.al.com",title:{selectors:[['meta[name="title"]',"value"]]},author:{selectors:[['meta[name="article_author"]',"value"]]},date_published:{selectors:[['meta[name="article_date_original"]',"value"]],timezone:"EST"},lead_image_url:{selectors:[['meta[name="og:image"]',"value"]]},content:{selectors:[".entry-content"],transforms:{},clean:[]}},WwwThepennyhoarderComExtractor:{domain:"www.thepennyhoarder.com",title:{selectors:[['meta[name="dcterms.title"]',"value"]]},author:{selectors:[['link[rel="author"]',"title"]]},date_published:{selectors:[['meta[name="article:published_time"]',"value"]]},lead_image_url:{selectors:[['meta[name="og:image"]',"value"]]},content:{selectors:[[".post-img",".post-text"],".post-text"],transforms:{},clean:[]}},WwwWesternjournalismComExtractor:{domain:"www.westernjournalism.com",title:{selectors:["title","h1.entry-title"]},author:{selectors:[['meta[name="author"]',"value"]]},date_published:{selectors:[['meta[name="DC.date.issued"]',"value"]]},dek:{selectors:[".subtitle"]},lead_image_url:{selectors:[['meta[name="og:image"]',"value"]]},content:{selectors:["div.article-sharing.top + div"],transforms:{},clean:[".ad-notice-small"]}},FusionNetExtractor:{domain:"fusion.net",title:{selectors:[".post-title",".single-title",".headline"]},author:{selectors:[".show-for-medium .byline"]},date_published:{selectors:[["time.local-time","datetime"]]},dek:{selectors:[]},lead_image_url:{selectors:[['meta[name="og:image"]',"value"]]},content:{selectors:[[".post-featured-media",".article-content"],".article-content"],transforms:{".fusion-youtube-oembed":"figure"},clean:[]}},WwwAmericanowComExtractor:{domain:"www.americanow.com",title:{selectors:[".title",['meta[name="title"]',"value"]]},author:{selectors:[".byline"]},date_published:{selectors:[['meta[name="publish_date"]',"value"]]},dek:{selectors:[]},lead_image_url:{selectors:[['meta[name="og:image"]',"value"]]},content:{selectors:[[".article-content",".image",".body"],".body"],transforms:{},clean:[".article-video-wrapper",".show-for-small-only"]}},ScienceflyComExtractor:{domain:"sciencefly.com",title:{selectors:[".entry-title",".cb-entry-title",".cb-single-title"]},author:{selectors:["div.cb-author","div.cb-author-title"]},date_published:{selectors:[['meta[name="article:published_time"]',"value"]]},dek:{selectors:[]},lead_image_url:{selectors:[["div.theiaPostSlider_slides img","src"]]},content:{selectors:["div.theiaPostSlider_slides"],transforms:{},clean:[]}},HellogigglesComExtractor:{domain:"hellogiggles.com",title:{selectors:[".title"]},author:{selectors:[".author-link"]},date_published:{selectors:[['meta[name="article:published_time"]',"value"]]},lead_image_url:{selectors:[['meta[name="og:image"]',"value"]]},content:{selectors:[".entry-content"],transforms:{},clean:[]}},ThoughtcatalogComExtractor:{domain:"thoughtcatalog.com",title:{selectors:["h1.title",['meta[name="og:title"]',"value"]]},author:{selectors:["div.col-xs-12.article_header div.writer-container.writer-container-inline.writer-no-avatar h4.writer-name","h1.writer-name"]},date_published:{selectors:[['meta[name="article:published_time"]',"value"]]},lead_image_url:{selectors:[['meta[name="og:image"]',"value"]]},content:{selectors:[".entry.post"],transforms:{},clean:[".tc_mark"]}},WwwNjComExtractor:{domain:"www.nj.com",title:{selectors:[['meta[name="title"]',"value"]]},author:{selectors:[['meta[name="article_author"]',"value"]]},date_published:{selectors:[['meta[name="article_date_original"]',"value"]],timezone:"America/New_York"},lead_image_url:{selectors:[['meta[name="og:image"]',"value"]]},content:{selectors:[".entry-content"],transforms:{},clean:[]}},WwwInquisitrComExtractor:{domain:"www.inquisitr.com",title:{selectors:["h1.entry-title.story--header--title"]},author:{selectors:["div.story--header--author"]},date_published:{selectors:[['meta[name="datePublished"]',"value"]]},lead_image_url:{selectors:[['meta[name="og:image"]',"value"]]},content:{selectors:["article.story",".entry-content."],transforms:{},clean:[".post-category",".story--header--socials",".story--header--content"]}},WwwNbcnewsComExtractor:{domain:"www.nbcnews.com",title:{selectors:["div.article-hed h1"]},author:{selectors:["span.byline_author"]},date_published:{selectors:[[".flag_article-wrapper time.timestamp_article[datetime]","datetime"],".flag_article-wrapper time"],timezone:"America/New_York"},lead_image_url:{selectors:[['meta[name="og:image"]',"value"]]},content:{selectors:["div.article-body"],transforms:{},clean:[]}},FortuneComExtractor:{domain:"fortune.com",title:{selectors:["h1"]},author:{selectors:[['meta[name="author"]',"value"]]},date_published:{selectors:[".MblGHNMJ"],timezone:"UTC"},lead_image_url:{selectors:[['meta[name="og:image"]',"value"]]},content:{selectors:[["picture","article.row"],"article.row"],transforms:{},clean:[]}},WwwLinkedinComExtractor:{domain:"www.linkedin.com",title:{selectors:[".article-title","h1"]},author:{selectors:[['meta[name="article:author"]',"value"],".entity-name a[rel=author]"]},date_published:{selectors:[['time[itemprop="datePublished"]',"datetime"]],timezone:"America/Los_Angeles"},dek:{selectors:[]},lead_image_url:{selectors:[['meta[name="og:image"]',"value"]]},content:{selectors:[["header figure",".prose"],".prose"],transforms:{},clean:[".entity-image"]}},ObamawhitehouseArchivesGovExtractor:{domain:"obamawhitehouse.archives.gov",supportedDomains:["whitehouse.gov"],title:{selectors:["h1",".pane-node-title"]},author:{selectors:[".blog-author-link",".node-person-name-link"]},date_published:{selectors:[['meta[name="article:published_time"]',"value"]]},dek:{selectors:[".field-name-field-forall-summary"]},lead_image_url:{selectors:[['meta[name="og:image"]',"value"]]},content:{defaultCleaner:!1,selectors:["div#content-start",".pane-node-field-forall-body"],transforms:{},clean:[".pane-node-title",".pane-custom.pane-1"]}},WwwOpposingviewsComExtractor:{domain:"www.opposingviews.com",title:{selectors:["h1.title"]},author:{selectors:["div.date span span a"]},date_published:{selectors:[['meta[name="publish_date"]',"value"]]},dek:{selectors:[]},lead_image_url:{selectors:[['meta[name="og:image"]',"value"]]},content:{selectors:[".article-content"],transforms:{},clean:[".show-for-small-only"]}},WwwProspectmagazineCoUkExtractor:{domain:"www.prospectmagazine.co.uk",title:{selectors:[".page-title"]},author:{selectors:[".aside_author .title"]},date_published:{selectors:[".post-info"],timezone:"Europe/London"},dek:{selectors:[".page-subtitle"]},lead_image_url:{selectors:[['meta[name="og:image"]',"value"]]},content:{selectors:["article .post_content"],transforms:{},clean:[]}},ForwardComExtractor:{domain:"forward.com",title:{selectors:[['meta[name="og:title"]',"value"]]},author:{selectors:[".author-name",['meta[name="sailthru.author"]',"value"]]},date_published:{selectors:[['meta[name="date"]',"value"]]},dek:{selectors:[]},lead_image_url:{selectors:[['meta[name="og:image"]',"value"]]},content:{selectors:[[".post-item-media-wrap",".post-item p"]],transforms:{},clean:[".donate-box",".message",".subtitle"]}},WwwQdailyComExtractor:{domain:"www.qdaily.com",title:{selectors:["h2","h2.title"]},author:{selectors:[".name"]},date_published:{selectors:[[".date.smart-date","data-origindate"]]},dek:{selectors:[".excerpt"]},lead_image_url:{selectors:[[".article-detail-hd img","src"]]},content:{selectors:[".detail"],transforms:{},clean:[".lazyload",".lazylad",".lazylood"]}},GothamistComExtractor:{domain:"gothamist.com",supportedDomains:["chicagoist.com","laist.com","sfist.com","shanghaiist.com","dcist.com"],title:{selectors:["h1",".entry-header h1"]},author:{selectors:[".author"]},date_published:{selectors:["abbr","abbr.published"],timezone:"America/New_York"},dek:{selectors:[null]},lead_image_url:{selectors:[['meta[name="og:image"]',"value"]]},content:{selectors:[".entry-body"],transforms:{"div.image-none":"figure",".image-none i":"figcaption","div.image-left":"figure",".image-left i":"figcaption","div.image-right":"figure",".image-right i":"figcaption"},clean:[".image-none br",".image-left br",".image-right br",".galleryEase"]}},WwwFoolComExtractor:{domain:"www.fool.com",title:{selectors:["h1"]},author:{selectors:[".author-inline .author-name"]},date_published:{selectors:[['meta[name="date"]',"value"]]},dek:{selectors:["header h2"]},lead_image_url:{selectors:[['meta[name="og:image"]',"value"]]},content:{selectors:[".article-content"],transforms:{".caption img":function(e){var t=e.attr("src");e.parent().replaceWith('
'))},".caption":"figcaption"},clean:["#pitch"]}},WwwSlateComExtractor:{domain:"www.slate.com",title:{selectors:[".hed","h1"]},author:{selectors:["a[rel=author]"]},date_published:{selectors:[".pub-date"],timezone:"America/New_York"},dek:{selectors:[".dek"]},lead_image_url:{selectors:[['meta[name="og:image"]',"value"]]},content:{selectors:[".body"],transforms:{},clean:[".about-the-author",".pullquote",".newsletter-signup-component",".top-comment"]}},IciRadioCanadaCaExtractor:{domain:"ici.radio-canada.ca",title:{selectors:["h1"]},author:{selectors:[['meta[name="dc.creator"]',"value"]]},date_published:{selectors:[['meta[name="dc.date.created"]',"value"]],timezone:"America/New_York"},dek:{selectors:[".bunker-component.lead"]},lead_image_url:{selectors:[['meta[name="og:image"]',"value"]]},content:{selectors:[[".main-multimedia-item",".news-story-content"]],transforms:{},clean:[]}},WwwFortinetComExtractor:{domain:"www.fortinet.com",title:{selectors:["h1"]},author:{selectors:[".b15-blog-meta__author"]},date_published:{selectors:[['meta[name="article:published_time"]',"value"]]},lead_image_url:{selectors:[['meta[name="og:image"]',"value"]]},content:{selectors:["div.responsivegrid.aem-GridColumn.aem-GridColumn--default--12"],transforms:{noscript:function(e){var t=e.children();return 1===t.length&&"img"===t.get(0).tagName?"figure":null}}}},WwwFastcompanyComExtractor:{domain:"www.fastcompany.com",title:{selectors:["h1"]},author:{selectors:[".post__by"]},date_published:{selectors:[['meta[name="article:published_time"]',"value"]]},dek:{selectors:[".post__deck"]},lead_image_url:{selectors:[['meta[name="og:image"]',"value"]]},content:{selectors:[".post__article"]}},BlisterreviewComExtractor:{domain:"blisterreview.com",title:{selectors:[['meta[name="og:title"]',"value"],"h1.entry-title"]},author:{selectors:["span.author-name"]},date_published:{selectors:[['meta[name="article:published_time"]',"value"],["time.entry-date","datetime"],['meta[itemprop="datePublished"]',"content"]]},dek:{selectors:[]},lead_image_url:{selectors:[['meta[name="og:image"]',"value"],['meta[property="og:image"]',"content"],['meta[itemprop="image"]',"content"],['meta[name="twitter:image"]',"content"],["img.attachment-large","src"]]},content:{selectors:[[".elementor-section-wrap",".elementor-text-editor > p, .elementor-text-editor > ul > li, .attachment-large, .wp-caption-text"]],transforms:{figcaption:"p"},clean:[".comments-area"]}},NewsMynaviJpExtractor:{domain:"news.mynavi.jp",title:{selectors:[['meta[name="og:title"]',"value"]]},author:{selectors:["main div.article-author a.article-author__name"]},date_published:{selectors:[['meta[name="article:published_time"]',"value"]]},dek:{selectors:[['meta[name="og:description"]',"value"]]},lead_image_url:{selectors:[['meta[name="og:image"]',"value"]]},content:{selectors:["main article div"],transforms:{img:function(e){var t=e.attr("data-original");""!==t&&e.attr("src",t)}},clean:[]}},ClinicaltrialsGovExtractor:{domain:"clinicaltrials.gov",title:{selectors:["h1.tr-solo_record"]},author:{selectors:["div#sponsor.tr-info-text"]},date_published:{selectors:['div:has(> span.term[data-term="Last Update Posted"])']},content:{selectors:["div#tab-body"],transforms:{},clean:[".usa-alert> img"]}},GithubComExtractor:{domain:"github.com",title:{selectors:[['meta[name="og:title"]',"value"]]},author:{selectors:[]},date_published:{selectors:[['span[itemprop="dateModified"] relative-time',"datetime"]]},dek:{selectors:['span[itemprop="about"]']},lead_image_url:{selectors:[['meta[name="og:image"]',"value"]]},content:{selectors:[["#readme article"]],transforms:{},clean:[]}},WwwRedditComExtractor:{domain:"www.reddit.com",title:{selectors:['div[data-test-id="post-content"] h2']},author:{selectors:['div[data-test-id="post-content"] a[href*="user/"]']},date_published:{selectors:['div[data-test-id="post-content"] a[data-click-id="timestamp"]']},lead_image_url:{selectors:[['meta[name="og:image"]',"value"]]},content:{selectors:[['div[data-test-id="post-content"] p'],['div[data-test-id="post-content"] a[target="_blank"]:not([data-click-id="timestamp"])','div[data-test-id="post-content"] div[data-click-id="media"]'],['div[data-test-id="post-content"] div[data-click-id="media"]'],['div[data-test-id="post-content"] a[target="_blank"]:not([data-click-id="timestamp"])'],'div[data-test-id="post-content"]'],transforms:{'div[role="img"]':function(e){var t=e.find("img"),n=e.css("background-image");return 1===t.length&&n?(t.attr("src",n.match(/\((.*?)\)/)[1].replace(/('|")/g,"")),t):e}},clean:[".icon"]}},OtrsComExtractor:{domain:"otrs.com",title:{selectors:["#main article h1"]},author:{selectors:["div.dateplusauthor a"]},date_published:{selectors:[['meta[name="article:published_time"]',"value"]]},dek:{selectors:[['meta[name="og:description"]',"value"]]},lead_image_url:{selectors:[['meta[name="og:image"]',"value"]]},content:{selectors:["#main article"],defaultCleaner:!1,transforms:{},clean:["div.dateplusauthor","div.gr-12.push-6.footershare","#atftbx","div.category-modul"]}},WwwOssnewsJpExtractor:{domain:"www.ossnews.jp",title:{selectors:["#alpha-block h1.hxnewstitle"]},author:null,date_published:{selectors:["p.fs12"],format:"YYYY年MM月DD日 HH:mm",timezone:"Asia/Tokyo"},dek:null,lead_image_url:{selectors:[['meta[name="og:image"]',"value"]]},content:{selectors:["#alpha-block .section:has(h1.hxnewstitle)"],defaultCleaner:!1,transforms:{},clean:[]}},BuzzapJpExtractor:{domain:"buzzap.jp",title:{selectors:["h1.entry-title"]},author:null,date_published:{selectors:[["time.entry-date","datetime"]]},dek:null,lead_image_url:{selectors:[['meta[name="og:image"]',"value"]]},content:{selectors:["div.ctiframe"],defaultCleaner:!1,transforms:{},clean:[]}},WwwAsahiComExtractor:{domain:"www.asahi.com",title:{selectors:[".ArticleTitle h1"]},author:{selectors:[['meta[name="article:author"]',"value"]]},date_published:{selectors:[['meta[name="pubdate"]',"value"]]},dek:null,excerpt:{selectors:[['meta[name="og:description"]',"value"]]},lead_image_url:{selectors:[['meta[name="og:image"]',"value"]]},content:{selectors:["#MainInner div.ArticleBody"],defaultCleaner:!1,transforms:{},clean:["div.AdMod","div.LoginSelectArea"]}},WwwSanwaCoJpExtractor:{domain:"www.sanwa.co.jp",title:{selectors:["#newsContent h1"]},author:null,date_published:{selectors:["p.date"],format:"YYYY.MM.DD",timezone:"Asia/Tokyo"},dek:{selectors:[['meta[name="og:description"]',"value"]]},lead_image_url:{selectors:[['meta[name="og:image"]',"value"]]},content:{selectors:["#newsContent"],defaultCleaner:!1,transforms:{},clean:["#smartphone","div.sns_box","div.contentFoot"]}},WwwElecomCoJpExtractor:{domain:"www.elecom.co.jp",title:{selectors:["title"]},author:null,date_published:{selectors:["p.section-last"],format:"YYYY.MM.DD",timezone:"Asia/Tokyo"},dek:null,lead_image_url:null,content:{selectors:["td.TableMain2"],defaultCleaner:!1,transforms:{table:function(e){e.attr("width","auto")}},clean:[]}},ScanNetsecurityNeJpExtractor:{domain:"scan.netsecurity.ne.jp",title:{selectors:["header.arti-header h1.head"]},author:null,date_published:{selectors:[['meta[name="article:modified_time"]',"value"]]},dek:{selectors:["header.arti-header p.arti-summary"]},lead_image_url:{selectors:[['meta[name="og:image"]',"value"]]},content:{selectors:["div.arti-content.arti-content--thumbnail"],defaultCleaner:!1,transforms:{},clean:["aside.arti-giga"]}},JvndbJvnJpExtractor:{domain:"jvndb.jvn.jp",title:{selectors:["title"]},author:null,date_published:{selectors:["div.modifytxt:nth-child(2)"],format:"YYYY/MM/DD",timezone:"Asia/Tokyo"},dek:null,lead_image_url:null,content:{selectors:["#news-list"],defaultCleaner:!1,transforms:{},clean:[]}},GeniusComExtractor:Ms,WwwJnsaOrgExtractor:{domain:"www.jnsa.org",title:{selectors:["#wgtitle h2"]},author:null,date_published:null,dek:null,excerpt:{selectors:[['meta[name="og:description"]',"value"]]},lead_image_url:{selectors:[['meta[name="og:image"]',"value"]]},content:{selectors:["#main_area"],transforms:{},clean:["#pankuzu","#side"]}},PhpspotOrgExtractor:{domain:"phpspot.org",title:{selectors:["h3.hl"]},author:null,date_published:{selectors:["h4.hl"],format:"YYYY年MM月DD日",timezone:"Asia/Tokyo"},dek:null,lead_image_url:null,content:{selectors:["div.entrybody"],defaultCleaner:!1,transforms:{},clean:[]}},WwwInfoqComExtractor:{domain:"www.infoq.com",title:{selectors:["h1.heading"]},author:{selectors:["div.widget.article__authors"]},date_published:{selectors:[".article__readTime.date"],format:"YYYY年MM月DD日",timezone:"Asia/Tokyo"},dek:{selectors:[['meta[name="og:description"]',"value"]]},lead_image_url:{selectors:[['meta[name="og:image"]',"value"]]},content:{selectors:["div.article__data"],defaultCleaner:!1,transforms:{},clean:[]}},WwwMoongiftJpExtractor:{domain:"www.moongift.jp",title:{selectors:["h1.title a"]},author:null,date_published:{selectors:["ul.meta li:not(.social):first-of-type"],timezone:"Asia/Tokyo"},dek:{selectors:[['meta[name="og:description"]',"value"]]},lead_image_url:{selectors:[['meta[name="og:image"]',"value"]]},content:{selectors:["#main"],transforms:{},clean:["ul.mg_service.cf"]}},WwwItmediaCoJpExtractor:{domain:"www.itmedia.co.jp",supportedDomains:["www.atmarkit.co.jp","techtarget.itmedia.co.jp","nlab.itmedia.co.jp"],title:{selectors:["#cmsTitle h1"]},author:{selectors:["#byline"]},date_published:{selectors:[['meta[name="article:modified_time"]',"value"]]},dek:{selectors:["#cmsAbstract h2"]},lead_image_url:{selectors:[['meta[name="og:image"]',"value"]]},content:{selectors:["#cmsBody"],defaultCleaner:!1,transforms:{},clean:["#snsSharebox"]}},WwwPublickey1JpExtractor:{domain:"www.publickey1.jp",title:{selectors:["h1"]},author:{selectors:["#subcol p:has(img)"]},date_published:{selectors:["div.pubdate"],format:"YYYY年MM月DD日",timezone:"Asia/Tokyo"},dek:null,lead_image_url:{selectors:[['meta[name="og:image"]',"value"]]},content:{selectors:["#maincol"],defaultCleaner:!1,transforms:{},clean:["#breadcrumbs","div.sbm","div.ad_footer"]}},TakagihiromitsuJpExtractor:{domain:"takagi-hiromitsu.jp",title:{selectors:["h3"]},author:{selectors:[['meta[name="author"]',"value"]]},date_published:{selectors:[['meta[http-equiv="Last-Modified"]',"value"]]},dek:null,lead_image_url:null,content:{selectors:["div.body"],defaultCleaner:!1,transforms:{},clean:[]}},BookwalkerJpExtractor:{domain:"bookwalker.jp",title:{selectors:["h1.main-heading"]},author:{selectors:["div.authors"]},date_published:{selectors:[".work-info .work-detail:first-of-type .work-detail-contents:last-of-type"],timezone:"Asia/Tokyo"},dek:null,lead_image_url:{selectors:[['meta[name="og:image"]',"value"]]},content:{selectors:[["div.main-info","div.main-cover-inner"]],defaultCleaner:!1,transforms:{},clean:["span.label.label--trial","dt.info-head.info-head--coin","dd.info-contents.info-contents--coin","div.info-notice.fn-toggleClass"]}},WwwYomiuriCoJpExtractor:{domain:"www.yomiuri.co.jp",title:{selectors:["h1.title-article.c-article-title"]},author:null,date_published:{selectors:[['meta[name="article:published_time"]',"value"]]},dek:null,lead_image_url:{selectors:[['meta[name="og:image"]',"value"]]},content:{selectors:["div.p-main-contents"],transforms:{},clean:[]}},JapanCnetComExtractor:{domain:"japan.cnet.com",title:{selectors:[".leaf-headline-ttl"]},author:{selectors:[".writer"]},date_published:{selectors:[".date"],format:"YYYY年MM月DD日 HH時mm分",timezone:"Asia/Tokyo"},dek:null,lead_image_url:{selectors:[['meta[name="og:image"]',"value"]]},content:{selectors:["div.article_body"],transforms:{},clean:[]}},DeadlineComExtractor:{domain:"deadline.com",title:{selectors:["h1"]},author:{selectors:["section.author h3"]},date_published:{selectors:[['meta[name="article:published_time"]',"value"]]},dek:null,lead_image_url:{selectors:[['meta[name="og:image"]',"value"]]},content:{selectors:["div.a-article-grid__main.pmc-a-grid article.pmc-a-grid-item"],transforms:{".embed-twitter":function(e){var t=e.html();e.replaceWith(t)}},clean:[]}},WwwGizmodoJpExtractor:{domain:"www.gizmodo.jp",title:{selectors:["h1.p-post-title"]},author:{selectors:["li.p-post-AssistAuthor"]},date_published:{selectors:[["li.p-post-AssistTime time","datetime"]]},dek:null,lead_image_url:{selectors:[['meta[name="og:image"]',"value"]]},content:{selectors:["article.p-post"],transforms:{"img.p-post-thumbnailImage":function(e){var t=e.attr("src");e.attr("src",t.replace(/^.*=%27/,"").replace(/%27;$/,""))}},clean:["h1.p-post-title","ul.p-post-Assist"]}},GetnewsJpExtractor:{domain:"getnews.jp",title:{selectors:["article h1"]},author:{selectors:["span.prof"]},date_published:{selectors:[["ul.cattag-top time","datetime"]]},dek:null,lead_image_url:{selectors:[['meta[name="og:image"]',"value"]]},content:{selectors:["div.post-bodycopy"],transforms:{},clean:[]}},WwwLifehackerJpExtractor:{domain:"www.lifehacker.jp",title:{selectors:["h1.lh-summary-title"]},author:{selectors:["p.lh-entryDetailInner--credit"]},date_published:{selectors:[["div.lh-entryDetail-header time","datetime"]]},dek:null,lead_image_url:{selectors:[['meta[name="og:image"]',"value"]]},content:{selectors:["div.lh-entryDetail-body"],transforms:{"img.lazyload":function(e){var t=e.attr("src");e.attr("src",t.replace(/^.*=%27/,"").replace(/%27;$/,""))}},clean:["p.lh-entryDetailInner--credit"]}},SectIijAdJpExtractor:{domain:"sect.iij.ad.jp",title:{selectors:["h3"]},author:{selectors:["dl.entrydate dd"]},date_published:{selectors:["dl.entrydate dd"],format:"YYYY年MM月DD日",timezone:"Asia/Tokyo"},dek:null,lead_image_url:{selectors:[['meta[name="og:image"]',"value"]]},content:{selectors:["#article"],transforms:{},clean:["dl.entrydate"]}},WwwOreillyCoJpExtractor:{domain:"www.oreilly.co.jp",title:{selectors:["h3"]},author:{selectors:['li[itemprop="author"]']},date_published:{selectors:[['meta[itemprop="datePublished"]',"value"]],timezone:"Asia/Tokyo"},dek:null,lead_image_url:{selectors:[['meta[name="og:image"]',"value"]]},content:{selectors:["#content"],defaultCleaner:!1,transforms:{},clean:[".social-tools"]}},WwwIpaGoJpExtractor:{domain:"www.ipa.go.jp",title:{selectors:["h1"]},author:null,date_published:{selectors:["p.ipar_text_right"],format:"YYYY年M月D日",timezone:"Asia/Tokyo"},dek:null,lead_image_url:null,content:{selectors:["#ipar_main"],defaultCleaner:!1,transforms:{},clean:["p.ipar_text_right"]}},WeeklyAsciiJpExtractor:{domain:"weekly.ascii.jp",title:{selectors:['h1[itemprop="headline"]']},author:{selectors:["p.author"]},date_published:{selectors:[['meta[name="odate"]',"value"]]},dek:null,lead_image_url:{selectors:[['meta[name="og:image"]',"value"]]},content:{selectors:["div.article"],transforms:{},clean:[]}},TechlogIijAdJpExtractor:{domain:"techlog.iij.ad.jp",title:{selectors:["h1.entry-title"]},author:{selectors:['a[rel="author"]']},date_published:{selectors:[["time.entry-date","datetime"]]},dek:null,lead_image_url:{selectors:[['meta[name="og:image"]',"value"]]},content:{selectors:["div.entry-content"],defaultCleaner:!1,transforms:{},clean:[]}},WiredJpExtractor:Ts,JapanZdnetComExtractor:{domain:"japan.zdnet.com",title:{selectors:["h1"]},author:{selectors:[['meta[name="cXenseParse:author"]',"value"]]},date_published:{selectors:[['meta[name="article:published_time"]',"value"]]},dek:null,lead_image_url:{selectors:[['meta[name="og:image"]',"value"]]},content:{selectors:["div.article_body"],transforms:{},clean:[]}},WwwRbbtodayComExtractor:{domain:"www.rbbtoday.com",title:{selectors:["h1"]},author:{selectors:[".writer.writer-name"]},date_published:{selectors:[["header time","datetime"]]},dek:{selectors:[".arti-summary"]},lead_image_url:{selectors:[['meta[name="og:image"]',"value"]]},content:{selectors:[".arti-content"],transforms:{},clean:[".arti-giga"]}},WwwLemondeFrExtractor:{domain:"www.lemonde.fr",title:{selectors:["h1.article__title"]},author:{selectors:[".author__name"]},date_published:{selectors:[['meta[name="og:article:published_time"]',"value"]]},dek:{selectors:[".article__desc"]},lead_image_url:{selectors:[['meta[name="og:image"]',"value"]]},content:{selectors:[".article__content"],transforms:{},clean:[]}},WwwPhoronixComExtractor:{domain:"www.phoronix.com",title:{selectors:["article header"]},author:{selectors:[".author a:first-child"]},date_published:{selectors:[".author"],format:"D MMMM YYYY at hh:mm",timezone:"America/New_York"},dek:null,lead_image_url:null,content:{selectors:[".content"],transforms:{},clean:[]}},PitchforkComExtractor:{domain:"pitchfork.com",title:{selectors:["title"]},author:{selectors:[".authors-detail__display-name"]},date_published:{selectors:[[".pub-date","datetime"]]},dek:{selectors:[".review-detail__abstract"]},lead_image_url:{selectors:[[".single-album-tombstone__art img","src"]]},content:{selectors:[".review-detail__text"]},extend:{score:{selectors:[".score"]}}},BiorxivOrgExtractor:{domain:"biorxiv.org",title:{selectors:["h1#page-title"]},author:{selectors:["div.highwire-citation-biorxiv-article-top > div.highwire-cite-authors"]},content:{selectors:["div#abstract-1"],transforms:{},clean:[]}},EpaperZeitDeExtractor:{domain:"epaper.zeit.de",title:{selectors:["p.title"]},author:{selectors:[".article__author"]},date_published:null,excerpt:{selectors:["subtitle"]},lead_image_url:null,content:{selectors:[".article"],transforms:{"p.title":"h1",".article__author":"p",byline:"p",linkbox:"p"},clean:["image-credits","box[type=citation]"]}}}),Ds=lt(Cs).reduce(function(e,t){var n=Cs[t];return pt({},e,_s(n))},{}),Os=e(function(e,t){(function(){var r="‎",a="‏",m="ltr",g="rtl",i="bidi",o="",v={Hebrew:["0590","05FF"],Arabic:["0600","06FF"],NKo:["07C0","07FF"],Syriac:["0700","074F"],Thaana:["0780","07BF"],Tifinagh:["2D30","2D7F"]};function e(e){if(void 0===e)throw new Error("TypeError missing argument");if("string"!=typeof e)throw new Error("TypeError getDirection expects strings");if(""===e)return o;if(-1>>0,r=0;rke(e)?(i=e+1,c-ke(e)):(i=e,c),{year:i,dayOfYear:o}}function Be(e,t,n){var r,a,i=He(e.year(),t,n),o=Math.floor((e.dayOfYear()-i-1)/7)+1;return o<1?(a=e.year()-1,r=o+Ge(a,t,n)):o>Ge(e.year(),t,n)?(r=o-Ge(e.year(),t,n),a=e.year()+1):(a=e.year(),r=o),{week:r,year:a}}function Ge(e,t,n){var r=He(e,t,n),a=He(e+1,t,n);return(ke(e)-r+a)/7}F("w",["ww",2],"wo","week"),F("W",["WW",2],"Wo","isoWeek"),j("week","w"),j("isoWeek","W"),L("week",5),L("isoWeek",5),ce("w",X),ce("ww",X,$),ce("W",X),ce("WW",X,$),pe(["w","ww","W","WW"],function(e,t,n,r){t[r.substr(0,1)]=x(e)}),F("d",0,"do","day"),F("dd",0,0,function(e){return this.localeData().weekdaysMin(this,e)}),F("ddd",0,0,function(e){return this.localeData().weekdaysShort(this,e)}),F("dddd",0,0,function(e){return this.localeData().weekdays(this,e)}),F("e",0,0,"weekday"),F("E",0,0,"isoWeekday"),j("day","d"),j("weekday","e"),j("isoWeekday","E"),L("day",11),L("weekday",11),L("isoWeekday",11),ce("d",X),ce("e",X),ce("E",X),ce("dd",function(e,t){return t.weekdaysMinRegex(e)}),ce("ddd",function(e,t){return t.weekdaysShortRegex(e)}),ce("dddd",function(e,t){return t.weekdaysRegex(e)}),pe(["dd","ddd","dddd"],function(e,t,n,r){var a=n._locale.weekdaysParse(e,r,n._strict);null!=a?t.d=a:y(n).invalidWeekday=e}),pe(["d","e","E"],function(e,t,n,r){t[r]=x(e)});var Ue="Sunday_Monday_Tuesday_Wednesday_Thursday_Friday_Saturday".split("_"),$e="Sun_Mon_Tue_Wed_Thu_Fri_Sat".split("_"),Ve="Su_Mo_Tu_We_Th_Fr_Sa".split("_");function Je(e,t,n){var r,a,i,o=e.toLocaleLowerCase();if(!this._weekdaysParse)for(this._weekdaysParse=[],this._shortWeekdaysParse=[],this._minWeekdaysParse=[],r=0;r<7;++r)i=h([2e3,1]).day(r),this._minWeekdaysParse[r]=this.weekdaysMin(i,"").toLocaleLowerCase(),this._shortWeekdaysParse[r]=this.weekdaysShort(i,"").toLocaleLowerCase(),this._weekdaysParse[r]=this.weekdays(i,"").toLocaleLowerCase();return n?"dddd"===t?-1!==(a=Se.call(this._weekdaysParse,o))?a:null:"ddd"===t?-1!==(a=Se.call(this._shortWeekdaysParse,o))?a:null:-1!==(a=Se.call(this._minWeekdaysParse,o))?a:null:"dddd"===t?-1!==(a=Se.call(this._weekdaysParse,o))?a:-1!==(a=Se.call(this._shortWeekdaysParse,o))?a:-1!==(a=Se.call(this._minWeekdaysParse,o))?a:null:"ddd"===t?-1!==(a=Se.call(this._shortWeekdaysParse,o))?a:-1!==(a=Se.call(this._weekdaysParse,o))?a:-1!==(a=Se.call(this._minWeekdaysParse,o))?a:null:-1!==(a=Se.call(this._minWeekdaysParse,o))?a:-1!==(a=Se.call(this._weekdaysParse,o))?a:-1!==(a=Se.call(this._shortWeekdaysParse,o))?a:null}var Ke=se,Xe=se,Ze=se;function Qe(){function e(e,t){return t.length-e.length}var t,n,r,a,i,o=[],s=[],u=[],c=[];for(t=0;t<7;t++)n=h([2e3,1]).day(t),r=this.weekdaysMin(n,""),a=this.weekdaysShort(n,""),i=this.weekdays(n,""),o.push(r),s.push(a),u.push(i),c.push(r),c.push(a),c.push(i);for(o.sort(e),s.sort(e),u.sort(e),c.sort(e),t=0;t<7;t++)s[t]=fe(s[t]),u[t]=fe(u[t]),c[t]=fe(c[t]);this._weekdaysRegex=new RegExp("^("+c.join("|")+")","i"),this._weekdaysShortRegex=this._weekdaysRegex,this._weekdaysMinRegex=this._weekdaysRegex,this._weekdaysStrictRegex=new RegExp("^("+u.join("|")+")","i"),this._weekdaysShortStrictRegex=new RegExp("^("+s.join("|")+")","i"),this._weekdaysMinStrictRegex=new RegExp("^("+o.join("|")+")","i")}function et(){return this.hours()%12||12}function tt(e,t){F(e,0,0,function(){return this.localeData().meridiem(this.hours(),this.minutes(),t)})}function nt(e,t){return t._meridiemParse}F("H",["HH",2],0,"hour"),F("h",["hh",2],0,et),F("k",["kk",2],0,function(){return this.hours()||24}),F("hmm",0,0,function(){return""+et.apply(this)+Y(this.minutes(),2)}),F("hmmss",0,0,function(){return""+et.apply(this)+Y(this.minutes(),2)+Y(this.seconds(),2)}),F("Hmm",0,0,function(){return""+this.hours()+Y(this.minutes(),2)}),F("Hmmss",0,0,function(){return""+this.hours()+Y(this.minutes(),2)+Y(this.seconds(),2)}),tt("a",!0),tt("A",!1),j("hour","h"),L("hour",13),ce("a",nt),ce("A",nt),ce("H",X),ce("h",X),ce("k",X),ce("HH",X,$),ce("hh",X,$),ce("kk",X,$),ce("hmm",Z),ce("hmmss",Q),ce("Hmm",Z),ce("Hmmss",Q),de(["H","HH"],ye),de(["k","kk"],function(e,t,n){var r=x(e);t[ye]=24===r?0:r}),de(["a","A"],function(e,t,n){n._isPm=n._locale.isPM(e),n._meridiem=e}),de(["h","hh"],function(e,t,n){t[ye]=x(e),y(n).bigHour=!0}),de("hmm",function(e,t,n){var r=e.length-2;t[ye]=x(e.substr(0,r)),t[_e]=x(e.substr(r)),y(n).bigHour=!0}),de("hmmss",function(e,t,n){var r=e.length-4,a=e.length-2;t[ye]=x(e.substr(0,r)),t[_e]=x(e.substr(r,2)),t[be]=x(e.substr(a)),y(n).bigHour=!0}),de("Hmm",function(e,t,n){var r=e.length-2;t[ye]=x(e.substr(0,r)),t[_e]=x(e.substr(r))}),de("Hmmss",function(e,t,n){var r=e.length-4,a=e.length-2;t[ye]=x(e.substr(0,r)),t[_e]=x(e.substr(r,2)),t[be]=x(e.substr(a))});var rt,at=Te("Hours",!0),it={calendar:{sameDay:"[Today at] LT",nextDay:"[Tomorrow at] LT",nextWeek:"dddd [at] LT",lastDay:"[Yesterday at] LT",lastWeek:"[Last] dddd [at] LT",sameElse:"L"},longDateFormat:{LTS:"h:mm:ss A",LT:"h:mm A",L:"MM/DD/YYYY",LL:"MMMM D, YYYY",LLL:"MMMM D, YYYY h:mm A",LLLL:"dddd, MMMM D, YYYY h:mm A"},invalidDate:"Invalid date",ordinal:"%d",dayOfMonthOrdinalParse:/\d{1,2}/,relativeTime:{future:"in %s",past:"%s ago",s:"a few seconds",ss:"%d seconds",m:"a minute",mm:"%d minutes",h:"an hour",hh:"%d hours",d:"a day",dd:"%d days",M:"a month",MM:"%d months",y:"a year",yy:"%d years"},months:Ne,monthsShort:ze,week:{dow:0,doy:6},weekdays:Ue,weekdaysMin:Ve,weekdaysShort:$e,meridiemParse:/[ap]\.?m?\.?/i},ot={},st={};function ut(e){return e?e.toLowerCase().replace("_","-"):e}function ct(e){var t=null;if(!ot[e]&&Un&&Un.exports)try{t=rt._abbr;var n=$n;n("./locale/"+e),lt(t)}catch(e){}return ot[e]}function lt(e,t){var n;return e&&((n=i(t)?ht(e):ft(e,t))?rt=n:"undefined"!=typeof console&&console.warn&&console.warn("Locale "+e+" not found. Did you forget to load it?")),rt._abbr}function ft(e,t){if(null===t)return delete ot[e],null;var n,r=it;if(t.abbr=e,null!=ot[e])M("defineLocaleOverride","use moment.updateLocale(localeName, config) to change an existing locale. moment.defineLocale(localeName, config) should only be used for creating a new locale See http://momentjs.com/guides/#/warnings/define-locale/ for more info."),r=ot[e]._config;else if(null!=t.parentLocale)if(null!=ot[t.parentLocale])r=ot[t.parentLocale]._config;else{if(null==(n=ct(t.parentLocale)))return st[t.parentLocale]||(st[t.parentLocale]=[]),st[t.parentLocale].push({name:e,config:t}),null;r=n._config}return ot[e]=new D(C(r,t)),st[e]&&st[e].forEach(function(e){ft(e.name,e.config)}),lt(e),ot[e]}function ht(e){var t;if(e&&e._locale&&e._locale._abbr&&(e=e._locale._abbr),!e)return rt;if(!s(e)){if(t=ct(e))return t;e=[e]}return function(e){for(var t,n,r,a,i=0;i=t&&k(a,n,!0)>=t-1)break;t--}i++}return rt}(e)}function dt(e){var t,n=e._a;return n&&-2===y(e).overflow&&(t=n[ge]<0||11Oe(n[me],n[ge])?ve:n[ye]<0||24Ge(n,i,o)?y(e)._overflowWeeks=!0:null!=u?y(e)._overflowWeekday=!0:(s=Fe(n,r,a,i,o),e._a[me]=s.year,e._dayOfYear=s.dayOfYear)}(e),null!=e._dayOfYear&&(i=pt(e._a[me],r[me]),(e._dayOfYear>ke(i)||0===e._dayOfYear)&&(y(e)._overflowDayOfYear=!0),n=Ie(i,0,e._dayOfYear),e._a[ge]=n.getUTCMonth(),e._a[ve]=n.getUTCDate()),t=0;t<3&&null==e._a[t];++t)e._a[t]=u[t]=r[t];for(;t<7;t++)e._a[t]=u[t]=null==e._a[t]?2===t?1:0:e._a[t];24===e._a[ye]&&0===e._a[_e]&&0===e._a[be]&&0===e._a[we]&&(e._nextDay=!0,e._a[ye]=0),e._d=(e._useUTC?Ie:function(e,t,n,r,a,i,o){var s=new Date(e,t,n,r,a,i,o);return e<100&&0<=e&&isFinite(s.getFullYear())&&s.setFullYear(e),s}).apply(null,u),a=e._useUTC?e._d.getUTCDay():e._d.getDay(),null!=e._tzm&&e._d.setUTCMinutes(e._d.getUTCMinutes()-e._tzm),e._nextDay&&(e._a[ye]=24),e._w&&void 0!==e._w.d&&e._w.d!==a&&(y(e).weekdayMismatch=!0)}}var gt=/^\s*((?:[+-]\d{6}|\d{4})-(?:\d\d-\d\d|W\d\d-\d|W\d\d|\d\d\d|\d\d))(?:(T| )(\d\d(?::\d\d(?::\d\d(?:[.,]\d+)?)?)?)([\+\-]\d\d(?::?\d\d)?|\s*Z)?)?$/,vt=/^\s*((?:[+-]\d{6}|\d{4})(?:\d\d\d\d|W\d\d\d|W\d\d|\d\d\d|\d\d))(?:(T| )(\d\d(?:\d\d(?:\d\d(?:[.,]\d+)?)?)?)([\+\-]\d\d(?::?\d\d)?|\s*Z)?)?$/,yt=/Z|[+-]\d\d(?::?\d\d)?/,_t=[["YYYYYY-MM-DD",/[+-]\d{6}-\d\d-\d\d/],["YYYY-MM-DD",/\d{4}-\d\d-\d\d/],["GGGG-[W]WW-E",/\d{4}-W\d\d-\d/],["GGGG-[W]WW",/\d{4}-W\d\d/,!1],["YYYY-DDD",/\d{4}-\d{3}/],["YYYY-MM",/\d{4}-\d\d/,!1],["YYYYYYMMDD",/[+-]\d{10}/],["YYYYMMDD",/\d{8}/],["GGGG[W]WWE",/\d{4}W\d{3}/],["GGGG[W]WW",/\d{4}W\d{2}/,!1],["YYYYDDD",/\d{7}/]],bt=[["HH:mm:ss.SSSS",/\d\d:\d\d:\d\d\.\d+/],["HH:mm:ss,SSSS",/\d\d:\d\d:\d\d,\d+/],["HH:mm:ss",/\d\d:\d\d:\d\d/],["HH:mm",/\d\d:\d\d/],["HHmmss.SSSS",/\d\d\d\d\d\d\.\d+/],["HHmmss,SSSS",/\d\d\d\d\d\d,\d+/],["HHmmss",/\d\d\d\d\d\d/],["HHmm",/\d\d\d\d/],["HH",/\d\d/]],wt=/^\/?Date\((\-?\d+)/i;function At(e){var t,n,r,a,i,o,s=e._i,u=gt.exec(s)||vt.exec(s);if(u){for(y(e).iso=!0,t=0,n=_t.length;tn.valueOf():n.valueOf()this.clone().month(0).utcOffset()||this.utcOffset()>this.clone().month(5).utcOffset()},hn.isLocal=function(){return!!this.isValid()&&!this._isUTC},hn.isUtcOffset=function(){return!!this.isValid()&&this._isUTC},hn.isUtc=Ft,hn.isUTC=Ft,hn.zoneAbbr=function(){return this._isUTC?"UTC":""},hn.zoneName=function(){return this._isUTC?"Coordinated Universal Time":""},hn.dates=n("dates accessor is deprecated. Use date instead.",on),hn.months=n("months accessor is deprecated. Use month instead",Re),hn.years=n("years accessor is deprecated. Use year instead",Me),hn.zone=n("moment().zone is deprecated, use moment().utcOffset instead. http://momentjs.com/guides/#/warnings/zone/",function(e,t){return null!=e?("string"!=typeof e&&(e=-e),this.utcOffset(e,t),this):-this.utcOffset()}),hn.isDSTShifted=n("isDSTShifted is deprecated. See http://momentjs.com/guides/#/warnings/dst-shifted/ for more information",function(){if(!i(this._isDSTShifted))return this._isDSTShifted;var e={};if(_(e,this),(e=Tt(e))._a){var t=e._isUTC?h(e._a):Dt(e._a);this._isDSTShifted=this.isValid()&&0= 2.6.0. You are using Moment.js "+i.version+". See momentjs.com"),p.prototype={_set:function(e){this.name=e.name,this.abbrs=e.abbrs,this.untils=e.untils,this.offsets=e.offsets,this.population=e.population},_index:function(e){var t,n=+e,r=this.untils;for(t=0;t= 2.9.0. You are using Moment.js "+i.version+"."),i.defaultZone=e?A(e):null,i};var j=i.momentProperties;return"[object Array]"===Object.prototype.toString.call(j)?(j.push("_z"),j.push("_a")):j&&(j._z=null),k({version:"2019b",zones:["Africa/Abidjan|GMT|0|0||48e5","Africa/Nairobi|EAT|-30|0||47e5","Africa/Algiers|CET|-10|0||26e5","Africa/Lagos|WAT|-10|0||17e6","Africa/Maputo|CAT|-20|0||26e5","Africa/Cairo|EET EEST|-20 -30|01010|1M2m0 gL0 e10 mn0|15e6","Africa/Casablanca|+00 +01|0 -10|010101010101010101010101010101010101|1H3C0 wM0 co0 go0 1o00 s00 dA0 vc0 11A0 A00 e00 y00 11A0 uM0 e00 Dc0 11A0 s00 e00 IM0 WM0 mo0 gM0 LA0 WM0 jA0 e00 28M0 e00 2600 e00 28M0 e00 2600 gM0|32e5","Europe/Paris|CET CEST|-10 -20|01010101010101010101010|1GNB0 1qM0 11A0 1o00 11A0 1o00 11A0 1o00 11A0 1qM0 WM0 1qM0 WM0 1qM0 11A0 1o00 11A0 1o00 11A0 1qM0 WM0 1qM0|11e6","Africa/Johannesburg|SAST|-20|0||84e5","Africa/Khartoum|EAT CAT|-30 -20|01|1Usl0|51e5","Africa/Sao_Tome|GMT WAT|0 -10|010|1UQN0 2q00","Africa/Tripoli|EET CET CEST|-20 -10 -20|0120|1IlA0 TA0 1o00|11e5","Africa/Windhoek|CAT WAT|-20 -10|0101010101010|1GQo0 11B0 1qL0 WN0 1qL0 11B0 1nX0 11B0 1nX0 11B0 1nX0 11B0|32e4","America/Adak|HST HDT|a0 90|01010101010101010101010|1GIc0 1zb0 Op0 1zb0 Op0 1zb0 Op0 1zb0 Rd0 1zb0 Op0 1zb0 Op0 1zb0 Op0 1zb0 Op0 1zb0 Rd0 1zb0 Op0 1zb0|326","America/Anchorage|AKST AKDT|90 80|01010101010101010101010|1GIb0 1zb0 Op0 1zb0 Op0 1zb0 Op0 1zb0 Rd0 1zb0 Op0 1zb0 Op0 1zb0 Op0 1zb0 Op0 1zb0 Rd0 1zb0 Op0 1zb0|30e4","America/Santo_Domingo|AST|40|0||29e5","America/Araguaina|-03 -02|30 20|010|1IdD0 Lz0|14e4","America/Fortaleza|-03|30|0||34e5","America/Asuncion|-03 -04|30 40|01010101010101010101010|1GTf0 1cN0 17b0 1ip0 17b0 1ip0 17b0 1ip0 19X0 1fB0 19X0 1fB0 19X0 1ip0 17b0 1ip0 17b0 1ip0 19X0 1fB0 19X0 1fB0|28e5","America/Panama|EST|50|0||15e5","America/Mexico_City|CST CDT|60 50|01010101010101010101010|1GQw0 1nX0 14p0 1lb0 14p0 1lb0 14p0 1lb0 14p0 1nX0 11B0 1nX0 11B0 1nX0 14p0 1lb0 14p0 1lb0 14p0 1nX0 11B0 1nX0|20e6","America/Bahia|-02 -03|20 30|01|1GCq0|27e5","America/Managua|CST|60|0||22e5","America/La_Paz|-04|40|0||19e5","America/Lima|-05|50|0||11e6","America/Denver|MST MDT|70 60|01010101010101010101010|1GI90 1zb0 Op0 1zb0 Op0 1zb0 Op0 1zb0 Rd0 1zb0 Op0 1zb0 Op0 1zb0 Op0 1zb0 Op0 1zb0 Rd0 1zb0 Op0 1zb0|26e5","America/Campo_Grande|-03 -04|30 40|0101010101010101|1GCr0 1zd0 Lz0 1C10 Lz0 1C10 On0 1zd0 On0 1zd0 On0 1zd0 On0 1HB0 FX0|77e4","America/Cancun|CST CDT EST|60 50 50|01010102|1GQw0 1nX0 14p0 1lb0 14p0 1lb0 Dd0|63e4","America/Caracas|-0430 -04|4u 40|01|1QMT0|29e5","America/Chicago|CST CDT|60 50|01010101010101010101010|1GI80 1zb0 Op0 1zb0 Op0 1zb0 Op0 1zb0 Rd0 1zb0 Op0 1zb0 Op0 1zb0 Op0 1zb0 Op0 1zb0 Rd0 1zb0 Op0 1zb0|92e5","America/Chihuahua|MST MDT|70 60|01010101010101010101010|1GQx0 1nX0 14p0 1lb0 14p0 1lb0 14p0 1lb0 14p0 1nX0 11B0 1nX0 11B0 1nX0 14p0 1lb0 14p0 1lb0 14p0 1nX0 11B0 1nX0|81e4","America/Phoenix|MST|70|0||42e5","America/Los_Angeles|PST PDT|80 70|01010101010101010101010|1GIa0 1zb0 Op0 1zb0 Op0 1zb0 Op0 1zb0 Rd0 1zb0 Op0 1zb0 Op0 1zb0 Op0 1zb0 Op0 1zb0 Rd0 1zb0 Op0 1zb0|15e6","America/New_York|EST EDT|50 40|01010101010101010101010|1GI70 1zb0 Op0 1zb0 Op0 1zb0 Op0 1zb0 Rd0 1zb0 Op0 1zb0 Op0 1zb0 Op0 1zb0 Op0 1zb0 Rd0 1zb0 Op0 1zb0|21e6","America/Rio_Branco|-04 -05|40 50|01|1KLE0|31e4","America/Fort_Nelson|PST PDT MST|80 70 70|01010102|1GIa0 1zb0 Op0 1zb0 Op0 1zb0 Op0|39e2","America/Halifax|AST ADT|40 30|01010101010101010101010|1GI60 1zb0 Op0 1zb0 Op0 1zb0 Op0 1zb0 Rd0 1zb0 Op0 1zb0 Op0 1zb0 Op0 1zb0 Op0 1zb0 Rd0 1zb0 Op0 1zb0|39e4","America/Godthab|-03 -02|30 20|01010101010101010101010|1GNB0 1qM0 11A0 1o00 11A0 1o00 11A0 1o00 11A0 1qM0 WM0 1qM0 WM0 1qM0 11A0 1o00 11A0 1o00 11A0 1qM0 WM0 1qM0|17e3","America/Grand_Turk|EST EDT AST|50 40 40|0101010121010101010|1GI70 1zb0 Op0 1zb0 Op0 1zb0 Op0 1zb0 5Ip0 1zb0 Op0 1zb0 Op0 1zb0 Rd0 1zb0 Op0 1zb0|37e2","America/Havana|CST CDT|50 40|01010101010101010101010|1GQt0 1qM0 Oo0 1zc0 Oo0 1zc0 Oo0 1zc0 Rc0 1zc0 Oo0 1zc0 Oo0 1zc0 Oo0 1zc0 Oo0 1zc0 Rc0 1zc0 Oo0 1zc0|21e5","America/Metlakatla|PST AKST AKDT|80 90 80|01212120121212121|1PAa0 Rd0 1zb0 Op0 1zb0 Op0 1zb0 uM0 jB0 1zb0 Op0 1zb0 Rd0 1zb0 Op0 1zb0|14e2","America/Miquelon|-03 -02|30 20|01010101010101010101010|1GI50 1zb0 Op0 1zb0 Op0 1zb0 Op0 1zb0 Rd0 1zb0 Op0 1zb0 Op0 1zb0 Op0 1zb0 Op0 1zb0 Rd0 1zb0 Op0 1zb0|61e2","America/Montevideo|-02 -03|20 30|01010101|1GI40 1o10 11z0 1o10 11z0 1o10 11z0|17e5","America/Noronha|-02|20|0||30e2","America/Port-au-Prince|EST EDT|50 40|010101010101010101010|1GI70 1zb0 Op0 1zb0 Op0 1zb0 Op0 1zb0 3iN0 1zb0 Op0 1zb0 Op0 1zb0 Op0 1zb0 Rd0 1zb0 Op0 1zb0|23e5","Antarctica/Palmer|-03 -04|30 40|010101010|1H3D0 Op0 1zb0 Rd0 1wn0 Rd0 46n0 Ap0|40","America/Santiago|-03 -04|30 40|010101010101010101010|1H3D0 Op0 1zb0 Rd0 1wn0 Rd0 46n0 Ap0 1Nb0 Ap0 1Nb0 Ap0 1zb0 11B0 1nX0 11B0 1nX0 11B0 1nX0 11B0|62e5","America/Sao_Paulo|-02 -03|20 30|0101010101010101|1GCq0 1zd0 Lz0 1C10 Lz0 1C10 On0 1zd0 On0 1zd0 On0 1zd0 On0 1HB0 FX0|20e6","Atlantic/Azores|-01 +00|10 0|01010101010101010101010|1GNB0 1qM0 11A0 1o00 11A0 1o00 11A0 1o00 11A0 1qM0 WM0 1qM0 WM0 1qM0 11A0 1o00 11A0 1o00 11A0 1qM0 WM0 1qM0|25e4","America/St_Johns|NST NDT|3u 2u|01010101010101010101010|1GI5u 1zb0 Op0 1zb0 Op0 1zb0 Op0 1zb0 Rd0 1zb0 Op0 1zb0 Op0 1zb0 Op0 1zb0 Op0 1zb0 Rd0 1zb0 Op0 1zb0|11e4","Antarctica/Casey|+11 +08|-b0 -80|0101|1GAF0 blz0 3m10|10","Antarctica/Davis|+05 +07|-50 -70|01|1GAI0|70","Pacific/Port_Moresby|+10|-a0|0||25e4","Pacific/Guadalcanal|+11|-b0|0||11e4","Asia/Tashkent|+05|-50|0||23e5","Pacific/Auckland|NZDT NZST|-d0 -c0|01010101010101010101010|1GQe0 1cM0 1fA0 1a00 1fA0 1a00 1fA0 1a00 1fA0 1a00 1fA0 1a00 1fA0 1cM0 1fA0 1a00 1fA0 1a00 1fA0 1a00 1fA0 1a00|14e5","Asia/Baghdad|+03|-30|0||66e5","Antarctica/Troll|+00 +02|0 -20|01010101010101010101010|1GNB0 1qM0 11A0 1o00 11A0 1o00 11A0 1o00 11A0 1qM0 WM0 1qM0 WM0 1qM0 11A0 1o00 11A0 1o00 11A0 1qM0 WM0 1qM0|40","Asia/Dhaka|+06|-60|0||16e6","Asia/Amman|EET EEST|-20 -30|010101010101010101010|1GPy0 4bX0 Dd0 1qM0 WM0 1qM0 11A0 1o00 11A0 1o00 11A0 1o00 11A0 1o00 11A0 1qM0 WM0 1qM0 11A0 1o00|25e5","Asia/Kamchatka|+12|-c0|0||18e4","Asia/Baku|+04 +05|-40 -50|010101010|1GNA0 1qM0 11A0 1o00 11A0 1o00 11A0 1o00|27e5","Asia/Bangkok|+07|-70|0||15e6","Asia/Barnaul|+07 +06|-70 -60|010|1N7v0 3rd0","Asia/Beirut|EET EEST|-20 -30|01010101010101010101010|1GNy0 1qL0 11B0 1nX0 11B0 1nX0 11B0 1nX0 11B0 1qL0 WN0 1qL0 WN0 1qL0 11B0 1nX0 11B0 1nX0 11B0 1qL0 WN0 1qL0|22e5","Asia/Kuala_Lumpur|+08|-80|0||71e5","Asia/Kolkata|IST|-5u|0||15e6","Asia/Chita|+10 +08 +09|-a0 -80 -90|012|1N7s0 3re0|33e4","Asia/Ulaanbaatar|+08 +09|-80 -90|01010|1O8G0 1cJ0 1cP0 1cJ0|12e5","Asia/Shanghai|CST|-80|0||23e6","Asia/Colombo|+0530|-5u|0||22e5","Asia/Damascus|EET EEST|-20 -30|01010101010101010101010|1GPy0 1nX0 11B0 1nX0 11B0 1qL0 WN0 1qL0 WN0 1qL0 11B0 1nX0 11B0 1nX0 11B0 1nX0 11B0 1qL0 WN0 1qL0 WN0 1qL0|26e5","Asia/Dili|+09|-90|0||19e4","Asia/Dubai|+04|-40|0||39e5","Asia/Famagusta|EET EEST +03|-20 -30 -30|0101010101201010101010|1GNB0 1qM0 11A0 1o00 11A0 1o00 11A0 1o00 11A0 15U0 2Ks0 WM0 1qM0 11A0 1o00 11A0 1o00 11A0 1qM0 WM0 1qM0","Asia/Gaza|EET EEST|-20 -30|01010101010101010101010|1GPy0 1a00 1fA0 1cL0 1cN0 1nX0 1210 1nz0 1220 1qL0 WN0 1qL0 WN0 1qL0 11c0 1oo0 11c0 1rc0 Wo0 1rc0 Wo0 1rc0|18e5","Asia/Hong_Kong|HKT|-80|0||73e5","Asia/Hovd|+07 +08|-70 -80|01010|1O8H0 1cJ0 1cP0 1cJ0|81e3","Asia/Irkutsk|+09 +08|-90 -80|01|1N7t0|60e4","Europe/Istanbul|EET EEST +03|-20 -30 -30|01010101012|1GNB0 1qM0 11A0 1o00 1200 1nA0 11A0 1tA0 U00 15w0|13e6","Asia/Jakarta|WIB|-70|0||31e6","Asia/Jayapura|WIT|-90|0||26e4","Asia/Jerusalem|IST IDT|-20 -30|01010101010101010101010|1GPA0 1aL0 1eN0 1oL0 10N0 1oL0 10N0 1oL0 10N0 1rz0 W10 1rz0 W10 1rz0 10N0 1oL0 10N0 1oL0 10N0 1rz0 W10 1rz0|81e4","Asia/Kabul|+0430|-4u|0||46e5","Asia/Karachi|PKT|-50|0||24e6","Asia/Kathmandu|+0545|-5J|0||12e5","Asia/Yakutsk|+10 +09|-a0 -90|01|1N7s0|28e4","Asia/Krasnoyarsk|+08 +07|-80 -70|01|1N7u0|10e5","Asia/Magadan|+12 +10 +11|-c0 -a0 -b0|012|1N7q0 3Cq0|95e3","Asia/Makassar|WITA|-80|0||15e5","Asia/Manila|PST|-80|0||24e6","Europe/Athens|EET EEST|-20 -30|01010101010101010101010|1GNB0 1qM0 11A0 1o00 11A0 1o00 11A0 1o00 11A0 1qM0 WM0 1qM0 WM0 1qM0 11A0 1o00 11A0 1o00 11A0 1qM0 WM0 1qM0|35e5","Asia/Novosibirsk|+07 +06|-70 -60|010|1N7v0 4eN0|15e5","Asia/Omsk|+07 +06|-70 -60|01|1N7v0|12e5","Asia/Pyongyang|KST KST|-90 -8u|010|1P4D0 6BA0|29e5","Asia/Qyzylorda|+06 +05|-60 -50|01|1Xei0|73e4","Asia/Rangoon|+0630|-6u|0||48e5","Asia/Sakhalin|+11 +10|-b0 -a0|010|1N7r0 3rd0|58e4","Asia/Seoul|KST|-90|0||23e6","Asia/Srednekolymsk|+12 +11|-c0 -b0|01|1N7q0|35e2","Asia/Tehran|+0330 +0430|-3u -4u|01010101010101010101010|1GLUu 1dz0 1cN0 1dz0 1cp0 1dz0 1cp0 1dz0 1cp0 1dz0 1cN0 1dz0 1cp0 1dz0 1cp0 1dz0 1cp0 1dz0 1cN0 1dz0 1cp0 1dz0|14e6","Asia/Tokyo|JST|-90|0||38e6","Asia/Tomsk|+07 +06|-70 -60|010|1N7v0 3Qp0|10e5","Asia/Vladivostok|+11 +10|-b0 -a0|01|1N7r0|60e4","Asia/Yekaterinburg|+06 +05|-60 -50|01|1N7w0|14e5","Europe/Lisbon|WET WEST|0 -10|01010101010101010101010|1GNB0 1qM0 11A0 1o00 11A0 1o00 11A0 1o00 11A0 1qM0 WM0 1qM0 WM0 1qM0 11A0 1o00 11A0 1o00 11A0 1qM0 WM0 1qM0|27e5","Atlantic/Cape_Verde|-01|10|0||50e4","Australia/Sydney|AEDT AEST|-b0 -a0|01010101010101010101010|1GQg0 1fA0 1cM0 1cM0 1cM0 1cM0 1cM0 1cM0 1cM0 1cM0 1cM0 1cM0 1cM0 1fA0 1cM0 1cM0 1cM0 1cM0 1cM0 1cM0 1cM0 1cM0|40e5","Australia/Adelaide|ACDT ACST|-au -9u|01010101010101010101010|1GQgu 1fA0 1cM0 1cM0 1cM0 1cM0 1cM0 1cM0 1cM0 1cM0 1cM0 1cM0 1cM0 1fA0 1cM0 1cM0 1cM0 1cM0 1cM0 1cM0 1cM0 1cM0|11e5","Australia/Brisbane|AEST|-a0|0||20e5","Australia/Darwin|ACST|-9u|0||12e4","Australia/Eucla|+0845|-8J|0||368","Australia/Lord_Howe|+11 +1030|-b0 -au|01010101010101010101010|1GQf0 1fAu 1cLu 1cMu 1cLu 1cMu 1cLu 1cMu 1cLu 1cMu 1cLu 1cMu 1cLu 1fAu 1cLu 1cMu 1cLu 1cMu 1cLu 1cMu 1cLu 1cMu|347","Australia/Perth|AWST|-80|0||18e5","Pacific/Easter|-05 -06|50 60|010101010101010101010|1H3D0 Op0 1zb0 Rd0 1wn0 Rd0 46n0 Ap0 1Nb0 Ap0 1Nb0 Ap0 1zb0 11B0 1nX0 11B0 1nX0 11B0 1nX0 11B0|30e2","Europe/Dublin|GMT IST|0 -10|01010101010101010101010|1GNB0 1qM0 11A0 1o00 11A0 1o00 11A0 1o00 11A0 1qM0 WM0 1qM0 WM0 1qM0 11A0 1o00 11A0 1o00 11A0 1qM0 WM0 1qM0|12e5","Etc/GMT-1|+01|-10|0|","Pacific/Fakaofo|+13|-d0|0||483","Pacific/Kiritimati|+14|-e0|0||51e2","Etc/GMT-2|+02|-20|0|","Pacific/Tahiti|-10|a0|0||18e4","Pacific/Niue|-11|b0|0||12e2","Etc/GMT+12|-12|c0|0|","Pacific/Galapagos|-06|60|0||25e3","Etc/GMT+7|-07|70|0|","Pacific/Pitcairn|-08|80|0||56","Pacific/Gambier|-09|90|0||125","Etc/UTC|UTC|0|0|","Europe/Ulyanovsk|+04 +03|-40 -30|010|1N7y0 3rd0|13e5","Europe/London|GMT BST|0 -10|01010101010101010101010|1GNB0 1qM0 11A0 1o00 11A0 1o00 11A0 1o00 11A0 1qM0 WM0 1qM0 WM0 1qM0 11A0 1o00 11A0 1o00 11A0 1qM0 WM0 1qM0|10e6","Europe/Chisinau|EET EEST|-20 -30|01010101010101010101010|1GNA0 1qM0 11A0 1o00 11A0 1o00 11A0 1o00 11A0 1qM0 WM0 1qM0 WM0 1qM0 11A0 1o00 11A0 1o00 11A0 1qM0 WM0 1qM0|67e4","Europe/Kaliningrad|+03 EET|-30 -20|01|1N7z0|44e4","Europe/Kirov|+04 +03|-40 -30|01|1N7y0|48e4","Europe/Moscow|MSK MSK|-40 -30|01|1N7y0|16e6","Europe/Saratov|+04 +03|-40 -30|010|1N7y0 5810","Europe/Simferopol|EET EEST MSK MSK|-20 -30 -40 -30|0101023|1GNB0 1qM0 11A0 1o00 11z0 1nW0|33e4","Europe/Volgograd|+04 +03|-40 -30|010|1N7y0 9Jd0|10e5","Pacific/Honolulu|HST|a0|0||37e4","MET|MET MEST|-10 -20|01010101010101010101010|1GNB0 1qM0 11A0 1o00 11A0 1o00 11A0 1o00 11A0 1qM0 WM0 1qM0 WM0 1qM0 11A0 1o00 11A0 1o00 11A0 1qM0 WM0 1qM0","Pacific/Chatham|+1345 +1245|-dJ -cJ|01010101010101010101010|1GQe0 1cM0 1fA0 1a00 1fA0 1a00 1fA0 1a00 1fA0 1a00 1fA0 1a00 1fA0 1cM0 1fA0 1a00 1fA0 1a00 1fA0 1a00 1fA0 1a00|600","Pacific/Apia|+14 +13|-e0 -d0|01010101010101010101010|1GQe0 1cM0 1fA0 1a00 1fA0 1a00 1fA0 1a00 1fA0 1a00 1fA0 1a00 1fA0 1cM0 1fA0 1a00 1fA0 1a00 1fA0 1a00 1fA0 1a00|37e3","Pacific/Bougainville|+10 +11|-a0 -b0|01|1NwE0|18e4","Pacific/Fiji|+13 +12|-d0 -c0|01010101010101010101010|1Goe0 1Nc0 Ao0 1Q00 xz0 1SN0 uM0 1SM0 uM0 1VA0 s00 1VA0 s00 1VA0 s00 1VA0 uM0 1SM0 uM0 1VA0 s00 1VA0|88e4","Pacific/Guam|ChST|-a0|0||17e4","Pacific/Marquesas|-0930|9u|0||86e2","Pacific/Pago_Pago|SST|b0|0||37e2","Pacific/Norfolk|+1130 +11|-bu -b0|01|1PoCu|25e4","Pacific/Tongatapu|+13 +14|-d0 -e0|010|1S4d0 s00|75e3"],links:["Africa/Abidjan|Africa/Accra","Africa/Abidjan|Africa/Bamako","Africa/Abidjan|Africa/Banjul","Africa/Abidjan|Africa/Bissau","Africa/Abidjan|Africa/Conakry","Africa/Abidjan|Africa/Dakar","Africa/Abidjan|Africa/Freetown","Africa/Abidjan|Africa/Lome","Africa/Abidjan|Africa/Monrovia","Africa/Abidjan|Africa/Nouakchott","Africa/Abidjan|Africa/Ouagadougou","Africa/Abidjan|Africa/Timbuktu","Africa/Abidjan|America/Danmarkshavn","Africa/Abidjan|Atlantic/Reykjavik","Africa/Abidjan|Atlantic/St_Helena","Africa/Abidjan|Etc/GMT","Africa/Abidjan|Etc/GMT+0","Africa/Abidjan|Etc/GMT-0","Africa/Abidjan|Etc/GMT0","Africa/Abidjan|Etc/Greenwich","Africa/Abidjan|GMT","Africa/Abidjan|GMT+0","Africa/Abidjan|GMT-0","Africa/Abidjan|GMT0","Africa/Abidjan|Greenwich","Africa/Abidjan|Iceland","Africa/Algiers|Africa/Tunis","Africa/Cairo|Egypt","Africa/Casablanca|Africa/El_Aaiun","Africa/Johannesburg|Africa/Maseru","Africa/Johannesburg|Africa/Mbabane","Africa/Lagos|Africa/Bangui","Africa/Lagos|Africa/Brazzaville","Africa/Lagos|Africa/Douala","Africa/Lagos|Africa/Kinshasa","Africa/Lagos|Africa/Libreville","Africa/Lagos|Africa/Luanda","Africa/Lagos|Africa/Malabo","Africa/Lagos|Africa/Ndjamena","Africa/Lagos|Africa/Niamey","Africa/Lagos|Africa/Porto-Novo","Africa/Maputo|Africa/Blantyre","Africa/Maputo|Africa/Bujumbura","Africa/Maputo|Africa/Gaborone","Africa/Maputo|Africa/Harare","Africa/Maputo|Africa/Kigali","Africa/Maputo|Africa/Lubumbashi","Africa/Maputo|Africa/Lusaka","Africa/Nairobi|Africa/Addis_Ababa","Africa/Nairobi|Africa/Asmara","Africa/Nairobi|Africa/Asmera","Africa/Nairobi|Africa/Dar_es_Salaam","Africa/Nairobi|Africa/Djibouti","Africa/Nairobi|Africa/Juba","Africa/Nairobi|Africa/Kampala","Africa/Nairobi|Africa/Mogadishu","Africa/Nairobi|Indian/Antananarivo","Africa/Nairobi|Indian/Comoro","Africa/Nairobi|Indian/Mayotte","Africa/Tripoli|Libya","America/Adak|America/Atka","America/Adak|US/Aleutian","America/Anchorage|America/Juneau","America/Anchorage|America/Nome","America/Anchorage|America/Sitka","America/Anchorage|America/Yakutat","America/Anchorage|US/Alaska","America/Campo_Grande|America/Cuiaba","America/Chicago|America/Indiana/Knox","America/Chicago|America/Indiana/Tell_City","America/Chicago|America/Knox_IN","America/Chicago|America/Matamoros","America/Chicago|America/Menominee","America/Chicago|America/North_Dakota/Beulah","America/Chicago|America/North_Dakota/Center","America/Chicago|America/North_Dakota/New_Salem","America/Chicago|America/Rainy_River","America/Chicago|America/Rankin_Inlet","America/Chicago|America/Resolute","America/Chicago|America/Winnipeg","America/Chicago|CST6CDT","America/Chicago|Canada/Central","America/Chicago|US/Central","America/Chicago|US/Indiana-Starke","America/Chihuahua|America/Mazatlan","America/Chihuahua|Mexico/BajaSur","America/Denver|America/Boise","America/Denver|America/Cambridge_Bay","America/Denver|America/Edmonton","America/Denver|America/Inuvik","America/Denver|America/Ojinaga","America/Denver|America/Shiprock","America/Denver|America/Yellowknife","America/Denver|Canada/Mountain","America/Denver|MST7MDT","America/Denver|Navajo","America/Denver|US/Mountain","America/Fortaleza|America/Argentina/Buenos_Aires","America/Fortaleza|America/Argentina/Catamarca","America/Fortaleza|America/Argentina/ComodRivadavia","America/Fortaleza|America/Argentina/Cordoba","America/Fortaleza|America/Argentina/Jujuy","America/Fortaleza|America/Argentina/La_Rioja","America/Fortaleza|America/Argentina/Mendoza","America/Fortaleza|America/Argentina/Rio_Gallegos","America/Fortaleza|America/Argentina/Salta","America/Fortaleza|America/Argentina/San_Juan","America/Fortaleza|America/Argentina/San_Luis","America/Fortaleza|America/Argentina/Tucuman","America/Fortaleza|America/Argentina/Ushuaia","America/Fortaleza|America/Belem","America/Fortaleza|America/Buenos_Aires","America/Fortaleza|America/Catamarca","America/Fortaleza|America/Cayenne","America/Fortaleza|America/Cordoba","America/Fortaleza|America/Jujuy","America/Fortaleza|America/Maceio","America/Fortaleza|America/Mendoza","America/Fortaleza|America/Paramaribo","America/Fortaleza|America/Recife","America/Fortaleza|America/Rosario","America/Fortaleza|America/Santarem","America/Fortaleza|Antarctica/Rothera","America/Fortaleza|Atlantic/Stanley","America/Fortaleza|Etc/GMT+3","America/Halifax|America/Glace_Bay","America/Halifax|America/Goose_Bay","America/Halifax|America/Moncton","America/Halifax|America/Thule","America/Halifax|Atlantic/Bermuda","America/Halifax|Canada/Atlantic","America/Havana|Cuba","America/La_Paz|America/Boa_Vista","America/La_Paz|America/Guyana","America/La_Paz|America/Manaus","America/La_Paz|America/Porto_Velho","America/La_Paz|Brazil/West","America/La_Paz|Etc/GMT+4","America/Lima|America/Bogota","America/Lima|America/Guayaquil","America/Lima|Etc/GMT+5","America/Los_Angeles|America/Dawson","America/Los_Angeles|America/Ensenada","America/Los_Angeles|America/Santa_Isabel","America/Los_Angeles|America/Tijuana","America/Los_Angeles|America/Vancouver","America/Los_Angeles|America/Whitehorse","America/Los_Angeles|Canada/Pacific","America/Los_Angeles|Canada/Yukon","America/Los_Angeles|Mexico/BajaNorte","America/Los_Angeles|PST8PDT","America/Los_Angeles|US/Pacific","America/Los_Angeles|US/Pacific-New","America/Managua|America/Belize","America/Managua|America/Costa_Rica","America/Managua|America/El_Salvador","America/Managua|America/Guatemala","America/Managua|America/Regina","America/Managua|America/Swift_Current","America/Managua|America/Tegucigalpa","America/Managua|Canada/Saskatchewan","America/Mexico_City|America/Bahia_Banderas","America/Mexico_City|America/Merida","America/Mexico_City|America/Monterrey","America/Mexico_City|Mexico/General","America/New_York|America/Detroit","America/New_York|America/Fort_Wayne","America/New_York|America/Indiana/Indianapolis","America/New_York|America/Indiana/Marengo","America/New_York|America/Indiana/Petersburg","America/New_York|America/Indiana/Vevay","America/New_York|America/Indiana/Vincennes","America/New_York|America/Indiana/Winamac","America/New_York|America/Indianapolis","America/New_York|America/Iqaluit","America/New_York|America/Kentucky/Louisville","America/New_York|America/Kentucky/Monticello","America/New_York|America/Louisville","America/New_York|America/Montreal","America/New_York|America/Nassau","America/New_York|America/Nipigon","America/New_York|America/Pangnirtung","America/New_York|America/Thunder_Bay","America/New_York|America/Toronto","America/New_York|Canada/Eastern","America/New_York|EST5EDT","America/New_York|US/East-Indiana","America/New_York|US/Eastern","America/New_York|US/Michigan","America/Noronha|Atlantic/South_Georgia","America/Noronha|Brazil/DeNoronha","America/Noronha|Etc/GMT+2","America/Panama|America/Atikokan","America/Panama|America/Cayman","America/Panama|America/Coral_Harbour","America/Panama|America/Jamaica","America/Panama|EST","America/Panama|Jamaica","America/Phoenix|America/Creston","America/Phoenix|America/Dawson_Creek","America/Phoenix|America/Hermosillo","America/Phoenix|MST","America/Phoenix|US/Arizona","America/Rio_Branco|America/Eirunepe","America/Rio_Branco|America/Porto_Acre","America/Rio_Branco|Brazil/Acre","America/Santiago|Chile/Continental","America/Santo_Domingo|America/Anguilla","America/Santo_Domingo|America/Antigua","America/Santo_Domingo|America/Aruba","America/Santo_Domingo|America/Barbados","America/Santo_Domingo|America/Blanc-Sablon","America/Santo_Domingo|America/Curacao","America/Santo_Domingo|America/Dominica","America/Santo_Domingo|America/Grenada","America/Santo_Domingo|America/Guadeloupe","America/Santo_Domingo|America/Kralendijk","America/Santo_Domingo|America/Lower_Princes","America/Santo_Domingo|America/Marigot","America/Santo_Domingo|America/Martinique","America/Santo_Domingo|America/Montserrat","America/Santo_Domingo|America/Port_of_Spain","America/Santo_Domingo|America/Puerto_Rico","America/Santo_Domingo|America/St_Barthelemy","America/Santo_Domingo|America/St_Kitts","America/Santo_Domingo|America/St_Lucia","America/Santo_Domingo|America/St_Thomas","America/Santo_Domingo|America/St_Vincent","America/Santo_Domingo|America/Tortola","America/Santo_Domingo|America/Virgin","America/Sao_Paulo|Brazil/East","America/St_Johns|Canada/Newfoundland","Antarctica/Palmer|America/Punta_Arenas","Asia/Baghdad|Antarctica/Syowa","Asia/Baghdad|Asia/Aden","Asia/Baghdad|Asia/Bahrain","Asia/Baghdad|Asia/Kuwait","Asia/Baghdad|Asia/Qatar","Asia/Baghdad|Asia/Riyadh","Asia/Baghdad|Etc/GMT-3","Asia/Baghdad|Europe/Minsk","Asia/Bangkok|Asia/Ho_Chi_Minh","Asia/Bangkok|Asia/Novokuznetsk","Asia/Bangkok|Asia/Phnom_Penh","Asia/Bangkok|Asia/Saigon","Asia/Bangkok|Asia/Vientiane","Asia/Bangkok|Etc/GMT-7","Asia/Bangkok|Indian/Christmas","Asia/Dhaka|Antarctica/Vostok","Asia/Dhaka|Asia/Almaty","Asia/Dhaka|Asia/Bishkek","Asia/Dhaka|Asia/Dacca","Asia/Dhaka|Asia/Kashgar","Asia/Dhaka|Asia/Qostanay","Asia/Dhaka|Asia/Thimbu","Asia/Dhaka|Asia/Thimphu","Asia/Dhaka|Asia/Urumqi","Asia/Dhaka|Etc/GMT-6","Asia/Dhaka|Indian/Chagos","Asia/Dili|Etc/GMT-9","Asia/Dili|Pacific/Palau","Asia/Dubai|Asia/Muscat","Asia/Dubai|Asia/Tbilisi","Asia/Dubai|Asia/Yerevan","Asia/Dubai|Etc/GMT-4","Asia/Dubai|Europe/Samara","Asia/Dubai|Indian/Mahe","Asia/Dubai|Indian/Mauritius","Asia/Dubai|Indian/Reunion","Asia/Gaza|Asia/Hebron","Asia/Hong_Kong|Hongkong","Asia/Jakarta|Asia/Pontianak","Asia/Jerusalem|Asia/Tel_Aviv","Asia/Jerusalem|Israel","Asia/Kamchatka|Asia/Anadyr","Asia/Kamchatka|Etc/GMT-12","Asia/Kamchatka|Kwajalein","Asia/Kamchatka|Pacific/Funafuti","Asia/Kamchatka|Pacific/Kwajalein","Asia/Kamchatka|Pacific/Majuro","Asia/Kamchatka|Pacific/Nauru","Asia/Kamchatka|Pacific/Tarawa","Asia/Kamchatka|Pacific/Wake","Asia/Kamchatka|Pacific/Wallis","Asia/Kathmandu|Asia/Katmandu","Asia/Kolkata|Asia/Calcutta","Asia/Kuala_Lumpur|Asia/Brunei","Asia/Kuala_Lumpur|Asia/Kuching","Asia/Kuala_Lumpur|Asia/Singapore","Asia/Kuala_Lumpur|Etc/GMT-8","Asia/Kuala_Lumpur|Singapore","Asia/Makassar|Asia/Ujung_Pandang","Asia/Rangoon|Asia/Yangon","Asia/Rangoon|Indian/Cocos","Asia/Seoul|ROK","Asia/Shanghai|Asia/Chongqing","Asia/Shanghai|Asia/Chungking","Asia/Shanghai|Asia/Harbin","Asia/Shanghai|Asia/Macao","Asia/Shanghai|Asia/Macau","Asia/Shanghai|Asia/Taipei","Asia/Shanghai|PRC","Asia/Shanghai|ROC","Asia/Tashkent|Antarctica/Mawson","Asia/Tashkent|Asia/Aqtau","Asia/Tashkent|Asia/Aqtobe","Asia/Tashkent|Asia/Ashgabat","Asia/Tashkent|Asia/Ashkhabad","Asia/Tashkent|Asia/Atyrau","Asia/Tashkent|Asia/Dushanbe","Asia/Tashkent|Asia/Oral","Asia/Tashkent|Asia/Samarkand","Asia/Tashkent|Etc/GMT-5","Asia/Tashkent|Indian/Kerguelen","Asia/Tashkent|Indian/Maldives","Asia/Tehran|Iran","Asia/Tokyo|Japan","Asia/Ulaanbaatar|Asia/Choibalsan","Asia/Ulaanbaatar|Asia/Ulan_Bator","Asia/Vladivostok|Asia/Ust-Nera","Asia/Yakutsk|Asia/Khandyga","Atlantic/Azores|America/Scoresbysund","Atlantic/Cape_Verde|Etc/GMT+1","Australia/Adelaide|Australia/Broken_Hill","Australia/Adelaide|Australia/South","Australia/Adelaide|Australia/Yancowinna","Australia/Brisbane|Australia/Lindeman","Australia/Brisbane|Australia/Queensland","Australia/Darwin|Australia/North","Australia/Lord_Howe|Australia/LHI","Australia/Perth|Australia/West","Australia/Sydney|Australia/ACT","Australia/Sydney|Australia/Canberra","Australia/Sydney|Australia/Currie","Australia/Sydney|Australia/Hobart","Australia/Sydney|Australia/Melbourne","Australia/Sydney|Australia/NSW","Australia/Sydney|Australia/Tasmania","Australia/Sydney|Australia/Victoria","Etc/UTC|Etc/UCT","Etc/UTC|Etc/Universal","Etc/UTC|Etc/Zulu","Etc/UTC|UCT","Etc/UTC|UTC","Etc/UTC|Universal","Etc/UTC|Zulu","Europe/Athens|Asia/Nicosia","Europe/Athens|EET","Europe/Athens|Europe/Bucharest","Europe/Athens|Europe/Helsinki","Europe/Athens|Europe/Kiev","Europe/Athens|Europe/Mariehamn","Europe/Athens|Europe/Nicosia","Europe/Athens|Europe/Riga","Europe/Athens|Europe/Sofia","Europe/Athens|Europe/Tallinn","Europe/Athens|Europe/Uzhgorod","Europe/Athens|Europe/Vilnius","Europe/Athens|Europe/Zaporozhye","Europe/Chisinau|Europe/Tiraspol","Europe/Dublin|Eire","Europe/Istanbul|Asia/Istanbul","Europe/Istanbul|Turkey","Europe/Lisbon|Atlantic/Canary","Europe/Lisbon|Atlantic/Faeroe","Europe/Lisbon|Atlantic/Faroe","Europe/Lisbon|Atlantic/Madeira","Europe/Lisbon|Portugal","Europe/Lisbon|WET","Europe/London|Europe/Belfast","Europe/London|Europe/Guernsey","Europe/London|Europe/Isle_of_Man","Europe/London|Europe/Jersey","Europe/London|GB","Europe/London|GB-Eire","Europe/Moscow|W-SU","Europe/Paris|Africa/Ceuta","Europe/Paris|Arctic/Longyearbyen","Europe/Paris|Atlantic/Jan_Mayen","Europe/Paris|CET","Europe/Paris|Europe/Amsterdam","Europe/Paris|Europe/Andorra","Europe/Paris|Europe/Belgrade","Europe/Paris|Europe/Berlin","Europe/Paris|Europe/Bratislava","Europe/Paris|Europe/Brussels","Europe/Paris|Europe/Budapest","Europe/Paris|Europe/Busingen","Europe/Paris|Europe/Copenhagen","Europe/Paris|Europe/Gibraltar","Europe/Paris|Europe/Ljubljana","Europe/Paris|Europe/Luxembourg","Europe/Paris|Europe/Madrid","Europe/Paris|Europe/Malta","Europe/Paris|Europe/Monaco","Europe/Paris|Europe/Oslo","Europe/Paris|Europe/Podgorica","Europe/Paris|Europe/Prague","Europe/Paris|Europe/Rome","Europe/Paris|Europe/San_Marino","Europe/Paris|Europe/Sarajevo","Europe/Paris|Europe/Skopje","Europe/Paris|Europe/Stockholm","Europe/Paris|Europe/Tirane","Europe/Paris|Europe/Vaduz","Europe/Paris|Europe/Vatican","Europe/Paris|Europe/Vienna","Europe/Paris|Europe/Warsaw","Europe/Paris|Europe/Zagreb","Europe/Paris|Europe/Zurich","Europe/Paris|Poland","Europe/Ulyanovsk|Europe/Astrakhan","Pacific/Auckland|Antarctica/McMurdo","Pacific/Auckland|Antarctica/South_Pole","Pacific/Auckland|NZ","Pacific/Chatham|NZ-CHAT","Pacific/Easter|Chile/EasterIsland","Pacific/Fakaofo|Etc/GMT-13","Pacific/Fakaofo|Pacific/Enderbury","Pacific/Galapagos|Etc/GMT+6","Pacific/Gambier|Etc/GMT+9","Pacific/Guadalcanal|Antarctica/Macquarie","Pacific/Guadalcanal|Etc/GMT-11","Pacific/Guadalcanal|Pacific/Efate","Pacific/Guadalcanal|Pacific/Kosrae","Pacific/Guadalcanal|Pacific/Noumea","Pacific/Guadalcanal|Pacific/Pohnpei","Pacific/Guadalcanal|Pacific/Ponape","Pacific/Guam|Pacific/Saipan","Pacific/Honolulu|HST","Pacific/Honolulu|Pacific/Johnston","Pacific/Honolulu|US/Hawaii","Pacific/Kiritimati|Etc/GMT-14","Pacific/Niue|Etc/GMT+11","Pacific/Pago_Pago|Pacific/Midway","Pacific/Pago_Pago|Pacific/Samoa","Pacific/Pago_Pago|US/Samoa","Pacific/Pitcairn|Etc/GMT+8","Pacific/Port_Moresby|Antarctica/DumontDUrville","Pacific/Port_Moresby|Etc/GMT-10","Pacific/Port_Moresby|Pacific/Chuuk","Pacific/Port_Moresby|Pacific/Truk","Pacific/Port_Moresby|Pacific/Yap","Pacific/Tahiti|Etc/GMT+10","Pacific/Tahiti|Pacific/Rarotonga"]}),i},e.exports?e.exports=n(Ks):n(t.moment)}),Zs=function(e,t){var n=e.toString();function r(r){return function(e,t,n){return r+t+(n[0].toUpperCase()===n[0]?"A":"a")}}if((t=t||{}).preferredOrder=t.preferredOrder||Yu,(n=(n=(n=(n=(n=(n=(n=(n=(n=(n=(n=(n=(n=(n=(n=(n=(n=(n=(n=(n=(n=(n=(n=(n=(n=(n=(n=(n=(n=(n=(n=(n=(n=(n=n.replace(Lu,"x")).replace(Ru,"X")).replace(Pu,"[$1]")).replace(Qs,"dddd")).replace(eu,"ddd")).replace(tu,"dd")).replace(au,"Do")).replace(nu,"MMMM")).replace(ru,"MMM")).replace(iu,function(e,t,n,r,a,i){var o,s=1===Math.min(n.length,a.length,i.length),u=4===Math.max(n.length,a.length,i.length),c="string"==typeof e.preferredOrder?e.preferredOrder:e.preferredOrder[r];return n=parseInt(n,10),a=parseInt(a,10),i=parseInt(i,10),o=[n,a,i],c=c.toUpperCase(),31t.length?e:t},"");return 10"']/g,Ho=RegExp(qo.source),Fo=RegExp(Io.source),Bo=/<%-([\s\S]+?)%>/g,Go=/<%([\s\S]+?)%>/g,Uo=/<%=([\s\S]+?)%>/g,$o=/\.|\[(?:[^[\]]*|(["'])(?:(?!\1)[^\\]|\\.)*?\1)\]/,Vo=/^\w*$/,Jo=/[^.[\]]+|\[(?:(-?\d+(?:\.\d+)?)|(["'])((?:(?!\2)[^\\]|\\.)*?)\2)\]|(?=(?:\.|\[\])(?:\.|\[\]|$))/g,Ko=/[\\^$.*+?()[\]{}|]/g,Xo=RegExp(Ko.source),Zo=/^\s+|\s+$/g,Qo=/^\s+/,es=/\s+$/,ts=/\{(?:\n\/\* \[wrapped with .+\] \*\/)?\n?/,ns=/\{\n\/\* \[wrapped with (.+)\] \*/,rs=/,? & /,as=/[^\x00-\x2f\x3a-\x40\x5b-\x60\x7b-\x7f]+/g,is=/\\(\\)?/g,os=/\$\{([^\\}]*(?:\\.[^\\}]*)*)\}/g,ss=/\w*$/,us=/^[-+]0x[0-9a-f]+$/i,cs=/^0b[01]+$/i,ls=/^\[object .+?Constructor\]$/,fs=/^0o[0-7]+$/i,hs=/^(?:0|[1-9]\d*)$/,ds=/[\xc0-\xd6\xd8-\xf6\xf8-\xff\u0100-\u017f]/g,ps=/($^)/,ms=/['\n\r\u2028\u2029\\]/g,e="\\ud800-\\udfff",t="\\u0300-\\u036f\\ufe20-\\ufe2f\\u20d0-\\u20ff",n="\\u2700-\\u27bf",r="a-z\\xdf-\\xf6\\xf8-\\xff",a="A-Z\\xc0-\\xd6\\xd8-\\xde",i="\\ufe0e\\ufe0f",o="\\xac\\xb1\\xd7\\xf7\\x00-\\x2f\\x3a-\\x40\\x5b-\\x60\\x7b-\\xbf\\u2000-\\u206f \\t\\x0b\\f\\xa0\\ufeff\\n\\r\\u2028\\u2029\\u1680\\u180e\\u2000\\u2001\\u2002\\u2003\\u2004\\u2005\\u2006\\u2007\\u2008\\u2009\\u200a\\u202f\\u205f\\u3000",s="["+e+"]",u="["+o+"]",c="["+t+"]",l="\\d+",f="["+n+"]",h="["+r+"]",d="[^"+e+o+l+n+r+a+"]",p="\\ud83c[\\udffb-\\udfff]",m="[^"+e+"]",g="(?:\\ud83c[\\udde6-\\uddff]){2}",v="[\\ud800-\\udbff][\\udc00-\\udfff]",y="["+a+"]",_="(?:"+h+"|"+d+")",b="(?:"+y+"|"+d+")",w="(?:['’](?:d|ll|m|re|s|t|ve))?",A="(?:['’](?:D|LL|M|RE|S|T|VE))?",x="(?:"+c+"|"+p+")"+"?",k="["+i+"]?",E=k+x+("(?:\\u200d(?:"+[m,g,v].join("|")+")"+k+x+")*"),S="(?:"+[f,g,v].join("|")+")"+E,M="(?:"+[m+c+"?",c,g,v,s].join("|")+")",gs=RegExp("['’]","g"),vs=RegExp(c,"g"),T=RegExp(p+"(?="+p+")|"+M+E,"g"),ys=RegExp([y+"?"+h+"+"+w+"(?="+[u,y,"$"].join("|")+")",b+"+"+A+"(?="+[u,y+_,"$"].join("|")+")",y+"?"+_+"+"+w,y+"+"+A,"\\d*(?:1ST|2ND|3RD|(?![123])\\dTH)(?=\\b|[a-z_])","\\d*(?:1st|2nd|3rd|(?![123])\\dth)(?=\\b|[A-Z_])",l,S].join("|"),"g"),C=RegExp("[\\u200d"+e+t+i+"]"),_s=/[a-z][A-Z]|[A-Z]{2}[a-z]|[0-9][a-zA-Z]|[a-zA-Z][0-9]|[^a-zA-Z0-9 ]/,bs=["Array","Buffer","DataView","Date","Error","Float32Array","Float64Array","Function","Int8Array","Int16Array","Int32Array","Map","Math","Object","Promise","RegExp","Set","String","Symbol","TypeError","Uint8Array","Uint8ClampedArray","Uint16Array","Uint32Array","WeakMap","_","clearTimeout","isFinite","parseInt","setTimeout"],ws=-1,As={};As[To]=As[Co]=As[Do]=As[Oo]=As[jo]=As[No]=As[zo]=As[Po]=As[Lo]=!0,As[co]=As[lo]=As[So]=As[fo]=As[Mo]=As[ho]=As[po]=As[mo]=As[vo]=As[yo]=As[_o]=As[wo]=As[Ao]=As[xo]=As[Eo]=!1;var xs={};xs[co]=xs[lo]=xs[So]=xs[Mo]=xs[fo]=xs[ho]=xs[To]=xs[Co]=xs[Do]=xs[Oo]=xs[jo]=xs[vo]=xs[yo]=xs[_o]=xs[wo]=xs[Ao]=xs[xo]=xs[ko]=xs[No]=xs[zo]=xs[Po]=xs[Lo]=!0,xs[po]=xs[mo]=xs[Eo]=!1;var D={"\\":"\\","'":"'","\n":"n","\r":"r","\u2028":"u2028","\u2029":"u2029"},ks=parseFloat,Es=parseInt,O="object"==typeof er&&er&&er.Object===Object&&er,j="object"==typeof self&&self&&self.Object===Object&&self,Ss=O||j||Function("return this")(),N=q&&!q.nodeType&&q,z=N&&W&&!W.nodeType&&W,Ms=z&&z.exports===N,P=Ms&&O.process,L=function(){try{var e=z&&z.require&&z.require("util").types;return e||P&&P.binding&&P.binding("util")}catch(e){}}(),Ts=L&&L.isArrayBuffer,Cs=L&&L.isDate,Ds=L&&L.isMap,Os=L&&L.isRegExp,js=L&&L.isSet,Ns=L&&L.isTypedArray;function zs(e,t,n){switch(n.length){case 0:return e.call(t);case 1:return e.call(t,n[0]);case 2:return e.call(t,n[0],n[1]);case 3:return e.call(t,n[0],n[1],n[2])}return e.apply(t,n)}function Ps(e,t,n,r){for(var a=-1,i=null==e?0:e.length;++a":">",'"':""","'":"'"});function lu(e){return"\\"+D[e]}function fu(e){return C.test(e)}function hu(e){var n=-1,r=Array(e.size);return e.forEach(function(e,t){r[++n]=[t,e]}),r}function du(t,n){return function(e){return t(n(e))}}function pu(e,t){for(var n=-1,r=e.length,a=0,i=[];++n",""":'"',"'":"'"});var _u=function e(t){var n,T=(t=null==t?Ss:_u.defaults(Ss.Object(),t,_u.pick(Ss,bs))).Array,r=t.Date,a=t.Error,g=t.Function,i=t.Math,k=t.Object,v=t.RegExp,l=t.String,C=t.TypeError,o=T.prototype,s=g.prototype,f=k.prototype,u=t["__core-js_shared__"],c=s.toString,E=f.hasOwnProperty,h=0,d=(n=/[^.]+$/.exec(u&&u.keys&&u.keys.IE_PROTO||""))?"Symbol(src)_1."+n:"",p=f.toString,m=c.call(k),y=Ss._,_=v("^"+c.call(E).replace(Ko,"\\$&").replace(/hasOwnProperty|(function).*?(?=\\\()| for .+?(?=\\\])/g,"$1.*?")+"$"),b=Ms?t.Buffer:to,w=t.Symbol,A=t.Uint8Array,x=b?b.allocUnsafe:to,S=du(k.getPrototypeOf,k),M=k.create,D=f.propertyIsEnumerable,O=o.splice,j=w?w.isConcatSpreadable:to,N=w?w.iterator:to,z=w?w.toStringTag:to,P=function(){try{var e=qn(k,"defineProperty");return e({},"",{}),e}catch(e){}}(),L=t.clearTimeout!==Ss.clearTimeout&&t.clearTimeout,R=r&&r.now!==Ss.Date.now&&r.now,Y=t.setTimeout!==Ss.setTimeout&&t.setTimeout,W=i.ceil,q=i.floor,I=k.getOwnPropertySymbols,H=b?b.isBuffer:to,F=t.isFinite,B=o.join,G=du(k.keys,k),U=i.max,$=i.min,V=r.now,J=t.parseInt,K=i.random,X=o.reverse,Z=qn(t,"DataView"),Q=qn(t,"Map"),ee=qn(t,"Promise"),te=qn(t,"Set"),ne=qn(t,"WeakMap"),re=qn(k,"create"),ae=ne&&new ne,ie={},oe=pr(Z),se=pr(Q),ue=pr(ee),ce=pr(te),le=pr(ne),fe=w?w.prototype:to,he=fe?fe.valueOf:to,de=fe?fe.toString:to;function pe(e){if(Oa(e)&&!ba(e)&&!(e instanceof ye)){if(e instanceof ve)return e;if(E.call(e,"__wrapped__"))return mr(e)}return new ve(e)}var me=function(){function n(){}return function(e){if(!Da(e))return{};if(M)return M(e);n.prototype=e;var t=new n;return n.prototype=to,t}}();function ge(){}function ve(e,t){this.__wrapped__=e,this.__actions__=[],this.__chain__=!!t,this.__index__=0,this.__values__=to}function ye(e){this.__wrapped__=e,this.__actions__=[],this.__dir__=1,this.__filtered__=!1,this.__iteratees__=[],this.__takeCount__=so,this.__views__=[]}function _e(e){var t=-1,n=null==e?0:e.length;for(this.clear();++t>>0,t>>>=0;for(var i=T(a);++r>>1,o=e[i];null!==o&&!Ya(o)&&(n?o<=t:o>>0)?(e=Va(e))&&("string"==typeof t||null!=t&&!Pa(t))&&!(t=Rt(t))&&fu(e)?Jt(vu(e),0,n):e.split(t,n):[]},pe.spread=function(r,a){if("function"!=typeof r)throw new C(no);return a=null==a?0:U(Ba(a),0),kt(function(e){var t=e[a],n=Jt(e,0,a);return t&&Fs(n,t),zs(r,this,n)})},pe.tail=function(e){var t=null==e?0:e.length;return t?Ot(e,1,t):[]},pe.take=function(e,t,n){return e&&e.length?Ot(e,0,(t=n||t===to?1:Ba(t))<0?0:t):[]},pe.takeRight=function(e,t,n){var r=null==e?0:e.length;return r?Ot(e,(t=r-(t=n||t===to?1:Ba(t)))<0?0:t,r):[]},pe.takeRightWhile=function(e,t){return e&&e.length?It(e,Rn(t,3),!1,!0):[]},pe.takeWhile=function(e,t){return e&&e.length?It(e,Rn(t,3)):[]},pe.tap=function(e,t){return t(e),e},pe.throttle=function(e,t,n){var r=!0,a=!0;if("function"!=typeof e)throw new C(no);return Da(n)&&(r="leading"in n?!!n.leading:r,a="trailing"in n?!!n.trailing:a),sa(e,t,{leading:r,maxWait:t,trailing:a})},pe.thru=Fr,pe.toArray=Ha,pe.toPairs=pi,pe.toPairsIn=mi,pe.toPath=function(e){return ba(e)?Hs(e,dr):Ya(e)?[e]:rn(hr(Va(e)))},pe.toPlainObject=$a,pe.transform=function(e,r,a){var t=ba(e),n=t||ka(e)||Wa(e);if(r=Rn(r,4),null==a){var i=e&&e.constructor;a=n?t?new i:[]:Da(e)&&Ma(i)?me(S(e)):{}}return(n?Ls:Ve)(e,function(e,t,n){return r(a,e,t,n)}),a},pe.unary=function(e){return ra(e,1)},pe.union=Or,pe.unionBy=jr,pe.unionWith=Nr,pe.uniq=function(e){return e&&e.length?Yt(e):[]},pe.uniqBy=function(e,t){return e&&e.length?Yt(e,Rn(t,2)):[]},pe.uniqWith=function(e,t){return t="function"==typeof t?t:to,e&&e.length?Yt(e,to,t):[]},pe.unset=function(e,t){return null==e||Wt(e,t)},pe.unzip=zr,pe.unzipWith=Pr,pe.update=function(e,t,n){return null==e?e:qt(e,t,Ut(n))},pe.updateWith=function(e,t,n,r){return r="function"==typeof r?r:to,null==e?e:qt(e,t,Ut(n),r)},pe.values=gi,pe.valuesIn=function(e){return null==e?[]:au(e,ui(e))},pe.without=Lr,pe.words=Mi,pe.wrap=function(e,t){return da(Ut(t),e)},pe.xor=Rr,pe.xorBy=Yr,pe.xorWith=Wr,pe.zip=qr,pe.zipObject=function(e,t){return Bt(e||[],t||[],Ce)},pe.zipObjectDeep=function(e,t){return Bt(e||[],t||[],Mt)},pe.zipWith=Ir,pe.entries=pi,pe.entriesIn=mi,pe.extend=Ka,pe.extendWith=Xa,Ri(pe,pe),pe.add=$i,pe.attempt=Ti,pe.camelCase=vi,pe.capitalize=yi,pe.ceil=Vi,pe.clamp=function(e,t,n){return n===to&&(n=t,t=to),n!==to&&(n=(n=Ua(n))==n?n:0),t!==to&&(t=(t=Ua(t))==t?t:0),Pe(Ua(e),t,n)},pe.clone=function(e){return Le(e,4)},pe.cloneDeep=function(e){return Le(e,5)},pe.cloneDeepWith=function(e,t){return Le(e,5,t="function"==typeof t?t:to)},pe.cloneWith=function(e,t){return Le(e,4,t="function"==typeof t?t:to)},pe.conformsTo=function(e,t){return null==t||Re(e,t,si(t))},pe.deburr=_i,pe.defaultTo=function(e,t){return null==e||e!=e?t:e},pe.divide=Ji,pe.endsWith=function(e,t,n){e=Va(e),t=Rt(t);var r=e.length,a=n=n===to?r:Pe(Ba(n),0,r);return 0<=(n-=t.length)&&e.slice(n,a)==t},pe.eq=ga,pe.escape=function(e){return(e=Va(e))&&Fo.test(e)?e.replace(Io,cu):e},pe.escapeRegExp=function(e){return(e=Va(e))&&Xo.test(e)?e.replace(Ko,"\\$&"):e},pe.every=function(e,t,n){var r=ba(e)?Ys:He;return n&&Vn(e,t,n)&&(t=to),r(e,Rn(t,3))},pe.find=Ur,pe.findIndex=_r,pe.findKey=function(e,t){return $s(e,Rn(t,3),Ve)},pe.findLast=$r,pe.findLastIndex=br,pe.findLastKey=function(e,t){return $s(e,Rn(t,3),Je)},pe.floor=Ki,pe.forEach=Vr,pe.forEachRight=Jr,pe.forIn=function(e,t){return null==e?e:Ue(e,Rn(t,3),ui)},pe.forInRight=function(e,t){return null==e?e:$e(e,Rn(t,3),ui)},pe.forOwn=function(e,t){return e&&Ve(e,Rn(t,3))},pe.forOwnRight=function(e,t){return e&&Je(e,Rn(t,3))},pe.get=ni,pe.gt=va,pe.gte=ya,pe.has=function(e,t){return null!=e&&Bn(e,t,tt)},pe.hasIn=ri,pe.head=Ar,pe.identity=Ni,pe.includes=function(e,t,n,r){e=Aa(e)?e:gi(e),n=n&&!r?Ba(n):0;var a=e.length;return n<0&&(n=U(a+n,0)),Ra(e)?n<=a&&-1=$(a=t,i=n)&&r=this.__values__.length;return{done:e,value:e?to:this.__values__[this.__index__++]}},pe.prototype.plant=function(e){for(var t,n=this;n instanceof ge;){var r=mr(n);r.__index__=0,r.__values__=to,t?a.__wrapped__=r:t=r;var a=r;n=n.__wrapped__}return a.__wrapped__=e,t},pe.prototype.reverse=function(){var e=this.__wrapped__;if(e instanceof ye){var t=e;return this.__actions__.length&&(t=new ye(this)),(t=t.reverse()).__actions__.push({func:Fr,args:[Dr],thisArg:to}),new ve(t,this.__chain__)}return this.thru(Dr)},pe.prototype.toJSON=pe.prototype.valueOf=pe.prototype.value=function(){return Ht(this.__wrapped__,this.__actions__)},pe.prototype.first=pe.prototype.head,N&&(pe.prototype[N]=function(){return this}),pe}();z?((z.exports=_u)._=_u,N._=_u):Ss._=_u}).call(this)});function Gu(e){return e.reduce(function(e,t,n,r){return e+t})}function Uu(e){if(Bu.isArray(e))return e;if("string"==typeof e)return e.split("");throw Error("Parameter must be a string or array.")}var $u={jarowinkler:function(e,t,n){var r,a;e=Uu(e),t=Uu(t),a=e.length>t.length?(r=e,t):(r=t,e);var i,o,s,u,c=n||.7,l=Math.floor(Math.max(r.length/2-1,0)),f=[],h=[],d=0;for(i=0;ie[1]?[t,f[t]]:e},[null,0]),u=ja(s,2),h=u[0];if(0>1])<0;)e[n]=i,n=o;return e[n]=a},f=function(e,t,n){var r,a,i,o,s;for(null==n&&(n=h),a=e.length,i=e[s=t],r=2*t+1;rt[n])return 1}return r-a},E=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},I=function(e){var t,n;for(t=0,n=e.length;ts&&(u[n]=!0,delete t[n]);return this.isbjunk=function(e){return E(i,e)},this.isbpopular=function(e){return E(u,e)}},e.prototype.findLongestMatch=function(e,t,n,r){var a,i,o,s,u,c,l,f,h,d,p,m,g,v,y,_,b,w,A,x,k;for(a=(_=[this.a,this.b,this.b2j,this.isbjunk])[0],i=_[1],o=_[2],f=_[3],s=(b=[e,n,0])[0],u=b[1],c=b[2],d={},l=g=e;e<=t?g 0: ("+n+")");if(!(0<=r&&r<=1))throw new Error("cutoff must be in [0.0, 1.0]: ("+r+")");for(a=[],(i=new ee).setSeq2(e),s=0,c=t.length;s=r&&i.quickRatio()>=r&&i.ratio()>=r&&a.push([i.ratio(),o]);for(h=[],u=0,l=(a=d.nlargest(a,n,S)).length;uh&&y.quickRatio()>h&&y.ratio()>h&&(h=($=[y.ratio(),A,x])[0],d=$[1],p=$[2])):null===b&&(b=(U=[A,x])[0],w=U[1]);if(h<_){if(null===b){for(O=0,N=(V=this._plainReplace(e,t,n,r,a,i)).length;O([^|]|$)|»([^|]|$))","i"),Ec=new RegExp("(first|last|end)","i"),Sc=new RegExp("(prev|earl|old|new|<|«)","i");function Mc(e){var t=e.links,k=e.articleUrl,E=e.baseUrl,S=e.parsedUrl,M=e.$,n=e.previousUrls,T=void 0===n?[]:n;S=S||Mr.parse(k);var C=new RegExp("^".concat(E),"i"),D=0e.score?n:e},{score:-100});return 50<=c.score?c.href:null}},Cc=["og:url"];function Dc(e){return{url:e,domain:(t=e,Mr.parse(t).hostname)};var t}var Oc={extract:function(e){var t=e.$,n=e.url,r=e.metaCache,a=t("link[rel=canonical]");if(0!==a.length){var i=a.attr("href");if(i)return Dc(i)}var o=Xo(t,Cc,r);return Dc(o||n)}},jc={ellipse:"…",chars:[" ","-"],max:140,truncate:!0};var Nc=function(e,t,n){if("string"!=typeof e||0===e.length)return"";if(0===t)return"";for(var r in n=n||{},jc)null!==n[r]&&void 0!==n[r]||(n[r]=jc[r]);return n.max=t||n.max,function(e,t,n,r,a){if(e.length
");e.each(function(e,t){n.append(t)}),e=n}else e=u(p);return e.wrap(u("
")),e=m(e=e.parent()),Vu[t]&&Vu[t](e,pt({},s,{defaultCleaner:h})),d?e.children().toArray().map(function(e){return u.html(u(e))}):u.html(e)}();if(Ma(p)){var g=ja(p,3),v=g[0],y=g[1],_=g[2];r=m(u(v)).map(function(e,t){var n=u(t).attr(y).trim();return _?_(n):n})}else r=m(u(p)).map(function(e,t){return u(t).text().trim()});return r=Ma(r.toArray())&&d?r.toArray():r[0],h&&Vu[t]?Vu[t](r,pt({},s,c)):r}function Hc(t,n){var r={};return si(t).forEach(function(e){r[e]||(r[e]=Ic(pt({},n,{type:e,extractionOpts:t[e]})))}),r}function Fc(e){var t=e.type,n=e.extractor,r=e.fallback,a=void 0===r||r,i=Ic(pt({},e,{extractionOpts:n[t]}));return i||(a?Yc[t](e):null)}var Bc,Gc={extract:function(){var e=0

Page ").concat(l,"

").concat(d.content)}),n=d.next_page_url,e.next=3;break;case 16:return p=Yc.word_count({content:"
".concat(o.content,"
")}),e.abrupt("return",pt({},o,{total_pages:l,pages_rendered:l,word_count:p}));case 18:case"end":return e.stop()}},e,this)}))).apply(this,arguments)}return{parse:(Bc=Qn(S.mark(function e(t){var n,r,a,i,o,s,u,c,l,f,h,d,p,m,g,v,y,_,b,w,A,x,k,E=arguments;return S.wrap(function(e){for(;;)switch(e.prev=e.next){case 0:if(n=1 *:first-child { flex-grow: 1; } .magazine-card .info { height: 16px; margin: 0; } .magazine-card h3.title, .magazine-card p.snippet { overflow: hidden; display: -webkit-box; -webkit-box-orient: vertical; margin: 0 0 12px; } .magazine-card h3.title { font-size: 18px; line-height: 27px; font-weight: 600; -webkit-line-clamp: 2; } .magazine-card p.snippet { font-size: 14px; line-height: 21px; -webkit-line-clamp: 3; } .compact-card { height: 31px; display: flex; border-bottom: 1px solid var(--neutralQuaternaryAlt); font-size: 14px; line-height: 31px; padding: 0 9px; transition: box-shadow linear 0.08s, background-color linear 0.08s; } .compact-card:hover, .ms-Fabric--isFocusVisible .compact-card:focus { box-shadow: #0004 0 0 10px; background-color: var(--white); } .compact-card:active { box-shadow: #0000 0 0 10px; } .compact-card > * { margin: 0 3px; flex-shrink: 0; } .compact-card .info { display: flex; line-height: 31px; width: 140px; } .compact-card .info .name { flex-grow: 1; } .compact-card .info img, .compact-card .info .read-indicator, .compact-card .info .starred-indicator { margin: 7.5px 5px 7.5px 0; } .compact-card .data { flex-grow: 1; flex-shrink: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .compact-card .data .title { font-weight: 600; margin-right: 6px; } .compact-card .data .snippet { color: var(--neutralSecondaryAlt); } .compact-card .time { font-size: 12px; } ================================================ FILE: dist/styles/dark.css ================================================ @media (prefers-color-scheme: dark) { .ms-Button--commandBar.active .ms-Button-icon { color: #c7e0f4; } .btn-group .btn:hover, .ms-Nav-compositeLink:hover { background-color: #fff1; } .btn-group .btn:active, .ms-Nav-compositeLink:active { background-color: #fff2; } .settings .loading { background-color: #000a; } .default-card { box-shadow: #0006 0px 5px 20px; } .default-card:hover, .ms-Fabric--isFocusVisible .default-card:focus { box-shadow: #0008 0px 5px 40px; } .default-card div.bg { background-color: #000b; } .list-card:hover, .ms-Fabric--isFocusVisible .list-card:focus { box-shadow: #0006 0px 5px 15px; } .list-card:active { box-shadow: #0000 0px 5px 15px, inset #0006 0px 0px 15px; } .magazine-card:hover, .ms-Fabric--isFocusVisible .magazine-card:focus { box-shadow: #0006 0px 5px 20px; } .magazine-card:active { box-shadow: #0000 0px 5px 20px; } .compact-card:hover, .ms-Fabric--isFocusVisible .compact-card:focus { box-shadow: #0008 0 0 10px; } .compact-card:active { box-shadow: #0000 0 0 10px; } } ================================================ FILE: dist/styles/feeds.css ================================================ @keyframes slideUp20 { 0% { transform: translateY(20px); } 100% { transform: translateY(0); } } .article-wrapper { margin: 32px auto 0; width: 860px; height: calc(100% - 50px); background-color: var(--white); box-shadow: 0 6.4px 14.4px 0 rgba(0, 0, 0, 0.132), 0 1.2px 3.6px 0 rgba(0, 0, 0, 0.108); border-radius: 5px; overflow: hidden; animation-name: slideUp20; } .article-container .btn-group .btn { color: #fff; } .article-container .btn-group { position: absolute; top: calc(50% - 32px); } .article-container .btn-group.prev { left: calc(50% - 486px); } .article-container .btn-group.next { right: calc(50% - 486px); } .article { height: 100%; user-select: none; } .article webview, .article .error-prompt { width: 100%; height: calc(100% - 36px); border: none; color: var(--black); } .article webview.error { display: none; } .article i.ms-Icon { color: var(--neutralDarker); } .article .actions { color: var(--black); border-bottom: 1px solid var(--neutralQuaternaryAlt); } .article .actions .favicon, .article .actions .ms-Spinner { margin: 8px 8px 11px 0; } .article .actions .ms-Spinner { display: inline-block; vertical-align: middle; } .article .actions .source-name { line-height: 35px; user-select: none; max-width: 320px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: inline-block; } .article .actions .creator { color: var(--neutralSecondaryAlt); user-select: text; } .article .actions .creator::before { display: inline-block; content: "/"; margin: 0 6px; } .side-article-wrapper, .side-logo-wrapper { flex-grow: 1; padding-top: var(--navHeight); height: calc(100% - var(--navHeight)); background: var(--white); } .side-logo-wrapper { display: flex; justify-content: center; align-items: center; } .side-logo-wrapper > img { width: 120px; height: 120px; user-select: none; -webkit-user-drag: none; } .side-logo-wrapper > img.dark { display: none; } @media (prefers-color-scheme: dark) { .side-logo-wrapper > img.light { display: none; } .side-logo-wrapper > img.dark { display: inline; } } .side-article-wrapper .article { display: flex; flex-direction: column-reverse; } .side-article-wrapper .article .actions { border-bottom: none; } .side-article-wrapper .article > .ms-Stack { border-top: 1px solid var(--neutralQuaternaryAlt); } .list-feed-container:first-child::before, .side-article-wrapper::before { content: ""; display: block; width: 100%; border-bottom: 1px solid var(--neutralQuaternaryAlt); position: absolute; top: calc(var(--navHeight) - 1px); } .list-main { display: flex; flex-wrap: wrap; height: 100%; position: relative; margin-top: calc(-1 * var(--navHeight)); overflow: hidden; background: var(--white); } .list-feed-container { width: 350px; background-color: var(--neutralLighterAlt); height: 100%; position: relative; } .list-feed-container::after { content: ""; display: block; pointer-events: none; position: absolute; top: -10%; right: 0; width: 120%; height: 120%; box-shadow: inset 5px 0 25px #0004; } .list-feed { margin-top: var(--navHeight); height: calc(100% - var(--navHeight)); overflow: hidden scroll; position: relative; } .list-feed > div.load-more-wrapper, .magazine-feed > div.load-more-wrapper, .compact-feed > div.load-more-wrapper { text-align: center; padding: 16px 0; } .magazine-feed, .compact-feed { padding-top: 28px; height: calc(100% - 60px); overflow: hidden scroll; margin-top: var(--navHeight); } .magazine-feed .ms-List-page { display: flex; flex-direction: column; align-items: center; } .cards-feed-container { display: inline-flex; flex-wrap: wrap; justify-content: space-around; padding: 12px; height: calc(100% - 32px); overflow: hidden scroll; margin-top: var(--navHeight); width: 100%; box-sizing: border-box; } .cards-feed-container .ms-List-page { display: flex; justify-content: space-around; flex-wrap: wrap; } .cards-feed-container > div.load-more-wrapper, .flex-fix { text-align: center; } .cards-feed-container > div.load-more-wrapper { width: 100%; margin: 16px 0; } .flex-fix { min-width: 280px; } .cards-feed-container > .empty, .list-feed > .empty, .magazine-feed > .empty, .compact-feed > .empty { width: 100%; height: calc(100vh - 64px); display: flex; justify-content: space-around; align-items: center; color: var(--neutralSecondary); font-size: 14px; user-select: none; } ================================================ FILE: dist/styles/global.css ================================================ :root { --neutralLighterAltOpacity: #faf9f8cc; --neutralLighterAlt: #faf9f8; --neutralLighter: #f3f2f1; --neutralLight: #edebe9; --neutralQuaternaryAlt: #e1dfdd; --neutralQuaternary: #d2d0ce; --neutralTertiaryAlt: #c8c6c4; --neutralTertiary: #a19f9d; --neutralSecondaryAlt: #8a8886; --neutralSecondary: #605e5c; --neutralPrimaryAlt: #3b3a39; --neutralPrimary: #323130; --neutralDark: #201f1e; --neutralDarker: #161514; --black: #000; --white: #fff; --whiteConstant: #fff; --primary: #0078d4; --navHeight: 32px; --transition-timing: cubic-bezier(0.1, 0.9, 0.2, 1); --blur: saturate(150%) blur(20px); } @media (prefers-color-scheme: dark) { :root { --neutralLighterAltOpacity: #282828cc; --neutralLighterAlt: #282828; --neutralLighter: #313131; --neutralLight: #3f3f3f; --neutralQuaternaryAlt: #484848; --neutralQuaternary: #4f4f4f; --neutralTertiaryAlt: #6d6d6d; --neutralTertiary: #c8c8c8; --neutralSecondaryAlt: #d2d0ce; --neutralSecondary: #d0d0d0; --neutralPrimaryAlt: #dadada; --neutralPrimary: #ffffff; --neutralDark: #f4f4f4; --neutralDarker: #f4f4f4; --black: #f8f8f8; --white: #1f1f1f; --whiteConstant: #f8f8f8; } } body.darwin { --navHeight: 38px; } html, body { background-color: transparent; font-family: "Segoe UI", "Source Han Sans Regular", sans-serif; height: 100%; overflow: hidden; margin: 0; } body:lang(zh-CN) { font-family: "Segoe UI", "Source Han Sans SC Regular", "Microsoft YaHei", sans-serif; } body:lang(zh-TW) { font-family: "Segoe UI", "Source Han Sans TC Regular", "Microsoft JhengHei", sans-serif; } body:lang(ja) { font-family: "Segoe UI", "Source Han Sans JP Regular", "Yu Gothic UI", sans-serif; } body:lang(ko) { font-family: "Segoe UI", "Source Han Sans KR Regular", "Malgun Gothic", sans-serif; } body.win32, body.linux { background-color: var(--neutralLighterAlt); } #root { height: 100%; } .ms-Link { user-select: none; } .ms-ContextualMenu-link, .ms-Button, .ms-ContextualMenu-item button { cursor: default; font-size: 13px; user-select: none; } .ms-Nav-link, .ms-Nav-chevronButton { font-size: 12px; line-height: 32px; height: 32px; background-color: transparent; color: var(--neutralPrimary); } .ms-Button--primary.danger { background: #d13438; border-color: #d13438; } .ms-Button--primary.danger:hover { background: #ba2d32; border-color: #ba2d32; } .ms-Button--primary.danger:active { background: #a4262c; border-color: #a4262c; } .ms-Button--primary.danger.is-disabled { background: var(--neutralLighter); border-color: var(--neutralLighter); } .ms-Button--commandBar.active { background-color: var(--neutralLight); color: var(--neutralDark); } .ms-Button--commandBar.active .ms-Button-icon { color: #005a9e; } i.ms-Nav-chevron { line-height: 32px; height: 32px; } .ms-Nav-groupContent { margin-bottom: 24px; } .ms-ActivityItem-activityTypeIcon, .ms-ActivityItem-timeStamp { user-select: none; } .ms-Label, .ms-Spinner-label { user-select: none; } .ms-ActivityItem, .ms-ActivityItem-commentText { color: var(--neutralSecondary); } .ms-ActivityItem-timeStamp { color: var(--neutralSecondaryAlt); } .ms-MessageBar { user-select: none; margin-bottom: 8px; } .ms-MessageBar:not(.ms-MessageBar--warning) { background: var(--neutralLighter); color: var(--neutralPrimary); } .ms-MessageBar:not(.ms-MessageBar--warning) i[data-icon-name="Info"] { color: var(--neutralPrimary); } .ms-Callout-main { border-radius: 5px; } #root > nav { height: var(--navHeight); -webkit-app-region: drag; user-select: none; overflow: hidden; } #root > nav .btn, #root > nav span { z-index: 1; position: relative; } body.blur #root > nav { --black: var(--neutralSecondaryAlt); } nav .progress { position: fixed; top: 0; left: 0; z-index: 10; width: 100%; height: 2px; overflow: hidden; } .ms-ProgressIndicator-itemProgress { padding: 0; } .ms-ProgressIndicator-progressTrack { background: none; } #root > nav span.title { font-size: 12px; line-height: var(--navHeight); vertical-align: top; letter-spacing: 2px; margin: 0 4px; display: inline-block; max-width: 280px; white-space: nowrap; text-overflow: ellipsis; overflow: hidden; color: var(--black); } body.darwin #root > nav span.title { max-width: 220px; } .btn-group { display: inline-block; user-select: none; -webkit-app-region: none; } .btn-group .seperator { display: inline-block; width: var(--navHeight); font-size: 12px; color: #c8c6c4; text-align: center; vertical-align: middle; } body.darwin .btn-group .seperator { display: none; } .btn-group .seperator::before { content: "|"; } .btn-group .btn { display: inline-block; width: 48px; height: 32px; text-decoration: none; text-align: center; line-height: 32px; color: var(--black); font-size: 14px; vertical-align: top; } #root > nav .btn-group .btn, .menu .btn-group .btn { height: var(--navHeight); line-height: var(--navHeight); } body.darwin.not-fullscreen #root > nav .btn-group:first-of-type { margin-left: 72px; } #root > nav .btn-group .btn.system { position: relative; z-index: 10; } nav.hide-btns .btn-group .btn { display: none; } nav.hide-btns .btn-group .btn.system { display: inline-block; } nav.item-on .btn-group .btn.system { color: var(--whiteConstant); } .btn-group .btn:hover, .ms-Nav-compositeLink:hover { background-color: #0001; } .btn-group .btn:active, .ms-Nav-compositeLink:active { background-color: #0002; } .ms-Nav-compositeLink:hover .ms-Nav-link { background: none; } .btn-group .btn.disabled, .btn-group .btn.fetching { background-color: unset !important; color: var(--neutralSecondaryAlt); } .btn-group .btn.fetching { animation: rotating linear 1.5s infinite; } @keyframes rotating { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } .btn-group .btn.close:hover { background-color: #e81123; color: var(--whiteConstant) !important; } .btn-group .btn.close:active { background-color: #f1707a; color: var(--whiteConstant) !important; } .btn-group .btn.inline-block-wide { display: none; } body.darwin .btn-group .btn.system { display: none; } ================================================ FILE: dist/styles/main.css ================================================ .qr-container { height: 150px; padding: 7px; margin: 8px; background: #fff; } @keyframes fade { 0% { opacity: 0; } 100% { opacity: 1; } } .menu-container, .article-container { position: fixed; z-index: 5; left: 0; top: 0; width: 100%; height: 100%; } .article-container { backdrop-filter: var(--blur); animation-name: fade; background-color: #0008; } .menu-container, .article-container, .article-wrapper { animation-duration: 0.5s; animation-timing-function: var(--transition-timing); animation-fill-mode: both; } .menu-container { pointer-events: none; } .menu-container.show { pointer-events: unset; } .article-container { z-index: 6; } .menu-container .menu { position: absolute; left: 0; top: 0; width: 280px; height: 100%; background-color: var(--neutralLighterAltOpacity); backdrop-filter: var(--blur); box-shadow: 5px 0 25px #0004; transition: clip-path var(--transition-timing) 0.367s, opacity cubic-bezier(0, 0, 0.2, 1) 0.367s; clip-path: inset(0 100% 0 0); opacity: 0; } .menu-container.show .menu { clip-path: inset(0 -50px 0 0); opacity: 1; } body.blur .menu .btn-group { --black: var(--neutralSecondaryAlt); } body.darwin .menu .btn-group { display: flex; flex-direction: row-reverse; } .menu-container .menu .nav-wrapper { max-height: calc(100% - var(--navHeight)); overflow: hidden auto; } .menu-container .menu p.subs-header { font-size: 12px; color: var(--neutralSecondaryAlt); margin: 2px 8px; user-select: none; } .menu .link-stack { overflow: hidden; } .menu .link-text { margin-top: 0px; margin-right: 4px; margin-bottom: 0px; margin-left: 4px; text-align: left; text-overflow: ellipsis; overflow: hidden; flex-grow: 1; } .menu .unread-count { color: var(--neutralSecondary); } .settings-container { position: fixed; z-index: 7; left: 0; top: 0; width: 100%; height: 100%; background-color: var(--neutralLight); overflow: hidden; } .settings-container .settings { margin: 64px auto 0; width: 680px; height: calc(100% - 64px); background-color: var(--white); box-shadow: 0 6.4px 14.4px 0 rgba(0, 0, 0, 0.132), 0 1.2px 3.6px 0 rgba(0, 0, 0, 0.108); overflow: hidden; } div[role="toolbar"] { height: 100%; } div[role="tabpanel"] { height: calc(100% - 68px); padding: 12px 16px; overflow-y: auto; position: relative; } .settings .loading { position: absolute; left: 0; top: 0; width: 100%; height: 100%; background-color: #fffa; z-index: 8; } .settings .loading .ms-Spinner { margin-top: 180px; } .settings .loading .ms-Spinner:focus { outline: none; } .tab-body .ms-StackItem { margin-right: 6px; margin-bottom: 12px; } .tab-body .ms-StackItem:last-child { margin-right: 0; } .tab-body .ms-ChoiceFieldGroup { margin-bottom: 20px; } .tab-body .ms-CommandBar { padding: 0; } img.favicon { width: 16px; height: 16px; vertical-align: middle; user-select: none; } img.favicon.dropdown { margin-right: 8px; vertical-align: sub; } .ms-DetailsList-contentWrapper { max-height: 400px; overflow-x: hidden; overflow-y: auto; margin-bottom: 16px; } .settings-hint { user-select: none; line-height: 32px; font-size: 12px; color: var(--neutralSecondary); } .settings-hint.up { position: relative; top: -12px; line-height: unset; } .settings-about { margin: 72px 0; color: var(--black); } .settings-about > * { margin: 0 !important; -webkit-user-drag: none; } .settings-rules-icons i { color: var(--black); user-select: none; } .settings-rules-icons i:last-of-type { color: var(--neutralSecondary); } .login-form { width: 300px; } .login-form .ms-Label { width: 72px; } .main { margin-top: calc(-1 * var(--navHeight)); height: 100%; overflow: hidden; background-color: var(--neutralLighterAlt); } .main::before { content: ""; display: block; position: relative; top: var(--navHeight); left: 0; width: calc(100% - 16px); height: var(--navHeight); margin-bottom: calc(-1 * var(--navHeight)); background: linear-gradient(var(--neutralLighterAlt), #faf9f800); z-index: 1; } .article-search { z-index: 4; position: absolute; top: 0; left: 36px; width: 100%; max-width: calc(100% - 484px); margin: 4px 16px; border: none; -webkit-app-region: none; height: calc(var(--navHeight) - 4px); box-shadow: 0 1.6px 3.6px 0 rgba(0, 0, 0, 0.132), 0 0.3px 0.9px 0 rgba(0, 0, 0, 0.108); } body.darwin.not-fullscreen .article-search { left: 108px; max-width: calc(100% - 384px); } .list-main .article-search { max-width: 294px; margin: 4px 10px; } body.darwin .list-main .article-search { max-width: 314px; left: 0; top: var(--navHeight); margin: 0 10px; } .main, .list-main { transition: margin-left var(--transition-timing) 0.367s; margin-left: 0; } @media (min-width: 1440px) { #root > nav.menu-on { padding-left: 296px; } #root > nav.menu-on span.title, body.darwin #root > nav.menu-on span.title { max-width: 300px; } nav.menu-on .btn-group .btn { display: inline-block; } nav.item-on .btn-group .btn.system { color: var(--whiteConstant); } .menu-container { width: 280px; background: none; backdrop-filter: none; } .menu-container .menu { background-color: var(--neutralLight); box-shadow: none; } .menu-container.show .menu { clip-path: inset(0); } body.darwin .menu-container .menu { background: none; } body.darwin .menu-container .menu.item-on { background-color: var(--neutralLight); } .menu-container .menu::after { content: ""; display: block; pointer-events: none; position: absolute; top: -10%; right: 0; width: 120%; height: 120%; box-shadow: inset 5px 0 25px #0004; } .main.menu-on, .list-main.menu-on { margin-left: 280px; } .menu-on .article-search { left: 280px; max-width: calc(100% - 728px); } body.darwin .menu-on .article-search { left: 280px; max-width: calc(100% - 556px); } .list-main.menu-on .article-search { left: 0; width: 330px; } body.darwin .list-main.menu-on .article-search { left: 0; top: 4px; } nav.hide-btns .btn-group .btn, nav.menu-on .btn-group .btn.hide-wide, .menu .btn-group .btn.hide-wide { display: none; } .btn-group .btn.inline-block-wide { display: inline-block; } } ================================================ FILE: dist/styles/scroll.css ================================================ ::-webkit-scrollbar { width: 16px; } ::-webkit-scrollbar-thumb { border: 2px solid transparent; background-color: #0004; background-clip: padding-box; } ::-webkit-scrollbar-thumb:hover { background-color: #0006; } ::-webkit-scrollbar-thumb:active { background-color: #0008; } @media (prefers-color-scheme: dark) { ::-webkit-scrollbar-thumb { background-color: #fff4; } ::-webkit-scrollbar-thumb:hover { background-color: #fff6; } ::-webkit-scrollbar-thumb:active { background-color: #fff8; } } ================================================ FILE: docs/index.html ================================================ Fluent Reader
Fluent Reader

Modern desktop RSS reader. Open-source.

Fluent Reader is a local, cross-platform news aggregator with a fresh look. Bring all your favorite sources with you and read distraction-free.

Open & Organized.

Stay in sync with Inoreader, Feedbin, or services compatible with Fever or Google Reader API. Alternatively, import your sources from an OPML file and read locally. Easily organize sources with groups. Move between computers with full data backups.

Read fluently.

Enjoy your contents like never before with the built-in article view for RSS full text tailored to maximize focus. Source only comes with snippets? Configure to load full content with Mercury Parser, load webpage in the app, or open externally by default.

Search. Filter.

Find anything you want with the power of regular expressions. Search in both titles and full contents of articles. Mark articles as starred, hidden, or unread and filter as they arrive with custom rules based on regular expressions.

Privacy first.

All your data stays with you.

All cookies cleared upon exit.

XSS blocked in an isolated context.

No personal information collected, ever.

Behavior tracking limited.

Strict Content Security Policy enforced.

Proxy support with PAC.


■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■

■ ■ ■ ■ ■ ■

■ ■ ■ ■ ■ ■ ■ ■ ■

■ ■ ■ ■ ■ ■ ■

Oh, it also comes in black.

Full system-level dark mode support for both UI and reading.

================================================ FILE: docs/styles.css ================================================ html, body { background-color: #f3f2f1; font-family: "Segoe UI", "Microsoft YaHei", sans-serif; margin: 0; line-height: 1.5; width: 100%; } html { overflow-x: hidden; } a { color: #0078d4; text-decoration: none; } a:hover, a:active { color: #004578; text-decoration: underline; } .elevate { box-shadow: 0 6.4px 14.4px 0 rgba(0, 0, 0, 0.132), 0 1.2px 3.6px 0 rgba(0, 0, 0, 0.108); } .logo-container { height: 100vh; width: 100%; min-height: 540px; position: relative; } .logo-container img { height: 180px; width: 180px; position: fixed; left: calc(50% - 90px); top: calc(50% - 230px); } .logo-container header { text-align: center; display: block; width: 100%; font-size: 1.75em; font-weight: 500; position: fixed; left: 0; top: calc(50% - 40px); } .screenshot { display: block; margin: 0 auto; width: 90%; max-width: 1464px; overflow: hidden; } .screenshot > img { width: 100%; } .light-container { padding-bottom: 48px; background-color: #fff; position: relative; } .light-container .screenshot { margin: 0 auto -280px; position: relative; top: -280px; } .light-container h1, .dark-container h1 { width: 95%; max-width: 800px; margin: 48px auto 24px; font-weight: 500; text-align: center; } .light-container p, .dark-container p { width: 85%; max-width: 750px; margin: 24px auto; text-align: center; font-size: 1.375em; color: #323130; } .features-container { padding: 48px 0; margin: 0 auto; width: 95%; max-width: 950px; display: flex; flex-wrap: wrap; justify-content: space-around; position: relative; background-color: #f3f2f1; } .features-container > section { display: block; width: 45%; height: 560px; padding: 18px 36px; background-color: #fff; margin: 24px 0; overflow: hidden; position: relative; box-sizing: border-box; } .features-container > section > h3 { font-weight: 500; color: #605e5c; margin: 0 0 0.5em; } .features-container > section > h3 > span { color: #d2d0ce; background-color: #d2d0ce; user-select: none; } .features-container > section > img { position: absolute; right: 0; bottom: 0; max-width: 90%; } .features-container > section > img.center { left: auto; right: auto; } .dark-container { position: relative; background-color: #1f1f1f; color: #fff; padding: 72px 0; overflow: hidden; } .dark-container p { color: #d2d0ce; } .get-container { height: 100vh; width: 100%; min-height: 540px; display: flex; align-items: center; justify-content: flex-end; flex-direction: column; position: relative; } .stores { display: flex; flex-direction: row; justify-content: center; align-items: center; } .stores > a { display: inline-block; margin: 0 16px 16px; } .ms-get { width: 142px; height: 52px; } .mac-get { height: 52px; } .links { display: flex; flex-direction: row; justify-content: center; margin: calc(50vh - 210px) 0 48px; } .links > a { display: inline-block; margin: 0 8px; } @media (max-width: 780px) { html, body { font-size: 14px; } .logo-container img { height: 140px; width: 140px; left: calc(50% - 70px); top: calc(50% - 190px); } .screenshot { margin-left: 5vw; } .light-container .screenshot { width: 95vw; margin: 0 0 -25vw 5vw; position: relative; top: -25vw; } .screenshot > img { width: 150%; } .features-container > section { width: 95%; height: auto; padding-bottom: 80%; } .features-container > section:last-of-type { padding-bottom: 36px; } .stores { flex-direction: column; } .links { margin-top: calc(50vh - 270px); } } ================================================ FILE: electron-builder-mas.yml ================================================ appId: DevHYLiu.FluentReader buildVersion: 29 productName: Fluent Reader copyright: Copyright © 2020 Haoyuan Liu files: - "./dist/**/*" - "!./dist/fonts.vbs" - "!**/*.js.map" asarUnpack: - "./dist/fontlist" directories: output: "./bin/${platform}/${arch}/" mac: darkModeSupport: true target: - dmg category: public.app-category.news electronLanguages: - zh_CN - zh_TW - en - fr - es - de - tr - ja - sv - uk - it - nl - ko - ru - pt_BR - pt_PT - cs minimumSystemVersion: 10.15.0 mas: entitlements: build/entitlements.mas.plist entitlementsInherit: build/entitlements.mas.inherit.plist provisioningProfile: build/embedded.provisionprofile hardenedRuntime: false gatekeeperAssess: false asarUnpack: [] ================================================ FILE: electron-builder.yml ================================================ appId: me.hyliu.fluentreader productName: Fluent Reader copyright: Copyright © 2020 Haoyuan Liu files: - "./dist/**/*" - "!./dist/fontlist" - "!**/*.js.map" directories: output: "./bin/${platform}/${arch}/" mac: darkModeSupport: true target: - dmg category: public.app-category.news electronLanguages: - zh_CN - zh_TW - en - fr - es - de - tr - ja - sv - uk - it - nl - ko - ru - pt_BR - pt_PT - cs win: target: - nsis - zip appx: applicationId: FluentReader identityName: 25286HaoyuanLiu.FluentReader publisher: CN=FD70E7FA-E5AC-41C4-B9C4-6E8708A6616A backgroundColor: transparent languages: - zh-CN - zh-TW - en-US - fr-FR - es - de - tr - ja - sv - uk - it - nl - ko - ru - pt-BR - pt-PT - cs showNameOnTiles: true setBuildNumber: true nsis: oneClick: false perMachine: true allowToChangeInstallationDirectory: true deleteAppDataOnUninstall: true linux: target: - AppImage icon: build/icons category: Utility desktop: StartupWMClass: fluent-reader ================================================ FILE: package.json ================================================ { "name": "fluent-reader", "version": "1.1.4", "description": "Modern desktop RSS reader", "main": "./dist/electron.js", "scripts": { "build": "webpack --config ./webpack.config.js", "electron": "electron ./dist/electron.js", "start": "npm run build && npm run electron", "format": "prettier --write .", "package-win": "electron-builder -w appx:x64 && electron-builder -w appx:ia32 && electron-builder -w appx:arm64", "package-win-ci": "electron-builder -w --x64 -p never && electron-builder -w --ia32 -p never", "package-mac": "electron-builder --mac --x64", "package-mas": "bash build/resignAndPackage.sh", "package-linux": "electron-builder --linux --x64 -p never" }, "keywords": [], "author": "Haoyuan Liu", "license": "BSD-3-Clause", "repository": "github:yang991178/fluent-reader", "devDependencies": { "@fluentui/react": "^7.126.2", "@types/lovefield": "^2.1.3", "@types/nedb": "^1.8.9", "@types/react": "^16.9.35", "@types/react-dom": "^16.9.8", "@types/react-redux": "^7.1.9", "electron": "^34.3.0", "electron-builder": "^23.0.3", "electron-react-devtools": "^0.5.3", "electron-store": "^5.2.0", "electron-window-state": "^5.0.3", "font-list": "^1.4.2", "html-webpack-plugin": "^5.5.3", "js-md5": "^0.7.3", "lovefield": "^2.1.12", "nedb": "^1.8.0", "prettier": "2.3.2", "qrcode.react": "^1.0.0", "react": "^16.13.1", "react-dom": "^16.13.1", "react-intl-universal": "^2.2.5", "react-redux": "^7.2.0", "redux": "^4.0.5", "redux-devtools": "^3.5.0", "redux-thunk": "^2.3.0", "reselect": "^4.0.0", "rss-parser": "^3.13.0", "ts-loader": "^7.0.4", "typescript": "^5.8.2", "webpack": "^5.89.0", "webpack-cli": "^5.1.4" }, "dependencies": { "node-polyfill-webpack-plugin": "^2.0.1" } } ================================================ FILE: src/bridges/settings.ts ================================================ import { SourceGroup, ViewType, ThemeSettings, SearchEngines, ServiceConfigs, ViewConfigs, } from "../schema-types" import { ipcRenderer } from "electron" const settingsBridge = { saveGroups: (groups: SourceGroup[]) => { ipcRenderer.invoke("set-groups", groups) }, loadGroups: (): SourceGroup[] => { return ipcRenderer.sendSync("get-groups") }, getDefaultMenu: (): boolean => { return ipcRenderer.sendSync("get-menu") }, setDefaultMenu: (state: boolean) => { ipcRenderer.invoke("set-menu", state) }, getProxyStatus: (): boolean => { return ipcRenderer.sendSync("get-proxy-status") }, toggleProxyStatus: () => { ipcRenderer.send("toggle-proxy-status") }, getProxy: (): string => { return ipcRenderer.sendSync("get-proxy") }, setProxy: (address: string = null) => { ipcRenderer.invoke("set-proxy", address) }, getDefaultView: (): ViewType => { return ipcRenderer.sendSync("get-view") }, setDefaultView: (viewType: ViewType) => { ipcRenderer.invoke("set-view", viewType) }, getThemeSettings: (): ThemeSettings => { return ipcRenderer.sendSync("get-theme") }, setThemeSettings: (theme: ThemeSettings) => { ipcRenderer.invoke("set-theme", theme) }, shouldUseDarkColors: (): boolean => { return ipcRenderer.sendSync("get-theme-dark-color") }, addThemeUpdateListener: (callback: (shouldDark: boolean) => any) => { ipcRenderer.on("theme-updated", (_, shouldDark) => { callback(shouldDark) }) }, setLocaleSettings: (option: string) => { ipcRenderer.invoke("set-locale", option) }, getLocaleSettings: (): string => { return ipcRenderer.sendSync("get-locale-settings") }, getCurrentLocale: (): string => { return ipcRenderer.sendSync("get-locale") }, getFontSize: (): number => { return ipcRenderer.sendSync("get-font-size") }, setFontSize: (size: number) => { ipcRenderer.invoke("set-font-size", size) }, getFont: (): string => { return ipcRenderer.sendSync("get-font") }, setFont: (font: string) => { ipcRenderer.invoke("set-font", font) }, getFetchInterval: (): number => { return ipcRenderer.sendSync("get-fetch-interval") }, setFetchInterval: (interval: number) => { ipcRenderer.invoke("set-fetch-interval", interval) }, getSearchEngine: (): SearchEngines => { return ipcRenderer.sendSync("get-search-engine") }, setSearchEngine: (engine: SearchEngines) => { ipcRenderer.invoke("set-search-engine", engine) }, getServiceConfigs: (): ServiceConfigs => { return ipcRenderer.sendSync("get-service-configs") }, setServiceConfigs: (configs: ServiceConfigs) => { ipcRenderer.invoke("set-service-configs", configs) }, getFilterType: (): number => { return ipcRenderer.sendSync("get-filter-type") }, setFilterType: (filterType: number) => { ipcRenderer.invoke("set-filter-type", filterType) }, getViewConfigs: (view: ViewType): ViewConfigs => { return ipcRenderer.sendSync("get-view-configs", view) }, setViewConfigs: (view: ViewType, configs: ViewConfigs) => { ipcRenderer.invoke("set-view-configs", view, configs) }, getNeDBStatus: (): boolean => { return ipcRenderer.sendSync("get-nedb-status") }, setNeDBStatus: (flag: boolean) => { ipcRenderer.invoke("set-nedb-status", flag) }, getAll: () => { return ipcRenderer.sendSync("get-all-settings") as Object }, setAll: configs => { ipcRenderer.invoke("import-all-settings", configs) }, } declare global { interface Window { settings: typeof settingsBridge } } export default settingsBridge ================================================ FILE: src/bridges/utils.ts ================================================ import { ipcRenderer } from "electron" import { ImageCallbackTypes, TouchBarTexts, WindowStateListenerType, } from "../schema-types" import { IObjectWithKey } from "@fluentui/react" const utilsBridge = { platform: process.platform, getVersion: (): string => { return ipcRenderer.sendSync("get-version") }, openExternal: (url: string, background = false) => { ipcRenderer.invoke("open-external", url, background) }, showErrorBox: (title: string, content: string, copy?: string) => { ipcRenderer.invoke("show-error-box", title, content, copy) }, showMessageBox: async ( title: string, message: string, confirm: string, cancel: string, defaultCancel = false, type = "none" ) => { return (await ipcRenderer.invoke( "show-message-box", title, message, confirm, cancel, defaultCancel, type )) as boolean }, showSaveDialog: async (filters: Electron.FileFilter[], path: string) => { let result = (await ipcRenderer.invoke( "show-save-dialog", filters, path )) as boolean if (result) { return (result: string, errmsg: string) => { ipcRenderer.invoke("write-save-result", result, errmsg) } } else { return null } }, showOpenDialog: async (filters: Electron.FileFilter[]) => { return (await ipcRenderer.invoke("show-open-dialog", filters)) as string }, getCacheSize: async (): Promise => { return await ipcRenderer.invoke("get-cache") }, clearCache: async () => { await ipcRenderer.invoke("clear-cache") }, addMainContextListener: ( callback: (pos: [number, number], text: string) => any ) => { ipcRenderer.removeAllListeners("window-context-menu") ipcRenderer.on("window-context-menu", (_, pos, text) => { callback(pos, text) }) }, addWebviewContextListener: ( callback: (pos: [number, number], text: string, url: string) => any ) => { ipcRenderer.removeAllListeners("webview-context-menu") ipcRenderer.on("webview-context-menu", (_, pos, text, url) => { callback(pos, text, url) }) }, imageCallback: (type: ImageCallbackTypes) => { ipcRenderer.invoke("image-callback", type) }, addWebviewKeydownListener: (callback: (event: Electron.Input) => any) => { ipcRenderer.removeAllListeners("webview-keydown") ipcRenderer.on("webview-keydown", (_, input) => { callback(input) }) }, addWebviewErrorListener: (callback: (reason: string) => any) => { ipcRenderer.removeAllListeners("webview-error") ipcRenderer.on("webview-error", (_, reason) => { callback(reason) }) }, writeClipboard: (text: string) => { ipcRenderer.invoke("write-clipboard", text) }, closeWindow: () => { ipcRenderer.invoke("close-window") }, minimizeWindow: () => { ipcRenderer.invoke("minimize-window") }, maximizeWindow: () => { ipcRenderer.invoke("maximize-window") }, isMaximized: () => { return ipcRenderer.sendSync("is-maximized") as boolean }, isFullscreen: () => { return ipcRenderer.sendSync("is-fullscreen") as boolean }, isFocused: () => { return ipcRenderer.sendSync("is-focused") as boolean }, focus: () => { ipcRenderer.invoke("request-focus") }, requestAttention: () => { ipcRenderer.invoke("request-attention") }, addWindowStateListener: ( callback: (type: WindowStateListenerType, state: boolean) => any ) => { ipcRenderer.removeAllListeners("maximized") ipcRenderer.on("maximized", () => { callback(WindowStateListenerType.Maximized, true) }) ipcRenderer.removeAllListeners("unmaximized") ipcRenderer.on("unmaximized", () => { callback(WindowStateListenerType.Maximized, false) }) ipcRenderer.removeAllListeners("enter-fullscreen") ipcRenderer.on("enter-fullscreen", () => { callback(WindowStateListenerType.Fullscreen, true) }) ipcRenderer.removeAllListeners("leave-fullscreen") ipcRenderer.on("leave-fullscreen", () => { callback(WindowStateListenerType.Fullscreen, false) }) ipcRenderer.removeAllListeners("window-focus") ipcRenderer.on("window-focus", () => { callback(WindowStateListenerType.Focused, true) }) ipcRenderer.removeAllListeners("window-blur") ipcRenderer.on("window-blur", () => { callback(WindowStateListenerType.Focused, false) }) }, addTouchBarEventsListener: (callback: (IObjectWithKey) => any) => { ipcRenderer.removeAllListeners("touchbar-event") ipcRenderer.on("touchbar-event", (_, key: string) => { callback({ key: key }) }) }, initTouchBar: (texts: TouchBarTexts) => { ipcRenderer.invoke("touchbar-init", texts) }, destroyTouchBar: () => { ipcRenderer.invoke("touchbar-destroy") }, initFontList: (): Promise> => { return ipcRenderer.invoke("init-font-list") }, } declare global { interface Window { utils: typeof utilsBridge fontList: Array } } export default utilsBridge ================================================ FILE: src/components/article.tsx ================================================ import * as React from "react" import intl from "react-intl-universal" import { renderToString } from "react-dom/server" import { RSSItem } from "../scripts/models/item" import { Stack, CommandBarButton, IContextualMenuProps, FocusZone, ContextualMenuItemType, Spinner, Icon, Link, } from "@fluentui/react" import { RSSSource, SourceOpenTarget, SourceTextDirection, } from "../scripts/models/source" import { shareSubmenu } from "./context-menu" import { platformCtrl, decodeFetchResponse } from "../scripts/utils" const FONT_SIZE_OPTIONS = [12, 13, 14, 15, 16, 17, 18, 19, 20] type ArticleProps = { item: RSSItem source: RSSSource locale: string shortcuts: (item: RSSItem, e: KeyboardEvent) => void dismiss: () => void offsetItem: (offset: number) => void toggleHasRead: (item: RSSItem) => void toggleStarred: (item: RSSItem) => void toggleHidden: (item: RSSItem) => void textMenu: (position: [number, number], text: string, url: string) => void imageMenu: (position: [number, number]) => void dismissContextMenu: () => void updateSourceTextDirection: ( source: RSSSource, direction: SourceTextDirection ) => void } type ArticleState = { fontFamily: string fontSize: number loadWebpage: boolean loadFull: boolean fullContent: string loaded: boolean error: boolean errorDescription: string } class Article extends React.Component { webview: Electron.WebviewTag constructor(props: ArticleProps) { super(props) this.state = { fontFamily: window.settings.getFont(), fontSize: window.settings.getFontSize(), loadWebpage: props.source.openTarget === SourceOpenTarget.Webpage, loadFull: props.source.openTarget === SourceOpenTarget.FullContent, fullContent: "", loaded: false, error: false, errorDescription: "", } window.utils.addWebviewContextListener(this.contextMenuHandler) window.utils.addWebviewKeydownListener(this.keyDownHandler) window.utils.addWebviewErrorListener(this.webviewError) if (props.source.openTarget === SourceOpenTarget.FullContent) this.loadFull() } setFontSize = (size: number) => { window.settings.setFontSize(size) this.setState({ fontSize: size }) } setFont = (font: string) => { window.settings.setFont(font) this.setState({ fontFamily: font }) } fontSizeMenuProps = (): IContextualMenuProps => ({ items: FONT_SIZE_OPTIONS.map(size => ({ key: String(size), text: String(size), canCheck: true, checked: size === this.state.fontSize, onClick: () => this.setFontSize(size), })), }) fontFamilyMenuProps = (): IContextualMenuProps => ({ items: window.fontList.map((font, idx) => ({ key: String(idx), text: font === "" ? intl.get("default") : font, canCheck: true, checked: this.state.fontFamily === font, onClick: () => this.setFont(font), })), }) updateTextDirection = (direction: SourceTextDirection) => { this.props.updateSourceTextDirection(this.props.source, direction) } directionMenuProps = (): IContextualMenuProps => ({ items: [ { key: "LTR", text: intl.get("article.LTR"), iconProps: { iconName: "Forward" }, canCheck: true, checked: this.props.source.textDir === SourceTextDirection.LTR, onClick: () => this.updateTextDirection(SourceTextDirection.LTR), }, { key: "RTL", text: intl.get("article.RTL"), iconProps: { iconName: "Back" }, canCheck: true, checked: this.props.source.textDir === SourceTextDirection.RTL, onClick: () => this.updateTextDirection(SourceTextDirection.RTL), }, { key: "Vertical", text: intl.get("article.Vertical"), iconProps: { iconName: "Down" }, canCheck: true, checked: this.props.source.textDir === SourceTextDirection.Vertical, onClick: () => this.updateTextDirection(SourceTextDirection.Vertical), }, ], }) moreMenuProps = (): IContextualMenuProps => ({ items: [ { key: "openInBrowser", text: intl.get("openExternal"), iconProps: { iconName: "NavigateExternalInline" }, onClick: e => { window.utils.openExternal( this.props.item.link, platformCtrl(e) ) }, }, { key: "copyURL", text: intl.get("context.copyURL"), iconProps: { iconName: "Link" }, onClick: () => { window.utils.writeClipboard(this.props.item.link) }, }, { key: "toggleHidden", text: this.props.item.hidden ? intl.get("article.unhide") : intl.get("article.hide"), iconProps: { iconName: this.props.item.hidden ? "View" : "Hide3", }, onClick: () => { this.props.toggleHidden(this.props.item) }, }, { key: "fontMenu", text: intl.get("article.font"), iconProps: { iconName: "Font" }, disabled: this.state.loadWebpage, subMenuProps: this.fontFamilyMenuProps(), }, { key: "fontSizeMenu", text: intl.get("article.fontSize"), iconProps: { iconName: "FontSize" }, disabled: this.state.loadWebpage, subMenuProps: this.fontSizeMenuProps(), }, { key: "directionMenu", text: intl.get("article.textDir"), iconProps: { iconName: "ChangeEntitlements" }, disabled: this.state.loadWebpage, subMenuProps: this.directionMenuProps(), }, { key: "divider_1", itemType: ContextualMenuItemType.Divider, }, ...shareSubmenu(this.props.item), ], }) contextMenuHandler = (pos: [number, number], text: string, url: string) => { if (pos) { if (text || url) this.props.textMenu(pos, text, url) else this.props.imageMenu(pos) } else { this.props.dismissContextMenu() } } keyDownHandler = (input: Electron.Input) => { if (input.type === "keyDown") { switch (input.key) { case "Escape": this.props.dismiss() break case "ArrowLeft": case "ArrowRight": this.props.offsetItem(input.key === "ArrowLeft" ? -1 : 1) break case "l": case "L": this.toggleWebpage() break case "w": case "W": this.toggleFull() break case "H": case "h": if (!input.meta) this.props.toggleHidden(this.props.item) break default: const keyboardEvent = new KeyboardEvent("keydown", { code: input.code, key: input.key, shiftKey: input.shift, altKey: input.alt, ctrlKey: input.control, metaKey: input.meta, repeat: input.isAutoRepeat, bubbles: true, }) this.props.shortcuts(this.props.item, keyboardEvent) document.dispatchEvent(keyboardEvent) break } } } webviewLoaded = () => { this.setState({ loaded: true }) } webviewError = (reason: string) => { this.setState({ error: true, errorDescription: reason }) } webviewReload = () => { if (this.webview) { this.setState({ loaded: false, error: false }) this.webview.reload() } else if (this.state.loadFull) { this.loadFull() } } componentDidMount = () => { let webview = document.getElementById("article") as Electron.WebviewTag if (webview != this.webview) { this.webview = webview if (webview) { webview.focus() this.setState({ loaded: false, error: false }) webview.addEventListener("did-stop-loading", this.webviewLoaded) let card = document.querySelector( `#refocus div[data-iid="${this.props.item._id}"]` ) as HTMLElement // @ts-ignore if (card) card.scrollIntoViewIfNeeded() } } } componentDidUpdate = (prevProps: ArticleProps) => { if (prevProps.item._id != this.props.item._id) { this.setState({ loadWebpage: this.props.source.openTarget === SourceOpenTarget.Webpage, loadFull: this.props.source.openTarget === SourceOpenTarget.FullContent, }) if (this.props.source.openTarget === SourceOpenTarget.FullContent) this.loadFull() } this.componentDidMount() } componentWillUnmount = () => { let refocus = document.querySelector( `#refocus div[data-iid="${this.props.item._id}"]` ) as HTMLElement if (refocus) refocus.focus() } toggleWebpage = () => { if (this.state.loadWebpage) { this.setState({ loadWebpage: false }) } else if ( this.props.item.link.startsWith("https://") || this.props.item.link.startsWith("http://") ) { this.setState({ loadWebpage: true, loadFull: false }) } } toggleFull = () => { if (this.state.loadFull) { this.setState({ loadFull: false }) } else if ( this.props.item.link.startsWith("https://") || this.props.item.link.startsWith("http://") ) { this.setState({ loadFull: true, loadWebpage: false }) this.loadFull() } } loadFull = async () => { this.setState({ fullContent: "", loaded: false, error: false }) const link = this.props.item.link try { const result = await fetch(link) if (!result || !result.ok) throw new Error() const html = await decodeFetchResponse(result, true) if (link === this.props.item.link) { this.setState({ fullContent: html }) } } catch { if (link === this.props.item.link) { this.setState({ loaded: true, error: true, errorDescription: "MERCURY_PARSER_FAILURE", }) } } } articleView = () => { const a = encodeURIComponent( this.state.loadFull ? this.state.fullContent : this.props.item.content ) const h = encodeURIComponent( renderToString( <>

{this.props.item.title}

{this.props.item.date.toLocaleString( this.props.locale, { hour12: !this.props.locale.startsWith("zh") } )}

) ) return `article/article.html?a=${a}&h=${h}&f=${encodeURIComponent( this.state.fontFamily )}&s=${this.state.fontSize}&d=${this.props.source.textDir}&u=${ this.props.item.link }&m=${this.state.loadFull ? 1 : 0}` } render = () => ( {this.state.loaded ? ( this.props.source.iconurl && ( ) ) : ( )} {this.props.source.name} {this.props.item.creator && ( {this.props.item.creator} )} this.props.toggleHasRead(this.props.item) } /> this.props.toggleStarred(this.props.item) } /> {(!this.state.loadFull || this.state.fullContent) && ( )} {this.state.error && ( {intl.get("article.error")} {intl.get("article.reload")} {this.state.errorDescription} )} ) } export default Article ================================================ FILE: src/components/cards/card.tsx ================================================ import * as React from "react" import { RSSSource, SourceOpenTarget } from "../../scripts/models/source" import { RSSItem } from "../../scripts/models/item" import { platformCtrl } from "../../scripts/utils" import { FeedFilter } from "../../scripts/models/feed" import { ViewConfigs } from "../../schema-types" export namespace Card { export type Props = { feedId: string item: RSSItem source: RSSSource filter: FeedFilter selected?: boolean viewConfigs?: ViewConfigs shortcuts: (item: RSSItem, e: KeyboardEvent) => void markRead: (item: RSSItem) => void contextMenu: (feedId: string, item: RSSItem, e) => void showItem: (fid: string, item: RSSItem) => void } const openInBrowser = (props: Props, e: React.MouseEvent) => { props.markRead(props.item) window.utils.openExternal(props.item.link, platformCtrl(e)) } export const bindEventsToProps = (props: Props) => ({ onClick: (e: React.MouseEvent) => onClick(props, e), onMouseUp: (e: React.MouseEvent) => onMouseUp(props, e), onKeyDown: (e: React.KeyboardEvent) => onKeyDown(props, e), }) const onClick = (props: Props, e: React.MouseEvent) => { e.preventDefault() e.stopPropagation() switch (props.source.openTarget) { case SourceOpenTarget.External: { openInBrowser(props, e) break } default: { props.markRead(props.item) props.showItem(props.feedId, props.item) break } } } const onMouseUp = (props: Props, e: React.MouseEvent) => { e.preventDefault() e.stopPropagation() switch (e.button) { case 1: openInBrowser(props, e) break case 2: props.contextMenu(props.feedId, props.item, e) } } const onKeyDown = (props: Props, e: React.KeyboardEvent) => { props.shortcuts(props.item, e.nativeEvent) } } ================================================ FILE: src/components/cards/compact-card.tsx ================================================ import * as React from "react" import { Card } from "./card" import CardInfo from "./info" import Time from "../utils/time" import Highlights from "./highlights" import { SourceTextDirection } from "../../scripts/models/source" const className = (props: Card.Props) => { let cn = ["card", "compact-card"] if (props.item.hidden) cn.push("hidden") if (props.source.textDir === SourceTextDirection.RTL) cn.push("rtl") return cn.join(" ") } const CompactCard: React.FunctionComponent = props => (
) export default CompactCard ================================================ FILE: src/components/cards/default-card.tsx ================================================ import * as React from "react" import { Card } from "./card" import CardInfo from "./info" import Highlights from "./highlights" import { SourceTextDirection } from "../../scripts/models/source" const className = (props: Card.Props) => { let cn = ["card", "default-card"] if (props.item.snippet && props.item.thumb) cn.push("transform") if (props.item.hidden) cn.push("hidden") if (props.source.textDir === SourceTextDirection.RTL) cn.push("rtl") return cn.join(" ") } const DefaultCard: React.FunctionComponent = props => (
{props.item.thumb ? ( ) : null}
{props.item.thumb ? ( ) : null}

) export default DefaultCard ================================================ FILE: src/components/cards/highlights.tsx ================================================ import * as React from "react" import { validateRegex } from "../../scripts/utils" import { FeedFilter, FilterType } from "../../scripts/models/feed" import { SourceTextDirection } from "../../scripts/models/source" type HighlightsProps = { text: string filter: FeedFilter title?: boolean } const Highlights: React.FunctionComponent = props => { const spans: [string, boolean][] = new Array() const flags = props.filter.type & FilterType.CaseInsensitive ? "ig" : "g" let regex: RegExp if ( props.filter.search === "" || !(regex = validateRegex(props.filter.search, flags)) ) { if (props.title) spans.push([props.text, false]) else spans.push([props.text.substr(0, 325), false]) } else if (props.title) { let match: RegExpExecArray do { const startIndex = regex.lastIndex match = regex.exec(props.text) if (match) { if (startIndex != match.index) { spans.push([ props.text.substring(startIndex, match.index), false, ]) } spans.push([match[0], true]) } else { spans.push([props.text.substr(startIndex), false]) } } while (match && regex.lastIndex < props.text.length) } else { const match = regex.exec(props.text) if (match) { if (match.index != 0) { const startIndex = Math.max( match.index - 25, props.text.lastIndexOf(" ", Math.max(match.index - 10, 0)) ) spans.push([ props.text.substring(Math.max(0, startIndex), match.index), false, ]) } spans.push([match[0], true]) if (regex.lastIndex < props.text.length) { spans.push([props.text.substr(regex.lastIndex, 300), false]) } } else { spans.push([props.text.substr(0, 325), false]) } } return ( <> {spans.map(([text, flag]) => flag ? {text} : text )} ) } export default Highlights ================================================ FILE: src/components/cards/info.tsx ================================================ import * as React from "react" import Time from "../utils/time" import { RSSSource } from "../../scripts/models/source" import { RSSItem } from "../../scripts/models/item" type CardInfoProps = { source: RSSSource item: RSSItem hideTime?: boolean showCreator?: boolean } const CardInfo: React.FunctionComponent = props => (

{props.source.iconurl ? : null} {props.source.name} {props.showCreator && props.item.creator && ( {props.item.creator} )} {props.item.starred ? ( ) : null} {props.item.hasRead ? null : } {props.hideTime ? null :

) export default CardInfo ================================================ FILE: src/components/cards/list-card.tsx ================================================ import * as React from "react" import { Card } from "./card" import CardInfo from "./info" import Highlights from "./highlights" import { ViewConfigs } from "../../schema-types" import { SourceTextDirection } from "../../scripts/models/source" const className = (props: Card.Props) => { let cn = ["card", "list-card"] if (props.item.hidden) cn.push("hidden") if (props.selected) cn.push("selected") if (props.viewConfigs & ViewConfigs.FadeRead && props.item.hasRead) cn.push("read") if (props.source.textDir === SourceTextDirection.RTL) cn.push("rtl") return cn.join(" ") } const ListCard: React.FunctionComponent = props => (
{props.item.thumb && props.viewConfigs & ViewConfigs.ShowCover ? (
) : null}

{Boolean(props.viewConfigs & ViewConfigs.ShowSnippet) && (

)}
) export default ListCard ================================================ FILE: src/components/cards/magazine-card.tsx ================================================ import * as React from "react" import { Card } from "./card" import CardInfo from "./info" import Highlights from "./highlights" import { SourceTextDirection } from "../../scripts/models/source" const className = (props: Card.Props) => { let cn = ["card", "magazine-card"] if (props.item.hasRead) cn.push("read") if (props.item.hidden) cn.push("hidden") if (props.source.textDir === SourceTextDirection.RTL) cn.push("rtl") return cn.join(" ") } const MagazineCard: React.FunctionComponent = props => (
{props.item.thumb ? (
) : null}

) export default MagazineCard ================================================ FILE: src/components/context-menu.tsx ================================================ import * as React from "react" import intl from "react-intl-universal" import QRCode from "qrcode.react" import { cutText, webSearch, getSearchEngineName, platformCtrl, } from "../scripts/utils" import { ContextualMenu, IContextualMenuItem, ContextualMenuItemType, DirectionalHint, } from "office-ui-fabric-react/lib/ContextualMenu" import { closeContextMenu, ContextMenuType } from "../scripts/models/app" import { markAllRead, markRead, markUnread, RSSItem, toggleHidden, toggleStarred, } from "../scripts/models/item" import { ViewType, ImageCallbackTypes, ViewConfigs } from "../schema-types" import { FilterType } from "../scripts/models/feed" import { useAppDispatch, useAppSelector } from "../scripts/reducer" import { setViewConfigs, showItem, switchFilter, switchView, toggleFilter, } from "../scripts/models/page" export const shareSubmenu = (item: RSSItem): IContextualMenuItem[] => [ { key: "qr", url: item.link, onRender: renderShareQR }, ] export const renderShareQR = (item: IContextualMenuItem) => (
) function getSearchItem(text: string): IContextualMenuItem { const engine = window.settings.getSearchEngine() return { key: "searchText", text: intl.get("context.search", { text: cutText(text, 15), engine: getSearchEngineName(engine), }), iconProps: { iconName: "Search" }, onClick: () => webSearch(text, engine), } } export function ContextMenu() { const { type } = useAppSelector(state => state.app.contextMenu) switch (type) { case ContextMenuType.Hidden: return null case ContextMenuType.Item: return case ContextMenuType.Text: return case ContextMenuType.Image: return case ContextMenuType.View: return case ContextMenuType.Group: return case ContextMenuType.MarkRead: return } } function ItemContextMenu() { const dispatch = useAppDispatch() const viewConfigs = useAppSelector(state => state.page.viewConfigs) const target = useAppSelector(state => state.app.contextMenu.target) const item = target[0] as RSSItem const feedId = target[1] as string const menuItems: IContextualMenuItem[] = [ { key: "showItem", text: intl.get("context.read"), iconProps: { iconName: "TextDocument" }, onClick: () => { dispatch(markRead(item)) dispatch(showItem(feedId, item)) }, }, { key: "openInBrowser", text: intl.get("openExternal"), iconProps: { iconName: "NavigateExternalInline" }, onClick: e => { dispatch(markRead(item)) window.utils.openExternal(item.link, platformCtrl(e)) }, }, { key: "markAsRead", text: item.hasRead ? intl.get("article.markUnread") : intl.get("article.markRead"), iconProps: item.hasRead ? { iconName: "RadioBtnOn", style: { fontSize: 14, textAlign: "center" }, } : { iconName: "StatusCircleRing" }, onClick: () => { if (item.hasRead) { dispatch(markUnread(item)) } else { dispatch(markRead(item)) } }, split: true, subMenuProps: { items: [ { key: "markBelow", text: intl.get("article.markBelow"), iconProps: { iconName: "Down", style: { fontSize: 14 }, }, onClick: () => { dispatch(markAllRead(null, item.date)) }, }, { key: "markAbove", text: intl.get("article.markAbove"), iconProps: { iconName: "Up", style: { fontSize: 14 }, }, onClick: () => { dispatch(markAllRead(null, item.date, false)) }, }, ], }, }, { key: "toggleStarred", text: item.starred ? intl.get("article.unstar") : intl.get("article.star"), iconProps: { iconName: item.starred ? "FavoriteStar" : "FavoriteStarFill", }, onClick: () => { dispatch(toggleStarred(item)) }, }, { key: "toggleHidden", text: item.hidden ? intl.get("article.unhide") : intl.get("article.hide"), iconProps: { iconName: item.hidden ? "View" : "Hide3", }, onClick: () => { dispatch(toggleHidden(item)) }, }, { key: "divider_1", itemType: ContextualMenuItemType.Divider, }, { key: "share", text: intl.get("context.share"), iconProps: { iconName: "Share" }, subMenuProps: { items: shareSubmenu(item), }, }, { key: "copyTitle", text: intl.get("context.copyTitle"), onClick: () => { window.utils.writeClipboard(item.title) }, }, { key: "copyURL", text: intl.get("context.copyURL"), onClick: () => { window.utils.writeClipboard(item.link) }, }, ...(viewConfigs !== undefined ? [ { key: "divider_2", itemType: ContextualMenuItemType.Divider, }, { key: "view", text: intl.get("context.view"), subMenuProps: { items: [ { key: "showCover", text: intl.get("context.showCover"), canCheck: true, checked: Boolean( viewConfigs & ViewConfigs.ShowCover ), onClick: () => dispatch( setViewConfigs( viewConfigs ^ ViewConfigs.ShowCover ) ), }, { key: "showSnippet", text: intl.get("context.showSnippet"), canCheck: true, checked: Boolean( viewConfigs & ViewConfigs.ShowSnippet ), onClick: () => dispatch( setViewConfigs( viewConfigs ^ ViewConfigs.ShowSnippet ) ), }, { key: "fadeRead", text: intl.get("context.fadeRead"), canCheck: true, checked: Boolean( viewConfigs & ViewConfigs.FadeRead ), onClick: () => dispatch( setViewConfigs( viewConfigs ^ ViewConfigs.FadeRead ) ), }, ], }, }, ] : []), ] return } function TextContextMenu() { const target = useAppSelector(state => state.app.contextMenu.target) as [ string, string ] const text = target[0] const url = target[1] const menuItems: IContextualMenuItem[] = text ? [ { key: "copyText", text: intl.get("context.copy"), iconProps: { iconName: "Copy" }, onClick: () => { window.utils.writeClipboard(text) }, }, getSearchItem(text), ] : [] if (url) { menuItems.push({ key: "urlSection", itemType: ContextualMenuItemType.Section, sectionProps: { topDivider: menuItems.length > 0, items: [ { key: "openInBrowser", text: intl.get("openExternal"), iconProps: { iconName: "NavigateExternalInline", }, onClick: e => { window.utils.openExternal(url, platformCtrl(e)) }, }, { key: "copyURL", text: intl.get("context.copyURL"), iconProps: { iconName: "Link" }, onClick: () => { window.utils.writeClipboard(url) }, }, ], }, }) } return } function ImageContextMenu() { const menuItems: IContextualMenuItem[] = [ { key: "openInBrowser", text: intl.get("openExternal"), iconProps: { iconName: "NavigateExternalInline" }, onClick: e => { if (platformCtrl(e)) { window.utils.imageCallback( ImageCallbackTypes.OpenExternalBg ) } else { window.utils.imageCallback(ImageCallbackTypes.OpenExternal) } }, }, { key: "saveImageAs", text: intl.get("context.saveImageAs"), iconProps: { iconName: "SaveTemplate" }, onClick: () => { window.utils.imageCallback(ImageCallbackTypes.SaveAs) }, }, { key: "copyImage", text: intl.get("context.copyImage"), iconProps: { iconName: "FileImage" }, onClick: () => { window.utils.imageCallback(ImageCallbackTypes.Copy) }, }, { key: "copyImageURL", text: intl.get("context.copyImageURL"), iconProps: { iconName: "Link" }, onClick: () => { window.utils.imageCallback(ImageCallbackTypes.CopyLink) }, }, ] return } function ViewContextMenu() { const dispatch = useAppDispatch() const viewType = useAppSelector(state => state.page.viewType) const filter = useAppSelector(state => state.page.filter.type) const menuItems: IContextualMenuItem[] = [ { key: "section_1", itemType: ContextualMenuItemType.Section, sectionProps: { title: intl.get("context.view"), bottomDivider: true, items: [ { key: "cardView", text: intl.get("context.cardView"), iconProps: { iconName: "GridViewMedium" }, canCheck: true, checked: viewType === ViewType.Cards, onClick: () => dispatch(switchView(ViewType.Cards)), }, { key: "listView", text: intl.get("context.listView"), iconProps: { iconName: "BacklogList" }, canCheck: true, checked: viewType === ViewType.List, onClick: () => dispatch(switchView(ViewType.List)), }, { key: "magazineView", text: intl.get("context.magazineView"), iconProps: { iconName: "Articles" }, canCheck: true, checked: viewType === ViewType.Magazine, onClick: () => dispatch(switchView(ViewType.Magazine)), }, { key: "compactView", text: intl.get("context.compactView"), iconProps: { iconName: "BulletedList" }, canCheck: true, checked: viewType === ViewType.Compact, onClick: () => dispatch(switchView(ViewType.Compact)), }, ], }, }, { key: "section_2", itemType: ContextualMenuItemType.Section, sectionProps: { title: intl.get("context.filter"), bottomDivider: true, items: [ { key: "allArticles", text: intl.get("allArticles"), iconProps: { iconName: "ClearFilter" }, canCheck: true, checked: (filter & ~FilterType.Toggles) == FilterType.Default, onClick: () => dispatch(switchFilter(FilterType.Default)), }, { key: "unreadOnly", text: intl.get("context.unreadOnly"), iconProps: { iconName: "RadioBtnOn", style: { fontSize: 14, textAlign: "center", }, }, canCheck: true, checked: (filter & ~FilterType.Toggles) == FilterType.UnreadOnly, onClick: () => dispatch(switchFilter(FilterType.UnreadOnly)), }, { key: "starredOnly", text: intl.get("context.starredOnly"), iconProps: { iconName: "FavoriteStarFill" }, canCheck: true, checked: (filter & ~FilterType.Toggles) == FilterType.StarredOnly, onClick: () => dispatch(switchFilter(FilterType.StarredOnly)), }, ], }, }, { key: "section_3", itemType: ContextualMenuItemType.Section, sectionProps: { title: intl.get("search"), bottomDivider: true, items: [ { key: "caseSensitive", text: intl.get("context.caseSensitive"), iconProps: { style: { fontSize: 12, fontStyle: "normal", }, children: "Aa", }, canCheck: true, checked: !(filter & FilterType.CaseInsensitive), onClick: () => dispatch(toggleFilter(FilterType.CaseInsensitive)), }, { key: "fullSearch", text: intl.get("context.fullSearch"), iconProps: { iconName: "Breadcrumb" }, canCheck: true, checked: Boolean(filter & FilterType.FullSearch), onClick: () => dispatch(toggleFilter(FilterType.FullSearch)), }, ], }, }, { key: "showHidden", text: intl.get("context.showHidden"), canCheck: true, checked: Boolean(filter & FilterType.ShowHidden), onClick: () => dispatch(toggleFilter(FilterType.ShowHidden)), }, ] return } function GroupContextMenu() { const dispatch = useAppDispatch() const sids = useAppSelector( state => state.app.contextMenu.target ) as number[] const menuItems: IContextualMenuItem[] = [ { key: "markAllRead", text: intl.get("nav.markAllRead"), iconProps: { iconName: "CheckMark" }, onClick: () => { dispatch(markAllRead(sids)) }, }, { key: "refresh", text: intl.get("nav.refresh"), iconProps: { iconName: "Sync" }, onClick: () => { dispatch(markAllRead(sids)) }, }, { key: "manage", text: intl.get("context.manageSources"), iconProps: { iconName: "Settings" }, onClick: () => { dispatch(markAllRead(sids)) }, }, ] return } function MarkReadContextMenu() { const dispatch = useAppDispatch() const menuItems: IContextualMenuItem[] = [ { key: "section_1", itemType: ContextualMenuItemType.Section, sectionProps: { title: intl.get("nav.markAllRead"), items: [ { key: "all", text: intl.get("allArticles"), iconProps: { iconName: "ReceiptCheck" }, onClick: () => { dispatch(markAllRead()) }, }, { key: "1d", text: intl.get("app.daysAgo", { days: 1 }), onClick: () => { let date = new Date() date.setTime(date.getTime() - 86400000) dispatch(markAllRead(null, date)) }, }, { key: "3d", text: intl.get("app.daysAgo", { days: 3 }), onClick: () => { let date = new Date() date.setTime(date.getTime() - 3 * 86400000) dispatch(markAllRead(null, date)) }, }, { key: "7d", text: intl.get("app.daysAgo", { days: 7 }), onClick: () => { let date = new Date() date.setTime(date.getTime() - 7 * 86400000) dispatch(markAllRead(null, date)) }, }, ], }, }, ] return } function ContextMenuBase({ menuItems, }: Readonly<{ menuItems: IContextualMenuItem[] }>) { const { event, position } = useAppSelector(state => state.app.contextMenu) const dispatch = useAppDispatch() return ( dispatch(closeContextMenu())} /> ) } ================================================ FILE: src/components/feeds/cards-feed.tsx ================================================ import * as React from "react" import intl from "react-intl-universal" import { FeedProps } from "./feed" import DefaultCard from "../cards/default-card" import { PrimaryButton, FocusZone } from "office-ui-fabric-react" import { RSSItem } from "../../scripts/models/item" import { List, AnimationClassNames } from "@fluentui/react" class CardsFeed extends React.Component { observer: ResizeObserver state = { width: window.innerWidth, height: window.innerHeight } updateWindowSize = (entries: ResizeObserverEntry[]) => { if (entries) { this.setState({ width: entries[0].contentRect.width - 40, height: window.innerHeight, }) } } componentDidMount() { this.setState({ width: document.querySelector(".main").clientWidth - 40, }) this.observer = new ResizeObserver(this.updateWindowSize) this.observer.observe(document.querySelector(".main")) } componentWillUnmount() { this.observer.disconnect() } getItemCountForPage = () => { let elemPerRow = Math.floor(this.state.width / 280) let rows = Math.ceil(this.state.height / 304) return elemPerRow * rows } getPageHeight = () => { return this.state.height + (304 - (this.state.height % 304)) } flexFixItems = () => { let elemPerRow = Math.floor(this.state.width / 280) let elemLastRow = this.props.items.length % elemPerRow let items = [...this.props.items] for (let i = 0; i < elemPerRow - elemLastRow; i += 1) items.push(null) return items } onRenderItem = (item: RSSItem, index: number) => item ? ( ) : (
) canFocusChild = (el: HTMLElement) => { if (el.id === "load-more") { const container = document.getElementById("refocus") const result = container.scrollTop > container.scrollHeight - 2 * container.offsetHeight if (!result) container.scrollTop += 100 return result } else { return true } } render() { return ( this.props.feed.loaded && ( {this.props.feed.loaded && !this.props.feed.allLoaded ? (
this.props.loadMore(this.props.feed) } />
) : null} {this.props.items.length === 0 && (
{intl.get("article.empty")}
)}
) ) } } export default CardsFeed ================================================ FILE: src/components/feeds/feed.tsx ================================================ import * as React from "react" import { RSSItem } from "../../scripts/models/item" import { FeedReduxProps } from "../../containers/feed-container" import { RSSFeed, FeedFilter } from "../../scripts/models/feed" import { ViewType, ViewConfigs } from "../../schema-types" import CardsFeed from "./cards-feed" import ListFeed from "./list-feed" export type FeedProps = FeedReduxProps & { feed: RSSFeed viewType: ViewType viewConfigs?: ViewConfigs items: RSSItem[] currentItem: number sourceMap: Object filter: FeedFilter shortcuts: (item: RSSItem, e: KeyboardEvent) => void markRead: (item: RSSItem) => void contextMenu: (feedId: string, item: RSSItem, e) => void loadMore: (feed: RSSFeed) => void showItem: (fid: string, item: RSSItem) => void } export class Feed extends React.Component { render() { switch (this.props.viewType) { case ViewType.Cards: return case ViewType.Magazine: case ViewType.Compact: case ViewType.List: return } } } ================================================ FILE: src/components/feeds/list-feed.tsx ================================================ import * as React from "react" import intl from "react-intl-universal" import { FeedProps } from "./feed" import { PrimaryButton, FocusZone, FocusZoneDirection, List, } from "office-ui-fabric-react" import { RSSItem } from "../../scripts/models/item" import { AnimationClassNames } from "@fluentui/react" import { ViewType } from "../../schema-types" import ListCard from "../cards/list-card" import MagazineCard from "../cards/magazine-card" import CompactCard from "../cards/compact-card" import { Card } from "../cards/card" class ListFeed extends React.Component { onRenderItem = (item: RSSItem) => { const props = { feedId: this.props.feed._id, key: item._id, item: item, source: this.props.sourceMap[item.source], filter: this.props.filter, viewConfigs: this.props.viewConfigs, shortcuts: this.props.shortcuts, markRead: this.props.markRead, contextMenu: this.props.contextMenu, showItem: this.props.showItem, } as Card.Props if ( this.props.viewType === ViewType.List && this.props.currentItem === item._id ) { props.selected = true } switch (this.props.viewType) { case ViewType.Magazine: return case ViewType.Compact: return default: return } } getClassName = () => { switch (this.props.viewType) { case ViewType.Magazine: return "magazine-feed" case ViewType.Compact: return "compact-feed" default: return "list-feed" } } canFocusChild = (el: HTMLElement) => { if (el.id === "load-more") { const container = document.getElementById("refocus") const result = container.scrollTop > container.scrollHeight - 2 * container.offsetHeight if (!result) container.scrollTop += 100 return result } else { return true } } render() { return ( this.props.feed.loaded && ( {this.props.feed.loaded && !this.props.feed.allLoaded ? (
this.props.loadMore(this.props.feed) } />
) : null} {this.props.items.length === 0 && (
{intl.get("article.empty")}
)}
) ) } } export default ListFeed ================================================ FILE: src/components/log-menu.tsx ================================================ import * as React from "react" import intl from "react-intl-universal" import { Callout, ActivityItem, Icon, DirectionalHint, Link, } from "@fluentui/react" import { AppLog, AppLogType, toggleLogMenu } from "../scripts/models/app" import Time from "./utils/time" import { useAppDispatch, useAppSelector } from "../scripts/reducer" import { showItemFromId } from "../scripts/models/page" function getLogIcon(log: AppLog) { switch (log.type) { case AppLogType.Info: return "Info" case AppLogType.Article: return "KnowledgeArticle" default: return "Warning" } } function LogMenu() { const dispatch = useAppDispatch() const { display, logs } = useAppSelector(state => state.app.logMenu) return ( display && ( dispatch(toggleLogMenu())}> {logs.length == 0 ? (

{intl.get("log.empty")}

) : ( logs .map((l, i) => ( { dispatch(toggleLogMenu()) dispatch( showItemFromId(l.iid) ) }}> {l.title} ) : ( {l.title} ) } comments={l.details} activityIcon={} timeStamp={
) ) } export default LogMenu ================================================ FILE: src/components/menu.tsx ================================================ import * as React from "react" import intl from "react-intl-universal" import { Icon } from "@fluentui/react/lib/Icon" import { Nav, INavLink, INavLinkGroup } from "office-ui-fabric-react/lib/Nav" import { SourceGroup } from "../schema-types" import { SourceState, RSSSource } from "../scripts/models/source" import { ALL } from "../scripts/models/feed" import { AnimationClassNames, Stack, FocusZone } from "@fluentui/react" export type MenuProps = { status: boolean display: boolean selected: string sources: SourceState groups: SourceGroup[] searchOn: boolean itemOn: boolean toggleMenu: () => void allArticles: (init?: boolean) => void selectSourceGroup: (group: SourceGroup, menuKey: string) => void selectSource: (source: RSSSource) => void groupContextMenu: (sids: number[], event: React.MouseEvent) => void updateGroupExpansion: ( event: React.MouseEvent, key: string, selected: string ) => void toggleSearch: () => void } export class Menu extends React.Component { countOverflow = (count: number) => (count >= 1000 ? " 999+" : ` ${count}`) getLinkGroups = (): INavLinkGroup[] => [ { links: [ { name: intl.get("search"), ariaLabel: intl.get("search") + (this.props.searchOn ? " ✓" : " "), key: "search", icon: "Search", onClick: this.props.toggleSearch, url: null, }, { name: intl.get("allArticles"), ariaLabel: intl.get("allArticles") + this.countOverflow( Object.values(this.props.sources) .filter(s => !s.hidden) .map(s => s.unreadCount) .reduce((a, b) => a + b, 0) ), key: ALL, icon: "TextDocument", onClick: () => this.props.allArticles(this.props.selected !== ALL), url: null, }, ], }, { name: intl.get("menu.subscriptions"), links: this.props.groups .filter(g => g.sids.length > 0) .map(g => { if (g.isMultiple) { let sources = g.sids.map(sid => this.props.sources[sid]) return { name: g.name, ariaLabel: g.name + this.countOverflow( sources .map(s => s.unreadCount) .reduce((a, b) => a + b, 0) ), key: "g-" + g.index, url: null, isExpanded: g.expanded, onClick: () => this.props.selectSourceGroup(g, "g-" + g.index), links: sources.map(this.getSource), } } else { return this.getSource(this.props.sources[g.sids[0]]) } }), }, ] getSource = (s: RSSSource): INavLink => ({ name: s.name, ariaLabel: s.name + this.countOverflow(s.unreadCount), key: "s-" + s.sid, onClick: () => this.props.selectSource(s), iconProps: s.iconurl ? this.getIconStyle(s.iconurl) : null, url: null, }) getIconStyle = (url: string) => ({ style: { width: 16 }, imageProps: { style: { width: "100%" }, src: url, }, }) onContext = (item: INavLink, event: React.MouseEvent) => { let sids: number[] let [type, index] = item.key.split("-") if (type === "s") { sids = [parseInt(index)] } else if (type === "g") { sids = this.props.groups[parseInt(index)].sids } else { return } this.props.groupContextMenu(sids, event) } _onRenderLink = (link: INavLink): JSX.Element => { let count = link.ariaLabel.split(" ").pop() return ( this.onContext(link, event)}>
{link.name}
{count && count !== "0" && (
{count}
)}
) } _onRenderGroupHeader = (group: INavLinkGroup): JSX.Element => { return (

{group.name}

) } render() { return ( this.props.status && (
e.stopPropagation()}>
) ) } } ================================================ FILE: src/components/nav.tsx ================================================ import * as React from "react" import { useState, useEffect, useCallback } from "react" import intl from "react-intl-universal" import { useSelector, useDispatch } from "react-redux" import { Icon } from "@fluentui/react/lib/Icon" import { ProgressIndicator, IObjectWithKey } from "@fluentui/react" import { RootState } from "../scripts/reducer" import { fetchItems } from "../scripts/models/item" import { toggleMenu, toggleLogMenu, toggleSettings, openViewMenu, openMarkAllMenu, } from "../scripts/models/app" import { toggleSearch } from "../scripts/models/page" import { ViewType , WindowStateListenerType } from "../schema-types" const Nav: React.FC = () => { const dispatch = useDispatch() const state = useSelector((state: RootState) => state.app) const itemShown = useSelector( (state: RootState) => state.page.itemId && state.page.viewType !== ViewType.List ) const [maximized, setMaximized] = useState(window.utils.isMaximized()) const setBodyFocusState = useCallback((focused: boolean) => { if (focused) document.body.classList.remove("blur") else document.body.classList.add("blur") }, []) const setBodyFullscreenState = useCallback((fullscreen: boolean) => { if (fullscreen) document.body.classList.remove("not-fullscreen") else document.body.classList.add("not-fullscreen") }, []) const windowStateListener = useCallback( (type: WindowStateListenerType, windowState: boolean) => { switch (type) { case WindowStateListenerType.Maximized: setMaximized(windowState) break case WindowStateListenerType.Fullscreen: setBodyFullscreenState(windowState) break case WindowStateListenerType.Focused: setBodyFocusState(windowState) break } }, [setBodyFocusState, setBodyFullscreenState] ) const canFetch = useCallback( () => state.sourceInit && state.feedInit && !state.syncing && !state.fetchingItems, [state.sourceInit, state.feedInit, state.syncing, state.fetchingItems] ) const fetch = useCallback(() => { if (canFetch()) dispatch(fetchItems()) }, [canFetch, dispatch]) const menu = useCallback(() => dispatch(toggleMenu()), [dispatch]) const logs = useCallback(() => dispatch(toggleLogMenu()), [dispatch]) const search = useCallback(() => dispatch(toggleSearch()), [dispatch]) const settings = useCallback(() => dispatch(toggleSettings()), [dispatch]) const markAll = useCallback(() => dispatch(openMarkAllMenu()), [dispatch]) const views = useCallback(() => { if (state.contextMenu.event !== "#view-toggle") { dispatch(openViewMenu()) } }, [state.contextMenu.event, dispatch]) const navShortcutsHandler = useCallback( (e: KeyboardEvent | IObjectWithKey) => { if (!state.settings.display) { switch (e.key) { case "F1": menu() break case "F2": search() break case "F5": fetch() break case "F6": markAll() break case "F7": if (!itemShown) logs() break case "F8": if (!itemShown) views() break case "F9": if (!itemShown) settings() break } } }, [state.settings.display, itemShown, menu, search, fetch, markAll, logs, views, settings] ) useEffect(() => { setBodyFocusState(window.utils.isFocused()) setBodyFullscreenState(window.utils.isFullscreen()) window.utils.addWindowStateListener(windowStateListener) return () => { // Cleanup will be handled by the event listener removal effect } }, [setBodyFocusState, setBodyFullscreenState, windowStateListener]) useEffect(() => { document.addEventListener("keydown", navShortcutsHandler) if (window.utils.platform === "darwin") window.utils.addTouchBarEventsListener(navShortcutsHandler) return () => { document.removeEventListener("keydown", navShortcutsHandler) } }, [navShortcutsHandler]) const minimize = () => { window.utils.minimizeWindow() } const maximize = () => { window.utils.maximizeWindow() setMaximized(!maximized) } const close = () => { window.utils.closeWindow() } const fetching = () => (!canFetch() ? " fetching" : "") const getClassNames = () => { const classNames = new Array() if (state.settings.display) classNames.push("hide-btns") if (state.menu) classNames.push("menu-on") if (itemShown) classNames.push("item-on") return classNames.join(" ") } const getProgress = () => { return state.fetchingTotal > 0 ? state.fetchingProgress / state.fetchingTotal : null } return ( ) } export default Nav ================================================ FILE: src/components/page.tsx ================================================ import * as React from "react" import { FeedContainer } from "../containers/feed-container" import { AnimationClassNames, Icon, FocusTrapZone } from "@fluentui/react" import ArticleContainer from "../containers/article-container" import { ViewType } from "../schema-types" import ArticleSearch from "./utils/article-search" type PageProps = { menuOn: boolean contextOn: boolean settingsOn: boolean feeds: string[] itemId: number itemFromFeed: boolean viewType: ViewType dismissItem: () => void offsetItem: (offset: number) => void } class Page extends React.Component { offsetItem = (event: React.MouseEvent, offset: number) => { event.stopPropagation() this.props.offsetItem(offset) } prevItem = (event: React.MouseEvent) => this.offsetItem(event, -1) nextItem = (event: React.MouseEvent) => this.offsetItem(event, 1) render = () => this.props.viewType !== ViewType.List ? ( <> {this.props.settingsOn ? null : (
{this.props.feeds.map(fid => ( ))}
)} {this.props.itemId && (
e.stopPropagation()}>
{this.props.itemFromFeed && ( <> )}
)} ) : ( <> {this.props.settingsOn ? null : (
{this.props.feeds.map(fid => ( ))}
{this.props.itemId ? (
) : (
)}
)} ) } export default Page ================================================ FILE: src/components/root.tsx ================================================ import * as React from "react" import { connect } from "react-redux" import { closeContextMenu } from "../scripts/models/app" import PageContainer from "../containers/page-container" import MenuContainer from "../containers/menu-container" import Nav from "./nav" import SettingsContainer from "../containers/settings-container" import { RootState } from "../scripts/reducer" import { ContextMenu } from "./context-menu" import LogMenu from "./log-menu" const Root = ({ locale, dispatch }) => locale && (
dispatch(closeContextMenu())}>
) const getLocale = (state: RootState) => ({ locale: state.app.locale }) export default connect(getLocale)(Root) ================================================ FILE: src/components/settings/about.tsx ================================================ import * as React from "react" import intl from "react-intl-universal" import { Stack, Link } from "@fluentui/react" class AboutTab extends React.Component { render = () => (

Fluent Reader

{intl.get("settings.version")} {window.utils.getVersion()}

Copyright © 2020 Haoyuan Liu. All rights reserved.

window.utils.openExternal( "https://github.com/yang991178/fluent-reader/wiki/Support#keyboard-shortcuts" ) }> {intl.get("settings.shortcuts")} window.utils.openExternal( "https://github.com/yang991178/fluent-reader" ) }> {intl.get("settings.openSource")} window.utils.openExternal( "https://github.com/yang991178/fluent-reader/issues" ) }> {intl.get("settings.feedback")}
) } export default AboutTab ================================================ FILE: src/components/settings/app.tsx ================================================ import * as React from "react" import intl from "react-intl-universal" import { urlTest, byteToMB, calculateItemSize, getSearchEngineName, } from "../../scripts/utils" import { ThemeSettings, SearchEngines } from "../../schema-types" import { getThemeSettings, setThemeSettings, exportAll, } from "../../scripts/settings" import { Stack, Label, Toggle, TextField, DefaultButton, ChoiceGroup, IChoiceGroupOption, Dropdown, IDropdownOption, PrimaryButton, } from "@fluentui/react" import DangerButton from "../utils/danger-button" type AppTabProps = { setLanguage: (option: string) => void setFetchInterval: (interval: number) => void deleteArticles: (days: number) => Promise importAll: () => Promise } type AppTabState = { pacStatus: boolean pacUrl: string themeSettings: ThemeSettings itemSize: string cacheSize: string deleteIndex: string } class AppTab extends React.Component { constructor(props) { super(props) this.state = { pacStatus: window.settings.getProxyStatus(), pacUrl: window.settings.getProxy(), themeSettings: getThemeSettings(), itemSize: null, cacheSize: null, deleteIndex: null, } this.getItemSize() this.getCacheSize() } getCacheSize = () => { window.utils.getCacheSize().then(size => { this.setState({ cacheSize: byteToMB(size) }) }) } getItemSize = () => { calculateItemSize().then(size => { this.setState({ itemSize: byteToMB(size) }) }) } clearCache = () => { window.utils.clearCache().then(() => { this.getCacheSize() }) } themeChoices = (): IChoiceGroupOption[] => [ { key: ThemeSettings.Default, text: intl.get("followSystem") }, { key: ThemeSettings.Light, text: intl.get("app.lightTheme") }, { key: ThemeSettings.Dark, text: intl.get("app.darkTheme") }, ] fetchIntervalOptions = (): IDropdownOption[] => [ { key: 0, text: intl.get("app.never") }, { key: 10, text: intl.get("time.minute", { m: 10 }) }, { key: 15, text: intl.get("time.minute", { m: 15 }) }, { key: 20, text: intl.get("time.minute", { m: 20 }) }, { key: 30, text: intl.get("time.minute", { m: 30 }) }, { key: 45, text: intl.get("time.minute", { m: 45 }) }, { key: 60, text: intl.get("time.hour", { h: 1 }) }, ] onFetchIntervalChanged = (item: IDropdownOption) => { this.props.setFetchInterval(item.key as number) } searchEngineOptions = (): IDropdownOption[] => [ SearchEngines.Google, SearchEngines.Bing, SearchEngines.Baidu, SearchEngines.DuckDuckGo, ].map(engine => ({ key: engine, text: getSearchEngineName(engine), })) onSearchEngineChanged = (item: IDropdownOption) => { window.settings.setSearchEngine(item.key as number) } deleteOptions = (): IDropdownOption[] => [ { key: "7", text: intl.get("app.daysAgo", { days: 7 }) }, { key: "14", text: intl.get("app.daysAgo", { days: 14 }) }, { key: "21", text: intl.get("app.daysAgo", { days: 21 }) }, { key: "28", text: intl.get("app.daysAgo", { days: 28 }) }, { key: "0", text: intl.get("app.deleteAll") }, ] deleteChange = (_, item: IDropdownOption) => { this.setState({ deleteIndex: item ? String(item.key) : null }) } confirmDelete = () => { this.setState({ itemSize: null }) this.props .deleteArticles(parseInt(this.state.deleteIndex)) .then(() => this.getItemSize()) } languageOptions = (): IDropdownOption[] => [ { key: "default", text: intl.get("followSystem") }, { key: "de", text: "Deutsch" }, { key: "en-US", text: "English" }, { key: "es", text: "Español" }, { key: "cs", text: "Čeština" }, { key: "fr-FR", text: "Français" }, { key: "it", text: "Italiano" }, { key: "nl", text: "Nederlands" }, { key: "pt-BR", text: "Português do Brasil" }, { key: "pt-PT", text: "Português de Portugal" }, { key: "fi-FI", text: "Suomi" }, { key: "sv", text: "Svenska" }, { key: "tr", text: "Türkçe" }, { key: "uk", text: "Українська" }, { key: "ru", text: "Русский" }, { key: "ko", text: "한글" }, { key: "ja", text: "日本語" }, { key: "zh-CN", text: "中文(简体)" }, { key: "zh-TW", text: "中文(繁體)" }, ] toggleStatus = () => { window.settings.toggleProxyStatus() this.setState({ pacStatus: window.settings.getProxyStatus(), pacUrl: window.settings.getProxy(), }) } handleInputChange = event => { const name: string = event.target.name // @ts-ignore this.setState({ [name]: event.target.value.trim() }) } setUrl = (event: React.FormEvent) => { event.preventDefault() if (urlTest(this.state.pacUrl)) window.settings.setProxy(this.state.pacUrl) } onThemeChange = (_, option: IChoiceGroupOption) => { setThemeSettings(option.key as ThemeSettings) this.setState({ themeSettings: option.key as ThemeSettings }) } render = () => (
this.props.setLanguage(String(option.key)) } style={{ width: 200 }} /> {this.state.pacStatus && (
urlTest(v.trim()) ? "" : intl.get("app.badUrl") } placeholder={intl.get("app.pac")} name="pacUrl" onChange={this.handleInputChange} value={this.state.pacUrl} /> {intl.get("app.pacHint")}
)} {this.state.itemSize ? intl.get("app.itemSize", { size: this.state.itemSize }) : intl.get("app.calculatingSize")} {this.state.cacheSize ? intl.get("app.cacheSize", { size: this.state.cacheSize }) : intl.get("app.calculatingSize")}
) } export default AppTab ================================================ FILE: src/components/settings/groups.tsx ================================================ import * as React from "react" import intl from "react-intl-universal" import { SourceGroup } from "../../schema-types" import { SourceState, RSSSource } from "../../scripts/models/source" import { IColumn, Selection, SelectionMode, DetailsList, Label, Stack, TextField, PrimaryButton, DefaultButton, Dropdown, IDropdownOption, CommandBarButton, MarqueeSelection, IDragDropEvents, MessageBar, MessageBarType, MessageBarButton, } from "@fluentui/react" import DangerButton from "../utils/danger-button" type GroupsTabProps = { sources: SourceState groups: SourceGroup[] serviceOn: boolean createGroup: (name: string) => void updateGroup: (group: SourceGroup) => void addToGroup: (groupIndex: number, sid: number) => void deleteGroup: (groupIndex: number) => void removeFromGroup: (groupIndex: number, sids: number[]) => void reorderGroups: (groups: SourceGroup[]) => void importGroups: () => Promise } type GroupsTabState = { [formName: string]: any selectedGroup: SourceGroup selectedSources: RSSSource[] dropdownIndex: number manageGroup: boolean } class GroupsTab extends React.Component { groupSelection: Selection groupDragDropEvents: IDragDropEvents groupDraggedItem: SourceGroup groupDraggedIndex = -1 sourcesSelection: Selection sourcesDragDropEvents: IDragDropEvents sourcesDraggedItem: RSSSource sourcesDraggedIndex = -1 constructor(props) { super(props) this.state = { editGroupName: "", newGroupName: "", selectedGroup: null, selectedSources: null, dropdownIndex: null, manageGroup: false, } this.groupDragDropEvents = this.getGroupDragDropEvents() this.sourcesDragDropEvents = this.getSourcesDragDropEvents() this.groupSelection = new Selection({ getKey: g => (g as SourceGroup).index, onSelectionChanged: () => { let g = this.groupSelection.getSelectedCount() ? (this.groupSelection.getSelection()[0] as SourceGroup) : null this.setState({ selectedGroup: g, editGroupName: g && g.isMultiple ? g.name : "", }) }, }) this.sourcesSelection = new Selection({ getKey: s => (s as RSSSource).sid, onSelectionChanged: () => { let sources = this.sourcesSelection.getSelectedCount() ? (this.sourcesSelection.getSelection() as RSSSource[]) : null this.setState({ selectedSources: sources, }) }, }) } groupColumns = (): IColumn[] => [ { key: "type", name: intl.get("groups.type"), minWidth: 40, maxWidth: 40, data: "string", onRender: (g: SourceGroup) => ( <> {g.isMultiple ? intl.get("groups.group") : intl.get("groups.source")} ), }, { key: "capacity", name: intl.get("groups.capacity"), minWidth: 40, maxWidth: 60, data: "string", onRender: (g: SourceGroup) => ( <>{g.isMultiple ? g.sids.length : ""} ), }, { key: "name", name: intl.get("name"), minWidth: 200, data: "string", isRowHeader: true, onRender: (g: SourceGroup) => ( <> {g.isMultiple ? g.name : this.props.sources[g.sids[0]].name} ), }, ] sourceColumns: IColumn[] = [ { key: "favicon", name: intl.get("icon"), fieldName: "name", isIconOnly: true, iconName: "ImagePixel", minWidth: 16, maxWidth: 16, onRender: (s: RSSSource) => s.iconurl && , }, { key: "name", name: intl.get("name"), fieldName: "name", minWidth: 200, data: "string", isRowHeader: true, }, { key: "url", name: "URL", fieldName: "url", minWidth: 280, data: "string", }, ] getGroupDragDropEvents = (): IDragDropEvents => ({ canDrop: () => true, canDrag: () => true, onDrop: (item?: SourceGroup) => { if (this.groupDraggedItem) { this.reorderGroups(item) } }, onDragStart: (item?: SourceGroup, itemIndex?: number) => { this.groupDraggedItem = item this.groupDraggedIndex = itemIndex! }, onDragEnd: () => { this.groupDraggedItem = undefined this.groupDraggedIndex = -1 }, }) reorderGroups = (item: SourceGroup) => { let draggedItem = this.groupSelection.isIndexSelected( this.groupDraggedIndex ) ? (this.groupSelection.getSelection()[0] as SourceGroup) : this.groupDraggedItem! let insertIndex = item.index let groups = this.props.groups.filter(g => g.index != draggedItem.index) groups.splice(insertIndex, 0, draggedItem) this.groupSelection.setAllSelected(false) this.props.reorderGroups(groups) } getSourcesDragDropEvents = (): IDragDropEvents => ({ canDrop: () => true, canDrag: () => true, onDrop: (item?: RSSSource) => { if (this.sourcesDraggedItem) { this.reorderSources(item) } }, onDragStart: (item?: RSSSource, itemIndex?: number) => { this.sourcesDraggedItem = item this.sourcesDraggedIndex = itemIndex! }, onDragEnd: () => { this.sourcesDraggedItem = undefined this.sourcesDraggedIndex = -1 }, }) reorderSources = (item: RSSSource) => { let draggedItems = this.sourcesSelection.isIndexSelected( this.sourcesDraggedIndex ) ? (this.sourcesSelection.getSelection() as RSSSource[]).map( s => s.sid ) : [this.sourcesDraggedItem!.sid] let insertIndex = this.state.selectedGroup.sids.indexOf(item.sid) let items = this.state.selectedGroup.sids.filter( sid => !draggedItems.includes(sid) ) items.splice(insertIndex, 0, ...draggedItems) let group = { ...this.state.selectedGroup, sids: items } this.props.updateGroup(group) this.setState({ selectedGroup: group }) } manageGroup = (g: SourceGroup) => { if (g.isMultiple) { this.setState({ selectedGroup: g, editGroupName: g && g.isMultiple ? g.name : "", manageGroup: true, }) } } dropdownOptions = () => this.props.groups .filter(g => g.isMultiple) .map(g => ({ key: g.index, text: g.name, })) handleInputChange = event => { const name: string = event.target.name this.setState({ [name]: event.target.value }) } validateNewGroupName = (v: string) => { const name = v.trim() if (name.length == 0) { return intl.get("emptyName") } for (let group of this.props.groups) { if (group.isMultiple && group.name === name) { return intl.get("groups.exist") } } return "" } createGroup = (event: React.FormEvent) => { event.preventDefault() let trimmed = this.state.newGroupName.trim() if (this.validateNewGroupName(trimmed) === "") this.props.createGroup(trimmed) } addToGroup = () => { this.props.addToGroup( this.state.dropdownIndex, this.state.selectedGroup.sids[0] ) } removeFromGroup = () => { this.props.removeFromGroup( this.state.selectedGroup.index, this.state.selectedSources.map(s => s.sid) ) this.setState({ selectedSources: null }) } deleteGroup = () => { this.props.deleteGroup(this.state.selectedGroup.index) this.groupSelection.setIndexSelected( this.state.selectedGroup.index, false, false ) this.setState({ selectedGroup: null }) } updateGroupName = () => { let group = this.state.selectedGroup group = { ...group, name: this.state.editGroupName.trim() } this.props.updateGroup(group) } dropdownChange = (_, item: IDropdownOption) => { this.setState({ dropdownIndex: item ? Number(item.key) : null }) } render = () => (
{this.state.manageGroup && this.state.selectedGroup && ( <> this.setState({ manageGroup: false }) } /> {this.state.selectedSources != null && ( )} this.props.sources[sid] )} columns={this.sourceColumns} dragDropEvents={this.sourcesDragDropEvents} setKey="multiple" selection={this.sourcesSelection} selectionMode={SelectionMode.multiple} /> {intl.get("groups.sourceHint")} )} {!this.state.manageGroup || !this.state.selectedGroup ? ( <> {this.props.serviceOn && ( }> {intl.get("service.groupsWarning")} )}
{this.state.selectedGroup ? ( this.state.selectedGroup.isMultiple ? ( <> v.trim().length == 0 ? intl.get("emptyName") : "" } validateOnLoad={false} placeholder={intl.get( "groups.enterName" )} value={this.state.editGroupName} name="editGroupName" onChange={this.handleInputChange} /> ) : ( <> ) ) : ( {intl.get("groups.groupHint")} )} ) : null}
) } export default GroupsTab ================================================ FILE: src/components/settings/rules.tsx ================================================ import * as React from "react" import intl from "react-intl-universal" import { SourceState, RSSSource } from "../../scripts/models/source" import { Stack, Label, Dropdown, IDropdownOption, TextField, PrimaryButton, Icon, DropdownMenuItemType, DefaultButton, DetailsList, IColumn, CommandBar, ICommandBarItemProps, Selection, SelectionMode, MarqueeSelection, IDragDropEvents, Link, IIconProps, } from "@fluentui/react" import { SourceRule, RuleActions } from "../../scripts/models/rule" import { FilterType } from "../../scripts/models/feed" import { MyParserItem, validateRegex } from "../../scripts/utils" import { RSSItem } from "../../scripts/models/item" const actionKeyMap = { "r-true": "article.markRead", "r-false": "article.markUnread", "s-true": "article.star", "s-false": "article.unstar", "h-true": "article.hide", "h-false": "article.unhide", "n-true": "article.notify", "n-false": "article.dontNotify", } type RulesTabProps = { sources: SourceState updateSourceRules: (source: RSSSource, rules: SourceRule[]) => void } type RulesTabState = { sid: string selectedRules: number[] editIndex: number regex: string searchType: number caseSensitive: boolean match: boolean actionKeys: string[] mockTitle: string mockCreator: string mockContent: string mockResult: string } class RulesTab extends React.Component { rulesSelection: Selection rulesDragDropEvents: IDragDropEvents rulesDraggedItem: SourceRule rulesDraggedIndex = -1 constructor(props) { super(props) this.state = { sid: null, selectedRules: [], editIndex: -1, regex: "", searchType: 0, caseSensitive: false, match: true, actionKeys: [], mockTitle: "", mockCreator: "", mockContent: "", mockResult: "", } this.rulesSelection = new Selection({ getKey: (_, i) => i, onSelectionChanged: () => { this.setState({ selectedRules: this.rulesSelection.getSelectedIndices(), }) }, }) this.rulesDragDropEvents = this.getRulesDragDropEvents() } getRulesDragDropEvents = (): IDragDropEvents => ({ canDrop: () => true, canDrag: () => true, onDrop: (item?: SourceRule) => { if (this.rulesDraggedItem) { this.reorderRules(item) } }, onDragStart: (item?: SourceRule, itemIndex?: number) => { this.rulesDraggedItem = item this.rulesDraggedIndex = itemIndex! }, onDragEnd: () => { this.rulesDraggedItem = undefined this.rulesDraggedIndex = -1 }, }) reorderRules = (item: SourceRule) => { let rules = this.getSourceRules() let draggedItems = this.rulesSelection.isIndexSelected( this.rulesDraggedIndex ) ? (this.rulesSelection.getSelection() as SourceRule[]) : [this.rulesDraggedItem] let insertIndex = rules.indexOf(item) let items = rules.filter(r => !draggedItems.includes(r)) items.splice(insertIndex, 0, ...draggedItems) this.rulesSelection.setAllSelected(false) let source = this.props.sources[parseInt(this.state.sid)] this.props.updateSourceRules(source, items) } initRuleEdit = (rule: SourceRule = null) => { let searchType = 0 if (rule) { if (rule.filter.type & FilterType.FullSearch) searchType = 1 else if (rule.filter.type & FilterType.CreatorSearch) searchType = 2 } this.setState({ regex: rule ? rule.filter.search : "", searchType: searchType, caseSensitive: rule ? !(rule.filter.type & FilterType.CaseInsensitive) : false, match: rule ? rule.match : true, actionKeys: rule ? RuleActions.toKeys(rule.actions) : [], }) } getSourceRules = () => this.props.sources[parseInt(this.state.sid)].rules ruleColumns = (): IColumn[] => [ { isRowHeader: true, key: "regex", name: intl.get("rules.regex"), minWidth: 100, maxWidth: 200, onRender: (rule: SourceRule) => rule.filter.search, }, { key: "actions", name: intl.get("rules.action"), minWidth: 100, onRender: (rule: SourceRule) => RuleActions.toKeys(rule.actions) .map(k => intl.get(actionKeyMap[k])) .join(", "), }, ] handleInputChange = event => { const name = event.target.name as "regex" this.setState({ [name]: event.target.value }) } sourceOptions = (): IDropdownOption[] => Object.entries(this.props.sources).map(([sid, s]) => ({ key: sid, text: s.name, data: { icon: s.iconurl }, })) onRenderSourceOption = (option: IDropdownOption) => (
{option.data && option.data.icon && ( )} {option.text}
) onRenderSourceTitle = (options: IDropdownOption[]) => { return this.onRenderSourceOption(options[0]) } onSourceOptionChange = (_, item: IDropdownOption) => { this.initRuleEdit() this.rulesSelection.setAllSelected(false) this.setState({ sid: item.key as string, selectedRules: [], editIndex: -1, mockTitle: "", mockCreator: "", mockContent: "", mockResult: "", }) } searchOptions = (): IDropdownOption[] => [ { key: 0, text: intl.get("rules.title") }, { key: 1, text: intl.get("rules.fullSearch") }, { key: 2, text: intl.get("rules.creator") }, ] onSearchOptionChange = (_, item: IDropdownOption) => { this.setState({ searchType: item.key as number }) } matchOptions = (): IDropdownOption[] => [ { key: 1, text: intl.get("rules.match") }, { key: 0, text: intl.get("rules.notMatch") }, ] onMatchOptionChange = (_, item: IDropdownOption) => { this.setState({ match: Boolean(item.key) }) } actionOptions = (): IDropdownOption[] => [ ...Object.entries(actionKeyMap).map(([k, t], i) => { if (k.includes("-false")) { return [ { key: k, text: intl.get(t) }, { key: i, text: "-", itemType: DropdownMenuItemType.Divider, }, ] } else { return [{ key: k, text: intl.get(t) }] } }), ].flat(1) onActionOptionChange = (_, item: IDropdownOption) => { if (item.selected) { this.setState(prevState => { let [a, f] = (item.key as string).split("-") let keys = prevState.actionKeys.filter( k => !k.startsWith(`${a}-`) ) keys.push(item.key as string) return { actionKeys: keys } }) } else { this.setState(prevState => ({ actionKeys: prevState.actionKeys.filter(k => k !== item.key), })) } } validateRegexField = (value: string) => { if (value.length === 0) return intl.get("emptyField") else if (validateRegex(value) === null) return intl.get("rules.badRegex") else return "" } saveRule = () => { let filterType = FilterType.Default | FilterType.ShowHidden if (!this.state.caseSensitive) filterType |= FilterType.CaseInsensitive if (this.state.searchType === 1) filterType |= FilterType.FullSearch else if (this.state.searchType === 2) filterType |= FilterType.CreatorSearch let rule = new SourceRule( this.state.regex, this.state.actionKeys, filterType, this.state.match ) let source = this.props.sources[parseInt(this.state.sid)] let rules = source.rules ? [...source.rules] : [] if (this.state.editIndex === -1) { rules.push(rule) } else { rules.splice(this.state.editIndex, 1, rule) } this.props.updateSourceRules(source, rules) this.setState({ editIndex: -1 }) this.initRuleEdit() } newRule = () => { this.initRuleEdit() this.setState({ editIndex: this.getSourceRules().length }) } editRule = (rule: SourceRule, index: number) => { this.initRuleEdit(rule) this.setState({ editIndex: index }) } deleteRules = () => { let rules = this.getSourceRules() for (let i of this.state.selectedRules) rules[i] = null let source = this.props.sources[parseInt(this.state.sid)] this.props.updateSourceRules( source, rules.filter(r => r !== null) ) this.initRuleEdit() } commandBarItems = (): ICommandBarItemProps[] => [ { key: "new", text: intl.get("rules.new"), iconProps: { iconName: "Add" }, onClick: this.newRule, }, ] commandBarFarItems = (): ICommandBarItemProps[] => { let items = [] if (this.state.selectedRules.length === 1) { let index = this.state.selectedRules[0] items.push({ key: "edit", text: intl.get("edit"), iconProps: { iconName: "Edit" }, onClick: () => this.editRule(this.getSourceRules()[index], index), }) } if (this.state.selectedRules.length > 0) { items.push({ key: "del", text: intl.get("delete"), iconProps: { iconName: "Delete", style: { color: "#d13438" } }, onClick: this.deleteRules, }) } return items } testMockItem = () => { let parsed = { title: this.state.mockTitle } let source = this.props.sources[parseInt(this.state.sid)] let item = new RSSItem(parsed as MyParserItem, source) item.snippet = this.state.mockContent item.creator = this.state.mockCreator SourceRule.applyAll(this.getSourceRules(), item) let result = [] result.push( intl.get(item.hasRead ? "article.markRead" : "article.markUnread") ) if (item.starred) result.push(intl.get("article.star")) if (item.hidden) result.push(intl.get("article.hide")) if (item.notify) result.push(intl.get("article.notify")) this.setState({ mockResult: result.join(", ") }) } toggleCaseSensitivity = () => { this.setState({ caseSensitive: !this.state.caseSensitive }) } regexCaseIconProps = (): IIconProps => ({ title: intl.get("context.caseSensitive"), children: "Aa", style: { fontSize: 12, fontStyle: "normal", cursor: "pointer", pointerEvents: "unset", color: this.state.caseSensitive ? "var(--black)" : "var(--neutralTertiary)", textDecoration: this.state.caseSensitive ? "underline" : "", }, onClick: this.toggleCaseSensitivity, }) render = () => (
{this.state.sid ? ( this.state.editIndex > -1 || !this.getSourceRules() || this.getSourceRules().length === 0 ? ( <> ( )} /> {this.state.editIndex > -1 && ( this.setState({ editIndex: -1 }) } /> )} ) : ( <> {intl.get("rules.hint")} {this.state.mockResult} ) ) : ( {intl.get("rules.intro")} window.utils.openExternal( "https://github.com/yang991178/fluent-reader/wiki/Support#rules" ) } style={{ marginLeft: 6 }}> {intl.get("rules.help")} )}
) } export default RulesTab ================================================ FILE: src/components/settings/service.tsx ================================================ import * as React from "react" import intl from "react-intl-universal" import { ServiceConfigs, SyncService } from "../../schema-types" import { Stack, Icon, Link, Dropdown, IDropdownOption } from "@fluentui/react" import FeverConfigsTab from "./services/fever" import FeedbinConfigsTab from "./services/feedbin" import GReaderConfigsTab from "./services/greader" import InoreaderConfigsTab from "./services/inoreader" import MinifluxConfigsTab from "./services/miniflux" import NextcloudConfigsTab from "./services/nextcloud" type ServiceTabProps = { configs: ServiceConfigs save: (configs: ServiceConfigs) => void sync: () => Promise remove: () => Promise blockActions: () => void authenticate: (configs: ServiceConfigs) => Promise reauthenticate: (configs: ServiceConfigs) => Promise } export type ServiceConfigsTabProps = ServiceTabProps & { exit: () => void } type ServiceTabState = { type: SyncService } export class ServiceTab extends React.Component< ServiceTabProps, ServiceTabState > { constructor(props: ServiceTabProps) { super(props) this.state = { type: props.configs.type, } } serviceOptions = (): IDropdownOption[] => [ { key: SyncService.Fever, text: "Fever API" }, { key: SyncService.Feedbin, text: "Feedbin" }, { key: SyncService.GReader, text: "Google Reader API (Beta)" }, { key: SyncService.Inoreader, text: "Inoreader" }, { key: SyncService.Miniflux, text: "Miniflux" }, { key: SyncService.Nextcloud, text: "Nextcloud News API" }, { key: -1, text: intl.get("service.suggest") }, ] onServiceOptionChange = (_, option: IDropdownOption) => { if (option.key === -1) { window.utils.openExternal( "https://github.com/yang991178/fluent-reader/issues/23" ) } else { this.setState({ type: option.key as number }) } } exitConfigsTab = () => { this.setState({ type: SyncService.None }) } getConfigsTab = () => { switch (this.state.type) { case SyncService.Fever: return ( ) case SyncService.Feedbin: return ( ) case SyncService.GReader: return ( ) case SyncService.Inoreader: return ( ) case SyncService.Miniflux: return ( ) case SyncService.Nextcloud: return ( ) default: return null } } render = () => (
{this.state.type === SyncService.None ? ( {intl.get("service.intro")} window.utils.openExternal( "https://github.com/yang991178/fluent-reader/wiki/Support#services" ) } style={{ marginLeft: 6 }}> {intl.get("rules.help")} ) : ( this.getConfigsTab() )}
) } ================================================ FILE: src/components/settings/services/feedbin.tsx ================================================ import * as React from "react" import intl from "react-intl-universal" import { ServiceConfigsTabProps } from "../service" import { FeedbinConfigs } from "../../../scripts/models/services/feedbin" import { SyncService } from "../../../schema-types" import { Stack, Icon, Label, TextField, PrimaryButton, DefaultButton, Checkbox, MessageBar, MessageBarType, Dropdown, IDropdownOption, } from "@fluentui/react" import DangerButton from "../../utils/danger-button" import { urlTest } from "../../../scripts/utils" import LiteExporter from "./lite-exporter" type FeedbinConfigsTabState = { existing: boolean endpoint: string username: string password: string fetchLimit: number importGroups: boolean } class FeedbinConfigsTab extends React.Component< ServiceConfigsTabProps, FeedbinConfigsTabState > { constructor(props: ServiceConfigsTabProps) { super(props) const configs = props.configs as FeedbinConfigs this.state = { existing: configs.type === SyncService.Feedbin, endpoint: configs.endpoint || "https://api.feedbin.me/v2/", username: configs.username || "", password: "", fetchLimit: configs.fetchLimit || 250, importGroups: true, } } fetchLimitOptions = (): IDropdownOption[] => [ { key: 250, text: intl.get("service.fetchLimitNum", { count: 250 }) }, { key: 500, text: intl.get("service.fetchLimitNum", { count: 500 }) }, { key: 750, text: intl.get("service.fetchLimitNum", { count: 750 }) }, { key: 1000, text: intl.get("service.fetchLimitNum", { count: 1000 }) }, { key: 1500, text: intl.get("service.fetchLimitNum", { count: 1500 }) }, { key: Number.MAX_SAFE_INTEGER, text: intl.get("service.fetchUnlimited"), }, ] onFetchLimitOptionChange = (_, option: IDropdownOption) => { this.setState({ fetchLimit: option.key as number }) } handleInputChange = event => { const name: string = event.target.name // @ts-expect-error this.setState({ [name]: event.target.value }) } checkNotEmpty = (v: string) => { return !this.state.existing && v.length == 0 ? intl.get("emptyField") : "" } validateForm = () => { return ( urlTest(this.state.endpoint.trim()) && (this.state.existing || (this.state.username && this.state.password)) ) } save = async () => { let configs: FeedbinConfigs if (this.state.existing) { configs = { ...this.props.configs, endpoint: this.state.endpoint, fetchLimit: this.state.fetchLimit, } as FeedbinConfigs if (this.state.password) configs.password = this.state.password } else { configs = { type: SyncService.Feedbin, endpoint: this.state.endpoint, username: this.state.username, password: this.state.password, fetchLimit: this.state.fetchLimit, } if (this.state.importGroups) configs.importGroups = true } this.props.blockActions() const valid = await this.props.authenticate(configs) if (valid) { this.props.save(configs) this.setState({ existing: true }) this.props.sync() } else { this.props.blockActions() window.utils.showErrorBox( intl.get("service.failure"), intl.get("service.failureHint") ) } } remove = async () => { this.props.exit() await this.props.remove() } render() { return ( <> {!this.state.existing && ( {intl.get("service.overwriteWarning")} )} urlTest(v.trim()) ? "" : intl.get("sources.badUrl") } validateOnLoad={false} name="endpoint" value={this.state.endpoint} onChange={this.handleInputChange} /> {!this.state.existing && ( this.setState({ importGroups: c }) } /> )} {this.state.existing ? ( ) : ( )} {this.state.existing && ( )} ) } } export default FeedbinConfigsTab ================================================ FILE: src/components/settings/services/fever.tsx ================================================ import * as React from "react" import intl from "react-intl-universal" import md5 from "js-md5" import { ServiceConfigsTabProps } from "../service" import { FeverConfigs } from "../../../scripts/models/services/fever" import { SyncService } from "../../../schema-types" import { Stack, Icon, Label, TextField, PrimaryButton, DefaultButton, Checkbox, MessageBar, MessageBarType, Dropdown, IDropdownOption, } from "@fluentui/react" import DangerButton from "../../utils/danger-button" import { urlTest } from "../../../scripts/utils" import LiteExporter from "./lite-exporter" type FeverConfigsTabState = { existing: boolean endpoint: string username: string password: string fetchLimit: number importGroups: boolean } class FeverConfigsTab extends React.Component< ServiceConfigsTabProps, FeverConfigsTabState > { constructor(props: ServiceConfigsTabProps) { super(props) const configs = props.configs as FeverConfigs this.state = { existing: configs.type === SyncService.Fever, endpoint: configs.endpoint || "", username: configs.username || "", password: "", fetchLimit: configs.fetchLimit || 250, importGroups: true, } } fetchLimitOptions = (): IDropdownOption[] => [ { key: 250, text: intl.get("service.fetchLimitNum", { count: 250 }) }, { key: 500, text: intl.get("service.fetchLimitNum", { count: 500 }) }, { key: 750, text: intl.get("service.fetchLimitNum", { count: 750 }) }, { key: 1000, text: intl.get("service.fetchLimitNum", { count: 1000 }) }, { key: 1500, text: intl.get("service.fetchLimitNum", { count: 1500 }) }, { key: Number.MAX_SAFE_INTEGER, text: intl.get("service.fetchUnlimited"), }, ] onFetchLimitOptionChange = (_, option: IDropdownOption) => { this.setState({ fetchLimit: option.key as number }) } handleInputChange = event => { const name: string = event.target.name // @ts-expect-error this.setState({ [name]: event.target.value }) } checkNotEmpty = (v: string) => { return !this.state.existing && v.length == 0 ? intl.get("emptyField") : "" } validateForm = () => { return ( urlTest(this.state.endpoint.trim()) && (this.state.existing || (this.state.username && this.state.password)) ) } save = async () => { let configs: FeverConfigs if (this.state.existing) { configs = { ...this.props.configs, endpoint: this.state.endpoint, fetchLimit: this.state.fetchLimit, } as FeverConfigs if (this.state.password) configs.apiKey = md5( `${configs.username}:${this.state.password}` ) } else { configs = { type: SyncService.Fever, endpoint: this.state.endpoint, username: this.state.username, fetchLimit: this.state.fetchLimit, apiKey: md5(`${this.state.username}:${this.state.password}`), } if (this.state.importGroups) configs.importGroups = true } this.props.blockActions() const valid = await this.props.authenticate(configs) if (valid) { this.props.save(configs) this.setState({ existing: true }) this.props.sync() } else { this.props.blockActions() window.utils.showErrorBox( intl.get("service.failure"), intl.get("service.failureHint") ) } } remove = async () => { this.props.exit() await this.props.remove() } render() { return ( <> {!this.state.existing && ( {intl.get("service.overwriteWarning")} )} urlTest(v.trim()) ? "" : intl.get("sources.badUrl") } validateOnLoad={false} name="endpoint" value={this.state.endpoint} onChange={this.handleInputChange} /> {!this.state.existing && ( this.setState({ importGroups: c }) } /> )} {this.state.existing ? ( ) : ( )} {this.state.existing && ( )} ) } } export default FeverConfigsTab ================================================ FILE: src/components/settings/services/greader.tsx ================================================ import * as React from "react" import intl from "react-intl-universal" import { ServiceConfigsTabProps } from "../service" import { GReaderConfigs } from "../../../scripts/models/services/greader" import { SyncService } from "../../../schema-types" import { Stack, Icon, Label, TextField, PrimaryButton, DefaultButton, Checkbox, MessageBar, MessageBarType, Dropdown, IDropdownOption, } from "@fluentui/react" import DangerButton from "../../utils/danger-button" import { urlTest } from "../../../scripts/utils" import LiteExporter from "./lite-exporter" type GReaderConfigsTabState = { existing: boolean endpoint: string username: string password: string fetchLimit: number importGroups: boolean } class GReaderConfigsTab extends React.Component< ServiceConfigsTabProps, GReaderConfigsTabState > { constructor(props: ServiceConfigsTabProps) { super(props) const configs = props.configs as GReaderConfigs this.state = { existing: configs.type === SyncService.GReader, endpoint: configs.endpoint || "", username: configs.username || "", password: "", fetchLimit: configs.fetchLimit || 250, importGroups: true, } } fetchLimitOptions = (): IDropdownOption[] => [ { key: 250, text: intl.get("service.fetchLimitNum", { count: 250 }) }, { key: 500, text: intl.get("service.fetchLimitNum", { count: 500 }) }, { key: 750, text: intl.get("service.fetchLimitNum", { count: 750 }) }, { key: 1000, text: intl.get("service.fetchLimitNum", { count: 1000 }) }, { key: 1500, text: intl.get("service.fetchLimitNum", { count: 1500 }) }, { key: Number.MAX_SAFE_INTEGER, text: intl.get("service.fetchUnlimited"), }, ] onFetchLimitOptionChange = (_, option: IDropdownOption) => { this.setState({ fetchLimit: option.key as number }) } handleInputChange = event => { const name: string = event.target.name // @ts-expect-error this.setState({ [name]: event.target.value }) } checkNotEmpty = (v: string) => { return !this.state.existing && v.length == 0 ? intl.get("emptyField") : "" } validateForm = () => { return ( urlTest(this.state.endpoint.trim()) && (this.state.existing || (this.state.username && this.state.password)) ) } save = async () => { let configs: GReaderConfigs if (this.state.existing) { configs = { ...this.props.configs, endpoint: this.state.endpoint, fetchLimit: this.state.fetchLimit, } as GReaderConfigs if (this.state.password) configs.password = this.state.password } else { configs = { type: SyncService.GReader, endpoint: this.state.endpoint, username: this.state.username, password: this.state.password, fetchLimit: this.state.fetchLimit, useInt64: !this.state.endpoint.endsWith("theoldreader.com"), } if (this.state.importGroups) configs.importGroups = true } this.props.blockActions() configs = (await this.props.reauthenticate(configs)) as GReaderConfigs const valid = await this.props.authenticate(configs) if (valid) { this.props.save(configs) this.setState({ existing: true }) this.props.sync() } else { this.props.blockActions() window.utils.showErrorBox( intl.get("service.failure"), intl.get("service.failureHint") ) } } remove = async () => { this.props.exit() await this.props.remove() } render() { return ( <> {!this.state.existing && ( {intl.get("service.overwriteWarning")} )} urlTest(v.trim()) ? "" : intl.get("sources.badUrl") } validateOnLoad={false} name="endpoint" value={this.state.endpoint} onChange={this.handleInputChange} /> {!this.state.existing && ( this.setState({ importGroups: c }) } /> )} {this.state.existing ? ( ) : ( )} {this.state.existing && ( )} ) } } export default GReaderConfigsTab ================================================ FILE: src/components/settings/services/inoreader.tsx ================================================ import * as React from "react" import intl from "react-intl-universal" import { ServiceConfigsTabProps } from "../service" import { GReaderConfigs } from "../../../scripts/models/services/greader" import { SyncService } from "../../../schema-types" import { Stack, Label, TextField, PrimaryButton, DefaultButton, Checkbox, MessageBar, MessageBarType, Dropdown, IDropdownOption, MessageBarButton, Link, } from "@fluentui/react" import DangerButton from "../../utils/danger-button" import LiteExporter from "./lite-exporter" type GReaderConfigsTabState = { existing: boolean endpoint: string username: string password: string apiId: string apiKey: string removeAd: boolean fetchLimit: number } const endpointOptions: IDropdownOption[] = [ "https://www.inoreader.com", "https://www.innoreader.com", "https://jp.inoreader.com", ].map(s => ({ key: s, text: s })) const openSupport = () => window.utils.openExternal( "https://github.com/yang991178/fluent-reader/wiki/Support#inoreader" ) class InoreaderConfigsTab extends React.Component< ServiceConfigsTabProps, GReaderConfigsTabState > { constructor(props: ServiceConfigsTabProps) { super(props) const configs = props.configs as GReaderConfigs this.state = { existing: configs.type === SyncService.Inoreader, endpoint: configs.endpoint || "https://www.inoreader.com", username: configs.username || "", password: "", apiId: configs.inoreaderId || "", apiKey: configs.inoreaderKey || "", removeAd: configs.removeInoreaderAd === undefined ? true : configs.removeInoreaderAd, fetchLimit: configs.fetchLimit || 250, } } fetchLimitOptions = (): IDropdownOption[] => [ { key: 250, text: intl.get("service.fetchLimitNum", { count: 250 }) }, { key: 500, text: intl.get("service.fetchLimitNum", { count: 500 }) }, { key: 750, text: intl.get("service.fetchLimitNum", { count: 750 }) }, { key: 1000, text: intl.get("service.fetchLimitNum", { count: 1000 }) }, { key: Number.MAX_SAFE_INTEGER, text: intl.get("service.fetchUnlimited"), }, ] onFetchLimitOptionChange = (_, option: IDropdownOption) => { this.setState({ fetchLimit: option.key as number }) } onEndpointChange = (_, option: IDropdownOption) => { this.setState({ endpoint: option.key as string }) } handleInputChange = event => { const name: string = event.target.name // @ts-expect-error this.setState({ [name]: event.target.value }) } checkNotEmpty = (v: string) => { return !this.state.existing && v.length == 0 ? intl.get("emptyField") : "" } validateForm = () => { return ( (this.state.existing || (this.state.username && this.state.password)) && this.state.apiId && this.state.apiKey ) } save = async () => { let configs: GReaderConfigs if (this.state.existing) { configs = { ...this.props.configs, endpoint: this.state.endpoint, fetchLimit: this.state.fetchLimit, inoreaderId: this.state.apiId, inoreaderKey: this.state.apiKey, removeInoreaderAd: this.state.removeAd, } as GReaderConfigs if (this.state.password) configs.password = this.state.password } else { configs = { type: SyncService.Inoreader, endpoint: this.state.endpoint, username: this.state.username, password: this.state.password, inoreaderId: this.state.apiId, inoreaderKey: this.state.apiKey, removeInoreaderAd: this.state.removeAd, fetchLimit: this.state.fetchLimit, importGroups: true, useInt64: true, } } this.props.blockActions() configs = (await this.props.reauthenticate(configs)) as GReaderConfigs const valid = await this.props.authenticate(configs) if (valid) { this.props.save(configs) this.setState({ existing: true }) this.props.sync() } else { this.props.blockActions() window.utils.showErrorBox( intl.get("service.failure"), intl.get("service.failureHint") ) } } createKey = () => window.utils.openExternal( this.state.endpoint + "/all_articles#preferences-developer" ) remove = async () => { this.props.exit() await this.props.remove() } render() { return ( <> }> {intl.get("service.rateLimitWarning")} {intl.get("rules.help")} {!this.state.existing && ( {intl.get("service.overwriteWarning")} )} this.setState({ removeAd: c })} /> {this.state.existing ? ( ) : ( )} {this.state.existing && ( )} ) } } export default InoreaderConfigsTab ================================================ FILE: src/components/settings/services/lite-exporter.tsx ================================================ import * as React from "react" import intl from "react-intl-universal" import { Stack, ContextualMenuItemType, DefaultButton, IContextualMenuProps, DirectionalHint, } from "@fluentui/react" import { ServiceConfigs, SyncService } from "../../../schema-types" import { renderShareQR } from "../../context-menu" import { platformCtrl } from "../../../scripts/utils" import { FeverConfigs } from "../../../scripts/models/services/fever" import { GReaderConfigs } from "../../../scripts/models/services/greader" import { FeedbinConfigs } from "../../../scripts/models/services/feedbin" type LiteExporterProps = { serviceConfigs: ServiceConfigs } const LEARN_MORE_URL = "https://github.com/yang991178/fluent-reader/wiki/Support#mobile-app" const LiteExporter: React.FunctionComponent = props => { let url = "https://hyliu.me/fr2l/?" const params = new URLSearchParams() switch (props.serviceConfigs.type) { case SyncService.Fever: { const configs = props.serviceConfigs as FeverConfigs params.set("t", "f") params.set("e", configs.endpoint) params.set("u", configs.username) params.set("k", configs.apiKey) break } case SyncService.GReader: case SyncService.Inoreader: { const configs = props.serviceConfigs as GReaderConfigs params.set("t", configs.type == SyncService.GReader ? "g" : "i") params.set("e", configs.endpoint) params.set("u", configs.username) params.set("p", btoa(configs.password)) if (configs.inoreaderId) { params.set("i", configs.inoreaderId) params.set("k", configs.inoreaderKey) } break } case SyncService.Feedbin: { const configs = props.serviceConfigs as FeedbinConfigs params.set("t", "fb") params.set("e", configs.endpoint) params.set("u", configs.username) params.set("p", btoa(configs.password)) break } } url += params.toString() const menuProps: IContextualMenuProps = { directionalHint: DirectionalHint.bottomCenter, items: [ { key: "qr", url: url, onRender: renderShareQR }, { key: "divider_1", itemType: ContextualMenuItemType.Divider }, { key: "openInBrowser", text: intl.get("rules.help"), iconProps: { iconName: "NavigateExternalInline" }, onClick: e => { window.utils.openExternal(LEARN_MORE_URL, platformCtrl(e)) }, }, ], } return ( <>} menuProps={menuProps} /> ) } export default LiteExporter ================================================ FILE: src/components/settings/services/miniflux.tsx ================================================ import * as React from "react" import intl from "react-intl-universal" import { ServiceConfigsTabProps } from "../service" import { SyncService } from "../../../schema-types" import { Stack, Icon, Label, TextField, PrimaryButton, DefaultButton, Checkbox, MessageBar, MessageBarType, Dropdown, IDropdownOption, } from "@fluentui/react" import DangerButton from "../../utils/danger-button" import { urlTest } from "../../../scripts/utils" import { MinifluxConfigs } from "../../../scripts/models/services/miniflux" type MinifluxConfigsTabState = { existing: boolean endpoint: string apiKeyAuth: boolean username: string password: string apiKey: string fetchLimit: number importGroups: boolean } class MinifluxConfigsTab extends React.Component< ServiceConfigsTabProps, MinifluxConfigsTabState > { constructor(props: ServiceConfigsTabProps) { super(props) const configs = props.configs as MinifluxConfigs this.state = { existing: configs.type === SyncService.Miniflux, endpoint: configs.endpoint || "", apiKeyAuth: true, username: "", password: "", apiKey: "", fetchLimit: configs.fetchLimit || 250, importGroups: true, } } fetchLimitOptions = (): IDropdownOption[] => [ { key: 250, text: intl.get("service.fetchLimitNum", { count: 250 }) }, { key: 500, text: intl.get("service.fetchLimitNum", { count: 500 }) }, { key: 750, text: intl.get("service.fetchLimitNum", { count: 750 }) }, { key: 1000, text: intl.get("service.fetchLimitNum", { count: 1000 }) }, { key: 1500, text: intl.get("service.fetchLimitNum", { count: 1500 }) }, { key: Number.MAX_SAFE_INTEGER, text: intl.get("service.fetchUnlimited"), }, ] onFetchLimitOptionChange = (_, option: IDropdownOption) => { this.setState({ fetchLimit: option.key as number }) } authenticationOptions = (): IDropdownOption[] => [ { key: "apiKey", text: "API Key" /*intl.get("service.password")*/ }, { key: "userPass", text: intl.get("service.username") + "/" + intl.get("service.password"), }, ] onAuthenticationOptionsChange = (_, option: IDropdownOption) => { this.setState({ apiKeyAuth: option.key == "apiKey" }) } handleInputChange = event => { const name: string = event.target.name // @ts-expect-error this.setState({ [name]: event.target.value }) } checkNotEmpty = (v: string) => { return !this.state.existing && v.length == 0 ? intl.get("emptyField") : "" } validateForm = () => { return ( urlTest(this.state.endpoint.trim()) && (this.state.existing || this.state.apiKey || (this.state.username && this.state.password)) ) } save = async () => { let configs: MinifluxConfigs if (this.state.existing) { configs = { ...this.props.configs, endpoint: this.state.endpoint, fetchLimit: this.state.fetchLimit, } as MinifluxConfigs if (this.state.apiKey || this.state.password) configs.authKey = this.state.apiKeyAuth ? this.state.apiKey : Buffer.from( this.state.username + ":" + this.state.password, "binary" ).toString("base64") } else { configs = { type: SyncService.Miniflux, endpoint: this.state.endpoint, apiKeyAuth: this.state.apiKeyAuth, authKey: this.state.apiKeyAuth ? this.state.apiKey : Buffer.from( this.state.username + ":" + this.state.password, "binary" ).toString("base64"), fetchLimit: this.state.fetchLimit, } if (this.state.importGroups) configs.importGroups = true } this.props.blockActions() const valid = await this.props.authenticate(configs) if (valid) { this.props.save(configs) this.setState({ existing: true }) this.props.sync() } else { this.props.blockActions() window.utils.showErrorBox( intl.get("service.failure"), intl.get("service.failureHint") ) } } remove = async () => { this.props.exit() await this.props.remove() } render() { return ( <> {!this.state.existing && ( {intl.get("service.overwriteWarning")} )} urlTest(v.trim()) ? "" : intl.get("sources.badUrl") } validateOnLoad={false} name="endpoint" value={this.state.endpoint} onChange={this.handleInputChange} /> {this.state.apiKeyAuth && ( )} {!this.state.apiKeyAuth && ( )} {!this.state.apiKeyAuth && ( )} {!this.state.existing && ( this.setState({ importGroups: c }) } /> )} {this.state.existing ? ( ) : ( )} ) } } export default MinifluxConfigsTab ================================================ FILE: src/components/settings/services/nextcloud.tsx ================================================ import * as React from "react" import intl from "react-intl-universal" import { ServiceConfigsTabProps } from "../service" import { NextcloudConfigs } from "../../../scripts/models/services/nextcloud" import { SyncService } from "../../../schema-types" import { Stack, Icon, Label, TextField, PrimaryButton, DefaultButton, Checkbox, MessageBar, MessageBarType, Dropdown, IDropdownOption, } from "@fluentui/react" import DangerButton from "../../utils/danger-button" import { urlTest } from "../../../scripts/utils" type NextcloudConfigsTabState = { existing: boolean endpoint: string username: string password: string fetchLimit: number importGroups: boolean } class NextcloudConfigsTab extends React.Component< ServiceConfigsTabProps, NextcloudConfigsTabState > { constructor(props: ServiceConfigsTabProps) { super(props) const configs = props.configs as NextcloudConfigs this.state = { existing: configs.type === SyncService.Nextcloud, endpoint: configs.endpoint || "https://nextcloud.com/", username: configs.username || "", password: "", fetchLimit: configs.fetchLimit || 250, importGroups: true, } } fetchLimitOptions = (): IDropdownOption[] => [ { key: 250, text: intl.get("service.fetchLimitNum", { count: 250 }) }, { key: 500, text: intl.get("service.fetchLimitNum", { count: 500 }) }, { key: 750, text: intl.get("service.fetchLimitNum", { count: 750 }) }, { key: 1000, text: intl.get("service.fetchLimitNum", { count: 1000 }) }, { key: 1500, text: intl.get("service.fetchLimitNum", { count: 1500 }) }, { key: Number.MAX_SAFE_INTEGER, text: intl.get("service.fetchUnlimited"), }, ] onFetchLimitOptionChange = (_, option: IDropdownOption) => { this.setState({ fetchLimit: option.key as number }) } handleInputChange = event => { const name: string = event.target.name // @ts-expect-error this.setState({ [name]: event.target.value }) } checkNotEmpty = (v: string) => { return !this.state.existing && v.length == 0 ? intl.get("emptyField") : "" } validateForm = () => { return ( urlTest(this.state.endpoint.trim()) && (this.state.existing || (this.state.username && this.state.password)) ) } save = async () => { let configs: NextcloudConfigs if (this.state.existing) { configs = { ...this.props.configs, endpoint: this.state.endpoint, fetchLimit: this.state.fetchLimit, } as NextcloudConfigs if (this.state.password) configs.password = this.state.password } else { configs = { type: SyncService.Nextcloud, endpoint: this.state.endpoint + "index.php/apps/news/api/v1-3", username: this.state.username, password: this.state.password, fetchLimit: this.state.fetchLimit, } if (this.state.importGroups) configs.importGroups = true } this.props.blockActions() const valid = await this.props.authenticate(configs) if (valid) { this.props.save(configs) this.setState({ existing: true }) this.props.sync() } else { this.props.blockActions() window.utils.showErrorBox( intl.get("service.failure"), intl.get("service.failureHint") ) } } remove = async () => { this.props.exit() await this.props.remove() } render() { return ( <> {!this.state.existing && ( {intl.get("service.overwriteWarning")} )} urlTest(v.trim()) ? "" : intl.get("sources.badUrl") } validateOnLoad={false} name="endpoint" value={this.state.endpoint} onChange={this.handleInputChange} /> {!this.state.existing && ( this.setState({ importGroups: c }) } /> )} {this.state.existing ? ( ) : ( )} ) } } export default NextcloudConfigsTab ================================================ FILE: src/components/settings/sources.tsx ================================================ import * as React from "react" import intl from "react-intl-universal" import { Label, DefaultButton, TextField, Stack, PrimaryButton, DetailsList, IColumn, SelectionMode, Selection, IChoiceGroupOption, ChoiceGroup, IDropdownOption, Dropdown, MessageBar, MessageBarType, Toggle, } from "@fluentui/react" import { SourceState, RSSSource, SourceOpenTarget, } from "../../scripts/models/source" import { urlTest } from "../../scripts/utils" import DangerButton from "../utils/danger-button" type SourcesTabProps = { sources: SourceState serviceOn: boolean sids: number[] acknowledgeSIDs: () => void addSource: (url: string) => void updateSourceName: (source: RSSSource, name: string) => void updateSourceIcon: (source: RSSSource, iconUrl: string) => Promise updateSourceOpenTarget: ( source: RSSSource, target: SourceOpenTarget ) => void updateFetchFrequency: (source: RSSSource, frequency: number) => void deleteSource: (source: RSSSource) => void deleteSources: (sources: RSSSource[]) => void importOPML: () => void exportOPML: () => void toggleSourceHidden: (source: RSSSource) => void } type SourcesTabState = { [formName: string]: string } & { selectedSource: RSSSource selectedSources: RSSSource[] } const enum EditDropdownKeys { Name = "n", Icon = "i", Url = "u", } class SourcesTab extends React.Component { selection: Selection constructor(props) { super(props) this.state = { newUrl: "", newSourceName: "", selectedSource: null, selectedSources: null, } this.selection = new Selection({ getKey: s => (s as RSSSource).sid, onSelectionChanged: () => { let count = this.selection.getSelectedCount() let sources = count ? (this.selection.getSelection() as RSSSource[]) : null this.setState({ selectedSource: count === 1 ? sources[0] : null, selectedSources: count > 1 ? sources : null, newSourceName: count === 1 ? sources[0].name : "", newSourceIcon: count === 1 ? sources[0].iconurl || "" : "", sourceEditOption: EditDropdownKeys.Name, }) }, }) } componentDidMount = () => { if (this.props.sids.length > 0) { for (let sid of this.props.sids) { this.selection.setKeySelected(String(sid), true, false) } this.props.acknowledgeSIDs() } } columns = (): IColumn[] => [ { key: "favicon", name: intl.get("icon"), fieldName: "name", isIconOnly: true, iconName: "ImagePixel", minWidth: 16, maxWidth: 16, onRender: (s: RSSSource) => s.iconurl && , }, { key: "name", name: intl.get("name"), fieldName: "name", minWidth: 200, data: "string", isRowHeader: true, }, { key: "url", name: "URL", fieldName: "url", minWidth: 280, data: "string", }, ] sourceEditOptions = (): IDropdownOption[] => [ { key: EditDropdownKeys.Name, text: intl.get("name") }, { key: EditDropdownKeys.Icon, text: intl.get("icon") }, { key: EditDropdownKeys.Url, text: "URL" }, ] onSourceEditOptionChange = (_, option: IDropdownOption) => { this.setState({ sourceEditOption: option.key as string }) } fetchFrequencyOptions = (): IDropdownOption[] => [ { key: "0", text: intl.get("sources.unlimited") }, { key: "15", text: intl.get("time.minute", { m: 15 }) }, { key: "30", text: intl.get("time.minute", { m: 30 }) }, { key: "60", text: intl.get("time.hour", { h: 1 }) }, { key: "120", text: intl.get("time.hour", { h: 2 }) }, { key: "180", text: intl.get("time.hour", { h: 3 }) }, { key: "360", text: intl.get("time.hour", { h: 6 }) }, { key: "720", text: intl.get("time.hour", { h: 12 }) }, { key: "1440", text: intl.get("time.day", { d: 1 }) }, ] onFetchFrequencyChange = (_, option: IDropdownOption) => { let frequency = parseInt(option.key as string) this.props.updateFetchFrequency(this.state.selectedSource, frequency) this.setState({ selectedSource: { ...this.state.selectedSource, fetchFrequency: frequency, } as RSSSource, }) } sourceOpenTargetChoices = (): IChoiceGroupOption[] => [ { key: String(SourceOpenTarget.Local), text: intl.get("sources.rssText"), }, { key: String(SourceOpenTarget.FullContent), text: intl.get("article.loadFull"), }, { key: String(SourceOpenTarget.Webpage), text: intl.get("sources.loadWebpage"), }, { key: String(SourceOpenTarget.External), text: intl.get("openExternal"), }, ] updateSourceName = () => { let newName = this.state.newSourceName.trim() this.props.updateSourceName(this.state.selectedSource, newName) this.setState({ selectedSource: { ...this.state.selectedSource, name: newName, } as RSSSource, }) } updateSourceIcon = () => { let newIcon = this.state.newSourceIcon.trim() this.props.updateSourceIcon(this.state.selectedSource, newIcon) this.setState({ selectedSource: { ...this.state.selectedSource, iconurl: newIcon }, }) } handleInputChange = event => { const name: string = event.target.name this.setState({ [name]: event.target.value }) } addSource = (event: React.FormEvent) => { event.preventDefault() let trimmed = this.state.newUrl.trim() if (urlTest(trimmed)) this.props.addSource(trimmed) } onOpenTargetChange = (_, option: IChoiceGroupOption) => { let newTarget = parseInt(option.key) as SourceOpenTarget this.props.updateSourceOpenTarget(this.state.selectedSource, newTarget) this.setState({ selectedSource: { ...this.state.selectedSource, openTarget: newTarget, } as RSSSource, }) } onToggleHidden = () => { this.props.toggleSourceHidden(this.state.selectedSource) this.setState({ selectedSource: { ...this.state.selectedSource, hidden: !this.state.selectedSource.hidden, } as RSSSource, }) } render = () => (
{this.props.serviceOn && ( {intl.get("sources.serviceWarning")} )}
urlTest(v.trim()) ? "" : intl.get("sources.badUrl") } validateOnLoad={false} placeholder={intl.get("sources.inputUrl")} value={this.state.newUrl} id="newUrl" name="newUrl" onChange={this.handleInputChange} />
= 10} items={Object.values(this.props.sources)} columns={this.columns()} getKey={s => s.sid} setKey="selected" selection={this.selection} selectionMode={SelectionMode.multiple} /> {this.state.selectedSource && ( <> {this.state.selectedSource.serviceRef && ( {intl.get("sources.serviceManaged")} )} {this.state.sourceEditOption === EditDropdownKeys.Name && ( <> v.trim().length == 0 ? intl.get("emptyName") : "" } validateOnLoad={false} placeholder={intl.get("sources.name")} value={this.state.newSourceName} name="newSourceName" onChange={this.handleInputChange} /> )} {this.state.sourceEditOption === EditDropdownKeys.Icon && ( <> urlTest(v.trim()) ? "" : intl.get("sources.badUrl") } validateOnLoad={false} placeholder={intl.get( "sources.inputUrl" )} value={this.state.newSourceIcon} name="newSourceIcon" onChange={this.handleInputChange} /> )} {this.state.sourceEditOption === EditDropdownKeys.Url && ( <> window.utils.writeClipboard( this.state.selectedSource.url ) } text={intl.get("context.copy")} /> )} {!this.state.selectedSource.serviceRef && ( <> )} {!this.state.selectedSource.serviceRef && ( this.props.deleteSource( this.state.selectedSource ) } key={this.state.selectedSource.sid} text={intl.get("sources.delete")} /> {intl.get("sources.deleteWarning")} )} )} {this.state.selectedSources && (this.state.selectedSources.filter(s => s.serviceRef).length === 0 ? ( <> this.props.deleteSources( this.state.selectedSources ) } text={intl.get("sources.delete")} /> {intl.get("sources.deleteWarning")} ) : ( {intl.get("sources.serviceManaged")} ))}
) } export default SourcesTab ================================================ FILE: src/components/settings.tsx ================================================ import * as React from "react" import intl from "react-intl-universal" import { Icon } from "@fluentui/react/lib/Icon" import { AnimationClassNames } from "@fluentui/react/lib/Styling" import AboutTab from "./settings/about" import { Pivot, PivotItem, Spinner, FocusTrapZone } from "@fluentui/react" import SourcesTabContainer from "../containers/settings/sources-container" import GroupsTabContainer from "../containers/settings/groups-container" import AppTabContainer from "../containers/settings/app-container" import RulesTabContainer from "../containers/settings/rules-container" import ServiceTabContainer from "../containers/settings/service-container" import { initTouchBarWithTexts } from "../scripts/utils" type SettingsProps = { display: boolean blocked: boolean exitting: boolean close: () => void } class Settings extends React.Component { constructor(props) { super(props) } onKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape" && !this.props.exitting) this.props.close() } componentDidUpdate = (prevProps: SettingsProps) => { if (this.props.display !== prevProps.display) { if (this.props.display) { if (window.utils.platform === "darwin") window.utils.destroyTouchBar() document.body.addEventListener("keydown", this.onKeyDown) } else { if (window.utils.platform === "darwin") initTouchBarWithTexts() document.body.removeEventListener("keydown", this.onKeyDown) } } } render = () => this.props.display && (
{this.props.blocked && ( )}
) } export default Settings ================================================ FILE: src/components/utils/ResizeObserver.d.ts ================================================ /** * The **ResizeObserver** interface reports changes to the dimensions of an * [Element](https://developer.mozilla.org/en-US/docs/Web/API/Element)'s content * or border box, or the bounding box of an * [SVGElement](https://developer.mozilla.org/en-US/docs/Web/API/SVGElement). * * > **Note**: The content box is the box in which content can be placed, * > meaning the border box minus the padding and border width. The border box * > encompasses the content, padding, and border. See * > [The box model](https://developer.mozilla.org/en-US/docs/Learn/CSS/Building_blocks/The_box_model) * > for further explanation. * * `ResizeObserver` avoids infinite callback loops and cyclic dependencies that * are often created when resizing via a callback function. It does this by only * processing elements deeper in the DOM in subsequent frames. Implementations * should, if they follow the specification, invoke resize events before paint * and after layout. * * @see https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver */ declare class ResizeObserver { /** * The **ResizeObserver** constructor creates a new `ResizeObserver` object, * which can be used to report changes to the content or border box of an * `Element` or the bounding box of an `SVGElement`. * * @example * var ResizeObserver = new ResizeObserver(callback) * * @param callback * The function called whenever an observed resize occurs. The function is * called with two parameters: * * **entries** * An array of * [ResizeObserverEntry](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserverEntry) * objects that can be used to access the new dimensions of the element * after each change. * * **observer** * A reference to the `ResizeObserver` itself, so it will definitely be * accessible from inside the callback, should you need it. This could be * used for example to automatically unobserve the observer when a certain * condition is reached, but you can omit it if you don't need it. * * The callback will generally follow a pattern along the lines of: * ```js * function(entries, observer) { * for (let entry of entries) { * // Do something to each entry * // and possibly something to the observer itself * } * } * ``` * * The following snippet is taken from the * [resize-observer-text.html](https://mdn.github.io/dom-examples/resize-observer/resize-observer-text.html) * ([see source](https://github.com/mdn/dom-examples/blob/master/resize-observer/resize-observer-text.html)) * example: * @example * const resizeObserver = new ResizeObserver(entries => { * for (let entry of entries) { * if(entry.contentBoxSize) { * h1Elem.style.fontSize = Math.max(1.5, entry.contentBoxSize.inlineSize/200) + 'rem'; * pElem.style.fontSize = Math.max(1, entry.contentBoxSize.inlineSize/600) + 'rem'; * } else { * h1Elem.style.fontSize = Math.max(1.5, entry.contentRect.width/200) + 'rem'; * pElem.style.fontSize = Math.max(1, entry.contentRect.width/600) + 'rem'; * } * } * }); * * resizeObserver.observe(divElem); */ constructor(callback: ResizeObserverCallback) /** * The **disconnect()** method of the * [ResizeObserver](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver) * interface unobserves all observed * [Element](https://developer.mozilla.org/en-US/docs/Web/API/Element) or * [SVGElement](https://developer.mozilla.org/en-US/docs/Web/API/SVGElement) * targets. */ disconnect: () => void /** * The `observe()` method of the * [ResizeObserver](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver) * interface starts observing the specified * [Element](https://developer.mozilla.org/en-US/docs/Web/API/Element) or * [SVGElement](https://developer.mozilla.org/en-US/docs/Web/API/SVGElement). * * @example * resizeObserver.observe(target, options); * * @param target * A reference to an * [Element](https://developer.mozilla.org/en-US/docs/Web/API/Element) or * [SVGElement](https://developer.mozilla.org/en-US/docs/Web/API/SVGElement) * to be observed. * * @param options * An options object allowing you to set options for the observation. * Currently this only has one possible option that can be set. */ observe: (target: Element, options?: ResizeObserverObserveOptions) => void /** * The **unobserve()** method of the * [ResizeObserver](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver) * interface ends the observing of a specified * [Element](https://developer.mozilla.org/en-US/docs/Web/API/Element) or * [SVGElement](https://developer.mozilla.org/en-US/docs/Web/API/SVGElement). */ unobserve: (target: Element) => void } interface ResizeObserverObserveOptions { /** * Sets which box model the observer will observe changes to. Possible values * are `content-box` (the default), and `border-box`. * * @default "content-box" */ box?: "content-box" | "border-box" } /** * The function called whenever an observed resize occurs. The function is * called with two parameters: * * @param entries * An array of * [ResizeObserverEntry](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserverEntry) * objects that can be used to access the new dimensions of the element after * each change. * * @param observer * A reference to the `ResizeObserver` itself, so it will definitely be * accessible from inside the callback, should you need it. This could be used * for example to automatically unobserve the observer when a certain condition * is reached, but you can omit it if you don't need it. * * The callback will generally follow a pattern along the lines of: * @example * function(entries, observer) { * for (let entry of entries) { * // Do something to each entry * // and possibly something to the observer itself * } * } * * @example * const resizeObserver = new ResizeObserver(entries => { * for (let entry of entries) { * if(entry.contentBoxSize) { * h1Elem.style.fontSize = Math.max(1.5, entry.contentBoxSize.inlineSize/200) + 'rem'; * pElem.style.fontSize = Math.max(1, entry.contentBoxSize.inlineSize/600) + 'rem'; * } else { * h1Elem.style.fontSize = Math.max(1.5, entry.contentRect.width/200) + 'rem'; * pElem.style.fontSize = Math.max(1, entry.contentRect.width/600) + 'rem'; * } * } * }); * * resizeObserver.observe(divElem); */ type ResizeObserverCallback = ( entries: ResizeObserverEntry[], observer: ResizeObserver ) => void /** * The **ResizeObserverEntry** interface represents the object passed to the * [ResizeObserver()](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver/ResizeObserver) * constructor's callback function, which allows you to access the new * dimensions of the * [Element](https://developer.mozilla.org/en-US/docs/Web/API/Element) or * [SVGElement](https://developer.mozilla.org/en-US/docs/Web/API/SVGElement) * being observed. */ interface ResizeObserverEntry { /** * An object containing the new border box size of the observed element when * the callback is run. */ readonly borderBoxSize: ResizeObserverEntryBoxSize /** * An object containing the new content box size of the observed element when * the callback is run. */ readonly contentBoxSize: ResizeObserverEntryBoxSize /** * A [DOMRectReadOnly](https://developer.mozilla.org/en-US/docs/Web/API/DOMRectReadOnly) * object containing the new size of the observed element when the callback is * run. Note that this is better supported than the above two properties, but * it is left over from an earlier implementation of the Resize Observer API, * is still included in the spec for web compat reasons, and may be deprecated * in future versions. */ // node_modules/typescript/lib/lib.dom.d.ts readonly contentRect: DOMRectReadOnly /** * A reference to the * [Element](https://developer.mozilla.org/en-US/docs/Web/API/Element) or * [SVGElement](https://developer.mozilla.org/en-US/docs/Web/API/SVGElement) * being observed. */ readonly target: Element } /** * The **borderBoxSize** read-only property of the * [ResizeObserverEntry](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserverEntry) * interface returns an object containing the new border box size of the * observed element when the callback is run. */ interface ResizeObserverEntryBoxSize { /** * The length of the observed element's border box in the block dimension. For * boxes with a horizontal * [writing-mode](https://developer.mozilla.org/en-US/docs/Web/CSS/writing-mode), * this is the vertical dimension, or height; if the writing-mode is vertical, * this is the horizontal dimension, or width. */ blockSize: number /** * The length of the observed element's border box in the inline dimension. * For boxes with a horizontal * [writing-mode](https://developer.mozilla.org/en-US/docs/Web/CSS/writing-mode), * this is the horizontal dimension, or width; if the writing-mode is * vertical, this is the vertical dimension, or height. */ inlineSize: number } interface Window { ResizeObserver: typeof ResizeObserver } ================================================ FILE: src/components/utils/article-search.tsx ================================================ import * as React from "react" import intl from "react-intl-universal" import { connect } from "react-redux" import { RootState } from "../../scripts/reducer" import { SearchBox, ISearchBox, Async } from "@fluentui/react" import { AppDispatch, validateRegex } from "../../scripts/utils" import { performSearch } from "../../scripts/models/page" type SearchProps = { searchOn: boolean initQuery: string dispatch: AppDispatch } type SearchState = { query: string } class ArticleSearch extends React.Component { debouncedSearch: (query: string) => void inputRef: React.RefObject constructor(props: SearchProps) { super(props) this.debouncedSearch = new Async().debounce((query: string) => { let regex = validateRegex(query) if (regex !== null) props.dispatch(performSearch(query)) }, 750) this.inputRef = React.createRef() this.state = { query: props.initQuery } } onSearchChange = (_, newValue: string) => { this.debouncedSearch(newValue) this.setState({ query: newValue }) } componentDidUpdate(prevProps: SearchProps) { if (this.props.searchOn && !prevProps.searchOn) { this.setState({ query: this.props.initQuery }) this.inputRef.current.focus() } } render() { return ( this.props.searchOn && ( ) ) } } const getSearchProps = (state: RootState) => ({ searchOn: state.page.searchOn, initQuery: state.page.filter.search, }) export default connect(getSearchProps)(ArticleSearch) ================================================ FILE: src/components/utils/danger-button.tsx ================================================ import * as React from "react" import intl from "react-intl-universal" import { PrimaryButton } from "@fluentui/react" class DangerButton extends PrimaryButton { timerID: NodeJS.Timeout state = { confirming: false, } clear = () => { this.timerID = null this.setState({ confirming: false }) } onClick = (event: React.MouseEvent) => { if (!this.props.disabled) { if (this.state.confirming) { if (this.props.onClick) this.props.onClick(event) clearTimeout(this.timerID) this.clear() } else { this.setState({ confirming: true }) this.timerID = setTimeout(() => { this.clear() }, 5000) } } } componentWillUnmount() { if (this.timerID) clearTimeout(this.timerID) } render = () => ( {this.props.children} ) } export default DangerButton ================================================ FILE: src/components/utils/time.tsx ================================================ import * as React from "react" import intl from "react-intl-universal" interface TimeProps { date: Date } class Time extends React.Component { timerID: NodeJS.Timeout state = { now: new Date() } componentDidMount() { this.timerID = setInterval(() => this.tick(), 60000) } componentWillUnmount() { clearInterval(this.timerID) } tick() { this.setState({ now: new Date() }) } displayTime(past: Date, now: Date): string { // difference in seconds let diff = (now.getTime() - past.getTime()) / 60000 if (diff < 1) return intl.get("time.now") else if (diff < 60) return Math.floor(diff) + intl.get("time.m") else if (diff < 1440) return Math.floor(diff / 60) + intl.get("time.h") else return Math.floor(diff / 1440) + intl.get("time.d") } render() { return ( {this.displayTime(this.props.date, this.state.now)} ) } } export default Time ================================================ FILE: src/containers/article-container.tsx ================================================ import { connect } from "react-redux" import { createSelector } from "reselect" import { RootState } from "../scripts/reducer" import { RSSItem, markUnread, markRead, toggleStarred, toggleHidden, itemShortcuts, } from "../scripts/models/item" import { AppDispatch } from "../scripts/utils" import { dismissItem, showOffsetItem } from "../scripts/models/page" import Article from "../components/article" import { openTextMenu, closeContextMenu, openImageMenu, } from "../scripts/models/app" import { RSSSource, SourceTextDirection, updateSource, } from "../scripts/models/source" type ArticleContainerProps = { itemId: number } const getItem = (state: RootState, props: ArticleContainerProps) => state.items[props.itemId] const getSource = (state: RootState, props: ArticleContainerProps) => state.sources[state.items[props.itemId].source] const getLocale = (state: RootState) => state.app.locale const makeMapStateToProps = () => { return createSelector( [getItem, getSource, getLocale], (item, source, locale) => ({ item: item, source: source, locale: locale, }) ) } const mapDispatchToProps = (dispatch: AppDispatch) => { return { shortcuts: (item: RSSItem, e: KeyboardEvent) => dispatch(itemShortcuts(item, e)), dismiss: () => dispatch(dismissItem()), offsetItem: (offset: number) => dispatch(showOffsetItem(offset)), toggleHasRead: (item: RSSItem) => dispatch(item.hasRead ? markUnread(item) : markRead(item)), toggleStarred: (item: RSSItem) => dispatch(toggleStarred(item)), toggleHidden: (item: RSSItem) => { if (!item.hidden) dispatch(dismissItem()) if (!item.hasRead && !item.hidden) dispatch(markRead(item)) dispatch(toggleHidden(item)) }, textMenu: (position: [number, number], text: string, url: string) => dispatch(openTextMenu(position, text, url)), imageMenu: (position: [number, number]) => dispatch(openImageMenu(position)), dismissContextMenu: () => dispatch(closeContextMenu()), updateSourceTextDirection: ( source: RSSSource, direction: SourceTextDirection ) => { dispatch( updateSource({ ...source, textDir: direction } as RSSSource) ) }, } } const ArticleContainer = connect( makeMapStateToProps, mapDispatchToProps )(Article) export default ArticleContainer ================================================ FILE: src/containers/feed-container.tsx ================================================ import { connect } from "react-redux" import { createSelector } from "reselect" import { RootState } from "../scripts/reducer" import { markRead, RSSItem, itemShortcuts } from "../scripts/models/item" import { openItemMenu } from "../scripts/models/app" import { loadMore, RSSFeed } from "../scripts/models/feed" import { showItem } from "../scripts/models/page" import { ViewType } from "../schema-types" import { Feed } from "../components/feeds/feed" interface FeedContainerProps { feedId: string viewType: ViewType } const getSources = (state: RootState) => state.sources const getItems = (state: RootState) => state.items const getFeed = (state: RootState, props: FeedContainerProps) => state.feeds[props.feedId] const getFilter = (state: RootState) => state.page.filter const getView = (_, props: FeedContainerProps) => props.viewType const getViewConfigs = (state: RootState) => state.page.viewConfigs const getCurrentItem = (state: RootState) => state.page.itemId const makeMapStateToProps = () => { return createSelector( [ getSources, getItems, getFeed, getView, getFilter, getViewConfigs, getCurrentItem, ], (sources, items, feed, viewType, filter, viewConfigs, currentItem) => ({ feed: feed, items: feed.iids.map(iid => items[iid]), sourceMap: sources, filter: filter, viewType: viewType, viewConfigs: viewConfigs, currentItem: currentItem, }) ) } const mapDispatchToProps = dispatch => { return { shortcuts: (item: RSSItem, e: KeyboardEvent) => dispatch(itemShortcuts(item, e)), markRead: (item: RSSItem) => dispatch(markRead(item)), contextMenu: (feedId: string, item: RSSItem, e) => dispatch(openItemMenu(item, feedId, e)), loadMore: (feed: RSSFeed) => dispatch(loadMore(feed)), showItem: (fid: string, item: RSSItem) => dispatch(showItem(fid, item)), } } const connector = connect(makeMapStateToProps, mapDispatchToProps) export type FeedReduxProps = typeof connector export const FeedContainer = connector(Feed) ================================================ FILE: src/containers/menu-container.tsx ================================================ import { connect } from "react-redux" import { createSelector } from "reselect" import { RootState } from "../scripts/reducer" import { Menu } from "../components/menu" import { toggleMenu, openGroupMenu } from "../scripts/models/app" import { toggleGroupExpansion } from "../scripts/models/group" import { SourceGroup } from "../schema-types" import { selectAllArticles, selectSources, toggleSearch, } from "../scripts/models/page" import { ViewType } from "../schema-types" import { initFeeds } from "../scripts/models/feed" import { RSSSource } from "../scripts/models/source" const getApp = (state: RootState) => state.app const getSources = (state: RootState) => state.sources const getGroups = (state: RootState) => state.groups const getSearchOn = (state: RootState) => state.page.searchOn const getItemOn = (state: RootState) => state.page.itemId !== null && state.page.viewType !== ViewType.List const mapStateToProps = createSelector( [getApp, getSources, getGroups, getSearchOn, getItemOn], (app, sources, groups, searchOn, itemOn) => ({ status: app.sourceInit && !app.settings.display, display: app.menu, selected: app.menuKey, sources: sources, groups: groups.map((g, i) => ({ ...g, index: i })), searchOn: searchOn, itemOn: itemOn, }) ) const mapDispatchToProps = dispatch => ({ toggleMenu: () => dispatch(toggleMenu()), allArticles: (init = false) => { dispatch(selectAllArticles(init)), dispatch(initFeeds()) }, selectSourceGroup: (group: SourceGroup, menuKey: string) => { dispatch(selectSources(group.sids, menuKey, group.name)) dispatch(initFeeds()) }, selectSource: (source: RSSSource) => { dispatch(selectSources([source.sid], "s-" + source.sid, source.name)) dispatch(initFeeds()) }, groupContextMenu: (sids: number[], event: React.MouseEvent) => { dispatch(openGroupMenu(sids, event)) }, updateGroupExpansion: ( event: React.MouseEvent, key: string, selected: string ) => { if ((event.target as HTMLElement).tagName === "I" || key === selected) { let [type, index] = key.split("-") if (type === "g") dispatch(toggleGroupExpansion(parseInt(index))) } }, toggleSearch: () => dispatch(toggleSearch()), }) const MenuContainer = connect(mapStateToProps, mapDispatchToProps)(Menu) export default MenuContainer ================================================ FILE: src/containers/page-container.tsx ================================================ import { connect } from "react-redux" import { createSelector } from "reselect" import { RootState } from "../scripts/reducer" import Page from "../components/page" import { AppDispatch } from "../scripts/utils" import { dismissItem, showOffsetItem } from "../scripts/models/page" import { ContextMenuType } from "../scripts/models/app" const getPage = (state: RootState) => state.page const getSettings = (state: RootState) => state.app.settings.display const getMenu = (state: RootState) => state.app.menu const getContext = (state: RootState) => state.app.contextMenu.type != ContextMenuType.Hidden const mapStateToProps = createSelector( [getPage, getSettings, getMenu, getContext], (page, settingsOn, menuOn, contextOn) => ({ feeds: [page.feedId], settingsOn: settingsOn, menuOn: menuOn, contextOn: contextOn, itemId: page.itemId, itemFromFeed: page.itemFromFeed, viewType: page.viewType, }) ) const mapDispatchToProps = (dispatch: AppDispatch) => ({ dismissItem: () => dispatch(dismissItem()), offsetItem: (offset: number) => dispatch(showOffsetItem(offset)), }) const PageContainer = connect(mapStateToProps, mapDispatchToProps)(Page) export default PageContainer ================================================ FILE: src/containers/settings/app-container.tsx ================================================ import { connect } from "react-redux" import { initIntl, saveSettings, setupAutoFetch, } from "../../scripts/models/app" import * as db from "../../scripts/db" import AppTab from "../../components/settings/app" import { importAll } from "../../scripts/settings" import { updateUnreadCounts } from "../../scripts/models/source" import { AppDispatch } from "../../scripts/utils" const mapDispatchToProps = (dispatch: AppDispatch) => ({ setLanguage: (option: string) => { window.settings.setLocaleSettings(option) dispatch(initIntl()) }, setFetchInterval: (interval: number) => { window.settings.setFetchInterval(interval) dispatch(setupAutoFetch()) }, deleteArticles: async (days: number) => { dispatch(saveSettings()) let date = new Date() date.setTime(date.getTime() - days * 86400000) await db.itemsDB .delete() .from(db.items) .where(db.items.date.lt(date)) .exec() await dispatch(updateUnreadCounts()) dispatch(saveSettings()) }, importAll: async () => { dispatch(saveSettings()) let cancelled = await importAll() if (cancelled) dispatch(saveSettings()) }, }) const AppTabContainer = connect(null, mapDispatchToProps)(AppTab) export default AppTabContainer ================================================ FILE: src/containers/settings/groups-container.tsx ================================================ import { connect } from "react-redux" import { createSelector } from "reselect" import { RootState } from "../../scripts/reducer" import GroupsTab from "../../components/settings/groups" import { createSourceGroup, updateSourceGroup, addSourceToGroup, deleteSourceGroup, removeSourceFromGroup, reorderSourceGroups, } from "../../scripts/models/group" import { SourceGroup, SyncService } from "../../schema-types" import { importGroups } from "../../scripts/models/service" import { AppDispatch } from "../../scripts/utils" const getSources = (state: RootState) => state.sources const getGroups = (state: RootState) => state.groups const getServiceOn = (state: RootState) => state.service.type !== SyncService.None const mapStateToProps = createSelector( [getSources, getGroups, getServiceOn], (sources, groups, serviceOn) => ({ sources: sources, groups: groups.map((g, i) => ({ ...g, index: i })), serviceOn: serviceOn, key: groups.length, }) ) const mapDispatchToProps = (dispatch: AppDispatch) => ({ createGroup: (name: string) => dispatch(createSourceGroup(name)), updateGroup: (group: SourceGroup) => dispatch(updateSourceGroup(group)), addToGroup: (groupIndex: number, sid: number) => dispatch(addSourceToGroup(groupIndex, sid)), deleteGroup: (groupIndex: number) => dispatch(deleteSourceGroup(groupIndex)), removeFromGroup: (groupIndex: number, sids: number[]) => dispatch(removeSourceFromGroup(groupIndex, sids)), reorderGroups: (groups: SourceGroup[]) => dispatch(reorderSourceGroups(groups)), importGroups: () => dispatch(importGroups()), }) const GroupsTabContainer = connect( mapStateToProps, mapDispatchToProps )(GroupsTab) export default GroupsTabContainer ================================================ FILE: src/containers/settings/rules-container.tsx ================================================ import { connect } from "react-redux" import { createSelector } from "reselect" import { RootState } from "../../scripts/reducer" import RulesTab from "../../components/settings/rules" import { AppDispatch } from "../../scripts/utils" import { RSSSource, updateSource } from "../../scripts/models/source" import { SourceRule } from "../../scripts/models/rule" const getSources = (state: RootState) => state.sources const mapStateToProps = createSelector([getSources], sources => ({ sources: sources, })) const mapDispatchToProps = (dispatch: AppDispatch) => ({ updateSourceRules: (source: RSSSource, rules: SourceRule[]) => { source.rules = rules dispatch(updateSource(source)) }, }) const RulesTabContainer = connect(mapStateToProps, mapDispatchToProps)(RulesTab) export default RulesTabContainer ================================================ FILE: src/containers/settings/service-container.tsx ================================================ import { connect } from "react-redux" import { createSelector } from "reselect" import { RootState } from "../../scripts/reducer" import { ServiceTab } from "../../components/settings/service" import { AppDispatch } from "../../scripts/utils" import { ServiceConfigs } from "../../schema-types" import { saveServiceConfigs, getServiceHooksFromType, removeService, syncWithService, } from "../../scripts/models/service" import { saveSettings } from "../../scripts/models/app" const getService = (state: RootState) => state.service const mapStateToProps = createSelector([getService], service => ({ configs: service, })) const mapDispatchToProps = (dispatch: AppDispatch) => ({ save: (configs: ServiceConfigs) => dispatch(saveServiceConfigs(configs)), remove: () => dispatch(removeService()), blockActions: () => dispatch(saveSettings()), sync: () => dispatch(syncWithService()), authenticate: async (configs: ServiceConfigs) => { const hooks = getServiceHooksFromType(configs.type) if (hooks.authenticate) return await hooks.authenticate(configs) else return true }, reauthenticate: async (configs: ServiceConfigs) => { const hooks = getServiceHooksFromType(configs.type) try { if (hooks.reauthenticate) return await hooks.reauthenticate(configs) } catch (err) { console.log(err) return configs } }, }) const ServiceTabContainer = connect( mapStateToProps, mapDispatchToProps )(ServiceTab) export default ServiceTabContainer ================================================ FILE: src/containers/settings/sources-container.tsx ================================================ import intl from "react-intl-universal" import { connect } from "react-redux" import { createSelector } from "reselect" import { RootState } from "../../scripts/reducer" import SourcesTab from "../../components/settings/sources" import { addSource, RSSSource, updateSource, deleteSource, SourceOpenTarget, deleteSources, toggleSourceHidden, } from "../../scripts/models/source" import { importOPML, exportOPML } from "../../scripts/models/group" import { AppDispatch, validateFavicon } from "../../scripts/utils" import { saveSettings, toggleSettings } from "../../scripts/models/app" import { SyncService } from "../../schema-types" const getSources = (state: RootState) => state.sources const getServiceOn = (state: RootState) => state.service.type !== SyncService.None const getSIDs = (state: RootState) => state.app.settings.sids const mapStateToProps = createSelector( [getSources, getServiceOn, getSIDs], (sources, serviceOn, sids) => ({ sources: sources, serviceOn: serviceOn, sids: sids, }) ) const mapDispatchToProps = (dispatch: AppDispatch) => { return { acknowledgeSIDs: () => dispatch(toggleSettings(true)), addSource: (url: string) => dispatch(addSource(url)), updateSourceName: (source: RSSSource, name: string) => { dispatch(updateSource({ ...source, name: name } as RSSSource)) }, updateSourceIcon: async (source: RSSSource, iconUrl: string) => { dispatch(saveSettings()) if (await validateFavicon(iconUrl)) { dispatch(updateSource({ ...source, iconurl: iconUrl })) } else { window.utils.showErrorBox(intl.get("sources.badIcon"), "") } dispatch(saveSettings()) }, updateSourceOpenTarget: ( source: RSSSource, target: SourceOpenTarget ) => { dispatch( updateSource({ ...source, openTarget: target } as RSSSource) ) }, updateFetchFrequency: (source: RSSSource, frequency: number) => { dispatch( updateSource({ ...source, fetchFrequency: frequency, } as RSSSource) ) }, deleteSource: (source: RSSSource) => dispatch(deleteSource(source)), deleteSources: (sources: RSSSource[]) => dispatch(deleteSources(sources)), importOPML: () => dispatch(importOPML()), exportOPML: () => dispatch(exportOPML()), toggleSourceHidden: (source: RSSSource) => dispatch(toggleSourceHidden(source)), } } const SourcesTabContainer = connect( mapStateToProps, mapDispatchToProps )(SourcesTab) export default SourcesTabContainer ================================================ FILE: src/containers/settings-container.tsx ================================================ import { connect } from "react-redux" import { createSelector } from "reselect" import { RootState } from "../scripts/reducer" import { exitSettings } from "../scripts/models/app" import Settings from "../components/settings" const getApp = (state: RootState) => state.app const mapStateToProps = createSelector([getApp], app => ({ display: app.settings.display, blocked: !app.sourceInit || app.syncing || app.fetchingItems || app.settings.saving, exitting: app.settings.saving, })) const mapDispatchToProps = dispatch => { return { close: () => dispatch(exitSettings()), } } const SettingsContainer = connect(mapStateToProps, mapDispatchToProps)(Settings) export default SettingsContainer ================================================ FILE: src/electron.ts ================================================ import { app, ipcMain, Menu, nativeTheme } from "electron" import { ThemeSettings, SchemaTypes } from "./schema-types" import { store } from "./main/settings" import performUpdate from "./main/update-scripts" import { WindowManager } from "./main/window" if (!process.mas) { const locked = app.requestSingleInstanceLock() if (!locked) { app.quit() } } if (!app.isPackaged) app.setAppUserModelId(process.execPath) else if (process.platform === "win32") app.setAppUserModelId("me.hyliu.fluentreader") let restarting = false function init() { performUpdate(store) nativeTheme.themeSource = store.get("theme", ThemeSettings.Default) } init() if (process.platform === "darwin") { const template = [ { label: "Application", submenu: [ { label: "Hide", accelerator: "Command+H", click: () => { app.hide() }, }, { label: "Quit", accelerator: "Command+Q", click: () => { if (winManager.hasWindow) winManager.mainWindow.close() }, }, ], }, { label: "Edit", submenu: [ { label: "Undo", accelerator: "CmdOrCtrl+Z", selector: "undo:", }, { label: "Redo", accelerator: "Shift+CmdOrCtrl+Z", selector: "redo:", }, { label: "Cut", accelerator: "CmdOrCtrl+X", selector: "cut:" }, { label: "Copy", accelerator: "CmdOrCtrl+C", selector: "copy:", }, { label: "Paste", accelerator: "CmdOrCtrl+V", selector: "paste:", }, { label: "Select All", accelerator: "CmdOrCtrl+A", selector: "selectAll:", }, ], }, { label: "Window", submenu: [ { label: "Close", accelerator: "Command+W", click: () => { if (winManager.hasWindow) winManager.mainWindow.close() }, }, { label: "Minimize", accelerator: "Command+M", click: () => { if (winManager.hasWindow()) winManager.mainWindow.minimize() }, }, { label: "Zoom", click: () => winManager.zoom() }, ], }, ] Menu.setApplicationMenu(Menu.buildFromTemplate(template)) } else { Menu.setApplicationMenu(null) } const winManager = new WindowManager() app.on("window-all-closed", () => { if (winManager.hasWindow()) { winManager.mainWindow.webContents.session.clearStorageData({ storages: ["cookies", "localstorage"], }) } winManager.mainWindow = null if (restarting) { restarting = false winManager.createWindow() } else { app.quit() } }) ipcMain.handle("import-all-settings", (_, configs: SchemaTypes) => { restarting = true store.clear() for (let [key, value] of Object.entries(configs)) { // @ts-ignore store.set(key, value) } performUpdate(store) nativeTheme.themeSource = store.get("theme", ThemeSettings.Default) setTimeout( () => { winManager.mainWindow.close() }, process.platform === "darwin" ? 1000 : 0 ) // Why ??? }) ================================================ FILE: src/index.html ================================================ Fluent Reader
================================================ FILE: src/index.tsx ================================================ import * as React from "react" import * as ReactDOM from "react-dom" import { Provider } from "react-redux" import { initializeIcons } from "@fluentui/react/lib/Icons" import Root from "./components/root" import { applyThemeSettings } from "./scripts/settings" import { initApp, openTextMenu } from "./scripts/models/app" import { rootStore } from "./scripts/reducer" window.settings.setProxy() applyThemeSettings() initializeIcons("icons/") rootStore.dispatch(initApp()) window.utils.addMainContextListener((pos, text) => { rootStore.dispatch(openTextMenu(pos, text)) }) window.fontList = [""] window.utils.initFontList().then(fonts => { window.fontList.push(...fonts) }) ReactDOM.render( , document.getElementById("app") ) ================================================ FILE: src/main/settings.ts ================================================ import Store = require("electron-store") import { SchemaTypes, SourceGroup, ViewType, ThemeSettings, SearchEngines, SyncService, ServiceConfigs, ViewConfigs, } from "../schema-types" import { ipcMain, session, nativeTheme, app } from "electron" import { WindowManager } from "./window" export const store = new Store() const GROUPS_STORE_KEY = "sourceGroups" ipcMain.handle("set-groups", (_, groups: SourceGroup[]) => { store.set(GROUPS_STORE_KEY, groups) }) ipcMain.on("get-groups", event => { event.returnValue = store.get(GROUPS_STORE_KEY, []) }) const MENU_STORE_KEY = "menuOn" ipcMain.on("get-menu", event => { event.returnValue = store.get(MENU_STORE_KEY, false) }) ipcMain.handle("set-menu", (_, state: boolean) => { store.set(MENU_STORE_KEY, state) }) const PAC_STORE_KEY = "pac" const PAC_STATUS_KEY = "pacOn" function getProxyStatus() { return store.get(PAC_STATUS_KEY, false) } function toggleProxyStatus() { store.set(PAC_STATUS_KEY, !getProxyStatus()) setProxy() } function getProxy() { return store.get(PAC_STORE_KEY, "") } function setProxy(address = null) { if (!address) { address = getProxy() } else { store.set(PAC_STORE_KEY, address) } if (getProxyStatus()) { let rules = { pacScript: address } session.defaultSession.setProxy(rules) session.fromPartition("sandbox").setProxy(rules) } } ipcMain.on("get-proxy-status", event => { event.returnValue = getProxyStatus() }) ipcMain.on("toggle-proxy-status", () => { toggleProxyStatus() }) ipcMain.on("get-proxy", event => { event.returnValue = getProxy() }) ipcMain.handle("set-proxy", (_, address = null) => { setProxy(address) }) const VIEW_STORE_KEY = "view" ipcMain.on("get-view", event => { event.returnValue = store.get(VIEW_STORE_KEY, ViewType.Cards) }) ipcMain.handle("set-view", (_, viewType: ViewType) => { store.set(VIEW_STORE_KEY, viewType) }) const THEME_STORE_KEY = "theme" ipcMain.on("get-theme", event => { event.returnValue = store.get(THEME_STORE_KEY, ThemeSettings.Default) }) ipcMain.handle("set-theme", (_, theme: ThemeSettings) => { store.set(THEME_STORE_KEY, theme) nativeTheme.themeSource = theme }) ipcMain.on("get-theme-dark-color", event => { event.returnValue = nativeTheme.shouldUseDarkColors }) export function setThemeListener(manager: WindowManager) { nativeTheme.removeAllListeners() nativeTheme.on("updated", () => { if (manager.hasWindow()) { let contents = manager.mainWindow.webContents if (!contents.isDestroyed()) { contents.send("theme-updated", nativeTheme.shouldUseDarkColors) } } }) } const LOCALE_STORE_KEY = "locale" ipcMain.handle("set-locale", (_, option: string) => { store.set(LOCALE_STORE_KEY, option) }) function getLocaleSettings() { return store.get(LOCALE_STORE_KEY, "default") } ipcMain.on("get-locale-settings", event => { event.returnValue = getLocaleSettings() }) ipcMain.on("get-locale", event => { let setting = getLocaleSettings() let locale = setting === "default" ? app.getLocale() : setting event.returnValue = locale }) const FONT_SIZE_STORE_KEY = "fontSize" ipcMain.on("get-font-size", event => { event.returnValue = store.get(FONT_SIZE_STORE_KEY, 16) }) ipcMain.handle("set-font-size", (_, size: number) => { store.set(FONT_SIZE_STORE_KEY, size) }) const FONT_STORE_KEY = "fontFamily" ipcMain.on("get-font", event => { event.returnValue = store.get(FONT_STORE_KEY, "") }) ipcMain.handle("set-font", (_, font: string) => { store.set(FONT_STORE_KEY, font) }) ipcMain.on("get-all-settings", event => { let output = {} for (let [key, value] of store) { output[key] = value } event.returnValue = output }) const FETCH_INTEVAL_STORE_KEY = "fetchInterval" ipcMain.on("get-fetch-interval", event => { event.returnValue = store.get(FETCH_INTEVAL_STORE_KEY, 0) }) ipcMain.handle("set-fetch-interval", (_, interval: number) => { store.set(FETCH_INTEVAL_STORE_KEY, interval) }) const SEARCH_ENGINE_STORE_KEY = "searchEngine" ipcMain.on("get-search-engine", event => { event.returnValue = store.get(SEARCH_ENGINE_STORE_KEY, SearchEngines.Google) }) ipcMain.handle("set-search-engine", (_, engine: SearchEngines) => { store.set(SEARCH_ENGINE_STORE_KEY, engine) }) const SERVICE_CONFIGS_STORE_KEY = "serviceConfigs" ipcMain.on("get-service-configs", event => { event.returnValue = store.get(SERVICE_CONFIGS_STORE_KEY, { type: SyncService.None, }) }) ipcMain.handle("set-service-configs", (_, configs: ServiceConfigs) => { store.set(SERVICE_CONFIGS_STORE_KEY, configs) }) const FILTER_TYPE_STORE_KEY = "filterType" ipcMain.on("get-filter-type", event => { event.returnValue = store.get(FILTER_TYPE_STORE_KEY, null) }) ipcMain.handle("set-filter-type", (_, filterType: number) => { store.set(FILTER_TYPE_STORE_KEY, filterType) }) const LIST_CONFIGS_STORE_KEY = "listViewConfigs" ipcMain.on("get-view-configs", (event, view: ViewType) => { switch (view) { case ViewType.List: event.returnValue = store.get( LIST_CONFIGS_STORE_KEY, ViewConfigs.ShowCover ) break default: event.returnValue = undefined break } }) ipcMain.handle( "set-view-configs", (_, view: ViewType, configs: ViewConfigs) => { switch (view) { case ViewType.List: store.set(LIST_CONFIGS_STORE_KEY, configs) break } } ) const NEDB_STATUS_STORE_KEY = "useNeDB" ipcMain.on("get-nedb-status", event => { event.returnValue = store.get(NEDB_STATUS_STORE_KEY, true) }) ipcMain.handle("set-nedb-status", (_, flag: boolean) => { store.set(NEDB_STATUS_STORE_KEY, flag) }) ================================================ FILE: src/main/touchbar.ts ================================================ import { TouchBarTexts } from "../schema-types" import { BrowserWindow, TouchBar } from "electron" function createTouchBarFunctionButton( window: BrowserWindow, text: string, key: string ) { return new TouchBar.TouchBarButton({ label: text, click: () => window.webContents.send("touchbar-event", key), }) } export function initMainTouchBar(texts: TouchBarTexts, window: BrowserWindow) { const touchBar = new TouchBar({ items: [ createTouchBarFunctionButton(window, texts.menu, "F1"), createTouchBarFunctionButton(window, texts.search, "F2"), new TouchBar.TouchBarSpacer({ size: "small" }), createTouchBarFunctionButton(window, texts.refresh, "F5"), createTouchBarFunctionButton(window, texts.markAll, "F6"), createTouchBarFunctionButton(window, texts.notifications, "F7"), ], }) window.setTouchBar(touchBar) } ================================================ FILE: src/main/update-scripts.ts ================================================ import { app } from "electron" import Store = require("electron-store") import { SchemaTypes } from "../schema-types" export default function performUpdate(store: Store) { let version = store.get("version", null) let useNeDB = store.get("useNeDB", undefined) let currentVersion = app.getVersion() if (useNeDB === undefined) { if (version !== null) { const revs = version.split(".").map(s => parseInt(s)) store.set( "useNeDB", (revs[0] === 0 && revs[1] < 8) || !app.isPackaged ) } else { store.set("useNeDB", false) } } if (version != currentVersion) { store.set("version", currentVersion) } } ================================================ FILE: src/main/utils.ts ================================================ import { ipcMain, shell, dialog, app, session, clipboard } from "electron" import { WindowManager } from "./window" import fs = require("fs") import { ImageCallbackTypes, TouchBarTexts } from "../schema-types" import { initMainTouchBar } from "./touchbar" import fontList = require("font-list") export function setUtilsListeners(manager: WindowManager) { async function openExternal(url: string, background = false) { if (url.startsWith("https://") || url.startsWith("http://")) { if (background && process.platform === "darwin") { shell.openExternal(url, { activate: false }) } else if (background && manager.hasWindow()) { manager.mainWindow.setAlwaysOnTop(true) await shell.openExternal(url) setTimeout(() => manager.mainWindow.setAlwaysOnTop(false), 1000) } else { shell.openExternal(url) } } } app.on("web-contents-created", (_, contents) => { contents.setWindowOpenHandler(details => { if (contents.getType() === "webview") openExternal( details.url, details.disposition === "background-tab" ) return { action: manager.hasWindow() ? "deny" : "allow", } }) contents.on("will-navigate", (event, url) => { event.preventDefault() if (contents.getType() === "webview") openExternal(url) }) }) ipcMain.on("get-version", event => { event.returnValue = app.getVersion() }) ipcMain.handle("open-external", (_, url: string, background: boolean) => { openExternal(url, background) }) ipcMain.handle( "show-error-box", async (_, title, content, copy?: string) => { if (manager.hasWindow() && copy != null) { const response = await dialog.showMessageBox( manager.mainWindow, { type: "error", title: title, message: title, detail: content, buttons: ["OK", copy], cancelId: 0, defaultId: 0, } ) if (response.response === 1) { clipboard.writeText(`${title}: ${content}`) } } else { dialog.showErrorBox(title, content) } } ) ipcMain.handle( "show-message-box", async (_, title, message, confirm, cancel, defaultCancel, type) => { if (manager.hasWindow()) { let response = await dialog.showMessageBox(manager.mainWindow, { type: type, title: title, message: title, detail: message, buttons: process.platform === "win32" ? ["Yes", "No"] : [confirm, cancel], cancelId: 1, defaultId: defaultCancel ? 1 : 0, }) return response.response === 0 } else { return false } } ) ipcMain.handle( "show-save-dialog", async (_, filters: Electron.FileFilter[], path: string) => { ipcMain.removeAllListeners("write-save-result") if (manager.hasWindow()) { let response = await dialog.showSaveDialog(manager.mainWindow, { defaultPath: path, filters: filters, }) if (!response.canceled) { ipcMain.handleOnce( "write-save-result", (_, result, errmsg) => { fs.writeFile(response.filePath, result, err => { if (err) dialog.showErrorBox(errmsg, String(err)) }) } ) return true } } return false } ) ipcMain.handle( "show-open-dialog", async (_, filters: Electron.FileFilter[]) => { if (manager.hasWindow()) { let response = await dialog.showOpenDialog(manager.mainWindow, { filters: filters, properties: ["openFile"], }) if (!response.canceled) { try { return await fs.promises.readFile( response.filePaths[0], "utf-8" ) } catch (err) { console.log(err) } } } return null } ) ipcMain.handle("get-cache", async () => { return await session.defaultSession.getCacheSize() }) ipcMain.handle("clear-cache", async () => { await session.defaultSession.clearCache() }) app.on("web-contents-created", (_, contents) => { if (contents.getType() === "webview") { contents.on( "did-fail-load", (event, code, desc, validated, isMainFrame) => { if (isMainFrame && manager.hasWindow()) { manager.mainWindow.webContents.send( "webview-error", desc ) } } ) contents.on("context-menu", (_, params) => { if ( (params.hasImageContents || params.selectionText || params.linkURL) && manager.hasWindow() ) { if (params.hasImageContents) { ipcMain.removeHandler("image-callback") ipcMain.handleOnce( "image-callback", (_, type: ImageCallbackTypes) => { switch (type) { case ImageCallbackTypes.OpenExternal: case ImageCallbackTypes.OpenExternalBg: openExternal( params.srcURL, type === ImageCallbackTypes.OpenExternalBg ) break case ImageCallbackTypes.SaveAs: contents.session.downloadURL( params.srcURL ) break case ImageCallbackTypes.Copy: contents.copyImageAt(params.x, params.y) break case ImageCallbackTypes.CopyLink: clipboard.writeText(params.srcURL) break } } ) manager.mainWindow.webContents.send( "webview-context-menu", [params.x, params.y] ) } else { manager.mainWindow.webContents.send( "webview-context-menu", [params.x, params.y], params.selectionText, params.linkURL ) } contents .executeJavaScript( `new Promise(resolve => { const dismiss = () => { document.removeEventListener("mousedown", dismiss) document.removeEventListener("scroll", dismiss) resolve() } document.addEventListener("mousedown", dismiss) document.addEventListener("scroll", dismiss) })` ) .then(() => { if (manager.hasWindow()) { manager.mainWindow.webContents.send( "webview-context-menu" ) } }) } }) contents.on("before-input-event", (_, input) => { if (manager.hasWindow()) { let contents = manager.mainWindow.webContents contents.send("webview-keydown", input) } }) } }) ipcMain.handle("write-clipboard", (_, text) => { clipboard.writeText(text) }) ipcMain.handle("close-window", () => { if (manager.hasWindow()) manager.mainWindow.close() }) ipcMain.handle("minimize-window", () => { if (manager.hasWindow()) manager.mainWindow.minimize() }) ipcMain.handle("maximize-window", () => { manager.zoom() }) ipcMain.on("is-maximized", event => { event.returnValue = Boolean(manager.mainWindow) && manager.mainWindow.isMaximized() }) ipcMain.on("is-focused", event => { event.returnValue = manager.hasWindow() && manager.mainWindow.isFocused() }) ipcMain.on("is-fullscreen", event => { event.returnValue = manager.hasWindow() && manager.mainWindow.isFullScreen() }) ipcMain.handle("request-focus", () => { if (manager.hasWindow()) { const win = manager.mainWindow if (win.isMinimized()) win.restore() if (process.platform === "win32") { win.setAlwaysOnTop(true) win.setAlwaysOnTop(false) } win.focus() } }) ipcMain.handle("request-attention", () => { if (manager.hasWindow() && !manager.mainWindow.isFocused()) { if (process.platform === "win32") { manager.mainWindow.flashFrame(true) manager.mainWindow.once("focus", () => { manager.mainWindow.flashFrame(false) }) } else if (process.platform === "darwin") { app.dock.bounce() } } }) ipcMain.handle("touchbar-init", (_, texts: TouchBarTexts) => { if (manager.hasWindow()) initMainTouchBar(texts, manager.mainWindow) }) ipcMain.handle("touchbar-destroy", () => { if (manager.hasWindow()) manager.mainWindow.setTouchBar(null) }) ipcMain.handle("init-font-list", () => { return fontList.getFonts({ disableQuoting: true, }) }) } ================================================ FILE: src/main/window.ts ================================================ import windowStateKeeper = require("electron-window-state") import { BrowserWindow, nativeTheme, app } from "electron" import path = require("path") import { setThemeListener } from "./settings" import { setUtilsListeners } from "./utils" export class WindowManager { mainWindow: BrowserWindow = null private mainWindowState: windowStateKeeper.State constructor() { this.init() } private init = () => { app.on("ready", () => { this.mainWindowState = windowStateKeeper({ defaultWidth: 1200, defaultHeight: 700, }) this.setListeners() this.createWindow() }) } private setListeners = () => { setThemeListener(this) setUtilsListeners(this) app.on("second-instance", () => { if (this.mainWindow !== null) { this.mainWindow.focus() } }) app.on("activate", () => { if (this.mainWindow === null) { this.createWindow() } }) } createWindow = () => { if (!this.hasWindow()) { this.mainWindow = new BrowserWindow({ title: "Fluent Reader", backgroundColor: process.platform === "darwin" ? "#00000000" : nativeTheme.shouldUseDarkColors ? "#282828" : "#faf9f8", vibrancy: "sidebar", x: this.mainWindowState.x, y: this.mainWindowState.y, width: this.mainWindowState.width, height: this.mainWindowState.height, minWidth: 992, minHeight: 600, frame: process.platform === "darwin", titleBarStyle: "hiddenInset", fullscreenable: process.platform === "darwin", show: false, webPreferences: { webviewTag: true, contextIsolation: true, spellcheck: false, preload: path.join( app.getAppPath(), (app.isPackaged ? "dist/" : "") + "preload.js" ), }, }) this.mainWindowState.manage(this.mainWindow) this.mainWindow.on("ready-to-show", () => { this.mainWindow.show() this.mainWindow.focus() if (!app.isPackaged) this.mainWindow.webContents.openDevTools() }) this.mainWindow.loadFile( (app.isPackaged ? "dist/" : "") + "index.html" ) this.mainWindow.on("maximize", () => { this.mainWindow.webContents.send("maximized") }) this.mainWindow.on("unmaximize", () => { this.mainWindow.webContents.send("unmaximized") }) this.mainWindow.on("enter-full-screen", () => { this.mainWindow.webContents.send("enter-fullscreen") }) this.mainWindow.on("leave-full-screen", () => { this.mainWindow.webContents.send("leave-fullscreen") }) this.mainWindow.on("focus", () => { this.mainWindow.webContents.send("window-focus") }) this.mainWindow.on("blur", () => { this.mainWindow.webContents.send("window-blur") }) this.mainWindow.webContents.on("context-menu", (_, params) => { if (params.selectionText) { this.mainWindow.webContents.send( "window-context-menu", [params.x, params.y], params.selectionText ) } }) } } zoom = () => { if (this.hasWindow()) { if (this.mainWindow.isMaximized()) { this.mainWindow.unmaximize() } else { this.mainWindow.maximize() } } } hasWindow = () => { return this.mainWindow !== null && !this.mainWindow.isDestroyed() } } ================================================ FILE: src/preload.ts ================================================ import { contextBridge } from "electron" import settingsBridge from "./bridges/settings" import utilsBridge from "./bridges/utils" contextBridge.exposeInMainWorld("settings", settingsBridge) contextBridge.exposeInMainWorld("utils", utilsBridge) ================================================ FILE: src/schema-types.ts ================================================ export class SourceGroup { isMultiple: boolean sids: number[] name?: string expanded?: boolean index?: number // available only from menu or groups tab container constructor(sids: number[], name: string = null) { name = (name && name.trim()) || "Source group" if (sids.length == 1) { this.isMultiple = false } else { this.isMultiple = true this.name = name this.expanded = true } this.sids = sids } } export const enum ViewType { Cards, List, Magazine, Compact, Customized, } export const enum ViewConfigs { ShowCover = 1 << 0, ShowSnippet = 1 << 1, FadeRead = 1 << 2, } export const enum ThemeSettings { Default = "system", Light = "light", Dark = "dark", } export const enum SearchEngines { Google, Bing, Baidu, DuckDuckGo, } export const enum ImageCallbackTypes { OpenExternal, OpenExternalBg, SaveAs, Copy, CopyLink, } export const enum SyncService { None, Fever, Feedbin, GReader, Inoreader, Miniflux, Nextcloud, } export interface ServiceConfigs { type: SyncService importGroups?: boolean } export const enum WindowStateListenerType { Maximized, Focused, Fullscreen, } export interface TouchBarTexts { menu: string search: string refresh: string markAll: string notifications: string } export type SchemaTypes = { version: string theme: ThemeSettings pac: string pacOn: boolean view: ViewType locale: string sourceGroups: SourceGroup[] fontSize: number fontFamily: string menuOn: boolean fetchInterval: number searchEngine: SearchEngines serviceConfigs: ServiceConfigs filterType: number listViewConfigs: ViewConfigs useNeDB: boolean } ================================================ FILE: src/scripts/db.ts ================================================ import intl from "react-intl-universal" import Datastore from "nedb" import lf from "lovefield" import { RSSSource } from "./models/source" import { RSSItem } from "./models/item" const sdbSchema = lf.schema.create("sourcesDB", 3) sdbSchema .createTable("sources") .addColumn("sid", lf.Type.INTEGER) .addPrimaryKey(["sid"], false) .addColumn("url", lf.Type.STRING) .addColumn("iconurl", lf.Type.STRING) .addColumn("name", lf.Type.STRING) .addColumn("openTarget", lf.Type.NUMBER) .addColumn("lastFetched", lf.Type.DATE_TIME) .addColumn("serviceRef", lf.Type.STRING) .addColumn("fetchFrequency", lf.Type.NUMBER) .addColumn("rules", lf.Type.OBJECT) .addColumn("textDir", lf.Type.NUMBER) .addColumn("hidden", lf.Type.BOOLEAN) .addNullable(["iconurl", "serviceRef", "rules"]) .addIndex("idxURL", ["url"], true) const idbSchema = lf.schema.create("itemsDB", 1) idbSchema .createTable("items") .addColumn("_id", lf.Type.INTEGER) .addPrimaryKey(["_id"], true) .addColumn("source", lf.Type.INTEGER) .addColumn("title", lf.Type.STRING) .addColumn("link", lf.Type.STRING) .addColumn("date", lf.Type.DATE_TIME) .addColumn("fetchedDate", lf.Type.DATE_TIME) .addColumn("thumb", lf.Type.STRING) .addColumn("content", lf.Type.STRING) .addColumn("snippet", lf.Type.STRING) .addColumn("creator", lf.Type.STRING) .addColumn("hasRead", lf.Type.BOOLEAN) .addColumn("starred", lf.Type.BOOLEAN) .addColumn("hidden", lf.Type.BOOLEAN) .addColumn("notify", lf.Type.BOOLEAN) .addColumn("serviceRef", lf.Type.STRING) .addNullable(["thumb", "creator", "serviceRef"]) .addIndex("idxDate", ["date"], false, lf.Order.DESC) .addIndex("idxService", ["serviceRef"], false) export let sourcesDB: lf.Database export let sources: lf.schema.Table export let itemsDB: lf.Database export let items: lf.schema.Table async function onUpgradeSourceDB(rawDb: lf.raw.BackStore) { const version = rawDb.getVersion() if (version < 2) { await rawDb.addTableColumn("sources", "textDir", 0) } if (version < 3) { await rawDb.addTableColumn("sources", "hidden", false) } } export async function init() { sourcesDB = await sdbSchema.connect({ onUpgrade: onUpgradeSourceDB }) sources = sourcesDB.getSchema().table("sources") itemsDB = await idbSchema.connect() items = itemsDB.getSchema().table("items") if (window.settings.getNeDBStatus()) { await migrateNeDB() } } async function migrateNeDB() { try { const sdb = new Datastore({ filename: "sources", autoload: true, onload: err => { if (err) window.console.log(err) }, }) const idb = new Datastore({ filename: "items", autoload: true, onload: err => { if (err) window.console.log(err) }, }) const sourceDocs = await new Promise(resolve => { sdb.find({}, (_, docs) => { resolve(docs) }) }) const itemDocs = await new Promise(resolve => { idb.find({}, (_, docs) => { resolve(docs) }) }) const sRows = sourceDocs.map(doc => { if (doc.serviceRef !== undefined) doc.serviceRef = String(doc.serviceRef) // @ts-ignore delete doc._id if (!doc.fetchFrequency) doc.fetchFrequency = 0 doc.textDir = 0 doc.hidden = false return sources.createRow(doc) }) const iRows = itemDocs.map(doc => { if (doc.serviceRef !== undefined) doc.serviceRef = String(doc.serviceRef) if (!doc.title) doc.title = intl.get("article.untitled") if (!doc.content) doc.content = "" if (!doc.snippet) doc.snippet = "" delete doc._id doc.starred = Boolean(doc.starred) doc.hidden = Boolean(doc.hidden) doc.notify = Boolean(doc.notify) return items.createRow(doc) }) await Promise.all([ sourcesDB.insert().into(sources).values(sRows).exec(), itemsDB.insert().into(items).values(iRows).exec(), ]) window.settings.setNeDBStatus(false) sdb.remove({}, { multi: true }, () => { sdb.persistence.compactDatafile() }) idb.remove({}, { multi: true }, () => { idb.persistence.compactDatafile() }) } catch (err) { window.utils.showErrorBox( "An error has occured during update. Please report this error on GitHub.", String(err) ) window.utils.closeWindow() } } ================================================ FILE: src/scripts/i18n/README.md ================================================ ## Internationalization Currently, Fluent Reader supports the following languages. | Locale | Language | Credit | | --- | --- | --- | | en-US | English | [@yang991178](https://github.com/yang991178) | | cs | Čeština | [@vikdevelop](https://github.com/vikdevelop) | | es | Español | [@kant](https://github.com/kant) | | fr-FR | Français | [@Toinane](https://github.com/Toinane) | | fi-FI | Suomi | [@SUPERHAMSTERI](https://github.com/SUPERHAMSTERI) | | zh-CN | 中文(简体) | [@yang991178](https://github.com/yang991178) | | zh-TW | 中文(繁體) | [@jerryc127](https://github.com/jerryc127) | | ja | 日本語 | [@tiancheng2000](https://github.com/tiancheng2000) | | de | Deutsch | [@NoNamePro0](https://github.com/NoNamePro0) | | sv | Svenska | [@eson57](https://github.com/eson57) | | tr | Türkçe | [@mustafagenc](https://github.com/mustafagenc) | | uk | Ukrainian | [@thevllad](https://github.com/thevllad) | | nl | Nederlands | [@Vistaus](https://github.com/Vistaus) | | it | Italiano | [@andrewasd](https://github.com/andrewasd) | | pt-BR | Português do Brasil | [@fabianski7](https://github.com/fabianski7) | | pt-PT | Português de Portugal | [@0x1336](https://github.com/0x1336) | | ko | 한글 | [@1drive](https://github.com/1drive) | | ru | Russian | [@nxblnd](https://github.com/nxblnd) | Refer to the repo of [react-intl-universal](https://github.com/alibaba/react-intl-universal) to get started on internationalization. ================================================ FILE: src/scripts/i18n/_locales.ts ================================================ import en_US from "./en-US.json" import cs from "./cs.json" import zh_CN from "./zh-CN.json" import zh_TW from "./zh-TW.json" import ja from "./ja.json" import fr_FR from "./fr-FR.json" import de from "./de.json" import nl from "./nl.json" import es from "./es.json" import sv from "./sv.json" import tr from "./tr.json" import it from "./it.json" import uk from "./uk.json" import ru from "./ru.json" import pt_BR from "./pt-BR.json" import fi_FI from "./fi-FI.json" import ko from "./ko.json" import pt_PT from "./pt-PT.json" const locales = { "en-US": en_US, "cs": cs, "zh-CN": zh_CN, "zh-TW": zh_TW, "ja": ja, "fr-FR": fr_FR, "de": de, "nl": nl, "es": es, "sv": sv, "tr": tr, "it": it, "uk": uk, "ru": ru, "pt-BR": pt_BR, "fi-FI": fi_FI, "ko": ko, "pt-PT": pt_PT, } export default locales ================================================ FILE: src/scripts/i18n/cs.json ================================================ { "allArticles": "Všechny články", "add": "Přidat", "create": "Vytvořit", "icon": "Ikona", "name": "Název", "openExternal": "Otevřít externě", "emptyName": "Toto pole nesmí být prázdné.", "emptyField": "Toto pole nesmí být prázdné.", "edit": "Upravit", "delete": "Odstranit", "followSystem": "Podle systému", "more": "Více", "close": "Zavřít", "search": "Hledat", "loadMore": "Načíst více", "dangerButton": "Potvrdit {action}?", "confirmMarkAll": "Opravdu chcete označit všechny články na této stránce jako přečtené?", "confirm": "Potvrdit", "cancel": "Zrušit", "default": "Výchozí", "time": { "now": "nyní", "m": "m", "h": "h", "d": "d", "minute": "{m, plural, =1 {# minute} other {# minutes}}", "hour": "{h, plural, =1 {# hour} other {# hours}}", "day": "{d, plural, =1 {# day} other {# days}}" }, "log": { "empty": "Žádné notifikace", "fetchFailure": "Nepodařilo se načíst zdroj \"{name}\".", "fetchSuccess": "Úspěšně načten {count, plural, =1 {# article} other {# articles}}.", "networkError": "Došlo k chybě sítě.", "parseError": "Při analýze kanálu XML došlo k chybě.", "syncFailure": "Došlo k chybě při synchronizaci se službou" }, "nav": { "menu": "Menu", "refresh": "Načíst znovu", "markAllRead": "Označit vše jako přečtené", "notifications": "Notifikace", "view": "Zobrazit", "settings": "Nastavení", "minimize": "Maximalizovat", "maximize": "Minimalizovat" }, "menu": { "close": "Zavřít menu", "subscriptions": "Odběry" }, "article": { "error": "Nepodařilo se načíst článek.", "reload": "Načíst znovu?", "empty": "Žádné články", "untitled": "(Bez titulku)", "hide": "Skrýt článek", "unhide": "Odkrýt článek", "markRead": "Označit jako přečtené", "markUnread": "Označit jako nepřečtené", "markAbove": "Označte výše uvedené jako přečtené", "markBelow": "Označte níže uvedené jako přečtené", "star": "Ohvězdičkovat", "unstar": "Odstranit hvězdu", "fontSize": "Velikost písma", "loadWebpage": "Načíst webovou stránku", "loadFull": "Načíst všechen obsah", "notify": "Upozornit, pokud je načteno na pozadí", "dontNotify": "Neupozorňovat", "textDir": "Směr textu", "LTR": "Zleva doprava", "RTL": "Zprava doleva", "Vertical": "Vertikálně", "font": "Písmo" }, "context": { "share": "Sdílet", "read": "Číst", "copyTitle": "Kopírovat titulek", "copyURL": "Kopírovat odkaz", "copy": "Kopírovat", "search": "Hledat \"{text}\" on {engine}", "view": "Zobrazit", "cardView": "Karty", "listView": "Seznam", "magazineView": "Časopis", "compactView": "Kompaktní", "filter": "Filtrování", "unreadOnly": "Jen nepřečtené", "starredOnly": "Jen ohvězdičkované", "fullSearch": "Vyhledat v plném textu", "showHidden": "Zobrazit skryté články", "manageSources": "Spravovat zdroje", "saveImageAs": "Uložit obrázek jako …", "copyImage": "Kopírovat obrázek", "copyImageURL": "Kopírovat adresu obrázku", "caseSensitive": "Rozlišovat velká a malá písmena", "showCover": "Zobrazit kryt", "showSnippet": "Zobrazit úryvek", "fadeRead": " Vyblednout čtenné články" }, "searchEngine": { "name": "Vyhledávač", "google": "Google", "bing": "Bing", "baidu": "Baidu", "duckduckgo": "DuckDuckGo" }, "settings": { "writeError": "Došlo k chybě při zapisování souboru.", "name": "Nastavení", "fetching": "Probíhá aktualizace zdrojů, prosím vyčkejte …", "exit": "Opustit nastavení", "sources": "Zdroje", "grouping": "Skupiny", "rules": "Pravidla", "service": "Služba", "app": "Předvolby", "about": "O aplikaci", "version": "Verze", "shortcuts": "Zkratky", "openSource": "Otevřený zdrojový kód", "feedback": "Zpětná vazba" }, "sources": { "serviceWarning": "Zde importované nebo přidané zdroje nebudou synchronizovány s vaší službou.", "serviceManaged": "Tento zdroj spravuje vaše služba.", "untitled": "Zdroj", "errorAdd": "Došlo k chybě při přidávání zdroje.", "errorParse": "Při analýze souboru OPML došlo k chybě.", "errorParseHint": "Ujistěte se, že soubor není poškozený a je kódován pomocí UTF-8.", "errorImport": "Objevila se chyba při importování {count, plural, =1 {# source} other {# sources}}.", "exist": "Tento zdroj již existuje.", "opmlFile": "Soubor OPML", "name": "Název zdroje", "editName": "Upravit název", "fetchFrequency": "Limit frekvence načítání", "unlimited": "Neomezené", "openTarget": "Výchozí otevřený cíl pro články", "delete": "Odstranit zdroj", "add": "Přidat zdroj", "import": "Importovat", "export": "Exportovat", "rssText": "Celý text RSS", "loadWebpage": "Načíst webovou stránku", "inputUrl": "Zadejte URL adresu", "badIcon": "Nesprávná ikona", "badUrl": "Nesprávná URL adresa", "deleteWarning": "Zdroj a všechny uložené články budou odstraněny.", "selected": "Vybraný zdroj", "selectedMulti": "Vybrat více zdrojů", "hidden": "Ukryto v \"all articles\"" }, "groups": { "exist": "Tato skupina již existuje", "type": "Typ", "group": "Skupina", "source": "Zdroj", "capacity": "Kapacita", "exitGroup": "Zpět na skupiny", "deleteSource": "Odstranit ze skupiny", "sourceHint": "Přetažením zdrojů změníte jejich pořadí.", "create": "Vytvořit skupinu", "selectedGroup": "Vybraná skupina", "selectedSource": "Vybraný zdroj", "enterName": "Zadejte název", "editName": "Editovat název", "deleteGroup": "Odstranit skupinu", "chooseGroup": "Vybrat skupinu", "addToGroup": "Přidat do ...", "groupHint": "Dvojklikem na skupinu upravíte zdroje. Přetažením změníte pořadí." }, "rules": { "intro": "Automatické označování článků nebo odesílání oznámení pomocí regulárních výrazů.", "help": "Zjistit více", "source": "Zdroj", "selectSource": "Vybrat zdroj", "new": "Nové pravidlo", "if": "Pokud", "then": "Poté", "title": "Titulek", "content": "Obsah", "fullSearch": "Titulek nebo obsah", "creator": "Autor", "match": "odpovídá", "notMatch": "neodpovídá", "regex": "Pravidelný výraz", "badRegex": "Nesprávný pravidelný výraz.", "action": "Akce", "selectAction": "Vybrat akce", "hint": "Pravidla se použijí v pořadí. Tažením myši můžete změnit pořadí.", "test": "Testovací pravidla" }, "service": { "intro": "Synchronizace mezi zařízeními pomocí služeb RSS.", "select": "Vyberte službu", "suggest": "Navrhnout novou službu", "overwriteWarning": "Místní zdroje budou odstraněny, pokud ve službě existují.", "groupsWarning": "Skupiny nejsou automaticky synchronizovány se službou.", "rateLimitWarning": "Abyste se vyhnuli omezování rychlosti, musíte si vytvořit vlastní klíč API.", "removeAd": "Odstranit reklamu", "endpoint": "Koncový bod", "username": "Uživatelské jméno", "password": "Heslo", "unchanged": "Nezměněno", "fetchLimit": "Limit synchronizace", "fetchLimitNum": "{count} nejnovějších článků", "importGroups": "Importovat skupiny", "failure": "Nedaří se připojit ke službě", "failureHint": "Zkontrolujte konfiguraci služby nebo stav sítě.", "fetchUnlimited": "Neomezený (nedoporučuje se)", "exportToLite": "Exportovat do Fluent reader Lite" }, "app": { "cleanup": "Vyčistit", "cache": "Vyčistit mezipaměť", "cacheSize": "Velikost {size} dat v mezipaměti", "deleteChoices": "Odstranit články před ... dny", "confirmDelete": "Odstranit", "daysAgo": "{days, plural, =1 {# day} other {# days}} ago", "deleteAll": "Odstranit věechny články", "calculatingSize": "Probíhá výpočet velikosti...", "itemSize": "Přibližně {size} místního úložiště zabírají články", "confirmImport": "Opravdu chcete importovat data ze záložního souboru? Všechna aktuální data budou vymazána.", "data": "Data aplikace", "backup": "Záloha", "restore": "Obnovit", "frData": "Data aplikace Fluent Reader", "language": "Zobrazovaný jazyk", "theme": "Motiv", "lightTheme": "Světlý motiv", "darkTheme": "Tmavý motiv", "enableProxy": "Povolit Proxy", "badUrl": "Nesprávná URL", "pac": "PAC Adresa", "setPac": "Nastavit PAC", "pacHint": "U proxy serverů Socks se doporučuje, aby PAC vracel \"SOCKS5\" pro DNS na straně proxy serveru. Vypnutí proxy vyžaduje restart.", "fetchInterval": "Automatický interval načítání", "never": "Nikdy" } } ================================================ FILE: src/scripts/i18n/de.json ================================================ { "allArticles": "Alle Artikel", "add": "Hinzufügen", "create": "Erstellen", "icon": "Icon", "name": "Name", "openExternal": "Extern öffnen", "emptyName": "Dieses Feld darf nicht leer sein.", "emptyField": "Dieses Feld darf nicht leer sein.", "edit": "Bearbeiten", "delete": "Entfernen", "followSystem": "Systemstandard verwenden", "more": "Mehr", "close": "Schließen", "search": "Suchen", "loadMore": "Mehr laden", "dangerButton": "Wirklich {action}?", "confirmMarkAll": "Möchtest du wirklich alle Artikel als gelesen markieren?", "confirm": "Bestätigen", "cancel": "Abbrechen", "time": { "now": "gerade eben", "m": "m", "h": "h", "d": "d", "minute": "{m, plural, =1 {# minute} other {# minutes}}", "hour": "{h, plural, =1 {# hour} other {# hours}}", "day": "{d, plural, =1 {# day} other {# days}}" }, "log": { "empty": "Keine Benachrichtigungen", "fetchFailure": "Fehler beim Laden von \"{name}\".", "fetchSuccess": "Erfolgreich {count, plural, =1 {# Artikel} other {# Artikel}} geladen.", "networkError": "Netzwerk-Fehler aufgetreten.", "parseError": "Fehler beim Lesen des XML-Feeds.", "syncFailure": "Fehler beim Synchronisieren." }, "nav": { "menu": "Menü", "refresh": "Neu laden", "markAllRead": "Alles als gelesen markieren", "notifications": "Benachrichtigungen", "view": "Ansicht", "settings": "Einstellungen", "minimize": "Minimieren", "maximize": "Maximieren" }, "menu": { "close": "Menü schließen", "subscriptions": "Abonnements" }, "article": { "error": "Fehler beim Laden des Artikels.", "reload": "Neu laden?", "empty": "Keine Artikel vorhanden", "untitled": "(Unbenannt)", "hide": "Artikel ausblenden", "unhide": "Artikel wieder anzeigen", "markRead": "Als gelesen markieren", "markUnread": "Als ungelesen markieren", "markAbove": "Darüber als gelesen markieren", "markBelow": "Darunter als gelesen markieren", "star": "Favorisieren", "unstar": "Favorit entfernen", "fontSize": "Schriftgröße", "loadWebpage": "Internetseite laden", "loadFull": "Kompletten Inhalt laden", "notify": "Benachrichtigen, wenn im Hintergrund geladen", "dontNotify": "Nicht benachrichtigen" }, "context": { "share": "Teilen", "read": "Lesen", "copyTitle": "Titel kopieren", "copyURL": "Link kopieren", "copy": "Kopieren", "search": "\"{text}\" mit {engine} suchen", "view": "Ansicht", "cardView": "Karten", "listView": "Liste", "magazineView": "Magazin", "compactView": "Kompakt", "filter": "Filter", "unreadOnly": "Nur ungelesene", "starredOnly": "Nur Favoriten", "fullSearch": "Suche im kompletten Text", "showHidden": "Ausgeblendete Artikel anzeigen", "manageSources": "Feeds verwalten", "saveImageAs": "Bild speichern unter …", "copyImage": "Bild kopieren", "copyImageURL": "Bild-Link kopieren", "caseSensitive": "Groß-/Kleinschreibung beachten", "showCover": "Cover anzeigen", "showSnippet": "Ausschnitt anzeigen", "fadeRead": "Gelesene Artikel ausblenden" }, "searchEngine": { "name": "Suchmaschine", "google": "Google", "bing": "Bing", "baidu": "Baidu", "duckduckgo": "DuckDuckGo" }, "settings": { "writeError": "Fehler beim Schreiben der Datei.", "name": "Einstellungen", "fetching": "Aktualisiere Feeds, bitte warten …", "exit": "Einstellungen schließen", "sources": "Feeds", "grouping": "Gruppen", "rules": "Regeln", "service": "Server", "app": "Einstellungen", "about": "Info", "version": "Version", "shortcuts": "Verknüpfungen", "openSource": "Open Source", "feedback": "Feedback" }, "sources": { "serviceWarning": "Feeds, die hier importiert oder hinzugefügt werden, können nicht mit dem Server synchronisiert werden.", "serviceManaged": "Dieser Feed wird vom Server verwaltet.", "untitled": "Feed", "errorAdd": "Beim Hinzufügen des Feeds ist ein Fehler aufgetreten.", "errorParse": "Beim Lesen der OPML-Datei ist ein Fehler aufgetreten.", "errorParseHint": "Bitte überprüfe, ob die Datei nicht beschädigt und mit UTF-8 formatiert ist.", "errorImport": "Fehler beim Importieren von {count, plural, =1 {# Feed} other {# Feeds}}.", "exist": "Dieser Feed existiert bereits.", "opmlFile": "OPML-Datei", "name": "Name des Feeds", "editName": "Name bearbeiten", "fetchFrequency": "Abrufhäufigkeit begrenzen", "unlimited": "Unbegrenzt", "openTarget": "Standard-Ziel für Artikel", "delete": "Feed entfernen", "add": "Feed hinzufügen", "import": "Importieren", "export": "Exportieren", "rssText": "RSS-Volltext", "loadWebpage": "Internetseite laden", "inputUrl": "URL einfügen", "badIcon": "Ungültiges Icon", "badUrl": "Ungültige URL", "deleteWarning": "Der Feed und alle gespeicherten Artikel werden entfernt.", "selected": "Ausgewählter Feed", "selectedMulti": "Ausgewählte Feeds" }, "groups": { "exist": "Diese Gruppe existiert bereits.", "type": "Typ", "group": "Gruppe", "source": "Feed", "capacity": "Größe", "exitGroup": "Zurück zu Gruppen", "deleteSource": "Aus Gruppe entfernen", "sourceHint": "Gruppen mit Drag & Drop sortieren.", "create": "Gruppe erstelllen", "selectedGroup": "Ausgewählte Gruppe", "selectedSource": "Ausgewählte Feeds", "enterName": "Name eingeben", "editName": "Name speichern", "deleteGroup": "Gruppe löschen", "chooseGroup": "Wähle ein Gruppe aus", "addToGroup": "Zur Gruppe hinzufügen", "groupHint": "Doppelklicke auf die Gruppe um die Feeds zu bearbeiten. Drag & Drop zum Sortieren." }, "rules": { "intro": "Artikel automatisch markieren oder Benachrichtigungen senden mit Hilfe regulärer Ausdrücke.", "help": "Mehr erfahren", "source": "Feed", "selectSource": "Wähle einen Feed aus", "new": "Neue Regel", "if": "Wenn", "then": "Dann", "title": "Titel", "content": "Inhalt", "fullSearch": "Titel oder Inhalt", "creator": "Autor/-in", "match": "enspricht", "notMatch": "entspricht nicht", "regex": "regulärer Ausdruck", "badRegex": "Ungültiger regulärer Ausdruck.", "action": "Aktionen", "selectAction": "Aktionen wählen", "hint": "Die Regeln werden in Reihenfolge verarbeitet. Drag & Drop zum Sortieren.", "test": "Regeln testen" }, "service": { "intro": "Mit RSS-Server über mehrere Geräte hinweg synchronisieren.", "select": "Wähle ein Anbieter aus", "suggest": "Schlage einen anderen Anbieter vor", "overwriteWarning": "Lokale Feeds werden gelöscht, wenn sie auf dem Server schon existieren.", "groupsWarning": "Gruppen werden nicht automatisch mit dem Server synchronisiert.", "rateLimitWarning": "Um Abruf-Begrenzungen zu vermeiden, musst du einen eigenen API-Schlüssel erstellen.", "removeAd": "Werbung entfernen", "endpoint": "Endpoint", "username": "Nutzername", "password": "Passwort", "unchanged": "Unverändert", "fetchLimit": "Synchronisations-Limit", "fetchLimitNum": "{count} Artikel", "importGroups": "Gruppen importieren", "failure": "Fehler beim Verbinden zum Server", "failureHint": "Bitte überprüfe die Server-Konfiguration oder deinen Netzwerk-Status.", "fetchUnlimited": "Unbegrenzt (nicht empfohlen)", "exportToLite": "Für Fluent Reader Lite exportieren" }, "app": { "cleanup": "Aufräumen", "cache": "Cache leeren", "cacheSize": "{size} Daten gecacht", "deleteChoices": "Entferne Artikel älter als ... Tage", "confirmDelete": "Löschen", "daysAgo": "{days} Tage", "deleteAll": "Alle Artikel entfernen", "calculatingSize": "Berechne Größe...", "itemSize": "Ungefähr {size} lokaler Speicher wird für Artikel genutzt.", "confirmImport": "Möchtest du deine Daten wirklich neu importieren? Deine aktuellen Daten werden gelöscht.", "data": "App-Daten", "backup": "Sichern", "restore": "Wiederherstellen", "frData": "Fluent Reader - Daten", "language": "Anzeigesprache", "theme": "Darstellung", "lightTheme": "Hell", "darkTheme": "Dunkel", "enableProxy": "Proxy aktivieren", "badUrl": "Ungültige URL", "pac": "PAC-Adresse", "setPac": "PAC-Adresse festlegen", "pacHint": "Für SOCKS-Proxies wird empfohlen, für proxy-seitiges DNS im PAC \"SOCKS5\" zurückzugeben. Das Deaktivieren des Proxies erfordert einen Neustart.", "fetchInterval": "Automatisches Abrufintervall", "never": "Nie" } } ================================================ FILE: src/scripts/i18n/en-US.json ================================================ { "allArticles": "All articles", "add": "Add", "create": "Create", "icon": "Icon", "name": "Name", "openExternal": "Open externally", "emptyName": "This field cannot be empty.", "emptyField": "This field cannot be empty.", "edit": "Edit", "delete": "Delete", "followSystem": "Follow system", "more": "More", "close": "Close", "search": "Search", "loadMore": "Load more", "dangerButton": "Confirm {action}?", "confirmMarkAll": "Do you really want to mark all articles on this page as read?", "confirm": "Confirm", "cancel": "Cancel", "default": "Default", "time": { "now": "now", "m": "m", "h": "h", "d": "d", "minute": "{m, plural, =1 {# minute} other {# minutes}}", "hour": "{h, plural, =1 {# hour} other {# hours}}", "day": "{d, plural, =1 {# day} other {# days}}" }, "log": { "empty": "No notifications", "fetchFailure": "Failed to load source \"{name}\".", "fetchSuccess": "Successfully fetched {count, plural, =1 {# article} other {# articles}}.", "networkError": "A network error has occurred.", "parseError": "An error has occurred when parsing the XML feed.", "syncFailure": "Failed to sync with service" }, "nav": { "menu": "Menu", "refresh": "Refresh", "markAllRead": "Mark all as read", "notifications": "Notifications", "view": "View", "settings": "Settings", "minimize": "Minimize", "maximize": "Maximize" }, "menu": { "close": "Close menu", "subscriptions": "Subscriptions" }, "article": { "error": "Failed to load article.", "reload": "Reload?", "empty": "No articles", "untitled": "(Untitled)", "hide": "Hide article", "unhide": "Unhide article", "markRead": "Mark as read", "markUnread": "Mark as unread", "markAbove": "Mark above as read", "markBelow": "Mark below as read", "star": "Star", "unstar": "Remove star", "fontSize": "Font size", "loadWebpage": "Load webpage", "loadFull": "Load full content", "notify": "Notify if fetched in background", "dontNotify": "Don't notify", "textDir": "Text direction", "LTR": "Left-to-right", "RTL": "Right-to-left", "Vertical": "Vertical", "font": "Font" }, "context": { "share": "Share", "read": "Read", "copyTitle": "Copy title", "copyURL": "Copy link", "copy": "Copy", "search": "Search \"{text}\" on {engine}", "view": "View", "cardView": "Card view", "listView": "List view", "magazineView": "Magazine view", "compactView": "Compact view", "filter": "Filtering", "unreadOnly": "Unread only", "starredOnly": "Starred only", "fullSearch": "Search in full text", "showHidden": "Show hidden articles", "manageSources": "Manage sources", "saveImageAs": "Save image as …", "copyImage": "Copy image", "copyImageURL": "Copy image link", "caseSensitive": "Case sensitive", "showCover": "Show cover", "showSnippet": "Show snippet", "fadeRead": "Fade read articles" }, "searchEngine": { "name": "Search engine", "google": "Google", "bing": "Bing", "baidu": "Baidu", "duckduckgo": "DuckDuckGo" }, "settings": { "writeError": "An error has occurred while writing the file.", "name": "Settings", "fetching": "Updating sources, please wait …", "exit": "Exit settings", "sources": "Sources", "grouping": "Groups", "rules": "Rules", "service": "Service", "app": "Preferences", "about": "About", "version": "Version", "shortcuts": "Shortcuts", "openSource": "Open source", "feedback": "Feedback" }, "sources": { "serviceWarning": "Sources imported or added here will not be synced with your service.", "serviceManaged": "This source is managed by your service.", "untitled": "Source", "errorAdd": "An error has occured when adding the source.", "errorParse": "An error has occurred when parsing the OPML file.", "errorParseHint": "Please ensure that the file isn't corrupted and is encoded with UTF-8.", "errorImport": "Error importing {count, plural, =1 {# source} other {# sources}}.", "exist": "This source already exists.", "opmlFile": "OPML File", "name": "Source name", "editName": "Edit name", "fetchFrequency": "Fetch frequency limit", "unlimited": "Unlimited", "openTarget": "Default open target for articles", "delete": "Delete source", "add": "Add source", "import": "Import", "export": "Export", "rssText": "RSS full text", "loadWebpage": "Load webpage", "inputUrl": "Enter URL", "badIcon": "Invalid icon", "badUrl": "Invalid URL", "deleteWarning": "The source and all saved articles will be removed.", "selected": "Selected source", "selectedMulti": "Selected multiple sources", "hidden": "Hide in \"all articles\"" }, "groups": { "exist": "This group already exists.", "type": "Type", "group": "Group", "source": "Source", "capacity": "Capacity", "exitGroup": "Back to groups", "deleteSource": "Delete from group", "sourceHint": "Drag and drop sources to reorder.", "create": "Create group", "selectedGroup": "Selected group", "selectedSource": "Selected source", "enterName": "Enter name", "editName": "Edit name", "deleteGroup": "Delete group", "chooseGroup": "Select a group", "addToGroup": "Add to ...", "groupHint": "Double click on group to edit sources. Drag and drop to reorder." }, "rules": { "intro": "Automatically mark articles or send notifications with regular expressions.", "help": "Learn more", "source": "Source", "selectSource": "Select a source", "new": "New Rule", "if": "If", "then": "Then", "title": "Title", "content": "Content", "fullSearch": "Title or content", "creator": "Author", "match": "matches", "notMatch": "doesn't match", "regex": "Regular expression", "badRegex": "Invalid regular expression.", "action": "Actions", "selectAction": "Select actions", "hint": "Rules will be applied in order. Drag and drop to reorder.", "test": "Test rules" }, "service": { "intro": "Sync across devices with RSS services.", "select": "Select a service", "suggest": "Suggest a new service", "overwriteWarning": "Local sources will be deleted if they exist in the service.", "groupsWarning": "Groups aren't automatically synced with the service.", "rateLimitWarning": "To avoid rate limiting, you need to create your own API Key.", "removeAd": "Remove Ad", "endpoint": "Endpoint", "username": "Username", "password": "Password", "unchanged": "Unchanged", "fetchLimit": "Sync limit", "fetchLimitNum": "{count} latest articles", "importGroups": "Import groups", "failure": "Cannot connect to service", "failureHint": "Please check the service configuration or network status.", "fetchUnlimited": "Unlimited (not recommended)", "exportToLite": "Export to Fluent Reader Lite" }, "app": { "cleanup": "Clean up", "cache": "Clear cache", "cacheSize": "Cached {size} of data", "deleteChoices": "Delete articles from ... days ago", "confirmDelete": "Delete", "daysAgo": "{days, plural, =1 {# day} other {# days}} ago", "deleteAll": "Delete all articles", "calculatingSize": "Calculating size...", "itemSize": "Around {size} of local storage is occupied by articles", "confirmImport": "Do you really want to import data from the backup file? All current data will be wiped.", "data": "Application Data", "backup": "Backup", "restore": "Restore", "frData": "Fluent Reader Data", "language": "Display language", "theme": "Theme", "lightTheme": "Light mode", "darkTheme": "Dark mode", "enableProxy": "Enable Proxy", "badUrl": "Invalid URL", "pac": "PAC Address", "setPac": "Set PAC", "pacHint": "For Socks proxies, it is recommended for PAC to return \"SOCKS5\" for proxy-side DNS. Turning off proxy requires restart.", "fetchInterval": "Automatic fetch interval", "never": "Never" } } ================================================ FILE: src/scripts/i18n/es.json ================================================ { "allArticles": "Todos los artículos", "add": "Agregar", "create": "Crear", "icon": "Icono", "name": "Nombre", "openExternal": "Abrir de modo externo", "emptyName": "Este campo no puede estar vacío.", "emptyField": "Este campo no puede estar vacío.", "edit": "Modificar", "delete": "Eliminar", "followSystem": "Seguir sistema", "more": "Más", "close": "Cerrar", "search": "Buscar", "loadMore": "Cargar más", "dangerButton": "Confirmar {action}?", "confirmMarkAll": "¿Desea realmente marcar todos los artículos en esta página como leídos?", "confirm": "Confirmar", "cancel": "Cancelar", "time": { "now": "ahora", "m": "m", "h": "h", "d": "d", "minute": "{m, plural, =1 {# minuto} other {# minutos}}", "hour": "{h, plural, =1 {# hora} other {# horas}}", "day": "{d, plural, =1 {# día} other {# días}}" }, "log": { "empty": "Sin notificaciones", "fetchFailure": "Falló al cargar fuente \"{name}\".", "fetchSuccess": "Recuperado exitosamente {count, plural, =1 {# artículo} other {# artículos}}." }, "nav": { "menu": "Menú", "refresh": "Actualizar", "markAllRead": "Marcar todo como leído", "notifications": "Notificaciones", "view": "Ver", "settings": "Configuraciones", "minimize": "Minimizar", "maximize": "Maximizar" }, "menu": { "close": "Cerrar menú", "subscriptions": "Suscripciones" }, "article": { "empty": "Sin artículos", "untitled": "(sin título)", "hide": "Ocultar artículo", "unhide": "Mostar artículo", "markRead": "Marcar como leído", "markUnread": "Marcar como no leído", "star": "Destacar", "unstar": "Quitar destacado", "fontSize": "Tamaño de la fuente", "loadWebpage": "Cargar página web" }, "context": { "share": "Compartir", "read": "Leer", "copyTitle": "Copiar título", "copyURL": "Copiar enlace", "copy": "Copiar", "search": "Buscar \"{text}\" en {engine}", "view": "Ver", "cardView": "Vista en modo tarjeta", "listView": "Vista en modo listado", "magazineView": "Vista en modo revista", "compactView": "Vista en modo compacta", "filter": "Filtrando", "unreadOnly": "Solo no leídos", "starredOnly": "Solo destacados", "fullSearch": "Buscar en todo el texto", "showHidden": "Mostrar artículos ocultos", "manageSources": "Gestionar fuentes" }, "settings": { "writeError": "Se produjo un error al escribir el archivo.", "name": "Configuraciones", "fetching": "Actualizando fuentes, por favor espere …", "exit": "Salir de Configuraciones", "sources": "Fuentes", "grouping": "Agrupamiento", "rules": "Reglas", "app": "Preferencias", "about": "Acerca", "version": "Versión", "shortcuts": "Atajos", "openSource": "Abrir fuente", "feedback": "Reacciones" }, "sources": { "untitled": "Fuente", "errorAdd": "Se ha producido un error al agregar la fuente.", "errorParse": "Se produjo un error al analizar el archivo OPML.", "errorParseHint": "Por favor asegúrese que el archivo no está corrupto y codificado en formato UTF-8.", "errorImport": "Error en la importación {count, plural, =1 {# fuente} other {# fuentes}}.", "opmlFile": "Archivo OPML", "name": "Nombre de la fuente", "editName": "Modificar nombre", "fetchFrequency": "Límite en la frecuencia de recolección de las fuentes", "unlimited": "Ilimitado", "openTarget": "Lugar predeterminado de apertura de los artículos", "delete": "Eliminar fuente", "add": "Agregar fuente", "import": "Importar", "export": "Exportar", "rssText": "RSS texto completo", "loadWebpage": "Cargar página web", "inputUrl": "Ingresar dirección URL", "badUrl": "URL no válida", "deleteWarning": "Se eliminarán la fuente y todos los artículos guardados..", "selected": "Fuente seleccionada", "selectedMulti": "Múltiples fuentes seleccionadas" }, "groups": { "type": "Tipo", "group": "Grupo", "source": "Fuente", "capacity": "Capacidad", "exitGroup": "Volver a los grupos", "deleteSource": "Eliminar del grupo", "sourceHint": "Arrastrar y soltar fuentes para reordenar.", "create": "Crear grupo", "selectedGroup": "Grupo seleccionado", "selectedSource": "Fuente seleccionada", "enterName": "Ingresar nombre", "editName": "Modificar nombre", "deleteGroup": "Eliminar grupo", "chooseGroup": "Seleccionar un grupo", "addToGroup": "Agregar a ...", "groupHint": "Doble clic sobre un grupo para modificar las fuentes. Arrastrar y soltar para reordenar." }, "rules": { "source": "Fuente", "selectSource": "Seleccionar una fuente", "new": "Nueva regla", "if": "Si", "then": "Luego", "title": "Título", "content": "Contenido", "fullSearch": "Título o contenido", "match": "coincidencias", "notMatch": "no coincide", "regex": "Expresión regular", "badRegex": "Expresión regular no válida.", "action": "Acciones", "selectAction": "Seleccionar acciones", "hint": "Las reglas se aplicarán en orden. Arrastrar y soltar para reordenar.", "test": "Probar reglas" }, "app": { "cleanup": "Limpiar", "cache": "Limpiar caché", "cacheSize": "{size} caché de datos", "deleteChoices": "Eliminar artículos de hace ... días atrás", "confirmDelete": "Eliminar", "daysAgo": "hace {days} atrás", "deleteAll": "Eliminar todos los artículos", "calculatingSize": "Calculando tamaño...", "itemSize": "Alrededor de {size} del almacenamiento local está ocupado por artículos", "confirmImport": "¿Realmente desea importar datos desde el archivo de resguardo? Todos los actuales datos serán eliminados.", "data": "Datos de la aplicación", "backup": "Resguardo", "restore": "Restaurar", "frData": "Lector de datos", "language": "Mostrar idioma", "theme": "Tema", "lightTheme": "Modo claro", "darkTheme": "Modo oscuro", "enableProxy": "Habilitar proxy", "badUrl": "URL no válida", "pac": "Dirección PAC", "setPac": "Establecer PAC", "pacHint": "Para proxies de tipo Socks proxies, se recomienda que PAC devuelva \"SOCKS5\" respecto del DNS del proxy. Desactivar el proxy requiere reiniciar." } } ================================================ FILE: src/scripts/i18n/fi-FI.json ================================================ { "add": "Lisää", "allArticles": "Kaikki artikkelit", "app": { "backup": "Varmuuskopiointi", "badUrl": "Virheellinen URL", "cache": "Tyhjennä välimuisti", "cacheSize": "Välimuistissa on {size} dataa", "calculatingSize": "Lasketaan kokoa...", "cleanup": "Siivoa", "confirmDelete": "Poista", "confirmImport": "Haluatko tuoda tiedot varmuuskopiosta? Kaikki nykyinen data poistetaan.", "darkTheme": "Tumma tila", "data": "Sovelluksen Data", "daysAgo": "{days, plural, =1 {# päivä} other {# päivää}} sitten", "deleteAll": "Poista kaikki artikkelit", "deleteChoices": "Poista ... päivää vanhemmat artikkelit", "enableProxy": "Käytä välityspalvelinta", "fetchInterval": "Automaattinen päivitys", "frData": "Fluent Reader Data", "itemSize": "Artikkelit käyttävät {size} paikallista tallennustilaa", "language": "Näyttökieli", "lightTheme": "Vaalea tila", "never": "Ei koskaan", "pac": "PAC Osoite", "pacHint": "Socks -välityspalvelimille on suositeltavaa asettaa PAC palauttamaan \"SOCKS5\" välityspalvelminen DNS:tä. Välityspalvelimen laittaminen pois päältä vaatii uudelleenkäynnistyksen,", "restore": "Palauta", "setPac": "Aseta PAC", "theme": "Teema" }, "article": { "dontNotify": "Älä ilmoita", "empty": "Ei artikkeleita", "error": "Artikkelin lataaminen epäonnistui.", "fontSize": "Fonttikoko", "hide": "Piilota artikkeli", "loadFull": "Lataa koko sisältö", "loadWebpage": "Lataa verkkosivu", "markAbove": "Merkitse ylemmät luetuksi", "markBelow": "Merkitse alemmat luetuksi", "markRead": "Merkitse luetuksi", "markUnread": "Merkitse lukemattomaksi", "notify": "Ilmoita, jos haetaan taustalla", "reload": "Ladataanko uudelleen?", "star": "Tähti", "unhide": "Näytä artikkeli", "unstar": "Poista tähti", "untitled": "(Nimetön)" }, "cancel": "Peruuta", "close": "Sulje", "confirm": "Vahvista", "confirmMarkAll": "Haluatko todella merkitä kaikki tämän sivun artikkelit luetuiksi?", "context": { "cardView": "Kortit", "caseSensitive": "Vain sama merkkikoko", "compactView": "Kompakti", "copy": "Kopioi", "copyImage": "Kopioi kuva", "copyImageURL": "Kopioi kuvalinkki", "copyTitle": "Kopioi otsikko", "copyURL": "Kopioi linkki", "fadeRead": "Häivytä luetut artikkelit", "filter": "Suodatus", "fullSearch": "Hae koko tekstistä", "listView": "Lista", "magazineView": "Lehti", "manageSources": "Hallitse lähteitä", "read": "Luettu", "saveImageAs": "Tallenna kuva nimellä…", "search": "Hae \"{text}\" {engine}lla", "share": "Jaa", "showCover": "Näytä artikkelin kuva", "showHidden": "Näytä piilotetut artikkelit", "showSnippet": "Näytä katkelma", "starredOnly": "Vain tähdellä merkityt", "unreadOnly": "Vain lukemattomat", "view": "Näkymä" }, "create": "Luo", "dangerButton": "Vahvistetaanko {action}?", "delete": "Poista", "edit": "Muokkaa", "emptyField": "Tämä kenttä ei voi olla tyhjä.", "emptyName": "Tämä kenttä ei voi olla tyhjä.", "followSystem": "Käytä järjestelmän teemaa", "groups": { "addToGroup": "Lisää ...", "capacity": "Määrä", "chooseGroup": "Valitse ryhmä", "create": "Luo ryhmä", "deleteGroup": "Poista ryhmä", "deleteSource": "Poista ryhmästä", "editName": "Muokkaa nimeä", "enterName": "Anna nimi", "exist": "Tämä ryhmä on jo olemassa.", "exitGroup": "Takaisin ryhmiin", "group": "Ryhmä", "groupHint": "Kaksoisnapsauta ryhmää muokataksesi sen lähteitä. Järjestä uudelleen vetämällä ja pudottamalla.", "selectedGroup": "Valittu ryhmä", "selectedSource": "Valittu lähde", "source": "Lähde", "sourceHint": "Järjestä uudelleen vetämällä ja pudottamalla lähteitä.", "type": "Tyyppi" }, "icon": "Kuvake", "loadMore": "Lataa lisää", "log": { "empty": "Ei ilmoituksia", "fetchFailure": "Lähteen \"{name}\" lataaminen epäonnistui.", "fetchSuccess": "Noudettiin onnistuneesti {count, plural, =1 {# artikkeli} other {# artikkelia}}.", "networkError": "Tapahtui verkkovirhe.", "parseError": "XML-syötteen jäsentämisessä tapahtui virhe.", "syncFailure": "Palvelun kanssa synkronointi epäonnistui" }, "menu": { "close": "Sulje valikko", "subscriptions": "Tilaukset" }, "more": "Lisää", "name": "Nimi", "nav": { "markAllRead": "Merkitse kaikki luetuksi", "maximize": "Laajenna", "menu": "Valikko", "minimize": "Pienennä", "notifications": "Ilmoitukset", "refresh": "Päivitä", "settings": "Asetukset", "view": "Näkymä" }, "openExternal": "Avaa selaimessa", "rules": { "action": "Toiminto", "badRegex": "Virheellinen sääntö.", "content": "Sisältö", "creator": "Kirjoittaja", "fullSearch": "Otsikko tai sisältö", "help": "Lisätietoja", "hint": "Sääntöjä sovelletaan järjestyksessä. Järjestä uudelleen vetämällä ja pudottamalla.", "if": "Jos", "intro": "Merkitse artikkelit automaattisesti tai lähetä ilmoituksia säännöllisin lausekkein.", "match": "täsmää", "new": "Uusi sääntö", "notMatch": "ei täsmää", "regex": "Säännöllinen lauseke", "selectAction": "Valitse toiminnot", "selectSource": "Valitse lähde", "source": "Lähde", "test": "Testaa sääntöä", "then": "Sitten", "title": "Otsikko" }, "search": "Hae", "searchEngine": { "baidu": "Baidu", "bing": "Bing", "duckduckgo": "DuckDuckGo", "google": "Google", "name": "Hakukone" }, "service": { "endpoint": "Osoite", "exportToLite": "Vie Fluent Reader Lite -ohjelmaan", "failure": "Palveluun ei voi muodostaa yhteyttä", "failureHint": "Tarkista palvelun asetukset tai verkkoyhteytesi.", "fetchLimit": "Synkronointiraja", "fetchLimitNum": "{count} viimeisintä artikkelia", "fetchUnlimited": "Rajoittamaton (ei suositella)", "groupsWarning": "Ryhmiä ei synkronoida automaattisesti palvelun kanssa.", "importGroups": "Tuo ryhmät", "intro": "Synkronoi laitteiden välillä RSS-palveluiden kanssa.", "overwriteWarning": "Paikalliset lähteet poistetaan, jos niitä on palvelussa.", "password": "Salasana", "rateLimitWarning": "Rajapintakäytön rajoituksien välttämiseksi sinun on luotava oma API-avain.", "removeAd": "Poista mainos", "select": "Valitse palvelu", "suggest": "Ehdota uutta palvelua", "unchanged": "Ei muutoksia", "username": "Käyttäjätunnus" }, "settings": { "about": "Tietoja", "app": "Asetukset", "exit": "Poistu asetuksista", "feedback": "Palaute", "fetching": "Päivitetään lähteitä, odota…", "grouping": "Ryhmät", "name": "Asetukset", "openSource": "Avoin lähdekoodi", "rules": "Säännöt", "service": "Palvelu", "shortcuts": "Pikakomennot", "sources": "Lähteet", "version": "Versio", "writeError": "Tiedostoa kirjoitettaessa tapahtui virhe." }, "sources": { "add": "Lisää lähde", "badIcon": "Virheellinen kuvake", "badUrl": "Virheellinen URL", "delete": "Poista lähde", "deleteWarning": "Lähde ja kaikki tallennetut artikkelit poistetaan.", "editName": "Muokkaa nimeä", "errorAdd": "Lähdettä lisättäessä tapahtui virhe.", "errorImport": "Virhe tuotaessa {count, plural, =1 {# lähdettä} other {# lähteitä}}.", "errorParse": "OPML -tiedoston jäsentämisessä tapahtui virhe.", "errorParseHint": "Varmista, että tiedosto ei ole vioittunut ja että se on koodattu UTF-8: lla.", "exist": "Tämä lähde on jo olemassa.", "export": "Vie", "fetchFrequency": "Tietojen hakemisen raja", "import": "Tuo", "inputUrl": "Syötä URL", "loadWebpage": "Lataa verkkosivu", "name": "Lähteen nimi", "openTarget": "Avaa artikkelit oletuksena", "opmlFile": "OPML -tiedosto", "rssText": "RSS koko teksti", "selected": "Valittu lähde", "selectedMulti": "Valittu useita lähteitä", "serviceManaged": "Palvelu hallinnoi tätä lähdettä.", "serviceWarning": "Täältä tuotuja tai lisättyjä lähteitä ei synkronoida palvelusi kanssa.", "unlimited": "Rajoittamaton", "untitled": "Lähde" }, "time": { "d": "pv", "day": "{d, plural, =1 {# päivä} other {# päivää}}", "h": "h", "hour": "{h, plural, =1 {# tunti} other {# tuntia}}", "m": "min", "minute": "{m, plural, =1 {# minutti} other {# minuttia}}", "now": "nyt" } } ================================================ FILE: src/scripts/i18n/fr-FR.json ================================================ { "allArticles": "Tous les articles", "add": "Ajouter", "create": "Créer", "icon": "Icône", "name": "Nom", "openExternal": "Ouvrir dans le navigateur", "emptyName": "Ce champ ne peut pas être vide.", "emptyField": "Ce champ ne peut pas être vide.", "edit": "Modifier", "delete": "Supprimer", "followSystem": "Suivre le système", "more": "Plus", "close": "Fermer", "search": "Rechercher", "loadMore": "Charger plus", "dangerButton": "Confirmer {action} ?", "confirmMarkAll": "Voulez-vous vraiment marquer tous les articles de cette page comme lus ?", "confirm": "Confirmer", "cancel": "Annuler", "time": { "now": "maintenant", "m": "m", "h": "h", "d": "j", "minute": "{m, plural, =1 {# minute} other {# minutes}}", "hour": "{h, plural, =1 {# heure} other {# heures}}", "day": "{d, plural, =1 {# jour} other {# jours}}" }, "log": { "empty": "Aucune notification", "fetchFailure": "Échec du chargement de la source \"{name}\".", "fetchSuccess": "Récupération réussi de {count, plural, =1 {# article} other {# articles}}.", "networkError": "Une erreur de réseau s'est produite.", "parseError": "Une erreur s'est produite lors de l'analyse du flux XML.", "syncFailure": "Échec de synchronisation avec le service" }, "nav": { "menu": "Menu", "refresh": "Actualiser", "markAllRead": "Marquer tout comme lu", "notifications": "Notifications", "view": "Affichage", "settings": "Paramètres", "minimize": "Réduire", "maximize": "Agrandir" }, "menu": { "close": "Fermer le menu", "subscriptions": "Abonnements" }, "article": { "error": "Impossible de charger l'article.", "reload": "Recharger ?", "empty": "Pas d'articles", "untitled": "(Sans titre)", "hide": "Masquer l'article", "unhide": "Afficher l'article", "markRead": "Marquer comme lu", "markUnread": "Marquer comme non lu", "markAbove": "Marquer ci-dessus comme lu", "markBelow": "Marquer ci-dessous comme lu", "star": "Mettre en favori", "unstar": "Supprimer le favori", "fontSize": "Taille de la police", "loadWebpage": "Charger la page web", "loadFull": "Charger tout le contenu", "notify": "Notifier le chargement en arrière-plan", "dontNotify": "Ne pas notifier" }, "context": { "share": "Partager", "read": "Lire", "copyTitle": "Copier le titre", "copyURL": "Copier le lien", "copy": "Copier", "search": "Rechercher \"{text}\" sur {engine}", "view": "Affichage", "cardView": "Vue par carte", "listView": "Vue par liste", "magazineView": "Vue par magazine", "compactView": "Vue par compact", "filter": "Filtrer", "unreadOnly": "Non lu uniquement", "starredOnly": "Favoris uniquement", "fullSearch": "Recherche dans le texte complet", "showHidden": "Afficher les articles cachés", "manageSources": "Gérer les sources", "saveImageAs": "Enregistrer l'image sous…", "copyImage": "Copier l'image", "copyImageURL": "Copier l'adresse de l'image", "caseSensitive": "Sensible à la casse", "showCover": "Afficher couverture", "showSnippet": "Afficher extrait", "fadeRead": "Atténuer les articles lus" }, "searchEngine": { "name": "Moteur de recherche", "google": "Google", "bing": "Bing", "baidu": "Baidu", "duckduckgo": "DuckDuckGo" }, "settings": { "writeError": "Une erreur s'est produite lors de l'écriture du fichier.", "name": "Paramètres", "fetching": "Mise à jour des sources, veuillez patienter…", "exit": "Fermer les paramètres", "sources": "Sources", "grouping": "Groupes", "rules": "Règles", "service": "Service", "app": "Préférences", "about": "À propos", "version": "Version", "shortcuts": "Raccourcis", "openSource": "Open source", "feedback": "Feedback" }, "sources": { "serviceWarning": "Les sources importées ou ajoutées ici ne seront pas synchronisées avec votre service.", "serviceManaged": "Cette source est gérée par votre service.", "untitled": "Source", "errorAdd": "Une erreur s'est produite lors de l'ajout de la source.", "errorParse": "Une erreur s'est produite lors de l'analyse du fichier OPML.", "errorParseHint": "Veuillez vous assurer que le fichier n'est pas corrompu et qu'il est encodé en UTF-8.", "errorImport": "Erreur d'importation pour {count, plural, =1 {# source} other {# sources}}.", "exist": "Cette source existe déjà.", "opmlFile": "Fichier OPML", "name": "Nom de la source", "editName": "Modifier le nom", "fetchFrequency": "Limitation de la fréquence de collecte des données", "unlimited": "Illimité", "openTarget": "Mode d'ouverture par défaut des articles", "delete": "Supprimer la source", "add": "Ajouter la source", "import": "Importer", "export": "Exporter", "rssText": "Texte complet RSS", "loadWebpage": "Charger la page web", "inputUrl": "Saisissez l'adresse URL", "badIcon": "Icône invalide", "badUrl": "Adresse URL invalide", "deleteWarning": "La source et tous les articles sauvegardés seront supprimés.", "selected": "Source sélectionnée", "selectedMulti": "Sources multiples sélectionnées" }, "groups": { "exist": "Ce groupe existe déjà.", "type": "Type", "group": "Groupe", "source": "Source", "capacity": "Contenu", "exitGroup": "Retour aux groupes", "deleteSource": "Supprimer du groupe", "sourceHint": "Glisser-déposer les sources pour les réorganiser.", "create": "Créer un groupe", "selectedGroup": "Groupe sélectionné", "selectedSource": "Source sélectionnée", "enterName": "Saisissez un nom", "editName": "Modifier le nom", "deleteGroup": "Supprimer le groupe", "chooseGroup": "Selectionner un groupe", "addToGroup": "Ajouter à ...", "groupHint": "Double-cliquez sur le groupe pour modifier ses sources. Faites un glisser-déposer pour réorganiser les sources." }, "rules": { "intro": "Marquez automatiquement les articles ou envoyez des notifications avec des expressions régulières.", "help": "En savoir plus", "source": "Source", "selectSource": "Sélectionner une source", "new": "Nouvelle règle", "if": "Si", "then": "Alors", "title": "Titre", "content": "Contenu", "fullSearch": "Titre ou contenu", "creator": "Auteur", "match": "correspond à", "notMatch": "ne correspond pas à", "regex": "Expression régulière", "badRegex": "Expression régulière invalide.", "action": "Actions", "selectAction": "Sélectionner action", "hint": "Les règles seront appliquées dans l'ordre. Glisser-déposer pour réorganiser.", "test": "Tester les règles" }, "service": { "intro": "Synchronisation entre les appareils avec des services RSS.", "select": "Sélectionner un service", "suggest": "Proposer un nouveau service", "overwriteWarning": "Les sources locales seront supprimées si elles existent dans le service.", "groupsWarning": "Les groupes ne sont pas automatiquement synchronisés avec le service.", "endpoint": "Adresse", "username": "Pseudo", "password": "Mot de passe", "unchanged": "Inchangé", "fetchLimit": "Limite de synchronisation", "fetchLimitNum": "{count} articles récents", "importGroups": "Importer des groupes", "failure": "Impossible de se connecter au service", "failureHint": "Veuillez vérifier la configuration du service ou l'état du réseau.", "fetchUnlimited": "Illimité (non recommandé)" }, "app": { "cleanup": "Nettoyage", "cache": "Supprimer le cache", "cacheSize": "{size} de données mises en cache", "deleteChoices": "Supprimer les articles antérieurs à ... jours", "confirmDelete": "Supprimer", "daysAgo": "{days, plural, =1 {# jour} other {# jours}}", "deleteAll": "Supprimer tous les articles", "calculatingSize": "Calcul de la taille...", "itemSize": "Environ {size} du stockage local est occupé par des articles", "confirmImport": "Voulez-vous vraiment importer des données du fichier de sauvegarde ? Toutes les données actuelles seront effacées.", "data": "Données de l'appplication", "backup": "Sauvegarder", "restore": "Restorer", "frData": "Données de Fluent Reader", "language": "Langue d'affichage", "theme": "Thème", "lightTheme": "Mode jour", "darkTheme": "Mode nuit", "enableProxy": "Activer le Proxy", "badUrl": "Adresse URL invalide", "pac": "Adresse PAC", "setPac": "Définir PAC", "pacHint": "Pour les proxies Sockets, il est recommandé que le PAC retourne \"SOCKS5\" pour le DNS côté proxies. La désactivation du proxy nécessite un redémarrage.", "fetchInterval": "Intervalle de récupération automatique", "never": "Jamais" } } ================================================ FILE: src/scripts/i18n/it.json ================================================ { "allArticles": "Tutti gli articoli", "add": "Aggiungi", "create": "Crea", "icon": "Icona", "name": "Nome", "openExternal": "Apri Esternamente", "emptyName": "Questo campo non puo essere vuoto", "emptyField": "Questo campo non puo essere vuoto", "edit": "Modifica", "delete": "Elimina", "followSystem": "segui impostazioni di sistema", "more": "di più", "close": "Chiudi", "search": "Cerca", "loadMore": "Carica piu feed", "dangerButton": "Confermi di {action}?", "confirmMarkAll": "Vuoi veramente segnare tutti i feed di questa pagina come letti?", "confirm": "Confema", "cancel": "Anulla", "time": { "now": "ora", "m": "m", "h": "h", "d": "g", "minute": "{m, plural, =1 {# minuto} other {# minuti}}", "hour": "{h, plural, =1 {# ora} other {# ore}}", "day": "{d, plural, =1 {# giorno} other {# giorni}}" }, "log": { "empty": "Non ci sono notifiche", "fetchFailure": "Errore nel caricare la fonte \"{name}\".", "fetchSuccess": "{count, plural, =1 {# articolo} other {# articoli}} caricato con successo", "networkError": "è occorso un errore di rete", "parseError": "è occorso un errore nel anallizzare il feed rss", "syncFailure": "Errore nel sicronizzarsi con il servizio" }, "nav": { "menu": "Menu", "refresh": "Aggiorna", "markAllRead": "Segna tutti come letti", "notifications": "Notifiche", "view": "View", "settings": "Impostazioni", "minimize": "Riduci a Icona", "maximize": "Ingrandisci" }, "menu": { "close": "Chiudi menu", "subscriptions": "Iscrizioni" }, "article": { "error": "Errore nel caricare articolo", "reload": "Aggiorna?", "empty": "Non ci sono articoli", "untitled": "(Senza titolo)", "hide": "Nascondi articolo", "unhide": "Mostra articolo", "markRead": "Segna come letto", "markUnread": "Segna come non letto", "markAbove": "Segna precedenti come letti", "markBelow": "Segna successivi come letti", "star": "salva", "unstar": "rimuovi dai salvati", "fontSize": "Dimensione testo", "loadWebpage": "Carica pagina", "loadFull": "Carica tutto il contenuto", "notify": "Notifica se caricato in background", "dontNotify": "Non notificare" }, "context": { "share": "Convidi", "read": "Leggi", "copyTitle": "Copia titolo", "copyURL": "Copia link", "copy": "Copia", "search": "Cerca \"{text}\" on {engine}", "view": "Visualizza", "cardView": "Card view", "listView": "List view", "magazineView": "Magazine view", "compactView": "Compact view", "filter": "Filtra", "unreadOnly": "Solo non letti", "starredOnly": "Solo Salvati", "fullSearch": "Cerca in tutto il lesto", "showHidden": "Visualizza articoli nascosti", "manageSources": "Gestisci fonti", "saveImageAs": "Salva immagine come …", "copyImage": "Copia immagine", "copyImageURL": "Copia immagine link", "caseSensitive": "Case sensitive", "showCover": "Mostra copertina", "showSnippet": "Show snippet", "fadeRead": "Scolorisci articoli letti" }, "searchEngine": { "name": "Motore di ricerca", "google": "Google", "bing": "Bing", "baidu": "Baidu", "duckduckgo": "DuckDuckGo" }, "settings": { "writeError": "è occorso un errore nello scrivere il file", "name": "Impostazioni", "fetching": "Aggiornamento delle fonti..Attendi", "exit": "Esci dalle Impostazioni", "sources": "Fonti", "grouping": "Gruppi", "rules": "Regole", "service": "Servizio", "app": "Preferenze", "about": "Informazioni", "version": "Versione", "shortcuts": "Shortcuts", "openSource": "Open source", "feedback": "Feedback" }, "sources": { "serviceWarning": "le fonti importate o aggiunte qui non saranno sincronizzate con il tuo servizio", "serviceManaged": "la fonte è gesita dal tuo servizio", "untitled": "Fonte", "errorAdd": "Un errore è occorso nel caricare la fonte", "errorParse": "Un errore è occorso nell analizzare il file OPML", "errorParseInt": "Assicurati che il file non sia corrotto è sia in formato utf-8", "errorImport": "Errore nel importare {count, plural, =1 {# fonte} other {# fonti}}.", "exist": "Questa fonte è gia stata aggiunta", "opmlFile": "OPML File", "name": "Nome fonte", "editName": "Modifica Nome", "fetchFrequency": "Limite frecquenza di aggiornamento", "unlimited": "Senza limite", "openTarget": "Luogo predefinito di apertura degli articoli", "delete": "Elimina fonte", "add": "Aggiungi fonte", "import": "Importa", "export": "Esporta", "rssText": "RSS visualizza tutto il testo", "loadWebpage": "Carica pagina", "inputUrl": "Inserisci URL", "badIcon": "Icona non valida", "badUrl": "URL non valido", "deleteWarning": "La fonte è tutti i relativi articoli salvati verranno eliminati", "selected": "Seleziona fonte", "selectedMulti": "Seleziona più fonti" }, "groups": { "exist": "Questo gruppo già esiste", "type": "Tipo", "group": "Gruppo", "source": "Fonte", "capacity": "Capacita", "exitGroup": "Ritorna ai gruppi", "deleteSource": "Rimuovi dal gruppo", "sourceHint": "Clicca e trascina le fonti per ordinarle", "create": "Crea gruppo", "selectedGroup": "Seleziona gruppo", "selectedSource": "Seleziona fonte", "enterName": "Inserisci nome", "editName": "Modificaname", "deleteGroup": "Elimina Gruppo", "chooseGroup": "Seleziona un gruppo", "addToGroup": "Aggiungi a ...", "groupHint": "Doppio-Click sul gruppo per modificare le fonti. Clicca e trascina le fonti per ordinarle" }, "rules": { "intro": "automaticamentente seleziona articoli o manda notifiche tramite Regex", "help": "Per saperne di più", "source": "Fonte", "selectSource": "Seleziona una fonte", "new": "Nuova Regola", "if": "Se", "then": "Allora", "title": "Titolo", "content": "Contenuto", "fullSearch": "Titolo o Contenuto", "creator": "Autore", "match": "Risultati", "notMatch": "Non ci sono risultati", "regex": "Espressione Regolare", "badRegex": "Espressione Regolare Invalida", "action": "Azioni", "selectAction": "Seleziona azioni", "hint": "Le regole verranno applicate sequenzialmente, Clicca e trascina per riordinare", "test": "Prova le regole" }, "service": { "intro": "Sincronizza attraverso i dispositivi i servizi RSS", "select": "Seleziona un servizio", "suggest": "Suggerisci un nuovo servizio", "overwriteWarning": "Le fonti locali verranno eliminate se sono gia presenti nel servizio ", "groupsWarning": "I gruppi non sono automaticamente sincronizzati con il servizio", "rateLimitWarning": "Per evitare un limite nella frecquenza di aggiornamento dei avere una API key personalizzata", "removeAd": "Rimuovi Annuncio", "endpoint": "Indirizzo", "username": "Username", "password": "Password", "unchanged": "Non modificato", "fetchLimit": "Limite di Sincronizzazione", "fetchLimitNum": "{count} articoli recenti", "importGroups": "Importa gruppi", "failure": "Impossibile connettersi al servizio", "failureHint": "Controlla le impostazioni del servizio o se ci sono problemi di connessione", "fetchUnlimited": "Illimitato (non Raccomandaot)", "exportToLite": "Esporta a Fluent Reader Lite" }, "app": { "cleanup": "Pulisci", "cache": "Elimina cache", "cacheSize": "Dimensione Cache {size}", "deleteChoices": "Rimuovi articoli di ... giorni fà", "confirmDelete": "Rimuovi", "daysAgo": "{days, plural, =1 {# giorno} other {# giorni}} fà", "deleteAll": "Rimuovi tutti gli articoli", "calculatingSize": "Calcolo dimensione...", "itemSize": "Gli articoli occupano {size} ", "confirmImport": "Vuoi veramente importare i dati dal file di backup? I dati correnti verranno eliminati", "data": "Memoria Applicazione", "backup": "Backup", "restore": "Ripristina", "frData": "Fluent Reader Data", "language": "Lingua Display", "theme": "Tema", "lightTheme": "Tema chiaro", "darkTheme": "Tema scuro", "enableProxy": "Abilita Proxy", "badUrl": "URL Invalido", "pac": "Indirizzo PAC", "setPac": " Imposta PAC", "pacHint": "Per proxies Socks, è raccomandato per i PAC di ritornare \"SOCKS5\" per proxy-side DNS. Disabilitare i proxy neccessita il riavvio dell'applicazione", "fetchInterval": "Ricarica intervallo Automaticamente", "never": "Mai" } } ================================================ FILE: src/scripts/i18n/ja.json ================================================ { "allArticles": "全ての記事", "add": "追加", "create": "新規", "icon": "アイコン", "name": "名称", "openExternal": "ブラウザで開く", "emptyName": "名称を入力してください。", "emptyField": "このフィールドは空にできません。", "edit": "編集", "delete": "削除", "followSystem": "システム設定に従う", "more": "さらに詳しく", "close": "閉じる", "search": "検索", "loadMore": "もっと読み込む", "dangerButton": "{action}を確認しますか?", "confirmMarkAll": "全ての記事を開封済みにしますか?", "confirm": "確認", "cancel": "キャンセル", "time": { "now": "now", "m": "m", "h": "h", "d": "d", "minute": "{m}分", "hour": "{h}時間", "day": "{d}日" }, "log": { "empty": "通知なし", "fetchFailure": "フィード“{name}”の読み込みに失敗しました。", "fetchSuccess": "{count} 件の記事をロードしました。", "networkError": "フィードの接続にエラーが発生しました。", "parseError": "XMLストリームの解析にエラーが発生しました。", "syncFailure": "サービスの同期に失敗しました。" }, "nav": { "menu": "メニュー", "refresh": "更新", "markAllRead": "一括で開封済み", "notifications": "通知", "view": "ビュー", "settings": "設定", "minimize": "最小化", "maximize": "最大化" }, "menu": { "close": "メニューを閉じる", "subscriptions": "購読" }, "article": { "error": "記事の読み込みに失敗しました。", "reload": "リロード", "empty": "記事なし", "untitled": "(無題)", "hide": "記事を非表示", "unhide": "記事を再表示", "markRead": "開封済みにする", "markUnread": "未開封にする", "markAbove": "上記の記事を開封済みに", "markBelow": "下記の記事を開封済みに", "star": "星マーク", "unstar": "星マークを外す", "fontSize": "フォントサイズ", "loadWebpage": "ウェブページを読み込む", "loadFull": "全体を読み込む", "notify": "バックグラウンドで取得された場合に通知する", "dontNotify": "通知しない" }, "context": { "share": "共有", "read": "読む", "copyTitle": "タイトルをコピー", "copyURL": "URLをコピー", "copy": "コピー", "search": "{engine} を使って“{text}”を検索する", "view": "ビュー", "cardView": "カードビュー", "listView": "リストビュー", "magazineView": "雑誌ビュー", "compactView": "コンパクトビュー", "filter": "フィルター", "unreadOnly": "未開封のみ", "starredOnly": "星付けのみ", "fullSearch": "全文検索", "showHidden": "非表示記事を表示", "manageSources": "フィードを管理", "saveImageAs": "イメージを保存", "copyImage": "イメージをコピー", "copyImageURL": "イメージリンクをコピー", "caseSensitive": "大文字と小文字を区別", "showCover": "表紙を表示", "showSnippet": "スニペットを表示", "fadeRead": "開封済みをフェード表示" }, "searchEngine": { "name": "検索エンジン", "google": "Google", "bing": "Bing", "baidu": "Baidu", "duckduckgo": "DuckDuckGo" }, "settings": { "writeError": "ファイルの書き込みにエラーが発生しました。", "name": "オプション", "fetching": "フィードを更新中、しばらくお待ちください…", "exit": "終了", "sources": "フィード", "grouping": "グルーピング", "rules": "ルール", "service": "サービス", "app": "環境設定", "about": "本アプリについて", "version": "バージョン", "shortcuts": "ショートカット", "openSource": "オープンソース", "feedback": "フィードバック" }, "sources": { "serviceWarning": "ここでインポートか追加されるフィードはサーバ側と同期しません。", "serviceManaged": "このフィードはサーバ側で管理されます。", "untitled": "フィード", "errorAdd": "フィードの追加にエラーが発生しました。", "errorParse": "OPMLファイルの解析にエラーが発生しました。", "errorParseHint": "OPMLファイルが破損しておらず、UTF-8 でエンコードされていることを確認してください。", "errorImport": "{count}件のフィードのインポート中にエラーが発生しました。", "exist": "このフィードは既に存在します。", "opmlFile": "OPMLファイル", "name": "フィード名", "editName": "名称変更", "fetchFrequency": "取得頻度制限", "unlimited": "無制限", "openTarget": "フィード記事を開く方法", "delete": "フィードを削除", "add": "フィードを追加", "import": "インポート", "export": "エクスポート", "rssText": "RSSテキスト", "loadWebpage": "ウェブページを読み込む", "inputUrl": "URLを入力", "badIcon": "無効なアイコン", "badUrl": "無効なURL", "deleteWarning": "フィード及び保管している記事を削除することになります。", "selected": "選択されたフィード", "selectedMulti": "選択されたフィード(複数)" }, "groups": { "exist": "このグループは既に存在します。", "type": "区分", "group": "グループ", "source": "フィード", "capacity": "容量", "exitGroup": "グループ一覧に戻る", "deleteSource": "グループからフィードを削除", "sourceHint": "フィードをドラッグ アンド ドロップして並べ替えます。", "create": "新規グループ", "selectedGroup": "選択されたグループ", "selectedSource": "選択されたフィード", "enterName": "名称を入力ください", "editName": "名称を編集ください", "deleteGroup": "グループを削除", "chooseGroup": "グループを選択", "addToGroup": "グループに追加", "groupHint": "グループをダブルクリックしてフィードを編集します。フィードをドラッグ アンド ドロップして並べ替えます。" }, "rules": { "intro": "正規表現で記事を自動的にマークするか、通知を送信します", "help": "さらに詳しく", "source": "フィード", "selectSource": "フィードを選ぶ", "new": "新規ルール", "if": "もし", "then": "なら", "title": "タイトル", "content": "コンテンツ", "fullSearch": "タイトルかコンテンツ", "creator": "著者", "match": "一致する", "notMatch": "一致しない", "regex": "正規表現", "badRegex": "無効な正規表現", "action": "アクション", "selectAction": "アクションを選択", "hint": "ルールは順番に適用されます。ドラッグ アンド ドロップして並べ替えます。", "test": "テスト用ルール" }, "service": { "intro": "RSSサービスを使用してディバイス間で同期します", "select": "サービスを選択", "suggest": "新たなサービスを推薦", "overwriteWarning": "ローカルフィードがサーバ側に既存する場合に無視されます。", "groupsWarning": "グループはサーバ側と自動的に同期されません。", "rateLimitWarning": "レート制限を回避するには、独自のAPI キーを作成する必要があります。", "removeAd": "広告を削除", "endpoint": "エンドポイント", "username": "ユーザー名", "password": "パスワード", "unchanged": "変更なし", "fetchLimit": "同期数制限", "fetchLimitNum": "{count} 件の最新記事", "importGroups": "グループをエクスポート", "failure": "サーバ接続中にエラーが発生しました。", "failureHint": "サーバ設定とネットワーク接続を確認ください", "fetchUnlimited": "無制限(非推奨)", "exportToLite": "Fluent Reader Liteにエクスポート" }, "app": { "cleanup": "一括削除", "cache": "キャッシュを削除", "cacheSize": "{size}のデータをキャッシュしている", "deleteChoices": "… 日前の記事を削除", "confirmDelete": "記事を削除", "daysAgo": "{days} 日前", "deleteAll": "全ての記事を削除", "calculatingSize": "占めるスペースを計算中…", "itemSize": "ローカル記事は{size}のスペースを占める", "confirmImport": "バックアップファイルからデータを導入しますか?全ての既存データを削除することになります。", "data": "アプリデータ", "backup": "バックアップ", "restore": "リストア", "frData": "Fluent Readerデータ", "language": "表示言語", "theme": "アプリテーマ", "lightTheme": "明るいテーマ", "darkTheme": "暗いテーマ", "enableProxy": "プロキシを使う", "badUrl": "正しいURLを入力ください", "pac": "PACアドレス", "setPac": "PACを設定", "pacHint": "Sockプロキシの場合にPACが“SOCKS5”を返すことをお勧めします。プロキシを停止してから再起動する必要があります。", "fetchInterval": "フェッチ間隔", "never": "しない" } } ================================================ FILE: src/scripts/i18n/ko.json ================================================ { "allArticles": "모든 글", "add": "추가", "create": "생성", "icon": "아이콘", "name": "이름", "openExternal": "브라우저를 통해 열기", "emptyName": "이 칸은 비어있을 수 없습니다.", "emptyField": "이 칸은 비어있을 수 없습니다.", "edit": "수정", "delete": "삭제", "followSystem": "시스템 설정에 따름", "more": "더 보기", "close": "닫기", "search": "검색", "loadMore": "더 불러오기", "dangerButton": "{action}하시겠습니까?", "confirmMarkAll": "이 페이지에 있는 모든 글을 읽은 것으로 처리하시겠습니까?", "confirm": "확인", "cancel": "취소", "time": { "now": "방금", "m": "분 전", "h": "시간 전", "d": "일 전", "minute": "{m}분", "hour": "{h}시간", "day": "{d}일" }, "log": { "empty": "알림 없음", "fetchFailure": "피드 \"{name}\"의 글을 불러오는 데에 실패했습니다.", "fetchSuccess": "{count}개의 글을 불러오는 데에 성공했습니다.", "networkError": "네트워크 오류가 발생했습니다.", "parseError": "XML 피드를 파싱하는 도중 오류가 발생했습니다.", "syncFailure": "서비스와 동기화에 실패했습니다." }, "nav": { "menu": "메뉴", "refresh": "새로고침", "markAllRead": "모두 읽은 것으로 처리", "notifications": "알림", "view": "보기", "settings": "설정", "minimize": "최소화", "maximize": "최대화" }, "menu": { "close": "메뉴 닫기", "subscriptions": "구독" }, "article": { "error": "글을 불러오지 못했습니다.", "reload": "다시 불러오시겠습니까?", "empty": "글 없음", "untitled": "(제목 없음)", "hide": "글 숨기기", "unhide": "글 숨기기 취소", "markRead": "읽은 것으로 표시", "markUnread": "읽지 않은 것으로 표시", "markAbove": "위에 있는 글을 모두 읽은 것으로 표시", "markBelow": "아래에 있는 글을 모두 읽은 것으로 표시", "star": "강조", "unstar": "강조 제거", "fontSize": "글꼴 크기", "loadWebpage": "웹페이지 불러오기", "loadFull": "모든 컨텐츠 불러오기", "notify": "백그라운드 동작 중 글을 불러오면 알림", "dontNotify": "알리지 않음" }, "context": { "share": "공유하기", "read": "읽기", "copyTitle": "제목 복사하기", "copyURL": "주소 복사하기", "copy": "복사", "search": "{engine}에서 \"{text}\" 검색", "view": "보기", "cardView": "카드", "listView": "리스트", "magazineView": "매거진", "compactView": "컴팩트", "filter": "필터", "unreadOnly": "읽지 않은 글만", "starredOnly": "강조 표시한 글만", "fullSearch": "본문에서도 검색", "showHidden": "숨겨진 글 표시", "manageSources": "피드 관리", "saveImageAs": "이미지 저장하기", "copyImage": "이미지 복사하기", "copyImageURL": "이미지 주소 복사하기", "caseSensitive": "대소문자 구분", "showCover": "커버 이미지 표시", "showSnippet": "스니펫 표시", "fadeRead": "읽은 글을 희미하게 표시" }, "searchEngine": { "name": "검색 엔진", "google": "Google", "bing": "Bing", "baidu": "Baidu", "duckduckgo": "DuckDuckGo" }, "settings": { "writeError": "파일을 작성하는 도중 오류가 발생했습니다.", "name": "설정", "fetching": "소스를 업데이트하는 중입니다. 잠시만 기다려주세요…", "exit": "종료", "sources": "피드", "grouping": "그룹", "rules": "규칙", "service": "서비스", "app": "환경설정", "about": "이 앱에 관하여", "version": "버전", "shortcuts": "단축키", "openSource": "오픈 피드", "feedback": "피드백 제출" }, "sources": { "serviceWarning": "여기서 불러오거나 추가한 피드는 연결한 서비스와 동기화되지 않습니다.", "serviceManaged": "이 피드는 연결한 서비스에 의해 관리되고 있습니다.", "untitled": "피드", "errorAdd": "피드를 추가하는 도중 오류가 발생했습니다.", "errorParse": "OPML 파일을 파싱할 수 없습니다.", "errorParseHint": "파일이 손상되지 않았고 UTF-8로 인코딩 되었는지 확인해주세요.", "errorImport": "{count}개의 피드를 불러오는 도중 오류가 발생했습니다.", "exist": "이미 존재하는 피드입니다.", "opmlFile": "OPML 파일", "name": "피드 이름", "editName": "이름 수정", "fetchFrequency": "피드 새로고침에 걸리는 시간", "unlimited": "무제한", "openTarget": "글 불러오는 방법", "delete": "피드 삭제", "add": "피드 추가", "import": "불러오기", "export": "내보내기", "rssText": "RSS 전문", "loadWebpage": "웹페이지 불러고이", "inputUrl": "URL 입력", "badIcon": "유효하지 않은 아이콘", "badUrl": "유효하지 않은 URL", "deleteWarning": "피드와 저장된 글들이 모두 삭제됩니다.", "selected": "선택된 피드", "selectedMulti": "선택된 여러 개의 피드" }, "groups": { "exist": "이미 존재하는 그룹입니다.", "type": "유형", "group": "그룹", "source": "피드", "capacity": "용량", "exitGroup": "그룹으로 돌아가기", "deleteSource": "그룹에서 삭제", "sourceHint": "피드를 끌어서 재정렬할 수 있습니다.", "create": "그룹 생성", "selectedGroup": "선택된 그룹", "selectedSource": "선택된 피드", "enterName": "이름 입력", "editName": "이름 수정", "deleteGroup": "그룹 삭제", "chooseGroup": "그룹 선택", "addToGroup": "그룹에 추가", "groupHint": "피드를 수정하려면 그룹을 더블 클릭하세요. 끌어서 재정렬할 수 있습니다." }, "rules": { "intro": "정규 표현식으로 글을 마크하거나 알림을 보냅니다.", "help": "더 알아보기", "source": "피드", "selectSource": "피드 선택", "new": "새로운 규칙", "if": "만약", "then": "이라면", "title": "제목", "content": "내용", "fullSearch": "제목 또는 내용", "creator": "작성자", "match": "일치", "notMatch": "일치하지 않음", "regex": "정규 표현식", "badRegex": "유효하지 않은 정규 표현식입니다.", "action": "행동", "selectAction": "행동 선택", "hint": "규칙은 순서대로 적용됩니다. 끌어서 재정렬할 수 있습니다.", "test": "규칙 테스트" }, "service": { "intro": "RSS 서비스를 이용하여 기기간 동기화합니다.", "select": "서비스 선택", "suggest": "새로운 서비스 제안", "overwriteWarning": "서비스에 포함된 이미 존재하는 로컬 피드는 삭제됩니다.", "groupsWarning": "그룹들은 서비스를 통해 동기화할 수 없습니다.", "rateLimitWarning": "제한을 피하려면 자신만의 API 키를 만들어야 합니다.", "removeAd": "광고 제거", "endpoint": "종단", "username": "사용자 이름", "password": "비밀번호", "unchanged": "변경점 없음", "fetchLimit": "동기화 제한", "fetchLimitNum": "최근 {count}개의 글", "importGroups": "그룹 불러오기", "failure": "서비스에 연결할 수 없음", "failureHint": "서비스 설정이나 네트워크 상태를 확인해보세요.", "fetchUnlimited": "제한 없음 (추천하지 않음)", "exportToLite": "Fluent Reader Lite에 내보내기" }, "app": { "cleanup": "일괄 삭제", "cache": "캐시 지우기", "cacheSize": "{size}의 캐시가 저장되어 있습니다.", "deleteChoices": "... 일 전에 작성된 글을 삭제", "confirmDelete": "삭제", "daysAgo": "{days}일 전", "deleteAll": "모든 글을 삭제", "calculatingSize": "크기를 계산하는 중입니다...", "itemSize": "대략 {size}의 로컬 저장소가 불러온 글에 의해 점유되고 있습니다.", "confirmImport": "백업 파일을 통해서 데이터를 불러오시겠습니까? 현재 저장된 데이터가 대체됩니다.", "data": "앱 데이터", "backup": "백업", "restore": "복원", "frData": "Fluent Reader 데이터", "language": "언어", "theme": "테마", "lightTheme": "라이트 모드", "darkTheme": "다크 모드", "enableProxy": "프록시 활성화", "badUrl": "유효하지 않은 URL", "pac": "PAC 주소", "setPac": "PAC 설정", "pacHint": "Socks 프록시의 경우에는 PAC이 프록시측 DNS에 \"SOCKS5\"를 리턴하는 것을 권장합니다.. 재시작하려면 프록시를 종료해야 합니다.", "fetchInterval": "피드를 불러오는 간격", "never": "하지 않음" } } ================================================ FILE: src/scripts/i18n/nl.json ================================================ { "allArticles": "Alle artikelen", "add": "Toevoegen", "create": "Maken", "icon": "Pictogram", "name": "Naam", "openExternal": "Openen in webbrowser", "emptyName": "Dit veld mag niet blanco zijn.", "emptyField": "Dit veld mag niet blanco zijn.", "edit": "Bewerken", "delete": "Verwijderen", "followSystem": "Systeeminstellingen gebruiken", "more": "Meer", "close": "Sluiten", "search": "Zoeken", "loadMore": "Meer laden", "dangerButton": "{action} bevestigen", "confirmMarkAll": "Weet je zeker dat je alle artikelen op deze pagina als gelezen wilt markeren?", "confirm": "Ja", "cancel": "Annuleren", "time": { "now": "nu", "m": "m", "h": "u", "d": "d", "minute": "{m, plural, =1 {# minuut} other {# minuten}}", "hour": "{h, plural, =1 {# uur} other {# uur}}", "day": "{d, plural, =1 {# dag} other {# dagen}}" }, "log": { "empty": "Geen meldingen", "fetchFailure": "De bron, {name}, kan niet worden geladen.", "fetchSuccess": "Artikelen opgehaald: {count, plural, =1 {# article} other {# articles}}.", "networkError": "Er is een netwerkfout opgetreden.", "parseError": "Er is een fout opgetreden tijdens het verwerken van de xml-feed.", "syncFailure": "Het synchroniseren met de dienst is mislukt" }, "nav": { "menu": "Menu", "refresh": "Verversen", "markAllRead": "Alles markeren als gelezen", "notifications": "Meldingen", "view": "Weergave", "settings": "Instellingen", "minimize": "Minimaliseren", "maximize": "Maximaliseren" }, "menu": { "close": "Menu sluiten", "subscriptions": "Abonnementen" }, "article": { "error": "Het artikel kan niet worden geladen.", "reload": "Herladen?", "empty": "Geen artikelen", "untitled": "(naamloos)", "hide": "Artikel verbergen", "unhide": "Artikel tonen", "markRead": "Markeren als gelezen", "markUnread": "Markeren als ongelezen", "markAbove": "Artikelen hierboven markeren als gelezen", "markBelow": "Artikelen hieronder markeren als gelezen", "star": "Toevoegen aan favorieten", "unstar": "Verwijderen uit favorieten", "fontSize": "Tekstgrootte", "loadWebpage": "Webpagina laden", "loadFull": "Volledige inhoud laden", "notify": "Melding tonen na ophalen op achtergrond", "dontNotify": "Geen melding tonen" }, "context": { "share": "Delen", "read": "Gelezen", "copyTitle": "Titel kopiëren", "copyURL": "Link kopiëren", "copy": "Kopiëren", "search": "Naar ‘{text}’ zoeken op {engine}", "view": "Weergave", "cardView": "Kaartweergave", "listView": "Lijstweergave", "magazineView": "Tijdschriftweergave", "compactView": "Compacte weergave", "filter": "Filteren", "unreadOnly": "Alleen ongelezen", "starredOnly": "Alleen favorieten", "fullSearch": "Volledige tekst doorzoeken", "showHidden": "Verborgen artikelen tonen", "manageSources": "Bronnen beheren", "saveImageAs": "Afbeelding opslaan als…", "copyImage": "Afbeelding kopiëren", "copyImageURL": "Afbeeldingsadres kopiëren", "caseSensitive": "Hoofdlettergevoelig", "showCover": "Omslag tonen", "showSnippet": "Knipsel tonen", "fadeRead": "Gelezen artikelen dimmen" }, "searchEngine": { "name": "Zoekmachine", "google": "Google", "bing": "Bing", "baidu": "Baidu", "duckduckgo": "DuckDuckGo" }, "settings": { "writeError": "Er is een fout opgetreden tijdens het wegschrijven van het bestand.", "name": "Instellingen", "fetching": "Bezig met bijwerken van bronnen…", "exit": "Instellingen afsluiten", "sources": "Bronnen", "grouping": "Groepen", "rules": "Regels", "service": "Dienst", "app": "Instellingen", "about": "Over", "version": "Versie", "shortcuts": "Sneltoetsen", "openSource": "Open source", "feedback": "Feedback" }, "sources": { "serviceWarning": "Hier toegevoegde of geïmporteerde bronnen worden niet gesynchroniseerd met je dienst.", "serviceManaged": "Deze bron wordt beheerd door je dienst.", "untitled": "Bron", "errorAdd": "Er is een fout opgetreden tijdens het toevoegen.", "errorParse": "Er is een fout opgetreden tijdens het verwerken van het opml-bestand.", "errorParseHint": "Controleer of het bestand niet beschadigd is en opgemaakt is met utf-8.", "errorImport": "Importeren mislukt: {count, plural, =1 {# source} other {# sources}}.", "exist": "Deze bron is al toegevoegd.", "opmlFile": "OPML-bestand", "name": "Bronnaam", "editName": "Naam bewerken", "fetchFrequency": "Ophaalfrequentie aanpassen", "unlimited": "Onbeperkt", "openTarget": "Standaarddoel bij artikelen", "delete": "Bron verwijderen", "add": "Bron toevoegen", "import": "Importeren", "export": "Exporteren", "rssText": "RSS met volledige tekst", "loadWebpage": "Webpagina laden", "inputUrl": "Voer een url in", "badIcon": "Ongeldig pictogram", "badUrl": "Ongeldige url", "deleteWarning": "De bron en alle opgeslagen artikelen worden verwijderd.", "selected": "Gekozen bron", "selectedMulti": "Meerdere bronnen gekozen" }, "groups": { "exist": "Deze groep bestaat al.", "type": "Soort", "group": "Groep", "source": "Bron", "capacity": "Capaciteit", "exitGroup": "Terug naar groepen", "deleteSource": "Verwijderen uit groep", "sourceHint": "Versleep bronnen om ze anders te rangschikken.", "create": "Groep aanmaken", "selectedGroup": "Gekozen groep", "selectedSource": "Gekozen bron", "enterName": "Voer een naam in", "editName": "Naam aanpassen", "deleteGroup": "Groep verwijderen", "chooseGroup": "Kies een groep", "addToGroup": "Toevoegen aan…", "groupHint": "Dubbelklik op een groep om de bronnen ervan aan te passen - versleep om anders te rangschikken." }, "rules": { "intro": "Markeer artikelen automatisch of verstuur meldingen met reguliere uitdrukkingen.", "help": "Meer informatie", "source": "Bron", "selectSource": "Kies een bron", "new": "Nieuwe regel", "if": "Als", "then": "Dan", "title": "Titel", "content": "Inhoud", "fullSearch": "Titel of inhoud", "creator": "Auteur", "match": "overeenkomsten", "notMatch": "komt niet overeen", "regex": "Reguliere uitdrukking", "badRegex": "Ongeldige reguliere uitdrukking.", "action": "Acties", "selectAction": "Kies acties", "hint": "Regels worden op volgorde toegepast. Versleep om anders te rangschikken.", "test": "Regels testen" }, "service": { "intro": "Synchroniseer tussen apparaten middels rss-diensten.", "select": "Kies een dienst", "suggest": "Nieuwe dienst aandragen", "overwriteWarning": "Lokale bronnen worden verwijderd als de dienst ze al kent.", "groupsWarning": "Groepen worden niet automatisch gesynchroniseerd met de dienst.", "rateLimitWarning": "Geef je eigen api-sleutel op om beperkingen te voorkomen.", "removeAd": "Reclame verwijderen", "endpoint": "Endpoint", "username": "Gebruikersnaam", "password": "Wachtwoord", "unchanged": "Ongewijzigd", "fetchLimit": "Synchronisatielimiet", "fetchLimitNum": "{count} recentste artikelen", "importGroups": "Groepen importeren", "failure": "Er kan geen verbinding worden gemaakt met de dienst", "failureHint": "Controleer de dienstinstellingen of netwerkstatus.", "fetchUnlimited": "Onbeperkt (niet aanbevolen)", "exportToLite": "Exporteren naar Fluent Reader Lite" }, "app": { "cleanup": "Opruimen", "cache": "Cache legen", "cacheSize": "In cache: {size} aan gegevens", "deleteChoices": "Artikelen verwijderen die ouder zijn dan … dagen", "confirmDelete": "Verwijderen", "daysAgo": "{days, plural, =1 {# dag} other {# dagen}} geleden", "deleteAll": "Alle artikelen verwijderen", "calculatingSize": "Bezig met grootteberekening…", "itemSize": "De artikelen nemen ongeveer {size} aan lokale opslag in beslag", "confirmImport": "Weet je zeker dat je artikelen wilt importeren uit een back-upbestand? Alle huidige gegevens worden verwijderd.", "data": "Programmagegevens", "backup": "Back-uppen", "restore": "Herstellen", "frData": "Fluent Reader-gegevens", "language": "Weergavetaal", "theme": "Thema", "lightTheme": "Licht", "darkTheme": "Donker", "enableProxy": "Proxy gebruiken", "badUrl": "Ongeldige url", "pac": "PAC-adres", "setPac": "PAC instellen", "pacHint": "Bij Socks-proxies is het aanbevolen om PAC ‘SOCKS5’ te laten ophalen aan de dns-kant. Herstart om de proxy uit te schakelen.", "fetchInterval": "Automatisch ophalen", "never": "Nooit" } } ================================================ FILE: src/scripts/i18n/pt-BR.json ================================================ { "allArticles": "Todos os artigos", "add": "Adicionar", "create": "Criar", "icon": "Ícone", "name": "Nome", "openExternal": "Abrir externamente", "emptyName": "Este campo não pode ficar vazio.", "emptyField": "Este campo não pode ficar vazio.", "edit": "Editar", "delete": "Deletar", "followSystem": "Seguir o sistema", "more": "Mais", "close": "Fechar", "search": "Pesquisar", "loadMore": "Carregar mais", "dangerButton": "Confirmar {action}?", "confirmMarkAll": "Você realmente deseja marcar todos os artigos dessa página como lidos?", "confirm": "Confirmar", "cancel": "Cancelar", "time": { "now": "agora", "m": "m", "h": "h", "d": "d", "minute": "{m, plural, =1 {# minuto} other {# minutos}}", "hour": "{h, plural, =1 {# hora} other {# horas}}", "day": "{d, plural, =1 {# dia} other {# dias}}" }, "log": { "empty": "Nenhuma notificação", "fetchFailure": "Falha ao carregar a fonte \"{name}\".", "fetchSuccess": "{count, plural, =1 {# Artigo} other {# Artigos}} foram atualizados com sucesso.", "networkError": "Ocorreu um erro na rede.", "parseError": "Ocorreu um erro ao analisar o feed XML.", "syncFailure": "Falha ao sincronizar com o serviço" }, "nav": { "menu": "Menu", "refresh": "Atualizar", "markAllRead": "Marcar todos como lidos", "notifications": "Notificações", "view": "Visualização", "settings": "Configurações", "minimize": "Minimizar", "maximize": "Maximizar" }, "menu": { "close": "Fechar menu", "subscriptions": "Inscrições" }, "article": { "error": "Falha ao carregar o artigo.", "reload": "Recarregar?", "empty": "Nenhum artigo", "untitled": "(Sem título)", "hide": "Ocultar artigo", "unhide": "Exibir artigo", "markRead": "Marcar como lido", "markUnread": "Marcar como não lido", "markAbove": "Marcar artigo(s) abaixo como lido(s)", "markBelow": "Marcar artigo(s) abaixo como não lido(s)", "star": "Marcar como favorito", "unstar": "Remover marcação", "fontSize": "Tamanho da fonte", "loadWebpage": "Carregar página web", "loadFull": "Carregar todo o conteúdo", "notify": "Notificar se atualizado em segundo plano", "dontNotify": "Não notificar" }, "context": { "share": "Compartilhar", "read": "Ler", "copyTitle": "Copiar título", "copyURL": "Copiar link", "copy": "Copiar", "search": "Pesquisar \"{text}\" no {engine}", "view": "Visualização", "cardView": "Como cartões", "listView": "Em lista", "magazineView": "Como revista", "compactView": "Compacto", "filter": "Filtrar", "unreadOnly": "Somente não lidos", "starredOnly": "Somente favoritos", "fullSearch": "Pesquisar em todo o texto", "showHidden": "Exibir artigos ocultos", "manageSources": "Gerenciar fontes", "saveImageAs": "Salvar imagem como …", "copyImage": "Copiar imagem", "copyImageURL": "Copiar link da imagem", "caseSensitive": "Diferenciar maiúsculas e minúsculas", "showCover": "Exibir capa", "showSnippet": "Mostrar trecho", "fadeRead": "Esmaecer artigos lidos" }, "searchEngine": { "name": "Motor de pesquisa", "google": "Google", "bing": "Bing", "baidu": "Baidu", "duckduckgo": "DuckDuckGo" }, "settings": { "writeError": "Ocorreu um erro ao gravar o arquivo.", "name": "Configurações", "fetching": "Atualizando fontes, por favor aguarde …", "exit": "Sair das Configurações", "sources": "Fontes", "grouping": "Grupos", "rules": "Regras", "service": "Serviço", "app": "Preferências", "about": "Sobre", "version": "Versão", "shortcuts": "Atalhos", "openSource": "Código aberto", "feedback": "Feedback" }, "sources": { "serviceWarning": "Fontes importadas ou adicionadas aqui não serão sincronizadas com o seu serviço.", "serviceManaged": "Essa fonte é gerenciada pelo seu serviço.", "untitled": "Fonte", "errorAdd": "Ocorreu um erro ao adicionar a fonte.", "errorParse": "Ocorreu um erro ao analisar o arquivo OPML.", "errorParseHint": "Certifique-se de que o arquivo não esteja corrompido e que esteja codificado com UTF-8.", "errorImport": "Erro ao importar {count, plural, =1 {# fonte} other {# fontes}}.", "exist": "Esta fonte já existe.", "opmlFile": "Arquivo OPML", "name": "Nome da fonte", "editName": "Editar nome", "fetchFrequency": "Limite da frequência de atualização", "unlimited": "Ilimitado", "openTarget": "Método padrão de carregamento dos artigos", "delete": "Deletar fonte", "add": "Adicionar fonte", "import": "Importar", "export": "Exportar", "rssText": "Texto completo do RSS", "loadWebpage": "Carregar página web", "inputUrl": "Insira a URL", "badIcon": "Ícone inválido", "badUrl": "URL inválida", "deleteWarning": "A fonte e todos os artigos salvos serão removidos.", "selected": "Fonte selecionada", "selectedMulti": "Múltiplas fontes selecionadas" }, "groups": { "exist": "Este grupo já existe.", "type": "Tipo", "group": "Grupo", "source": "Fonte", "capacity": "Capacidade", "exitGroup": "Voltar para os grupos", "deleteSource": "Deletar deste grupo", "sourceHint": "Arraste e solte as fontes para reorganizá-las.", "create": "Criar grupo", "selectedGroup": "Grupo selecionado", "selectedSource": "Fonte selecionada", "enterName": "Insira o nome", "editName": "Editar o nome", "deleteGroup": "Deletar grupo", "chooseGroup": "Selecione um grupo", "addToGroup": "Adicionar para ...", "groupHint": "Clique duplo nos grupos para editar as fontes; Arraste e solte para reorganizá-los." }, "rules": { "intro": "Marcar artigos automaticamente ou enviar notificações com base em regras de expressões regulares.", "help": "Saiba mais", "source": "Fonte", "selectSource": "Selecionar uma fonte", "new": "Nova regra", "if": "Se", "then": "Então", "title": "Título", "content": "Conteúdo", "fullSearch": "Título ou conteúdo", "creator": "Autor", "match": "corresponde", "notMatch": "não corresponde", "regex": "Expressão regular", "badRegex": "Expressão regular inválida.", "action": "Ações", "selectAction": "Selecionar ações", "hint": "As regras serão aplicadas em ordem. Arraste e solte para reorganizar.", "test": "Testar regras" }, "service": { "intro": "Sincronização entre dispositivos com serviços RSS.", "select": "Selecione um serviço", "suggest": "Sugerir um novo serviço", "overwriteWarning": "Fontes locais serão deletas se elas existirem no serviço.", "groupsWarning": "Grupos não são automaticamente sincronizados com o serviço.", "rateLimitWarning": "Para evitar limitações do serviço, você precisa criar sua própria chave de API.", "removeAd": "Remover Ad", "endpoint": "Endpoint", "username": "Usuário", "password": "Senha", "unchanged": "Inalterado", "fetchLimit": "Limite de sincronização", "fetchLimitNum": "{count} últimos artigos", "importGroups": "Importar grupos", "failure": "Não foi possível conectar ao serviço", "failureHint": "Por favor verifique a configuração do serviço ou o status da rede.", "fetchUnlimited": "Ilimitado (não recomendado)", "exportToLite": "Exportar para o Fluent Reader Lite" }, "app": { "cleanup": "Limpeza", "cache": "Limpar cache", "cacheSize": "{size} de dados em cache", "deleteChoices": "Deletar artigos de ... dias atrás", "confirmDelete": "Deletar", "daysAgo": "{days, plural, =1 {# dia} other {# dias}} atrás", "deleteAll": "Deletar todos os artigos", "calculatingSize": "Calculando tamanho...", "itemSize": "Cerca de {size} do armazenamento local é ocupado por artigos", "confirmImport": "Você realmente deseja importar os dados do arquivo de backup? Todos os dados atuais serão apagados.", "data": "Dados da aplicação", "backup": "Backup", "restore": "Restaurar", "frData": "Dados do Fluent Reader", "language": "Idioma", "theme": "Tema", "lightTheme": "Modo claro", "darkTheme": "Modo escuro", "enableProxy": "Ativar proxy", "badUrl": "URL inválida", "pac": "Endereço PAC", "setPac": "Definir PAC", "pacHint": "For Socks proxies, it is recommended for PAC to return \"SOCKS5\" for proxy-side DNS. Turning off proxy requires restart.", "fetchInterval": "Intervalo de atualização automática", "never": "Nunca" } } ================================================ FILE: src/scripts/i18n/pt-PT.json ================================================ { "allArticles": "Todos os artigos", "add": "Adicionar", "create": "Criar", "icon": "Ícone", "name": "Nome", "openExternal": "Abrir externamente", "emptyName": "Este campo não pode ficar vazio.", "emptyField": "Este campo não pode ficar vazio.", "edit": "Editar", "delete": "Eliminar", "followSystem": "Seguir o sistema", "more": "Mais", "close": "Fechar", "search": "Pesquisar", "loadMore": "Carregar mais", "dangerButton": "Confirmar {action}?", "confirmMarkAll": "Deseja mesmo marcar todos os artigos desta página como lidos?", "confirm": "Confirmar", "cancel": "Cancelar", "default": "Padrão", "time": { "now": "agora", "m": "m", "h": "h", "d": "d", "minute": "{m, plural, =1 {# minuto} other {# minutos}}", "hour": "{h, plural, =1 {# hora} other {# horas}}", "day": "{d, plural, =1 {# dia} other {# dias}}" }, "log": { "empty": "Nenhuma notificação", "fetchFailure": "Falha ao carregar a fonte \"{name}\".", "fetchSuccess": "{count, plural, =1 {# Artigo} other {# Artigos}} foram atualizados com sucesso.", "networkError": "Ocorreu um erro na rede.", "parseError": "Ocorreu um erro ao analisar o feed XML.", "syncFailure": "Falha ao sincronizar com o serviço" }, "nav": { "menu": "Menu", "refresh": "Atualizar", "markAllRead": "Marcar todos como lidos", "notifications": "Notificações", "view": "Visualização", "settings": "Definições", "minimize": "Minimizar", "maximize": "Maximizar" }, "menu": { "close": "Fechar menu", "subscriptions": "Subscrições" }, "article": { "error": "Falha ao carregar o artigo.", "reload": "Recarregar?", "empty": "Nenhum artigo", "untitled": "(Sem título)", "hide": "Ocultar artigo", "unhide": "Exibir artigo", "markRead": "Marcar como lido", "markUnread": "Marcar como não lido", "markAbove": "Marcar artigo(s) acima como lido(s)", "markBelow": "Marcar artigo(s) abaixo como não lido(s)", "star": "Marcar como favorito", "unstar": "Remover marcação", "fontSize": "Tamanho da fonte", "loadWebpage": "Carregar página web", "loadFull": "Carregar todo o conteúdo", "notify": "Notificar se atualizado em segundo plano", "dontNotify": "Não notificar", "textDir": "Direção do texto", "LTR": "Esquerda-para-direita", "RTL": "Direita-para-esquerda", "Vertical": "Vertical", "font": "Fonte" }, "context": { "share": "Partilhar", "read": "Ler", "copyTitle": "Copiar título", "copyURL": "Copiar link", "copy": "Copiar", "search": "Pesquisar \"{text}\" no {engine}", "view": "Visualizar", "cardView": "Como cartões", "listView": "Em lista", "magazineView": "Como revista", "compactView": "Compacto", "filter": "Filtrar", "unreadOnly": "Apenas não lidos", "starredOnly": "Apenas favoritos", "fullSearch": "Pesquisar em todo o texto", "showHidden": "Exibir artigos ocultos", "manageSources": "Gerir fontes", "saveImageAs": "Guardar imagem como …", "copyImage": "Copiar imagem", "copyImageURL": "Copiar link da imagem", "caseSensitive": "Diferenciar maiúsculas e minúsculas", "showCover": "Mostrar capa", "showSnippet": "Mostrar trecho", "fadeRead": "Desaparecer artigos lidos" }, "searchEngine": { "name": "Motor de pesquisa", "google": "Google", "bing": "Bing", "baidu": "Baidu", "duckduckgo": "DuckDuckGo" }, "settings": { "writeError": "Ocorreu um erro ao gravar o ficheiro.", "name": "Definições", "fetching": "Atualizando fontes, aguarde por favor …", "exit": "Sair das definições", "sources": "Fontes", "grouping": "Grupos", "rules": "Regras", "service": "Serviço", "app": "Preferências", "about": "Sobre", "version": "Versão", "shortcuts": "Atalhos", "openSource": "Código aberto", "feedback": "Feedback" }, "sources": { "serviceWarning": "Fontes importadas ou adicionadas aqui não serão sincronizadas com o seu serviço.", "serviceManaged": "Esta fonte é gerenciada pelo seu serviço.", "untitled": "Fonte", "errorAdd": "Ocorreu um erro ao adicionar a fonte.", "errorParse": "Ocorreu um erro ao analisar o arquivo OPML.", "errorParseHint": "Certifique-se de que o arquivo não esteja corrompido e que esteja codificado com UTF-8.", "errorImport": "Erro ao importar {count, plural, =1 {# fonte} other {# fontes}}.", "exist": "Esta fonte já existe.", "opmlFile": "Ficheiro OPML", "name": "Nome da fonte", "editName": "Editar nome", "fetchFrequency": "Limite da frequência de atualização", "unlimited": "Ilimitado", "openTarget": "Método padrão de carregamento dos artigos", "delete": "Eliminar fonte", "add": "Adicionar fonte", "import": "Importar", "export": "Exportar", "rssText": "Texto completo do RSS", "loadWebpage": "Carregar página web", "inputUrl": "Insira a URL", "badIcon": "Ícone inválido", "badUrl": "URL inválida", "deleteWarning": "A fonte e todos os artigos guardados serão removidos.", "selected": "Fonte selecionada", "selectedMulti": "Múltiplas fontes selecionadas" }, "groups": { "exist": "Este grupo já existe.", "type": "Tipo", "group": "Grupo", "source": "Fonte", "capacity": "Capacidade", "exitGroup": "Voltar para os grupos", "deleteSource": "Eliminar do grupo", "sourceHint": "Arraste e solte as fontes para reorganizá-las.", "create": "Criar grupo", "selectedGroup": "Grupo selecionado", "selectedSource": "Fonte selecionada", "enterName": "Insira o nome", "editName": "Editar nome", "deleteGroup": "Eliminar grupo", "chooseGroup": "Selecione um grupo", "addToGroup": "Adicionar a ...", "groupHint": "Clique duplo no grupo para editar as fontes. Arraste e solte para reorganizá-los." }, "rules": { "intro": "Marcar artigos automaticamente ou enviar notificações com expressões regulares.", "help": "Saber mais", "source": "Fonte", "selectSource": "Selecionar uma fonte", "new": "Nova regra", "if": "Se", "then": "Então", "title": "Título", "content": "Conteúdo", "fullSearch": "Título ou conteúdo", "creator": "Autor", "match": "corresponde", "notMatch": "não corresponde", "regex": "Expressão regular", "badRegex": "Expressão regular inválida.", "action": "Ações", "selectAction": "Selecionar ações", "hint": "As regras serão aplicadas em ordem. Arraste e solte para reorganizar.", "test": "Testar regras" }, "service": { "intro": "Sincronização entre dispositivos com serviços RSS.", "select": "Selecione um serviço", "suggest": "Sugerir um novo serviço", "overwriteWarning": "Fontes locais serão eliminadas se elas existirem no serviço.", "groupsWarning": "Grupos não são automaticamente sincronizados com o serviço.", "rateLimitWarning": "Para evitar limitações do serviço, você precisa criar sua própria chave de API.", "removeAd": "Remover Anuncio", "endpoint": "Endpoint", "username": "Utilizador", "password": "Password", "unchanged": "Inalterado", "fetchLimit": "Limite de sincronização", "fetchLimitNum": "{count} últimos artigos", "importGroups": "Importar grupos", "failure": "Não foi possível conectar ao serviço", "failureHint": "Por favor verifique a configuração do serviço ou o estado da rede.", "fetchUnlimited": "Ilimitado (não recomendado)", "exportToLite": "Exportar para o Fluent Reader Lite" }, "app": { "cleanup": "Limpar", "cache": "Limpar cache", "cacheSize": "{size} de dados em cache", "deleteChoices": "Eliminar artigos de ... dias atrás", "confirmDelete": "Eliminar", "daysAgo": "{days, plural, =1 {# dia} other {# dias}} atrás", "deleteAll": "Eliminar todos os artigos", "calculatingSize": "Calculando tamanho...", "itemSize": "Cerca de {size} do armazenamento local é ocupado por artigos", "confirmImport": "Do you really want to import data from the backup file? All current data will be wiped.", "data": "Dados da aplicação", "backup": "Backup", "restore": "Restorar", "frData": "Dados do Fluent Reader", "language": "Idioma", "theme": "Tema", "lightTheme": "Modo claro", "darkTheme": "Modo escuro", "enableProxy": "Ativar proxy", "badUrl": "URL inválida", "pac": "Endereço PAC", "setPac": "Definir PAC", "pacHint": "Para proxies Socks, é recomendado para o PAC retornar \"SOCKS5\" para o proxy-side DNS. Desligar o proxy requer um reinicio.", "fetchInterval": "Intervalo de atualização automática", "never": "Nunca" } } ================================================ FILE: src/scripts/i18n/ru.json ================================================ { "allArticles": "Все статьи", "add": "Добавить", "create": "Создать", "icon": "Иконка", "name": "Название", "openExternal": "Открыть внешней программой", "emptyName": "Это поле не может быть пустым.", "emptyField": "Это поле не может быть пустым.", "edit": "Редактировать", "delete": "Удалить", "followSystem": "Как в системе", "more": "Ещё", "close": "Закрыть", "search": "Поиск", "loadMore": "Загрузить ещё", "dangerButton": "Подтвердить {action}?", "confirmMarkAll": "Вы действительно хотите отметить все статьи на этой странице прочитанными?", "confirm": "Подтвердить", "cancel": "Отмена", "default": "По умолчанию", "time": { "now": "сейчас", "m": "м", "h": "ч", "d": "д", "minute": "{m, plural, =1 {# минута} other {# минут}}", "hour": "{h, plural, =1 {# час} other {# часов}}", "day": "{d, plural, =1 {# день} other {# дней}}" }, "log": { "empty": "Нет уведомлений", "fetchFailure": "Не удалось загрузить из источника \"{name}\".", "fetchSuccess": "Успешно {count, plural, =1 {получена # статья} other {получены # статей}}.", "networkError": "Ошибка сети.", "parseError": "Возникла ошибка при разборе XML.", "syncFailure": "Не удалось синхронизировать с сервисом" }, "nav": { "menu": "Меню", "refresh": "Обновить", "markAllRead": "Отметить всё прочитанным", "notifications": "Уведомления", "view": "Вид", "settings": "Настройки", "minimize": "Свернуть", "maximize": "Развернуть" }, "menu": { "close": "Закрыть меню", "subscriptions": "Подписки" }, "article": { "error": "Не удалось загрузить статью.", "reload": "Перезагрузить?", "empty": "Нет статей", "untitled": "(Без названия)", "hide": "Спрятать статью", "unhide": "Показать статью", "markRead": "Отметить как прочитанное", "markUnread": "Отметить как непрочитанное", "markAbove": "Отметить выше как прочитанное", "markBelow": "Отметить ниже как прочитанное", "star": "В избранное", "unstar": "Убрать из избранного", "fontSize": "Размер шрифта", "loadWebpage": "Загрузить веб-страницу", "loadFull": "Загрузить полное содержимое", "notify": "Уведомить, если получено в фоновом режиме", "dontNotify": "Не уведомлять", "textDir": "Направление текста", "LTR": "Слева направо", "RTL": "Справа налево", "Vertical": "Вертикально", "font": "Шрифт" }, "context": { "share": "Поделиться", "read": "Читать", "copyTitle": "Копировать заголовок", "copyURL": "Копировать ссылку", "copy": "Копировать", "search": "Искать \"{text}\" в {engine}", "view": "Вид", "cardView": "Карточки", "listView": "Список", "magazineView": "Журнал", "compactView": "Компактный", "filter": "Фильтры", "unreadOnly": "Только непрочитанные", "starredOnly": "Только избранные", "fullSearch": "Поиск по всему тексту", "showHidden": "Показывать скрытые", "manageSources": "Управление источниками", "saveImageAs": "Сохранить изображение как …", "copyImage": "Копировать изображение", "copyImageURL": "Копировать ссылку на изображение", "caseSensitive": "С учётом регистра", "showCover": "Показать обложку", "showSnippet": "Показать отрывок", "fadeRead": "Высветлять прочитанные статьи" }, "searchEngine": { "name": "Поисковая система", "google": "Google", "bing": "Bing", "baidu": "Baidu", "duckduckgo": "DuckDuckGo" }, "settings": { "writeError": "Произошла ошибка при записи файла.", "name": "Настройки", "fetching": "Обновление источников. Пожалуйста, подождите.", "exit": "Выйти из настроек", "sources": "Источники", "grouping": "Группы", "rules": "Правила", "service": "Сервисы", "app": "Предпочтения", "about": "О программе", "version": "Версия", "shortcuts": "Сочетания клавиш", "openSource": "Открытый исходный код", "feedback": "Обратная связь" }, "sources": { "serviceWarning": "Импортированные или добавленные здесь источники не будут синхронизированы с Вашим сервисом.", "serviceManaged": "Этот источник управляется Вашим сервисом.", "untitled": "Источник", "errorAdd": "Возникла ошибка при добавлении источника.", "errorParse": "Возникла ошибка при разборе OPML файла.", "errorParseHint": "Пожалуйста, удостоверьтесь что файл не повреждён и использует кодировку UTF-8.", "errorImport": "Ошибка импорта {count, plural, =1 {# источника} other {# источников}}.", "exist": "Этот источник уже существует.", "opmlFile": "OPML файл", "name": "Название источника", "editName": "Изменить название", "fetchFrequency": "Ограничение частоты обновлений", "unlimited": "Без ограничений", "openTarget": "Метод открытия статей по умолчанию", "delete": "Удалить источник", "add": "Добавить источник", "import": "Импорт", "export": "Экспорт", "rssText": "Полный текст RSS", "loadWebpage": "Загрузить веб-страницу", "inputUrl": "Введите URL", "badIcon": "Недопустимая иконка", "badUrl": "Недопустимый URL", "deleteWarning": "Источник и все сохранённые статьи будут удалены.", "selected": "Выберите источник", "selectedMulti": "Выберите несколько источников" }, "groups": { "exist": "Эта группа уже существует.", "type": "Тип", "group": "Группа", "source": "Источник", "capacity": "Ёмкость", "exitGroup": "Назад к группам", "deleteSource": "Удалить из группы", "sourceHint": "Перетаскивайте источники для изменения порядка.", "create": "Создать группу", "selectedGroup": "Выбранная группа", "selectedSource": "Выбранный источник", "enterName": "Введите название", "editName": "Изменить название", "deleteGroup": "Удалить группу", "chooseGroup": "Выбрать группу", "addToGroup": "Добавить в …", "groupHint": "Сделайте двойной щелчок по группе для редактирования источников. Перетаскивайте для изменения порядка." }, "rules": { "intro": "Автоматически отмечать статьи или отправлять уведомления с помощью регулярных выражений.", "help": "Узнать больше", "source": "Источник", "selectSource": "Выбрать источник", "new": "Новое правило", "if": "Если", "then": "То", "title": "Название", "content": "Содержимое", "fullSearch": "Название или содержимое", "creator": "Автор", "match": "совпадает", "notMatch": "не совпадает", "regex": "Регулярное выражение", "badRegex": "Недопустимое регулярное выражение.", "action": "Действия", "selectAction": "Выберите действия", "hint": "Правила применяются по порядку. Перетащите для изменения порядка.", "test": "Проверить правила" }, "service": { "intro": "Синхронизация между устройствами с помощью RSS сервисов.", "select": "Выберите сервис", "suggest": "Предложить новый сервис", "overwriteWarning": "Локальные источники будут удалены если они существуют в сервисе.", "groupsWarning": "Группы не синхронизируются автоматически через сервис.", "rateLimitWarning": "Чтобы избежать ограничения частоты запросов, Вам нужно создать свой ключ API.", "removeAd": "Убрать рекламу", "endpoint": "Endpoint", "username": "Имя пользователя", "password": "Пароль", "unchanged": "Без изменений", "fetchLimit": "Ограничение синхронизации", "fetchLimitNum": "{count, plural, =1 {# последняя статья} other {# последних статей}}", "importGroups": "Импортировать группы", "failure": "Нет подключения к сервису", "failureHint": "Please check the service configuration or network status.", "fetchUnlimited": "Без ограничений (не рекомендуется)", "exportToLite": "Экспорт в Fluent Reader Lite" }, "app": { "cleanup": "Очистка", "cache": "Очистить кэш", "cacheSize": "Закэшировано {size} данных", "deleteChoices": "Удалить статьи старше … дней", "confirmDelete": "Удалить", "daysAgo": "{days, plural, =1 {# дня} other {# дней}} назад", "deleteAll": "Удалить все статьи", "calculatingSize": "Вычисление размера…", "itemSize": "Статьями занято примерно {size} пространства локального хранилища", "confirmImport": "Вы действительно хотите импортировать данные из файла резервной копии? Все текущие данные будут удалены.", "data": "Данные приложения", "backup": "Резервная копия", "restore": "Восстановление", "frData": "Данные Fluent Reader", "language": "Язык интерфейса", "theme": "Тема", "lightTheme": "Светлая", "darkTheme": "Тёмная", "enableProxy": "Включить прокси", "badUrl": "Недопустимый URL", "pac": "PAC Адрес", "setPac": "Установить PAC", "pacHint": "Для Socks прокси рекомендуется, чтобы PAC возвращал \"SOCKS5\" для DNS на стороне прокси. Выключение прокси требует перезапуска.", "fetchInterval": "Интервал автоматического обновления", "never": "Никогда" } } ================================================ FILE: src/scripts/i18n/sv.json ================================================ { "allArticles": "Alla artiklar", "add": "Lägg till", "create": "Skapa", "icon": "Ikon", "name": "Namn", "openExternal": "Öppna externt", "emptyName": "Detta fält kan inte vara tomt.", "emptyField": "Detta fält kan inte vara tomt.", "edit": "Redigera", "delete": "Ta bort", "followSystem": "Följ systemet", "more": "Mer", "close": "Stäng", "search": "Sök", "loadMore": "Läs in mer", "dangerButton": "Bekräfta {action}?", "confirmMarkAll": "Vill du verkligen markera alla artiklar på denna sida som lästa?", "confirm": "Bekräfta", "cancel": "Avbryt", "time": { "now": "nu", "m": "m", "h": "t", "d": "d", "minute": "{m, plural, =1 {# minut} other {# minuter}}", "hour": "{h, plural, =1 {# timma} other {# timmar}}", "day": "{d, plural, =1 {# dag} other {# dagar}}" }, "log": { "empty": "Ingen avisering", "fetchFailure": "Kunde inte läsa in källan \"{name}\".", "fetchSuccess": "Lyckades hämta {count, plural, =1 {# artikel} other {# artiklar}}.", "networkError": "Ett nätverksfel uppstod.", "parseError": "Ett fel uppstod vid tolkning av XML-flödet.", "syncFailure": "Kunde inte synkronisera med tjänsten" }, "nav": { "menu": "Meny", "refresh": "Uppdatera", "markAllRead": "Markera alla som lästa", "notifications": "Aviseringar", "view": "Visa", "settings": "Inställningar", "minimize": "Minimera", "maximize": "Maximera" }, "menu": { "close": "Stäng menyn", "subscriptions": "Prenumerationer" }, "article": { "error": "Kunde inte läsa in artikeln.", "reload": "Vill du läsa in igen?", "empty": "Inga artiklar", "untitled": "(Namnlös)", "hide": "Dölj artikel", "unhide": "Visa artikel", "markRead": "Markera som läst", "markUnread": "Markera som oläst", "markAbove": "Markera ovanstående som läst", "markBelow": "Markera nedanstående som läst", "star": "Stjärna", "unstar": "Ta bort stjärna", "fontSize": "Teckenstorlek", "loadWebpage": "Läs in webbsidan", "loadFull": "Läs in fullständigt innehåll", "notify": "Avisera om hämtat i bakgrunden", "dontNotify": "Avisera inte" }, "context": { "share": "Dela", "read": "Läs", "copyTitle": "Kopiera titel", "copyURL": "Kopiera länk", "copy": "Kopiera", "search": "Sök \"{text}\" på {engine}", "view": "Visa", "cardView": "Kortvy", "listView": "Listvy", "magazineView": "Tidningsvy", "compactView": "Kompakt vy", "filter": "Filtrering", "unreadOnly": "Endast oläst", "starredOnly": "Endast stjärnmärkt", "fullSearch": "Sök i hela texten", "showHidden": "Visa dolda artiklar", "manageSources": "Hantera källor", "saveImageAs": "Spara bild som …", "copyImage": "Kopiera bild", "copyImageURL": "Kopiera bildlänk", "caseSensitive": "Skiftlägeskänslig", "showCover": "Visa omslag", "showSnippet": "Visa utdrag", "fadeRead": "Tona lästa artiklar" }, "searchEngine": { "name": "Sökmotor", "google": "Google", "bing": "Bing", "baidu": "Baidu", "duckduckgo": "DuckDuckGo" }, "settings": { "writeError": "Ett fel uppstod vid skrivning av filen.", "name": "Inställningar", "fetching": "Uppdaterar källor, vänta …", "exit": "Stäng inställningar", "sources": "Källor", "grouping": "Grupper", "rules": "Regler", "service": "Tjänst", "app": "Inställningar", "about": "Om", "version": "Version", "shortcuts": "Genvägar", "openSource": "Open source", "feedback": "Återkoppling" }, "sources": { "serviceWarning": "Källor tillagda eller importerade här, kommer inte att synkroniseras med din tjänst.", "serviceManaged": "Denna källa hanteras av din tjänst.", "untitled": "Källa", "errorAdd": "Ett fel uppstod då källan lades till.", "errorParse": "Ett fel uppstod vid tolkning av OPML-filen.", "errorParseHint": "Tillse att filen inte är skadad och är kodad med UTF-8.", "errorImport": "Fel vid import av {count, plural, =1 {# källa} other {# källor}}.", "exist": "Denna källa finns redan.", "opmlFile": "OPML-fil", "name": "Källnamn", "editName": "Redigera namn", "fetchFrequency": "Hämta frekvensbegränsning", "unlimited": "Obegränsad", "openTarget": "Standard öppet mål för artiklar", "delete": "Ta bort källa", "add": "Lägg till källa", "import": "Importera", "export": "Exportera", "rssText": "RSS fulltext", "loadWebpage": "Läs in webbsidaq", "inputUrl": "Ange URL", "badIcon": "Ogiltig ikon", "badUrl": "Ogiltig URL", "deleteWarning": "Källan och alla sparade artiklar kommer att tas bort.", "selected": "Valde källa", "selectedMulti": "Valde flera källor" }, "groups": { "exist": "Denna grupp finns redan.", "type": "Typ", "group": "Grupp", "source": "Källa", "capacity": "Kapacitet", "exitGroup": "Tillbaka till grupper", "deleteSource": "Ta bort från gruppen", "sourceHint": "Dra och släpp källor för att sortera.", "create": "Skapa grupp", "selectedGroup": "Valde grupp", "selectedSource": "Valde källa", "enterName": "Ange namn", "editName": "Redigera namn", "deleteGroup": "Ta bort grupp", "chooseGroup": "Välj en grupp", "addToGroup": "Lägg till i ...", "groupHint": "Dubbelklicka på en grupp för att redigera källor. Dra och släpp för att sortera." }, "rules": { "intro": "Markera artiklar automatiskt eller avisera med regular expressions.", "help": "Läs mer", "source": "Källa", "selectSource": "Välj en källa", "new": "Ny regel", "if": "Om", "then": "Då", "title": "Titel", "content": "Innehåll", "fullSearch": "Titel eller innehåll", "creator": "Författare", "match": "matchar", "notMatch": "matchar inte", "regex": "Regular expression", "badRegex": "Ogiltigt regular expression.", "action": "Åtgärder", "selectAction": "Välj åtgärder", "hint": "Regler kommer att tillämpas i ordning. Dra och släpp för att ändra ordningen.", "test": "Testa regler" }, "service": { "intro": "Synkronisera mellan enheter med RSS-tjänster.", "select": "Välj en tjänst", "suggest": "Föreslå en ny tjänst", "overwriteWarning": "Lokala källor tas bort, om de finns i tjänsten.", "groupsWarning": "Grupper synkroniseras inte automatiskt med tjänsten.", "rateLimitWarning": "För att undvika frekvensbegränsning måste du skapa din egen API-nyckel.", "removeAd": "Ta bort annons", "endpoint": "Slutpunkt", "username": "Användarnamn", "password": "Lösenord", "unchanged": "Oförändrad", "fetchLimit": "Synkroniseringsgräns", "fetchLimitNum": "{count} senaste artiklar", "importGroups": "Importera grupper", "failure": "Kan inte ansluta till tjänsten", "failureHint": "Kontrollera tjänstkonfigurationen eller nätverksstatus.", "fetchUnlimited": "Obegränsat (rekommenderas ej)", "exportToLite": "Exportera till Fluent Reader Lite" }, "app": { "cleanup": "Rensa", "cache": "Rensa cache", "cacheSize": "Cache-lagra {size} data", "deleteChoices": "Ta bort artiklar från ... dagar sedan", "confirmDelete": "Ta bort", "daysAgo": "{days, plural, =1 {# dag} other {# dagar}} sedan", "deleteAll": "Ta bort alla artiklar", "calculatingSize": "Beräknar storlek...", "itemSize": "Omkring {size} lokal datalagring upptas av artiklar", "confirmImport": "Vill du verkligen importera data från säkerhetskopian? All aktuell data kommer att raderas.", "data": "Applikationsdata", "backup": "Säkerhetskopier", "restore": "Återställ", "frData": "Fluent Reader Data", "language": "Skärmspråk", "theme": "Tema", "lightTheme": "Ljust läge", "darkTheme": "Mörkt läge", "enableProxy": "Aktivera proxy", "badUrl": "Ogiltig URL", "pac": "PAC-adress", "setPac": "Ange PAC", "pacHint": "För Socks-proxyservrar rekommenderas att PAC returnerar \"SOCKS5\" för DNS på proxysidan. Om du stänger av proxyn krävs omstart.", "fetchInterval": "Automatiskt hämtningsintervall", "never": "Aldrig" } } ================================================ FILE: src/scripts/i18n/tr.json ================================================ { "allArticles": "Tüm Yazılar", "add": "Ekle", "create": "Ekle", "icon": "Simge", "name": "Ad", "openExternal": "Yeni pencerede aç", "emptyName": "Bu alan boş olamaz.", "emptyField": "Bu alan boş olamaz.", "edit": "Düzenle", "delete": "Sil", "followSystem": "Sistem ayarlarını kullan", "more": "Daha fazla", "close": "Kapat", "search": "Ara", "loadMore": "Daha fazla", "dangerButton": "İşleme devam et ({action})", "confirmMarkAll": "Bu sayfadaki tüm yazıları gerçekten okundu olarak işaretlemek istiyor musunuz?", "confirm": "Tamam", "cancel": "İptal Et", "time": { "now": "şu an", "m": "d", "h": "s", "d": "g", "minute": "{m, plural, =1 {# dakika} other {# dakika}}", "hour": "{h, plural, =1 {# saat} other {# saat}}", "day": "{d, plural, =1 {# gün} other {# gün}}" }, "log": { "empty": "Bildirim yok", "fetchFailure": "\"{name}\" kaynağı yüklenemedi.", "fetchSuccess": "{count, plural, =1 {# yazı} other {# yazı}} başarıyla güncellendi.", "networkError": "Ağ hatası oluştu.", "parseError": "XML beslemesi ayrıştırılırken bir hata oluştu..", "syncFailure": "Hizmet ile senkronize edilemedi" }, "nav": { "menu": "Menü", "refresh": "Yenile", "markAllRead": "Tümünü okundu olarak işaretle", "notifications": "Bildirimler", "view": "Görünüm", "settings": "Ayarlar", "minimize": "Simge durumuna küçült", "maximize": "Ekranı kapla" }, "menu": { "close": "Kapat", "subscriptions": "Abonelikler" }, "article": { "error": "Yazı yüklenemedi.", "reload": "Tekrar yükle?", "empty": "Yazı yok", "untitled": "(Başlıksız)", "hide": "Yazıyı gizle", "unhide": "Yazıyı göster", "markRead": "Okundu olarak işaretle", "markUnread": "Okunmamış olarak işaretle", "markAbove": "Yukarıdakileri okundu olarak işaretle", "markBelow": "Aşağıdakileri okundu olarak işaretle", "star": "Yıldız ekle", "unstar": "Yıldızı kaldır", "fontSize": "Yazı boyutu", "loadWebpage": "Web sayfasını yükle", "loadFull": "Tam içeriği yükle", "notify": "Arka planda getirilirse bildir", "dontNotify": "Bildirme" }, "context": { "share": "Paylaş", "read": "Oku", "copyTitle": "Başlığı kopyala", "copyURL": "Bağlantıyı kopyala", "copy": "Kopyala", "search": "{engine}'da \"{text}\" ara", "view": "Görünüm", "cardView": "Kart görünümü", "listView": "Liste görünümü", "magazineView": "Dergi görünümü", "compactView": "Kompakt görünüm", "filter": "Filtreleme", "unreadOnly": "Okunmamışlar", "starredOnly": "Yıldızlılar", "fullSearch": "Tam metinde ara", "showHidden": "Gizli yazıları göster", "manageSources": "Kaynakları yönet", "saveImageAs": "Resmi farklı kaydet …", "copyImage": "Resmi kopyala", "copyImageURL": "Resim bağlantısını kopyala", "caseSensitive": "Harfe duyarlı", "showCover": "Resmi göster", "showSnippet": "Açıklama göster", "fadeRead": "Okunanları karart" }, "searchEngine": { "name": "Arama motoru", "google": "Google", "bing": "Bing", "baidu": "Baidu", "duckduckgo": "DuckDuckGo" }, "settings": { "writeError": "Dosyayı yazarken bir hata oluştu.", "name": "Ayarlar", "fetching": "Kaynaklar güncelleniyor, lütfen bekleyin…", "exit": "Çıkış", "sources": "Kaynaklar", "grouping": "Gruplar", "rules": "Kurallar", "service": "Hizmetler", "app": "Tercihler", "about": "Hakkında", "version": "Sürüm", "shortcuts": "Kısayollar", "openSource": "Açık Kaynak", "feedback": "Geri Bildirim" }, "sources": { "serviceWarning": "Sources imported or added here will not be synced with your service.", "serviceManaged": "Bu kaynak, hizmetiniz tarafından yönetilmektedir.", "untitled": "Kaynak", "errorAdd": "Kaynak eklenirken bir hata oluştu.", "errorParse": "OPML dosyası ayrıştırılırken bir hata oluştu.", "errorParseHint": "Lütfen dosyanın bozuk olmadığından ve UTF-8 formatında olduğundan emin olun.", "errorImport": "İçe aktarma hatası {count, plural, =1 {# kaynak} other {# kaynak}}.", "exist": "Bu kaynak zaten var.", "opmlFile": "OPML Dosyası", "name": "Kaynak adı", "editName": "Güncelle", "fetchFrequency": "Yenileme sıklığı", "unlimited": "Sınırsız", "openTarget": "Yazılar için varsayılan işlem", "delete": "Sil", "add": "Kaynak ekle", "import": "İçe Aktar", "export": "Dışa Aktar", "rssText": "RSS tam metni", "loadWebpage": "Web sayfasını yükle", "inputUrl": "URL girin", "badIcon": "Geçersiz simge", "badUrl": "Geçersiz URL", "deleteWarning": "Kaynak ve kaydedilen tüm yazılar kaldırılacaktır.", "selected": "Seçilen kaynak", "selectedMulti": "Birden çok kaynak seçildi" }, "groups": { "exist": "Bu grup zaten var.", "type": "Tip", "group": "Grup", "source": "Kaynak", "capacity": "Kapasite", "exitGroup": "Geri", "deleteSource": "Sil", "sourceHint": "Yeniden sıralamak için kaynakları sürükleyip bırakın.", "create": "Grup oluştur", "selectedGroup": "Grup", "selectedSource": "Kaynağı seç", "enterName": "Ad girin", "editName": "Güncelle", "deleteGroup": "Sil", "chooseGroup": "Grup seç", "addToGroup": "Ekle", "groupHint": "Kaynakları düzenlemek için gruba çift tıklayın. Yeniden sıralamak için sürükleyip bırakın." }, "rules": { "intro": "Yazıları otomatik olarak işaretle yada düzenli ifadeler (regex) ile bildirimler gönder.", "help": "Daha fazla bilgi edin", "source": "Kaynak", "selectSource": "Kaynak Seçin", "new": "Yeni kural", "if": "Eğer", "then": "Sonra", "title": "Başlık", "content": "İçerik", "fullSearch": "Başlık veya İçerik", "creator": "Yazar", "match": "eşleşiyorsa", "notMatch": "eşleşmiyorsa", "regex": "Düzenli ifade (Regex)", "badRegex": "Geçersiz düzenli ifade (Regex).", "action": "Eylemler", "selectAction": "Eylemleri Seçin", "hint": "Kurallar sırayla uygulanacaktır. Yeniden sıralamak için sürükleyip bırakın.", "test": "Test Et" }, "service": { "intro": "RSS hizmetleriyle cihazlar arasında senkronize edin.", "select": "Hizmet seçin", "suggest": "Yeni hizmet öner", "overwriteWarning": "Kaynak hizmet içerisinde varsa, yerel kaynaklar silinecek..", "groupsWarning": "Gruplar hizmetle otomatik olarak senkronize edilmez.", "endpoint": "Endpoint", "username": "Kullanıcı Adı", "password": "Parola", "unchanged": "Değişmedi", "fetchLimit": "Senkronizasyon sınırı", "fetchLimitNum": "Son {count} yazı", "importGroups": "Grupları içe aktar", "failure": "Hizmete bağlanılamıyor", "failureHint": "Lütfen servis ayarlarını veya internet bağlantınızı kontrol edin.", "fetchUnlimited": "Sınırsız (tavsiye edilmez)" }, "app": { "cleanup": "Temizle", "cache": "Önbelleği temizle", "cacheSize": "{size} veri önbelleğe alındı", "deleteChoices": "... gün öncesine ait yazıları sil", "confirmDelete": "Sil", "daysAgo": "{days} gün önce", "deleteAll": "Tüm yazıları sil", "calculatingSize": "Boyut hesaplanıyor...", "itemSize": "Kullanılan depolama alanınızın yaklaşık {size}", "confirmImport": "İşleme devam etmek istediğinize emin misiniz? Mevcut tüm veriler silinecek.", "data": "Uygulama Verileri", "backup": "Yedekle", "restore": "Geri Yükle", "frData": "Fluent Reader Verileri", "language": "Görüntüleme dili", "theme": "Tema", "lightTheme": "Aydınlık", "darkTheme": "Karanlık", "enableProxy": "Proxy'yi Etkinleştir", "badUrl": "Geçersiz URL", "pac": "PAC Adresi", "setPac": "PAC ayarla", "pacHint": "Socks proxy'leri için, PAC'ın proxy tarafı DNS için \"SOCKS5\" döndürmesi önerilir. Proxy'yi kapatmak, yeniden başlatmayı gerektirir.", "fetchInterval": "Otomatik getirme aralığı", "never": "Asla" } } ================================================ FILE: src/scripts/i18n/uk.json ================================================ { "allArticles": "Всі статті", "add": "Додати", "create": "Створити", "icon": "Іконка", "name": "Ім'я", "openExternal": "Відкрити зовні", "emptyName": "Це поле не може бути порожнім.", "emptyField": "Це поле не може бути порожнім.", "edit": "Редагувати", "delete": "Видалити", "followSystem": "Як у системі", "more": "Більше", "close": "Закрити", "search": "Шукати", "loadMore": "Завантажити більше", "dangerButton": "Справді {action}?", "confirmMarkAll": "Ви дійсно хочете позначити всі статті на цій сторінці як прочитані?", "confirm": "Підтвердити", "cancel": "Назад", "time": { "now": "щойно", "m": "х", "h": "г", "d": "д", "minute": "{m, plural, =1 {# хвилина} other {# хвилин}}", "hour": "{h, plural, =1 {# година} other {# годин}}", "day": "{d, plural, =1 {# день} other {# днів}}" }, "log": { "empty": "Немає сповіщень", "fetchFailure": "Не вдалося завантажити джерело \"{name}\".", "fetchSuccess": "Успішно завантажено {count, plural, =1 {# стаття} other {# статів}}.", "networkError": "Сталася помилка мережі.", "parseError": "Під час аналізу стрічки XML сталася помилка.", "syncFailure": "Не вдалося синхронізувати зі службою" }, "nav": { "menu": "Меню", "refresh": "Оновити", "markAllRead": "Позначити всі як прочитані", "notifications": "Сповіщення", "view": "Вигляд", "settings": "Налаштування", "minimize": "Мінімізувати", "maximize": "Максимізувати" }, "menu": { "close": "Закрити меню", "subscriptions": "Підписки" }, "article": { "error": "Не вдалося завантажити статтю.", "reload": "Перезавантажити?", "empty": "Статей немає", "untitled": "(Без назви)", "hide": "Сховати статтю", "unhide": "Показати статтю", "markRead": "Позначити як прочитане", "markUnread": "Позначити як непрочитане", "markAbove": "Позначити вище як прочитане", "markBelow": "Позначити нижче як прочитане", "star": "Зберегти", "unstar": "Прибрати з збереженого", "fontSize": "Розмір шрифту", "loadWebpage": "Завантажити веб-сторінку", "loadFull": "Завантажте повний вміст", "notify": "Сповісти, якщо отримано у фоновому режимі", "dontNotify": "Не сповіщати" }, "context": { "share": "Поділитися", "read": "Читати", "copyTitle": "Копіювати заголовок", "copyURL": "Копіювати посилання", "copy": "Копіювати", "search": "Шукати \"{text}\" в {engine}", "view": "Вигляд", "cardView": "Карточний", "listView": "Списком", "magazineView": "Журнальний", "compactView": "Компактний", "filter": "Фільтр", "unreadOnly": "Лише непрочитане", "starredOnly": "Лише збережені", "fullSearch": "Шукати в повному тексті", "showHidden": "Показати приховані статті", "manageSources": "Керувати джерелами", "saveImageAs": "Зберегти зображення як …", "copyImage": "Копіювати зображення", "copyImageURL": "Копіювати адресу зображення", "caseSensitive": "З урахуванням регістру", "showCover": "Показати обкладинку", "showSnippet": "Показати фрагмент", "fadeRead": "Fade читати статті" }, "searchEngine": { "name": "Пошукова система", "google": "Google", "bing": "Bing", "baidu": "Baidu", "duckduckgo": "DuckDuckGo" }, "settings": { "writeError": "Під час запису файлу сталася помилка.", "name": "Налаштування", "fetching": "Оновлення джерел, зачекайте …", "exit": "Вийти з налаштувань", "sources": "Джерела", "grouping": "Групи", "rules": "Правила", "service": "Сервіси", "app": "Вподобання", "about": "Відомості", "version": "Версія", "shortcuts": "Комбінації клавіш", "openSource": "Відкрити джерело", "feedback": "Зворотній зв'язок" }, "sources": { "serviceWarning": "Джерела, імпортовані або додані сюди, не будуть синхронізовані з вашою службою.", "serviceManaged": "Цим джерелом керує ваша служба.", "untitled": "Джерело", "errorAdd": "Під час додавання джерела сталася помилка.", "errorParse": "Під час аналізу файлу OPML сталася помилка.", "errorParseHint": "Переконайтеся, що файл не пошкоджений і має кодування UTF-8.", "errorImport": "Помилка імпорту {count, plural, =1 {# джерела} other {# джерел}}.", "exist": "Це джерело вже існує.", "opmlFile": "OPML Файл", "name": "Назва джерела", "editName": "Редагувати назву", "fetchFrequency": "Вибір обмеження частоти", "unlimited": "Необмежений", "openTarget": "Відкрита ціль за замовчуванням для статей", "delete": "Видалити джерело", "add": "Додати джерело", "import": "Імпорт", "export": "Експорт", "rssText": "Повний текст RSS", "loadWebpage": "Завантажити веб-сторінку", "inputUrl": "Введіть URL-адресу", "badIcon": "Недійсна іконка", "badUrl": "Недійсна URL-адреса", "deleteWarning": "Джерело та всі збережені статті будуть видалені.", "selected": "Вибране джерело", "selectedMulti": "Вибрано кілька джерел" }, "groups": { "exist": "Ця група вже існує.", "type": "Тип", "group": "Група", "source": "Джерело", "capacity": "Місткість", "exitGroup": "Повернутися до груп", "deleteSource": "Видалити з групи", "sourceHint": "Перетягніть джерела, щоб змінити порядок.", "create": "Створити групу", "selectedGroup": "Вибрана група", "selectedSource": "Вибране джерело", "enterName": "Введіть назву", "editName": "Редагувати назву", "deleteGroup": "Видалити групу", "chooseGroup": "Вибрати групу", "addToGroup": "Додати до ...", "groupHint": "Двічі клацніть на групі, щоб редагувати джерела. Перетягніть, щоб змінити порядок." }, "rules": { "intro": "Автоматично позначайте статті або надсилайте сповіщення регулярними виразами.", "help": "Дізнатися більше", "source": "Джерело", "selectSource": "Виберіть джерело", "new": "Нове правило", "if": "Якщо", "then": "Тоді", "title": "Заголовок", "content": "Вміст", "fullSearch": "Заголовок та вміст", "creator": "Автор", "match": "якщо збігається", "notMatch": "не збігається", "regex": "Регулярний вираз (Regex)", "badRegex": "Недійсний регулярний вираз. (Regex)", "action": "Дії", "selectAction": "Вибрати дії", "hint": "Правила застосовуватимуться по порядку. Перетягніть, щоб змінити порядок.", "test": "Тест правил" }, "service": { "intro": "Синхронізація між пристроями за допомогою служб RSS.", "select": "Вибрати службу", "suggest": "Запропонуйте нову службу", "overwriteWarning": "Локальні джерела будуть видалені, якщо вони існують в службі.", "groupsWarning": "Групи не синхронізуються автоматично зі службою.", "rateLimitWarning": "Щоб уникнути обмеження швидкості, вам потрібно створити власний ключ API.", "removeAd": "Видалити рекламу", "endpoint": "Кінцева точка", "username": "Ім'я користувача", "password": "Пароль", "unchanged": "Без змін", "fetchLimit": "Обмеження синхронізації", "fetchLimitNum": "{count} останніх статей", "importGroups": "Імпортувати групи", "failure": "Не вдається підключитися до служби", "failureHint": "Будь ласка, перевірте конфігурацію служби або стан мережі.", "fetchUnlimited": "Необмежений (не рекомендується)", "exportToLite": "Експорт у програму Fluent Reader Lite" }, "app": { "cleanup": "Прибирати", "cache": "Очистити кеш", "cacheSize": "Кешовано {size} даних", "deleteChoices": "Видалити статті з ... днів тому", "confirmDelete": "Видалити", "daysAgo": "{days, plural, =1 {# день} other {# днів}} тому", "deleteAll": "Видалити всі статті", "calculatingSize": "Розрахунок розміру ...", "itemSize": "Близько {size} локальної пам'яті займають статті", "confirmImport": "Ви дійсно хочете імпортувати дані з файлу резервної копії? Усі поточні дані будуть знищені.", "data": "Дані програми", "backup": "Резервне копіювання", "restore": "Відновити", "frData": "Дані Fluent Reader", "language": "Мова", "theme": "Тема", "lightTheme": "Світлий режим", "darkTheme": "Темний режим", "enableProxy": "Увімкнути Proxy", "badUrl": "Недійсна URL-адреса", "pac": "Адреса PAC", "setPac": "Встановити PAC", "pacHint": "Для проксі Socks рекомендується, щоб PAC повертав \"SOCKS5\" для DNS на стороні проксі. Для відключення проксі потрібне перезавантаження.", "fetchInterval": "Інтервал автоматичної вибірки", "never": "Ніколи" } } ================================================ FILE: src/scripts/i18n/zh-CN.json ================================================ { "allArticles": "全部文章", "add": "添加", "create": "新建", "icon": "图标", "name": "名称", "openExternal": "在浏览器中打开", "emptyName": "名称不得为空", "emptyField": "此项不得为空", "edit": "编辑", "delete": "删除", "followSystem": "跟随系统", "more": "更多", "close": "关闭", "search": "搜索", "loadMore": "加载更多", "dangerButton": "确认{action}?", "confirmMarkAll": "确认将本页所有文章标为已读?", "confirm": "确认", "cancel": "取消", "default": "默认", "time": { "now": "now", "m": "m", "h": "h", "d": "d", "minute": "{m}分钟", "hour": "{h}小时", "day": "{d}天" }, "log": { "empty": "无消息", "fetchFailure": "无法加载订阅源“{name}”", "fetchSuccess": "成功加载 {count} 篇文章", "networkError": "连接订阅源时出错", "parseError": "解析XML信息流时出错", "syncFailure": "无法与服务同步" }, "nav": { "menu": "菜单", "refresh": "刷新", "markAllRead": "全部标为已读", "notifications": "消息", "view": "视图", "settings": "选项", "minimize": "最小化", "maximize": "最大化" }, "menu": { "close": "关闭菜单", "subscriptions": "订阅源" }, "article": { "error": "文章加载失败", "reload": "重新加载", "empty": "无文章", "untitled": "(无标题)", "hide": "隐藏文章", "unhide": "取消隐藏", "markRead": "标为已读", "markUnread": "标为未读", "markAbove": "将以上标为已读", "markBelow": "将以下标为已读", "star": "标为星标", "unstar": "取消星标", "fontSize": "字体大小", "loadWebpage": "加载网页", "loadFull": "抓取全文", "notify": "后台抓取时发送通知", "dontNotify": "不发送通知", "textDir": "文本方向", "LTR": "从左到右", "RTL": "从右到左", "Vertical": "纵书", "font": "字体" }, "context": { "share": "分享", "read": "阅读", "copyTitle": "复制标题", "copyURL": "复制链接", "copy": "复制", "search": "使用 {engine} 搜索“{text}”", "view": "视图", "cardView": "卡片视图", "listView": "列表视图", "magazineView": "杂志视图", "compactView": "紧凑视图", "filter": "筛选", "unreadOnly": "仅未读文章", "starredOnly": "仅星标文章", "fullSearch": "在正文中搜索", "showHidden": "显示隐藏文章", "manageSources": "管理订阅源", "saveImageAs": "将图像另存为", "copyImage": "复制图像", "copyImageURL": "复制图像链接", "caseSensitive": "区分大小写", "showCover": "显示封面", "showSnippet": "显示摘要", "fadeRead": "淡化已读文章" }, "searchEngine": { "name": "搜索引擎", "bing": "必应", "baidu": "百度" }, "settings": { "writeError": "写入文件时发生错误", "name": "选项", "fetching": "正在更新订阅源,请稍候…", "exit": "退出选项", "sources": "订阅源", "grouping": "分组与排序", "rules": "规则", "service": "服务", "app": "应用偏好", "about": "关于", "version": "版本", "shortcuts": "快捷键", "openSource": "开源项目", "feedback": "反馈" }, "sources": { "serviceWarning": "此处导入或添加的订阅源将不会与服务端同步", "serviceManaged": "该订阅源由服务端管理", "untitled": "订阅源", "errorAdd": "添加订阅源时出错", "errorParse": "解析OPML文件时出错", "errorParseHint": "请确保OPML文件完整且使用UTF-8编码。", "errorImport": "导入{count}项订阅源时出错", "exist": "该订阅源已存在", "opmlFile": "OPML文件", "name": "订阅源名称", "editName": "修改名称", "fetchFrequency": "抓取频率限制", "unlimited": "无限制", "openTarget": "订阅源文章打开方式", "delete": "删除订阅源", "add": "添加订阅源", "import": "导入文件", "export": "导出文件", "rssText": "RSS正文", "loadWebpage": "加载网页", "inputUrl": "输入URL", "badIcon": "图标不存在或非图片", "badUrl": "请正确输入URL", "deleteWarning": "这将移除订阅源与所有已保存的文章", "selected": "选中订阅源", "selectedMulti": "选中多个订阅源", "hidden": "从“全部文章”中隐藏" }, "groups": { "exist": "该分组已存在", "type": "类型", "group": "分组", "source": "订阅源", "capacity": "容量", "exitGroup": "退出分组", "deleteSource": "从分组删除订阅源", "sourceHint": "拖拽订阅源以排序", "create": "新建分组", "selectedGroup": "选中分组", "selectedSource": "选中订阅源", "enterName": "输入名称", "editName": "修改名称", "deleteGroup": "删除分组", "chooseGroup": "选择分组", "addToGroup": "添加至分组", "groupHint": "双击分组以修改订阅源,可通过拖拽排序" }, "rules": { "intro": "通过正则表达式自动标记文章或推送通知", "help": "了解更多", "source": "订阅源", "selectSource": "选择一个订阅源", "new": "新建规则", "if": "若", "then": "则", "title": "标题", "content": "正文", "fullSearch": "标题或正文", "creator": "作者", "match": "匹配", "notMatch": "不匹配", "regex": "正则表达式", "badRegex": "正则表达式非法", "action": "行为", "selectAction": "选择行为", "hint": "规则将按顺序执行,拖拽以排序", "test": "测试规则" }, "service": { "intro": "通过 RSS 服务跨设备保持同步", "select": "选择服务", "suggest": "建议一项新服务", "overwriteWarning": "若本地与服务端存在URL相同的订阅源,则本地订阅源将被删除", "groupsWarning": "分组不会自动与服务端保持同步", "rateLimitWarning": "为避免限流,您需要新建自己的 API Key", "removeAd": "移除广告", "endpoint": "端点", "username": "用户名", "password": "密码", "unchanged": "未更改", "fetchLimit": "同步数量", "fetchLimitNum": "最近 {count} 篇文章", "importGroups": "导入分组", "failure": "连接到服务时出错", "failureHint": "请检查服务配置或网络连接", "fetchUnlimited": "无限制(不建议)", "exportToLite": "导出至 Fluent Reader Lite" }, "app": { "cleanup": "清理", "cache": "清空缓存", "cacheSize": "已缓存{size}数据", "deleteChoices": "删除 … 天前的文章", "confirmDelete": "删除文章", "daysAgo": "{days} 天前", "deleteAll": "删除全部文章", "calculatingSize": "正在计算占用空间…", "itemSize": "本地文章约占用{size}空间", "confirmImport": "确认要从备份文件导入数据吗?这将清除所有应用数据。", "data": "应用数据", "backup": "备份", "restore": "还原", "frData": "Fluent Reader数据", "language": "界面语言", "theme": "应用主题", "lightTheme": "浅色模式", "darkTheme": "深色模式", "enableProxy": "启用代理", "badUrl": "请正确输入URL", "pac": "PAC地址", "setPac": "设置PAC", "pacHint": "对于Socks代理建议PAC返回“SOCKS5”以启用代理端解析。关闭代理需重启应用后生效。", "fetchInterval": "自动抓取频率", "never": "从不" } } ================================================ FILE: src/scripts/i18n/zh-TW.json ================================================ { "allArticles": "全部文章", "add": "新增", "create": "新建", "icon": "圖示", "name": "名稱", "openExternal": "在瀏覽器中開啟", "emptyName": "名稱不得為空", "emptyField": "此項不得為空", "edit": "編輯", "delete": "刪除", "followSystem": "跟隨系統", "more": "更多", "close": "關閉", "search": "搜尋", "loadMore": "載入更多", "dangerButton": "確認{action}?", "confirmMarkAll": "確認將本頁所有文章標為已讀?", "confirm": "確認", "cancel": "取消", "default": "預設", "time": { "now": "now", "m": "m", "h": "h", "d": "d", "minute": "{m}分鐘", "hour": "{h}小時", "day": "{d}天" }, "log": { "empty": "無訊息", "fetchFailure": "無法載入訂閱源“{name}”", "fetchSuccess": "成功載入 {count} 篇文章", "networkError": "連線訂閱源時出錯", "parseError": "解析XML資訊流時出錯", "syncFailure": "無法與服務同步" }, "nav": { "menu": "選單", "refresh": "重新整理", "markAllRead": "全部標為已讀", "notifications": "訊息", "view": "檢視", "settings": "選項", "minimize": "最小化", "maximize": "最大化" }, "menu": { "close": "關閉選單", "subscriptions": "訂閱源" }, "article": { "error": "文章載入失敗", "reload": "重新載入", "empty": "無文章", "untitled": "(無標題)", "hide": "隱藏文章", "unhide": "取消隱藏", "markRead": "標為已讀", "markUnread": "標為未讀", "markAbove": "將以上標為已讀", "markBelow": "將以下標為已讀", "star": "標為星標", "unstar": "取消星標", "fontSize": "字型大小", "loadWebpage": "載入網頁", "loadFull": "抓取全文", "notify": "後臺抓取時傳送通知", "dontNotify": "不傳送通知", "textDir": "文本方向", "LTR": "從左到右", "RTL": "從右到左", "Vertical": "縱書", "font": "字體" }, "context": { "share": "分享", "read": "閱讀", "copyTitle": "複製標題", "copyURL": "複製連結", "copy": "複製", "search": "使用 {engine} 搜尋“{text}”", "view": "檢視", "cardView": "卡片檢視", "listView": "列表檢視", "magazineView": "雜誌檢視", "compactView": "緊湊檢視", "filter": "篩選", "unreadOnly": "僅未讀文章", "starredOnly": "僅星標文章", "fullSearch": "在正文中搜尋", "showHidden": "顯示隱藏文章", "manageSources": "管理訂閱源", "saveImageAs": "將影象另存為", "copyImage": "複製影象", "copyImageURL": "複製影象連結", "caseSensitive": "區分大小寫", "showCover": "顯示封面", "showSnippet": "顯示摘要", "fadeRead": "淡化已讀文章" }, "searchEngine": { "name": "搜尋引擎", "bing": "必應", "baidu": "百度" }, "settings": { "writeError": "寫入檔案時發生錯誤", "name": "選項", "fetching": "正在更新訂閱源,請稍候…", "exit": "退出選項", "sources": "訂閱源", "grouping": "分組與排序", "rules": "規則", "service": "服務", "app": "應用偏好", "about": "關於", "version": "版本", "shortcuts": "快捷鍵", "openSource": "開源項目", "feedback": "反饋" }, "sources": { "serviceWarning": "此處匯入或新增的訂閱源將不會與服務端同步", "serviceManaged": "該訂閱源由服務端管理", "untitled": "訂閱源", "errorAdd": "新增訂閱源時出錯", "errorParse": "解析OPML檔案時出錯", "errorParseHint": "請確保OPML檔案完整且使用UTF-8編碼。", "errorImport": "匯入{count}項訂閱源時出錯", "exist": "該訂閱源已存在", "opmlFile": "OPML檔案", "name": "訂閱源名稱", "editName": "修改名稱", "fetchFrequency": "抓取頻率限制", "unlimited": "無限制", "openTarget": "訂閱源文章開啟方式", "delete": "刪除訂閱源", "add": "新增訂閱源", "import": "匯入檔案", "export": "匯出檔案", "rssText": "RSS正文", "loadWebpage": "載入網頁", "inputUrl": "輸入URL", "badIcon": "圖示不存在或非圖片", "badUrl": "請正確輸入URL", "deleteWarning": "這將移除訂閱源與所有已儲存的文章", "selected": "選中訂閱源", "selectedMulti": "選中多個訂閱源", "hidden": "從“全部文章”中隱藏" }, "groups": { "exist": "該分組已存在", "type": "類型", "group": "分組", "source": "訂閱源", "capacity": "容量", "exitGroup": "退出分組", "deleteSource": "從分組刪除訂閱源", "sourceHint": "拖拽訂閱源以排序", "create": "新建分組", "selectedGroup": "選中分組", "selectedSource": "選中訂閱源", "enterName": "輸入名稱", "editName": "修改名稱", "deleteGroup": "刪除分組", "chooseGroup": "選擇分組", "addToGroup": "新增至分組", "groupHint": "雙擊分組以修改訂閱源,可通過拖拽排序" }, "rules": { "intro": "通過正規表示式自動標記文章或推送通知", "help": "瞭解更多", "source": "訂閱源", "selectSource": "選擇一個訂閱源", "new": "新建規則", "if": "若", "then": "則", "title": "標題", "content": "正文", "fullSearch": "標題或正文", "creator": "作者", "match": "匹配", "notMatch": "不匹配", "regex": "正規表示式", "badRegex": "正規表示式非法", "action": "行為", "selectAction": "選擇行為", "hint": "規則將按順序執行,拖拽以排序", "test": "測試規則" }, "service": { "intro": "通過 RSS 服務跨裝置保持同步", "select": "選擇服務", "suggest": "建議一項新服務", "overwriteWarning": "若本地與服務端存在URL相同的訂閱源,則本地訂閱源將被刪除", "groupsWarning": "分組不會自動與服務端保持同步", "rateLimitWarning": "為避免限流,您需要新建自己的 API Key", "removeAd": "移除廣告", "endpoint": "端點", "username": "使用者名稱", "password": "密碼", "unchanged": "未更改", "fetchLimit": "同步數量", "fetchLimitNum": "最近 {count} 篇文章", "importGroups": "匯入分組", "failure": "連線到服務時出錯", "failureHint": "請檢查服務配置或網路連線", "fetchUnlimited": "無限制(不建議)", "exportToLite": "匯出至 Fluent Reader Lite" }, "app": { "cleanup": "清理", "cache": "清空快取", "cacheSize": "已快取{size}資料", "deleteChoices": "刪除 … 天前的文章", "confirmDelete": "刪除文章", "daysAgo": "{days} 天前", "deleteAll": "刪除全部文章", "calculatingSize": "正在計算佔用空間…", "itemSize": "本地文章約佔用{size}空間", "confirmImport": "確認要從備份檔案匯入資料嗎?這將清除所有應用資料。", "data": "應用資料", "backup": "備份", "restore": "還原", "frData": "Fluent Reader資料", "language": "介面語言", "theme": "應用主題", "lightTheme": "淺色模式", "darkTheme": "深色模式", "enableProxy": "啟用代理", "badUrl": "請正確輸入URL", "pac": "PAC地址", "setPac": "設定PAC", "pacHint": "對於Socks代理建議PAC返回“SOCKS5”以啟用代理端解析。關閉代理需重啟應用後生效。", "fetchInterval": "自動抓取頻率", "never": "從不" } } ================================================ FILE: src/scripts/models/app.ts ================================================ import intl from "react-intl-universal" import { INIT_SOURCES, SourceActionTypes, ADD_SOURCE, UPDATE_SOURCE, DELETE_SOURCE, initSources, SourceOpenTarget, updateFavicon, } from "./source" import { RSSItem, ItemActionTypes, FETCH_ITEMS, fetchItems } from "./item" import { ActionStatus, AppThunk, getWindowBreakpoint, initTouchBarWithTexts, } from "../utils" import { INIT_FEEDS, FeedActionTypes, ALL, initFeeds } from "./feed" import { SourceGroupActionTypes, UPDATE_SOURCE_GROUP, ADD_SOURCE_TO_GROUP, DELETE_SOURCE_GROUP, REMOVE_SOURCE_FROM_GROUP, REORDER_SOURCE_GROUPS, } from "./group" import { PageActionTypes, SELECT_PAGE, PageType, selectAllArticles, showItemFromId, } from "./page" import { getCurrentLocale, setThemeDefaultFont } from "../settings" import locales from "../i18n/_locales" import { SYNC_SERVICE, ServiceActionTypes } from "./service" export const enum ContextMenuType { Hidden, Item, Text, View, Group, Image, MarkRead, } export const enum AppLogType { Info, Warning, Failure, Article, } export class AppLog { type: AppLogType title: string details?: string iid?: number time: Date constructor( type: AppLogType, title: string, details: string = null, iid: number = null ) { this.type = type this.title = title this.details = details this.iid = iid this.time = new Date() } } export class AppState { locale = null as string sourceInit = false feedInit = false syncing = false fetchingItems = false fetchingProgress = 0 fetchingTotal = 0 lastFetched = new Date() menu = getWindowBreakpoint() && window.settings.getDefaultMenu() menuKey = ALL title = "" settings = { display: false, changed: false, sids: new Array(), saving: false, } logMenu = { display: false, notify: false, logs: new Array(), } contextMenu: { type: ContextMenuType event?: MouseEvent | string position?: [number, number] target?: [RSSItem, string] | number[] | [string, string] } constructor() { this.contextMenu = { type: ContextMenuType.Hidden, } } } export const CLOSE_CONTEXT_MENU = "CLOSE_CONTEXT_MENU" export const OPEN_ITEM_MENU = "OPEN_ITEM_MENU" export const OPEN_TEXT_MENU = "OPEN_TEXT_MENU" export const OPEN_VIEW_MENU = "OPEN_VIEW_MENU" export const OPEN_GROUP_MENU = "OPEN_GROUP_MENU" export const OPEN_IMAGE_MENU = "OPEN_IMAGE_MENU" export const OPEN_MARK_ALL_MENU = "OPEN_MARK_ALL_MENU" interface CloseContextMenuAction { type: typeof CLOSE_CONTEXT_MENU } interface OpenItemMenuAction { type: typeof OPEN_ITEM_MENU event: MouseEvent item: RSSItem feedId: string } interface OpenTextMenuAction { type: typeof OPEN_TEXT_MENU position: [number, number] item: [string, string] } interface OpenViewMenuAction { type: typeof OPEN_VIEW_MENU } interface OpenMarkAllMenuAction { type: typeof OPEN_MARK_ALL_MENU } interface OpenGroupMenuAction { type: typeof OPEN_GROUP_MENU event: MouseEvent sids: number[] } interface OpenImageMenuAction { type: typeof OPEN_IMAGE_MENU position: [number, number] } export type ContextMenuActionTypes = | CloseContextMenuAction | OpenItemMenuAction | OpenTextMenuAction | OpenViewMenuAction | OpenGroupMenuAction | OpenImageMenuAction | OpenMarkAllMenuAction export const TOGGLE_LOGS = "TOGGLE_LOGS" export const PUSH_NOTIFICATION = "PUSH_NOTIFICATION" interface ToggleLogMenuAction { type: typeof TOGGLE_LOGS } interface PushNotificationAction { type: typeof PUSH_NOTIFICATION iid: number title: string source: string } export type LogMenuActionType = ToggleLogMenuAction | PushNotificationAction export const TOGGLE_MENU = "TOGGLE_MENU" export interface MenuActionTypes { type: typeof TOGGLE_MENU } export const TOGGLE_SETTINGS = "TOGGLE_SETTINGS" export const SAVE_SETTINGS = "SAVE_SETTINGS" export const FREE_MEMORY = "FREE_MEMORY" interface ToggleSettingsAction { type: typeof TOGGLE_SETTINGS open: boolean sids: number[] } interface SaveSettingsAction { type: typeof SAVE_SETTINGS } interface FreeMemoryAction { type: typeof FREE_MEMORY iids: Set } export type SettingsActionTypes = | ToggleSettingsAction | SaveSettingsAction | FreeMemoryAction export function closeContextMenu(): AppThunk { return (dispatch, getState) => { if (getState().app.contextMenu.type !== ContextMenuType.Hidden) { dispatch({ type: CLOSE_CONTEXT_MENU }) } } } export function openItemMenu( item: RSSItem, feedId: string, event: React.MouseEvent ): ContextMenuActionTypes { return { type: OPEN_ITEM_MENU, event: event.nativeEvent, item: item, feedId: feedId, } } export function openTextMenu( position: [number, number], text: string, url: string = null ): ContextMenuActionTypes { return { type: OPEN_TEXT_MENU, position: position, item: [text, url], } } export const openViewMenu = (): ContextMenuActionTypes => ({ type: OPEN_VIEW_MENU, }) export function openGroupMenu( sids: number[], event: React.MouseEvent ): ContextMenuActionTypes { return { type: OPEN_GROUP_MENU, event: event.nativeEvent, sids: sids, } } export function openImageMenu( position: [number, number] ): ContextMenuActionTypes { return { type: OPEN_IMAGE_MENU, position: position, } } export const openMarkAllMenu = (): ContextMenuActionTypes => ({ type: OPEN_MARK_ALL_MENU, }) export function toggleMenu(): AppThunk { return (dispatch, getState) => { dispatch({ type: TOGGLE_MENU }) window.settings.setDefaultMenu(getState().app.menu) } } export const toggleLogMenu = () => ({ type: TOGGLE_LOGS }) export const saveSettings = () => ({ type: SAVE_SETTINGS }) export const toggleSettings = (open = true, sids = new Array()) => ({ type: TOGGLE_SETTINGS, open: open, sids: sids, }) export function exitSettings(): AppThunk> { return async (dispatch, getState) => { if (!getState().app.settings.saving) { if (getState().app.settings.changed) { dispatch(saveSettings()) dispatch(selectAllArticles(true)) await dispatch(initFeeds(true)) dispatch(toggleSettings(false)) freeMemory() } else { dispatch(toggleSettings(false)) } } } } function freeMemory(): AppThunk { return (dispatch, getState) => { const iids = new Set() for (let feed of Object.values(getState().feeds)) { if (feed.loaded) feed.iids.forEach(iids.add, iids) } dispatch({ type: FREE_MEMORY, iids: iids, }) } } let fetchTimeout: NodeJS.Timeout export function setupAutoFetch(): AppThunk { return (dispatch, getState) => { clearTimeout(fetchTimeout) const setupTimeout = (interval?: number) => { if (!interval) interval = window.settings.getFetchInterval() if (interval) { fetchTimeout = setTimeout(() => { let state = getState() if (!state.app.settings.display) { if (!state.app.fetchingItems) dispatch(fetchItems(true)) } else { setupTimeout(1) } }, interval * 60000) } } setupTimeout() } } export function pushNotification(item: RSSItem): AppThunk { return (dispatch, getState) => { const sourceName = getState().sources[item.source].name if (!window.utils.isFocused()) { const options = { body: sourceName } as any if (item.thumb) options.icon = item.thumb const notification = new Notification(item.title, options) notification.onclick = () => { const state = getState() if ( state.sources[item.source].openTarget === SourceOpenTarget.External ) { window.utils.openExternal(item.link) } else if (!state.app.settings.display) { window.utils.focus() dispatch(showItemFromId(item._id)) } } } dispatch({ type: PUSH_NOTIFICATION, iid: item._id, title: item.title, source: sourceName, }) } } export const INIT_INTL = "INIT_INTL" export interface InitIntlAction { type: typeof INIT_INTL locale: string } export const initIntlDone = (locale: string): InitIntlAction => { document.documentElement.lang = locale setThemeDefaultFont(locale) return { type: INIT_INTL, locale: locale, } } export function initIntl(): AppThunk> { return dispatch => { let locale = getCurrentLocale() return intl .init({ currentLocale: locale, locales: locales, fallbackLocale: "en-US", }) .then(() => { dispatch(initIntlDone(locale)) }) } } export function initApp(): AppThunk { return dispatch => { document.body.classList.add(window.utils.platform) dispatch(initIntl()) .then(async () => { if (window.utils.platform === "darwin") initTouchBarWithTexts() await dispatch(initSources()) }) .then(() => dispatch(initFeeds())) .then(async () => { dispatch(selectAllArticles()) await dispatch(fetchItems()) }) .then(() => { dispatch(updateFavicon()) }) } } export function appReducer( state = new AppState(), action: | SourceActionTypes | ItemActionTypes | ContextMenuActionTypes | SettingsActionTypes | InitIntlAction | MenuActionTypes | LogMenuActionType | FeedActionTypes | PageActionTypes | SourceGroupActionTypes | ServiceActionTypes ): AppState { switch (action.type) { case INIT_INTL: return { ...state, locale: action.locale, } case INIT_SOURCES: switch (action.status) { case ActionStatus.Success: return { ...state, sourceInit: true, } default: return state } case ADD_SOURCE: switch (action.status) { case ActionStatus.Request: return { ...state, fetchingItems: true, settings: { ...state.settings, changed: true, saving: true, }, } default: return { ...state, fetchingItems: state.fetchingTotal !== 0, settings: { ...state.settings, saving: action.batch, }, } } case UPDATE_SOURCE: case DELETE_SOURCE: case UPDATE_SOURCE_GROUP: case ADD_SOURCE_TO_GROUP: case REMOVE_SOURCE_FROM_GROUP: case REORDER_SOURCE_GROUPS: case DELETE_SOURCE_GROUP: return { ...state, settings: { ...state.settings, changed: true, }, } case INIT_FEEDS: switch (action.status) { case ActionStatus.Request: return state default: return { ...state, feedInit: true, } } case SYNC_SERVICE: switch (action.status) { case ActionStatus.Request: return { ...state, syncing: true, } case ActionStatus.Failure: return { ...state, syncing: false, logMenu: { ...state.logMenu, notify: true, logs: [ ...state.logMenu.logs, new AppLog( AppLogType.Failure, intl.get("log.syncFailure"), String(action.err) ), ], }, } default: return { ...state, syncing: false, } } case FETCH_ITEMS: switch (action.status) { case ActionStatus.Request: return { ...state, fetchingItems: true, fetchingProgress: 0, fetchingTotal: action.fetchCount, } case ActionStatus.Failure: return { ...state, logMenu: { ...state.logMenu, notify: !state.logMenu.display, logs: [ ...state.logMenu.logs, new AppLog( AppLogType.Failure, intl.get("log.fetchFailure", { name: action.errSource.name, }), String(action.err) ), ], }, } case ActionStatus.Success: return { ...state, fetchingItems: false, fetchingTotal: 0, logMenu: action.items.length == 0 ? state.logMenu : { ...state.logMenu, logs: [ ...state.logMenu.logs, new AppLog( AppLogType.Info, intl.get("log.fetchSuccess", { count: action.items.length, }) ), ], }, } case ActionStatus.Intermediate: return { ...state, fetchingProgress: state.fetchingProgress + 1, } default: return state } case SELECT_PAGE: switch (action.pageType) { case PageType.AllArticles: return { ...state, menu: state.menu && action.keepMenu, menuKey: ALL, title: intl.get("allArticles"), } case PageType.Sources: return { ...state, menu: state.menu && action.keepMenu, menuKey: action.menuKey, title: action.title, } } case CLOSE_CONTEXT_MENU: return { ...state, contextMenu: { type: ContextMenuType.Hidden, }, } case OPEN_ITEM_MENU: return { ...state, contextMenu: { type: ContextMenuType.Item, event: action.event, target: [action.item, action.feedId], }, } case OPEN_TEXT_MENU: return { ...state, contextMenu: { type: ContextMenuType.Text, position: action.position, target: action.item, }, } case OPEN_VIEW_MENU: return { ...state, contextMenu: { type: state.contextMenu.type === ContextMenuType.View ? ContextMenuType.Hidden : ContextMenuType.View, event: "#view-toggle", }, } case OPEN_GROUP_MENU: return { ...state, contextMenu: { type: ContextMenuType.Group, event: action.event, target: action.sids, }, } case OPEN_IMAGE_MENU: return { ...state, contextMenu: { type: ContextMenuType.Image, position: action.position, }, } case OPEN_MARK_ALL_MENU: return { ...state, contextMenu: { type: state.contextMenu.type === ContextMenuType.MarkRead ? ContextMenuType.Hidden : ContextMenuType.MarkRead, event: "#mark-all-toggle", }, } case TOGGLE_MENU: return { ...state, menu: !state.menu, } case SAVE_SETTINGS: return { ...state, settings: { ...state.settings, display: true, changed: true, saving: !state.settings.saving, }, } case TOGGLE_SETTINGS: return { ...state, settings: { display: action.open, changed: false, sids: action.sids, saving: false, }, } case TOGGLE_LOGS: return { ...state, logMenu: { ...state.logMenu, display: !state.logMenu.display, notify: false, }, } case PUSH_NOTIFICATION: return { ...state, logMenu: { ...state.logMenu, notify: true, logs: [ ...state.logMenu.logs, new AppLog( AppLogType.Article, action.title, action.source, action.iid ), ], }, } default: return state } } ================================================ FILE: src/scripts/models/feed.ts ================================================ import * as db from "../db" import lf from "lovefield" import { SourceActionTypes, INIT_SOURCES, ADD_SOURCE, DELETE_SOURCE, UNHIDE_SOURCE, HIDE_SOURCE, } from "./source" import { ItemActionTypes, FETCH_ITEMS, RSSItem, TOGGLE_HIDDEN, applyItemReduction, } from "./item" import { ActionStatus, AppThunk, mergeSortedArrays } from "../utils" import { PageActionTypes, SELECT_PAGE, PageType, APPLY_FILTER } from "./page" export enum FilterType { None, ShowRead = 1 << 0, ShowNotStarred = 1 << 1, ShowHidden = 1 << 2, FullSearch = 1 << 3, CaseInsensitive = 1 << 4, CreatorSearch = 1 << 5, Default = ShowRead | ShowNotStarred, UnreadOnly = ShowNotStarred, StarredOnly = ShowRead, Toggles = ShowHidden | FullSearch | CaseInsensitive, } export class FeedFilter { type: FilterType search: string constructor(type: FilterType = null, search = "") { if ( type === null && (type = window.settings.getFilterType()) === null ) { type = FilterType.Default | FilterType.CaseInsensitive } this.type = type this.search = search } static toPredicates(filter: FeedFilter) { let type = filter.type const predicates = new Array() if (!(type & FilterType.ShowRead)) predicates.push(db.items.hasRead.eq(false)) if (!(type & FilterType.ShowNotStarred)) predicates.push(db.items.starred.eq(true)) if (!(type & FilterType.ShowHidden)) predicates.push(db.items.hidden.eq(false)) if (filter.search !== "") { const flags = type & FilterType.CaseInsensitive ? "i" : "" const regex = RegExp(filter.search, flags) if (type & FilterType.FullSearch) { predicates.push( lf.op.or( db.items.title.match(regex), db.items.snippet.match(regex) ) ) } else { predicates.push(db.items.title.match(regex)) } } return predicates } static testItem(filter: FeedFilter, item: RSSItem) { let type = filter.type let flag = true if (!(type & FilterType.ShowRead)) flag = flag && !item.hasRead if (!(type & FilterType.ShowNotStarred)) flag = flag && item.starred if (!(type & FilterType.ShowHidden)) flag = flag && !item.hidden if (filter.search !== "") { const flags = type & FilterType.CaseInsensitive ? "i" : "" const regex = RegExp(filter.search, flags) if (type & FilterType.FullSearch) { flag = flag && (regex.test(item.title) || regex.test(item.snippet)) } else if (type & FilterType.CreatorSearch) { flag = flag && regex.test(item.creator || "") } else { flag = flag && regex.test(item.title) } } return Boolean(flag) } } export const ALL = "ALL" export const SOURCE = "SOURCE" const LOAD_QUANTITY = 50 export class RSSFeed { _id: string loaded: boolean loading: boolean allLoaded: boolean sids: number[] iids: number[] filter: FeedFilter constructor(id: string = null, sids = [], filter = null) { this._id = id this.sids = sids this.iids = [] this.loaded = false this.allLoaded = false this.filter = filter === null ? new FeedFilter() : filter } static async loadFeed(feed: RSSFeed, skip = 0): Promise { const predicates = FeedFilter.toPredicates(feed.filter) predicates.push(db.items.source.in(feed.sids)) return (await db.itemsDB .select() .from(db.items) .where(lf.op.and.apply(null, predicates)) .orderBy(db.items.date, lf.Order.DESC) .skip(skip) .limit(LOAD_QUANTITY) .exec()) as RSSItem[] } } export type FeedState = { [_id: string]: RSSFeed } export const INIT_FEEDS = "INIT_FEEDS" export const INIT_FEED = "INIT_FEED" export const LOAD_MORE = "LOAD_MORE" export const DISMISS_ITEMS = "DISMISS_ITEMS" interface initFeedsAction { type: typeof INIT_FEEDS status: ActionStatus } interface initFeedAction { type: typeof INIT_FEED status: ActionStatus feed?: RSSFeed items?: RSSItem[] err? } interface loadMoreAction { type: typeof LOAD_MORE status: ActionStatus feed: RSSFeed items?: RSSItem[] err? } interface dismissItemsAction { type: typeof DISMISS_ITEMS fid: string iids: Set } export type FeedActionTypes = | initFeedAction | initFeedsAction | loadMoreAction | dismissItemsAction export function dismissItems(): AppThunk { return (dispatch, getState) => { const state = getState() let fid = state.page.feedId let filter = state.feeds[fid].filter let iids = new Set() for (let iid of state.feeds[fid].iids) { let item = state.items[iid] if (!FeedFilter.testItem(filter, item)) { iids.add(iid) } } dispatch({ type: DISMISS_ITEMS, fid: fid, iids: iids, }) } } export function initFeedsRequest(): FeedActionTypes { return { type: INIT_FEEDS, status: ActionStatus.Request, } } export function initFeedsSuccess(): FeedActionTypes { return { type: INIT_FEEDS, status: ActionStatus.Success, } } export function initFeedSuccess( feed: RSSFeed, items: RSSItem[] ): FeedActionTypes { return { type: INIT_FEED, status: ActionStatus.Success, items: items, feed: feed, } } export function initFeedFailure(err): FeedActionTypes { return { type: INIT_FEED, status: ActionStatus.Failure, err: err, } } export function initFeeds(force = false): AppThunk> { return (dispatch, getState) => { dispatch(initFeedsRequest()) let promises = new Array>() for (let feed of Object.values(getState().feeds)) { if (!feed.loaded || force) { let p = RSSFeed.loadFeed(feed) .then(items => { dispatch(initFeedSuccess(feed, items)) }) .catch(err => { console.log(err) dispatch(initFeedFailure(err)) }) promises.push(p) } } return Promise.allSettled(promises).then(() => { dispatch(initFeedsSuccess()) }) } } export function loadMoreRequest(feed: RSSFeed): FeedActionTypes { return { type: LOAD_MORE, status: ActionStatus.Request, feed: feed, } } export function loadMoreSuccess( feed: RSSFeed, items: RSSItem[] ): FeedActionTypes { return { type: LOAD_MORE, status: ActionStatus.Success, feed: feed, items: items, } } export function loadMoreFailure(feed: RSSFeed, err): FeedActionTypes { return { type: LOAD_MORE, status: ActionStatus.Failure, feed: feed, err: err, } } export function loadMore(feed: RSSFeed): AppThunk> { return (dispatch, getState) => { if (feed.loaded && !feed.loading && !feed.allLoaded) { dispatch(loadMoreRequest(feed)) const state = getState() const skipNum = feed.iids.filter(i => FeedFilter.testItem(feed.filter, state.items[i]) ).length return RSSFeed.loadFeed(feed, skipNum) .then(items => { dispatch(loadMoreSuccess(feed, items)) }) .catch(e => { console.log(e) dispatch(loadMoreFailure(feed, e)) }) } return new Promise((_, reject) => { reject() }) } } export function feedReducer( state: FeedState = { [ALL]: new RSSFeed(ALL) }, action: | SourceActionTypes | ItemActionTypes | FeedActionTypes | PageActionTypes ): FeedState { switch (action.type) { case INIT_SOURCES: switch (action.status) { case ActionStatus.Success: return { ...state, [ALL]: new RSSFeed( ALL, Object.values(action.sources) .filter(s => !s.hidden) .map(s => s.sid) ), } default: return state } case ADD_SOURCE: case UNHIDE_SOURCE: switch (action.status) { case ActionStatus.Success: return { ...state, [ALL]: new RSSFeed( ALL, [...state[ALL].sids, action.source.sid], state[ALL].filter ), } default: return state } case DELETE_SOURCE: case HIDE_SOURCE: { let nextState = {} for (let [id, feed] of Object.entries(state)) { nextState[id] = new RSSFeed( id, feed.sids.filter(sid => sid != action.source.sid), feed.filter ) } return nextState } case APPLY_FILTER: { let nextState = {} for (let [id, feed] of Object.entries(state)) { nextState[id] = { ...feed, filter: action.filter, } } return nextState } case FETCH_ITEMS: switch (action.status) { case ActionStatus.Success: { let nextState = { ...state } for (let feed of Object.values(state)) { if (feed.loaded) { let items = action.items.filter( i => feed.sids.includes(i.source) && FeedFilter.testItem(feed.filter, i) ) if (items.length > 0) { let oldItems = feed.iids.map( id => action.itemState[id] ) let nextItems = mergeSortedArrays( oldItems, items, (a, b) => b.date.getTime() - a.date.getTime() ) nextState[feed._id] = { ...feed, iids: nextItems.map(i => i._id), } } } } return nextState } default: return state } case DISMISS_ITEMS: let nextState = { ...state } let feed = state[action.fid] nextState[action.fid] = { ...feed, iids: feed.iids.filter(iid => !action.iids.has(iid)), } return nextState case INIT_FEED: switch (action.status) { case ActionStatus.Success: return { ...state, [action.feed._id]: { ...action.feed, loaded: true, allLoaded: action.items.length < LOAD_QUANTITY, iids: action.items.map(i => i._id), }, } default: return state } case LOAD_MORE: switch (action.status) { case ActionStatus.Request: return { ...state, [action.feed._id]: { ...action.feed, loading: true, }, } case ActionStatus.Success: return { ...state, [action.feed._id]: { ...action.feed, loading: false, allLoaded: action.items.length < LOAD_QUANTITY, iids: [ ...action.feed.iids, ...action.items.map(i => i._id), ], }, } case ActionStatus.Failure: return { ...state, [action.feed._id]: { ...action.feed, loading: false, }, } default: return state } case TOGGLE_HIDDEN: { let nextItem = applyItemReduction(action.item, action.type) let filteredFeeds = Object.values(state).filter( feed => feed.loaded && !FeedFilter.testItem(feed.filter, nextItem) ) if (filteredFeeds.length > 0) { let nextState = { ...state } for (let feed of filteredFeeds) { nextState[feed._id] = { ...feed, iids: feed.iids.filter(id => id != nextItem._id), } } return nextState } else { return state } } case SELECT_PAGE: switch (action.pageType) { case PageType.Sources: return { ...state, [SOURCE]: new RSSFeed( SOURCE, action.sids, action.filter ), } case PageType.AllArticles: return action.init ? { ...state, [ALL]: { ...state[ALL], loaded: false, filter: action.filter, }, } : state default: return state } default: return state } } ================================================ FILE: src/scripts/models/group.ts ================================================ import intl from "react-intl-universal" import { SourceActionTypes, ADD_SOURCE, DELETE_SOURCE, addSource, RSSSource, SourceState, } from "./source" import { SourceGroup } from "../../schema-types" import { ActionStatus, AppThunk, domParser } from "../utils" import { saveSettings } from "./app" import { fetchItemsIntermediate, fetchItemsRequest, fetchItemsSuccess, } from "./item" export const CREATE_SOURCE_GROUP = "CREATE_SOURCE_GROUP" export const ADD_SOURCE_TO_GROUP = "ADD_SOURCE_TO_GROUP" export const REMOVE_SOURCE_FROM_GROUP = "REMOVE_SOURCE_FROM_GROUP" export const UPDATE_SOURCE_GROUP = "UPDATE_SOURCE_GROUP" export const REORDER_SOURCE_GROUPS = "REORDER_SOURCE_GROUPS" export const DELETE_SOURCE_GROUP = "DELETE_SOURCE_GROUP" export const TOGGLE_GROUP_EXPANSION = "TOGGLE_GROUP_EXPANSION" interface CreateSourceGroupAction { type: typeof CREATE_SOURCE_GROUP group: SourceGroup } interface AddSourceToGroupAction { type: typeof ADD_SOURCE_TO_GROUP groupIndex: number sid: number } interface RemoveSourceFromGroupAction { type: typeof REMOVE_SOURCE_FROM_GROUP groupIndex: number sids: number[] } interface UpdateSourceGroupAction { type: typeof UPDATE_SOURCE_GROUP groupIndex: number group: SourceGroup } interface ReorderSourceGroupsAction { type: typeof REORDER_SOURCE_GROUPS groups: SourceGroup[] } interface DeleteSourceGroupAction { type: typeof DELETE_SOURCE_GROUP groupIndex: number } interface ToggleGroupExpansionAction { type: typeof TOGGLE_GROUP_EXPANSION groupIndex: number } export type SourceGroupActionTypes = | CreateSourceGroupAction | AddSourceToGroupAction | RemoveSourceFromGroupAction | UpdateSourceGroupAction | ReorderSourceGroupsAction | DeleteSourceGroupAction | ToggleGroupExpansionAction export function createSourceGroupDone( group: SourceGroup ): SourceGroupActionTypes { return { type: CREATE_SOURCE_GROUP, group: group, } } export function createSourceGroup(name: string): AppThunk { return (dispatch, getState) => { let groups = getState().groups for (let i = 0; i < groups.length; i += 1) { const g = groups[i] if (g.isMultiple && g.name === name) { return i } } let group = new SourceGroup([], name) dispatch(createSourceGroupDone(group)) groups = getState().groups window.settings.saveGroups(groups) return groups.length - 1 } } function addSourceToGroupDone( groupIndex: number, sid: number ): SourceGroupActionTypes { return { type: ADD_SOURCE_TO_GROUP, groupIndex: groupIndex, sid: sid, } } export function addSourceToGroup(groupIndex: number, sid: number): AppThunk { return (dispatch, getState) => { dispatch(addSourceToGroupDone(groupIndex, sid)) window.settings.saveGroups(getState().groups) } } function removeSourceFromGroupDone( groupIndex: number, sids: number[] ): SourceGroupActionTypes { return { type: REMOVE_SOURCE_FROM_GROUP, groupIndex: groupIndex, sids: sids, } } export function removeSourceFromGroup( groupIndex: number, sids: number[] ): AppThunk { return (dispatch, getState) => { dispatch(removeSourceFromGroupDone(groupIndex, sids)) window.settings.saveGroups(getState().groups) } } function deleteSourceGroupDone(groupIndex: number): SourceGroupActionTypes { return { type: DELETE_SOURCE_GROUP, groupIndex: groupIndex, } } export function deleteSourceGroup(groupIndex: number): AppThunk { return (dispatch, getState) => { dispatch(deleteSourceGroupDone(groupIndex)) window.settings.saveGroups(getState().groups) } } function updateSourceGroupDone(group: SourceGroup): SourceGroupActionTypes { return { type: UPDATE_SOURCE_GROUP, groupIndex: group.index, group: group, } } export function updateSourceGroup(group: SourceGroup): AppThunk { return (dispatch, getState) => { dispatch(updateSourceGroupDone(group)) window.settings.saveGroups(getState().groups) } } function reorderSourceGroupsDone( groups: SourceGroup[] ): SourceGroupActionTypes { return { type: REORDER_SOURCE_GROUPS, groups: groups, } } export function reorderSourceGroups(groups: SourceGroup[]): AppThunk { return (dispatch, getState) => { dispatch(reorderSourceGroupsDone(groups)) window.settings.saveGroups(getState().groups) } } export function toggleGroupExpansion(groupIndex: number): AppThunk { return (dispatch, getState) => { dispatch({ type: TOGGLE_GROUP_EXPANSION, groupIndex: groupIndex, }) window.settings.saveGroups(getState().groups) } } export function fixBrokenGroups(sources: SourceState): AppThunk { return (dispatch, getState) => { const { groups } = getState() const sids = new Set(Object.values(sources).map(s => s.sid)) let isBroken = false const newGroups: SourceGroup[] = groups .map(group => { const newGroup: SourceGroup = { ...group, sids: group.sids.filter(sid => sids.delete(sid)), } if (newGroup.sids.length !== group.sids.length) { isBroken = true } return newGroup }) .filter(group => group.isMultiple || group.sids.length > 0) if (isBroken || sids.size > 0) { for (let sid of sids) { newGroups.push(new SourceGroup([sid])) } dispatch(reorderSourceGroups(newGroups)) } } } function outlineToSource( outline: Element ): [ReturnType, string] { let url = outline.getAttribute("xmlUrl") let name = outline.getAttribute("text") || outline.getAttribute("title") if (url) { return [addSource(url.trim(), name, true), url] } else { return null } } export function importOPML(): AppThunk { return async dispatch => { const filters = [ { name: intl.get("sources.opmlFile"), extensions: ["xml", "opml"] }, ] window.utils.showOpenDialog(filters).then(data => { if (data) { dispatch(saveSettings()) let doc = domParser .parseFromString(data, "text/xml") .getElementsByTagName("body") if (doc.length == 0) { dispatch(saveSettings()) return } let parseError = doc[0].getElementsByTagName("parsererror") if (parseError.length > 0) { dispatch(saveSettings()) window.utils.showErrorBox( intl.get("sources.errorParse"), intl.get("sources.errorParseHint") ) return } let sources: [ReturnType, number, string][] = [] let errors: [string, any][] = [] for (let el of doc[0].children) { if (el.getAttribute("type") === "rss") { let source = outlineToSource(el) if (source) sources.push([source[0], -1, source[1]]) } else if ( el.hasAttribute("text") || el.hasAttribute("title") ) { let groupName = el.getAttribute("text") || el.getAttribute("title") let gid = dispatch(createSourceGroup(groupName)) for (let child of el.children) { let source = outlineToSource(child) if (source) sources.push([source[0], gid, source[1]]) } } } dispatch(fetchItemsRequest(sources.length)) let promises = sources.map(([s, gid, url]) => { return dispatch(s) .then(sid => { if (sid !== null && gid > -1) dispatch(addSourceToGroup(gid, sid)) }) .catch(err => { errors.push([url, err]) }) .finally(() => { dispatch(fetchItemsIntermediate()) }) }) Promise.allSettled(promises).then(() => { dispatch(fetchItemsSuccess([], {})) dispatch(saveSettings()) if (errors.length > 0) { window.utils.showErrorBox( intl.get("sources.errorImport", { count: errors.length, }), errors .map(e => { return e[0] + "\n" + String(e[1]) }) .join("\n"), intl.get("context.copy") ) } }) } }) } } function sourceToOutline(source: RSSSource, xml: Document) { let outline = xml.createElement("outline") outline.setAttribute("text", source.name) outline.setAttribute("title", source.name) outline.setAttribute("type", "rss") outline.setAttribute("xmlUrl", source.url) return outline } export function exportOPML(): AppThunk { return (_, getState) => { const filters = [ { name: intl.get("sources.opmlFile"), extensions: ["opml"] }, ] window.utils .showSaveDialog(filters, "*/Fluent_Reader_Export.opml") .then(write => { if (write) { let state = getState() let xml = domParser.parseFromString( 'Fluent Reader Export', "text/xml" ) let body = xml.getElementsByTagName("body")[0] for (let group of state.groups) { if (group.isMultiple) { let outline = xml.createElement("outline") outline.setAttribute("text", group.name) outline.setAttribute("title", group.name) for (let sid of group.sids) { outline.appendChild( sourceToOutline(state.sources[sid], xml) ) } body.appendChild(outline) } else { body.appendChild( sourceToOutline( state.sources[group.sids[0]], xml ) ) } } let serializer = new XMLSerializer() write( serializer.serializeToString(xml), intl.get("settings.writeError") ) } }) } } export type GroupState = SourceGroup[] export function groupReducer( state = window.settings.loadGroups(), action: SourceActionTypes | SourceGroupActionTypes ): GroupState { switch (action.type) { case ADD_SOURCE: switch (action.status) { case ActionStatus.Success: return [...state, new SourceGroup([action.source.sid])] default: return state } case DELETE_SOURCE: return [ ...state .map(group => ({ ...group, sids: group.sids.filter( sid => sid != action.source.sid ), })) .filter(g => g.isMultiple || g.sids.length == 1), ] case CREATE_SOURCE_GROUP: return [...state, action.group] case ADD_SOURCE_TO_GROUP: return state .map((g, i) => ({ ...g, sids: i == action.groupIndex ? [ ...g.sids.filter(sid => sid !== action.sid), action.sid, ] : g.sids.filter(sid => sid !== action.sid), })) .filter(g => g.isMultiple || g.sids.length > 0) case REMOVE_SOURCE_FROM_GROUP: return [ ...state.slice(0, action.groupIndex), { ...state[action.groupIndex], sids: state[action.groupIndex].sids.filter( sid => !action.sids.includes(sid) ), }, ...action.sids.map(sid => new SourceGroup([sid])), ...state.slice(action.groupIndex + 1), ] case UPDATE_SOURCE_GROUP: return [ ...state.slice(0, action.groupIndex), action.group, ...state.slice(action.groupIndex + 1), ] case REORDER_SOURCE_GROUPS: return action.groups case DELETE_SOURCE_GROUP: return [ ...state.slice(0, action.groupIndex), ...state[action.groupIndex].sids.map( sid => new SourceGroup([sid]) ), ...state.slice(action.groupIndex + 1), ] case TOGGLE_GROUP_EXPANSION: return state.map((g, i) => i == action.groupIndex ? { ...g, expanded: !g.expanded, } : g ) default: return state } } ================================================ FILE: src/scripts/models/item.ts ================================================ import * as db from "../db" import lf from "lovefield" import intl from "react-intl-universal" import type { MyParserItem } from "../utils" import { domParser, htmlDecode, ActionStatus, AppThunk, platformCtrl, } from "../utils" import { RSSSource, updateSource, updateUnreadCounts } from "./source" import { FeedActionTypes, INIT_FEED, LOAD_MORE, dismissItems } from "./feed" import { pushNotification, setupAutoFetch, SettingsActionTypes, FREE_MEMORY, } from "./app" import { getServiceHooks, syncWithService, ServiceActionTypes, SYNC_LOCAL_ITEMS, } from "./service" export class RSSItem { _id: number source: number title: string link: string date: Date fetchedDate: Date thumb?: string content: string snippet: string creator?: string hasRead: boolean starred: boolean hidden: boolean notify: boolean serviceRef?: string constructor(item: MyParserItem, source: RSSSource) { for (let field of ["title", "link", "creator"]) { const content = item[field] if (content && typeof content !== "string") delete item[field] } this.source = source.sid this.title = item.title || intl.get("article.untitled") this.link = item.link || "" this.fetchedDate = new Date() this.date = new Date(item.isoDate ?? item.pubDate ?? this.fetchedDate) this.creator = item.creator this.hasRead = false this.starred = false this.hidden = false this.notify = false } static parseContent(item: RSSItem, parsed: MyParserItem) { for (let field of ["thumb", "content", "fullContent"]) { const content = parsed[field] if (content && typeof content !== "string") delete parsed[field] } if (parsed.fullContent) { item.content = parsed.fullContent item.snippet = htmlDecode(parsed.fullContent) } else { item.content = parsed.content || "" item.snippet = htmlDecode(parsed.contentSnippet || "") } if (parsed.thumb) { item.thumb = parsed.thumb } else if (parsed.image?.$?.url) { item.thumb = parsed.image.$.url } else if (parsed.image && typeof parsed.image === "string") { item.thumb = parsed.image } else if (parsed.mediaContent) { let images = parsed.mediaContent.filter( c => c.$ && c.$.medium === "image" && c.$.url ) if (images.length > 0) item.thumb = images[0].$.url } if (!item.thumb) { let dom = domParser.parseFromString(item.content, "text/html") let baseEl = dom.createElement("base") baseEl.setAttribute( "href", item.link.split("/").slice(0, 3).join("/") ) dom.head.append(baseEl) let img = dom.querySelector("img") if (img && img.src) item.thumb = img.src } if ( item.thumb && !item.thumb.startsWith("https://") && !item.thumb.startsWith("http://") ) { delete item.thumb } } } export type ItemState = { [_id: number]: RSSItem } export const FETCH_ITEMS = "FETCH_ITEMS" export const MARK_READ = "MARK_READ" export const MARK_ALL_READ = "MARK_ALL_READ" export const MARK_UNREAD = "MARK_UNREAD" export const TOGGLE_STARRED = "TOGGLE_STARRED" export const TOGGLE_HIDDEN = "TOGGLE_HIDDEN" interface FetchItemsAction { type: typeof FETCH_ITEMS status: ActionStatus fetchCount?: number items?: RSSItem[] itemState?: ItemState errSource?: RSSSource err? } interface MarkReadAction { type: typeof MARK_READ item: RSSItem } interface MarkAllReadAction { type: typeof MARK_ALL_READ sids: number[] time?: number before?: boolean } interface MarkUnreadAction { type: typeof MARK_UNREAD item: RSSItem } interface ToggleStarredAction { type: typeof TOGGLE_STARRED item: RSSItem } interface ToggleHiddenAction { type: typeof TOGGLE_HIDDEN item: RSSItem } export type ItemActionTypes = | FetchItemsAction | MarkReadAction | MarkAllReadAction | MarkUnreadAction | ToggleStarredAction | ToggleHiddenAction export function fetchItemsRequest(fetchCount = 0): ItemActionTypes { return { type: FETCH_ITEMS, status: ActionStatus.Request, fetchCount: fetchCount, } } export function fetchItemsSuccess( items: RSSItem[], itemState: ItemState ): ItemActionTypes { return { type: FETCH_ITEMS, status: ActionStatus.Success, items: items, itemState: itemState, } } export function fetchItemsFailure(source: RSSSource, err): ItemActionTypes { return { type: FETCH_ITEMS, status: ActionStatus.Failure, errSource: source, err: err, } } export function fetchItemsIntermediate(): ItemActionTypes { return { type: FETCH_ITEMS, status: ActionStatus.Intermediate, } } export async function insertItems(items: RSSItem[]): Promise { items.sort((a, b) => a.date.getTime() - b.date.getTime()) const rows = items.map(item => db.items.createRow(item)) return (await db.itemsDB .insert() .into(db.items) .values(rows) .exec()) as RSSItem[] } export function fetchItems( background = false, sids: number[] = null ): AppThunk> { return async (dispatch, getState) => { let promises = new Array>() const initState = getState() if (!initState.app.fetchingItems && !initState.app.syncing) { if ( sids === null || sids.filter( sid => initState.sources[sid].serviceRef !== undefined ).length > 0 ) await dispatch(syncWithService(background)) let timenow = new Date().getTime() const sourcesState = getState().sources let sources = sids === null ? Object.values(sourcesState).filter(s => { let last = s.lastFetched ? s.lastFetched.getTime() : 0 return ( !s.serviceRef && (last > timenow || last + (s.fetchFrequency || 0) * 60000 <= timenow) ) }) : sids .map(sid => sourcesState[sid]) .filter(s => !s.serviceRef) for (let source of sources) { let promise = RSSSource.fetchItems(source) promise.then(() => dispatch( updateSource({ ...source, lastFetched: new Date() }) ) ) promise.finally(() => dispatch(fetchItemsIntermediate())) promises.push(promise) } dispatch(fetchItemsRequest(promises.length)) const results = await Promise.allSettled(promises) return await new Promise((resolve, reject) => { let items = new Array() results.map((r, i) => { if (r.status === "fulfilled") items.push(...r.value) else { console.log(r.reason) dispatch(fetchItemsFailure(sources[i], r.reason)) } }) insertItems(items) .then(inserted => { dispatch( fetchItemsSuccess( inserted.reverse(), getState().items ) ) resolve() if (background) { for (let item of inserted) { if (item.notify) { dispatch(pushNotification(item)) } } if (inserted.length > 0) { window.utils.requestAttention() } } else { dispatch(dismissItems()) } dispatch(setupAutoFetch()) }) .catch(err => { dispatch(fetchItemsSuccess([], getState().items)) window.utils.showErrorBox( "A database error has occurred.", String(err) ) console.log(err) reject(err) }) }) } } } const markReadDone = (item: RSSItem): ItemActionTypes => ({ type: MARK_READ, item: item, }) const markUnreadDone = (item: RSSItem): ItemActionTypes => ({ type: MARK_UNREAD, item: item, }) export function markRead(item: RSSItem): AppThunk { return (dispatch, getState) => { item = getState().items[item._id] if (!item.hasRead) { db.itemsDB .update(db.items) .where(db.items._id.eq(item._id)) .set(db.items.hasRead, true) .exec() dispatch(markReadDone(item)) if (item.serviceRef) { dispatch(dispatch(getServiceHooks()).markRead?.(item)) } } } } export function markAllRead( sids: number[] = null, date: Date = null, before = true ): AppThunk> { return async (dispatch, getState) => { let state = getState() if (sids === null) { let feed = state.feeds[state.page.feedId] sids = feed.sids } const action = dispatch(getServiceHooks()).markAllRead?.( sids, date, before ) if (action) await dispatch(action) const predicates: lf.Predicate[] = [ db.items.source.in(sids), db.items.hasRead.eq(false), ] if (date) { predicates.push( before ? db.items.date.lte(date) : db.items.date.gte(date) ) } const query = lf.op.and.apply(null, predicates) await db.itemsDB .update(db.items) .set(db.items.hasRead, true) .where(query) .exec() if (date) { dispatch({ type: MARK_ALL_READ, sids: sids, time: date.getTime(), before: before, }) dispatch(updateUnreadCounts()) } else { dispatch({ type: MARK_ALL_READ, sids: sids, }) } } } export function markUnread(item: RSSItem): AppThunk { return (dispatch, getState) => { item = getState().items[item._id] if (item.hasRead) { db.itemsDB .update(db.items) .where(db.items._id.eq(item._id)) .set(db.items.hasRead, false) .exec() dispatch(markUnreadDone(item)) if (item.serviceRef) { dispatch(dispatch(getServiceHooks()).markUnread?.(item)) } } } } const toggleStarredDone = (item: RSSItem): ItemActionTypes => ({ type: TOGGLE_STARRED, item: item, }) export function toggleStarred(item: RSSItem): AppThunk { return dispatch => { db.itemsDB .update(db.items) .where(db.items._id.eq(item._id)) .set(db.items.starred, !item.starred) .exec() dispatch(toggleStarredDone(item)) if (item.serviceRef) { const hooks = dispatch(getServiceHooks()) if (item.starred) dispatch(hooks.unstar?.(item)) else dispatch(hooks.star?.(item)) } } } const toggleHiddenDone = (item: RSSItem): ItemActionTypes => ({ type: TOGGLE_HIDDEN, item: item, }) export function toggleHidden(item: RSSItem): AppThunk { return dispatch => { db.itemsDB .update(db.items) .where(db.items._id.eq(item._id)) .set(db.items.hidden, !item.hidden) .exec() dispatch(toggleHiddenDone(item)) } } export function itemShortcuts(item: RSSItem, e: KeyboardEvent): AppThunk { return dispatch => { if (e.metaKey) return switch (e.key) { case "m": case "M": if (item.hasRead) dispatch(markUnread(item)) else dispatch(markRead(item)) break case "b": case "B": if (!item.hasRead) dispatch(markRead(item)) window.utils.openExternal(item.link, platformCtrl(e)) break case "s": case "S": dispatch(toggleStarred(item)) break case "h": case "H": if (!item.hasRead && !item.hidden) dispatch(markRead(item)) dispatch(toggleHidden(item)) break } } } export function applyItemReduction(item: RSSItem, type: string) { let nextItem = { ...item } switch (type) { case MARK_READ: case MARK_UNREAD: { nextItem.hasRead = type === MARK_READ break } case TOGGLE_STARRED: { nextItem.starred = !item.starred break } case TOGGLE_HIDDEN: { nextItem.hidden = !item.hidden break } } return nextItem } export function itemReducer( state: ItemState = {}, action: | ItemActionTypes | FeedActionTypes | ServiceActionTypes | SettingsActionTypes ): ItemState { switch (action.type) { case FETCH_ITEMS: switch (action.status) { case ActionStatus.Success: { let newMap = {} for (let i of action.items) { newMap[i._id] = i } return { ...newMap, ...state } } default: return state } case MARK_UNREAD: case MARK_READ: case TOGGLE_STARRED: case TOGGLE_HIDDEN: { return { ...state, [action.item._id]: applyItemReduction( state[action.item._id], action.type ), } } case MARK_ALL_READ: { let nextState = { ...state } let sids = new Set(action.sids) for (let item of Object.values(state)) { if (sids.has(item.source) && !item.hasRead) { if ( !action.time || (action.before ? item.date.getTime() <= action.time : item.date.getTime() >= action.time) ) { nextState[item._id] = { ...item, hasRead: true, } } } } return nextState } case LOAD_MORE: case INIT_FEED: { switch (action.status) { case ActionStatus.Success: { let nextState = { ...state } for (let i of action.items) { nextState[i._id] = i } return nextState } default: return state } } case SYNC_LOCAL_ITEMS: { let nextState = { ...state } for (let item of Object.values(state)) { if (item.hasOwnProperty("serviceRef")) { const nextItem = { ...item } nextItem.hasRead = !action.unreadIds.has(item.serviceRef) nextItem.starred = action.starredIds.has(item.serviceRef) nextState[item._id] = nextItem } } return nextState } case FREE_MEMORY: { const nextState: ItemState = {} for (let item of Object.values(state)) { if (action.iids.has(item._id)) nextState[item._id] = item } return nextState } default: return state } } ================================================ FILE: src/scripts/models/page.ts ================================================ import { ALL, SOURCE, loadMore, FeedFilter, FilterType, initFeeds, FeedActionTypes, INIT_FEED, } from "./feed" import { getWindowBreakpoint, AppThunk, ActionStatus } from "../utils" import { RSSItem, markRead } from "./item" import { SourceActionTypes, DELETE_SOURCE } from "./source" import { toggleMenu } from "./app" import { ViewType, ViewConfigs } from "../../schema-types" export const SELECT_PAGE = "SELECT_PAGE" export const SWITCH_VIEW = "SWITCH_VIEW" export const SET_VIEW_CONFIGS = "SET_VIEW_CONFIGS" export const SHOW_ITEM = "SHOW_ITEM" export const SHOW_OFFSET_ITEM = "SHOW_OFFSET_ITEM" export const DISMISS_ITEM = "DISMISS_ITEM" export const APPLY_FILTER = "APPLY_FILTER" export const TOGGLE_SEARCH = "TOGGLE_SEARCH" export enum PageType { AllArticles, Sources, Page, } interface SelectPageAction { type: typeof SELECT_PAGE pageType: PageType init: boolean keepMenu: boolean filter: FeedFilter sids?: number[] menuKey?: string title?: string } interface SwitchViewAction { type: typeof SWITCH_VIEW viewType: ViewType } interface SetViewConfigsAction { type: typeof SET_VIEW_CONFIGS configs: ViewConfigs } interface ShowItemAction { type: typeof SHOW_ITEM feedId: string item: RSSItem } interface ApplyFilterAction { type: typeof APPLY_FILTER filter: FeedFilter } interface DismissItemAction { type: typeof DISMISS_ITEM } interface ToggleSearchAction { type: typeof TOGGLE_SEARCH } export type PageActionTypes = | SelectPageAction | SwitchViewAction | ShowItemAction | DismissItemAction | ApplyFilterAction | ToggleSearchAction | SetViewConfigsAction export function selectAllArticles(init = false): AppThunk { return (dispatch, getState) => { dispatch({ type: SELECT_PAGE, keepMenu: getWindowBreakpoint(), filter: getState().page.filter, pageType: PageType.AllArticles, init: init, } as PageActionTypes) } } export function selectSources( sids: number[], menuKey: string, title: string ): AppThunk { return (dispatch, getState) => { if (getState().app.menuKey !== menuKey) { dispatch({ type: SELECT_PAGE, pageType: PageType.Sources, keepMenu: getWindowBreakpoint(), filter: getState().page.filter, sids: sids, menuKey: menuKey, title: title, init: true, } as PageActionTypes) } } } export function switchView(viewType: ViewType): PageActionTypes { return { type: SWITCH_VIEW, viewType: viewType, } } export function setViewConfigs(configs: ViewConfigs): AppThunk { return (dispatch, getState) => { window.settings.setViewConfigs(getState().page.viewType, configs) dispatch({ type: "SET_VIEW_CONFIGS", configs: configs, }) } } export function showItem(feedId: string, item: RSSItem): AppThunk { return (dispatch, getState) => { const state = getState() if ( state.items.hasOwnProperty(item._id) && state.sources.hasOwnProperty(item.source) ) { dispatch({ type: SHOW_ITEM, feedId: feedId, item: item, }) } } } export function showItemFromId(iid: number): AppThunk { return (dispatch, getState) => { const state = getState() const item = state.items[iid] if (!item.hasRead) dispatch(markRead(item)) if (item) dispatch(showItem(null, item)) } } export const dismissItem = (): PageActionTypes => ({ type: DISMISS_ITEM }) export const toggleSearch = (): AppThunk => { return (dispatch, getState) => { let state = getState() dispatch({ type: TOGGLE_SEARCH }) if (!getWindowBreakpoint() && state.app.menu) { dispatch(toggleMenu()) } if (state.page.searchOn) { dispatch( applyFilter({ ...state.page.filter, search: "", }) ) } } } export function showOffsetItem(offset: number): AppThunk { return (dispatch, getState) => { let state = getState() if (!state.page.itemFromFeed) return let [itemId, feedId] = [state.page.itemId, state.page.feedId] let feed = state.feeds[feedId] let iids = feed.iids let itemIndex = iids.indexOf(itemId) let newIndex = itemIndex + offset if (itemIndex < 0) { let item = state.items[itemId] let prevs = feed.iids .map( (id, index) => [state.items[id], index] as [RSSItem, number] ) .filter(([i, _]) => i.date > item.date) if (prevs.length > 0) { let prev = prevs[0] for (let j = 1; j < prevs.length; j += 1) { if (prevs[j][0].date < prev[0].date) prev = prevs[j] } newIndex = prev[1] + offset + (offset < 0 ? 1 : 0) } else { newIndex = offset - 1 } } if (newIndex >= 0) { if (newIndex < iids.length) { let item = state.items[iids[newIndex]] dispatch(markRead(item)) dispatch(showItem(feedId, item)) return } else if (!feed.allLoaded) { dispatch(loadMore(feed)) .then(() => { dispatch(showOffsetItem(offset)) }) .catch(() => dispatch(dismissItem())) return } } dispatch(dismissItem()) } } const applyFilterDone = (filter: FeedFilter): PageActionTypes => ({ type: APPLY_FILTER, filter: filter, }) function applyFilter(filter: FeedFilter): AppThunk { return (dispatch, getState) => { const oldFilterType = getState().page.filter.type if (filter.type !== oldFilterType) window.settings.setFilterType(filter.type) dispatch(applyFilterDone(filter)) dispatch(initFeeds(true)) } } export function switchFilter(filter: FilterType): AppThunk { return (dispatch, getState) => { let oldFilter = getState().page.filter let oldType = oldFilter.type let newType = filter | (oldType & FilterType.Toggles) if (oldType != newType) { dispatch( applyFilter({ ...oldFilter, type: newType, }) ) } } } export function toggleFilter(filter: FilterType): AppThunk { return (dispatch, getState) => { let nextFilter = { ...getState().page.filter } nextFilter.type ^= filter dispatch(applyFilter(nextFilter)) } } export function performSearch(query: string): AppThunk { return (dispatch, getState) => { let state = getState() if (state.page.searchOn) { dispatch( applyFilter({ ...state.page.filter, search: query, }) ) } } } export class PageState { viewType = window.settings.getDefaultView() viewConfigs = window.settings.getViewConfigs( window.settings.getDefaultView() ) filter = new FeedFilter() feedId = ALL itemId = null as number itemFromFeed = true searchOn = false } export function pageReducer( state = new PageState(), action: PageActionTypes | SourceActionTypes | FeedActionTypes ): PageState { switch (action.type) { case SELECT_PAGE: switch (action.pageType) { case PageType.AllArticles: return { ...state, feedId: ALL, itemId: null, } case PageType.Sources: return { ...state, feedId: SOURCE, itemId: null, } default: return state } case SWITCH_VIEW: return { ...state, viewType: action.viewType, viewConfigs: window.settings.getViewConfigs(action.viewType), itemId: null, } case SET_VIEW_CONFIGS: return { ...state, viewConfigs: action.configs, } case APPLY_FILTER: return { ...state, filter: action.filter, } case SHOW_ITEM: return { ...state, itemId: action.item._id, itemFromFeed: Boolean(action.feedId), } case INIT_FEED: switch (action.status) { case ActionStatus.Success: return { ...state, itemId: action.feed._id === state.feedId && action.items.filter(i => i._id === state.itemId) .length === 0 ? null : state.itemId, } default: return state } case DELETE_SOURCE: case DISMISS_ITEM: return { ...state, itemId: null, } case TOGGLE_SEARCH: return { ...state, searchOn: !state.searchOn, } default: return state } } ================================================ FILE: src/scripts/models/rule.ts ================================================ import { FeedFilter, FilterType } from "./feed" import { RSSItem } from "./item" export const enum ItemAction { Read = "r", Star = "s", Hide = "h", Notify = "n", } export type RuleActions = { [type in ItemAction]: boolean } export namespace RuleActions { export function toKeys(actions: RuleActions): string[] { return Object.entries(actions).map(([t, f]) => `${t}-${f}`) } export function fromKeys(strs: string[]): RuleActions { const fromKey = (str: string): [ItemAction, boolean] => { let [t, f] = str.split("-") as [ItemAction, string] if (f) return [t, f === "true"] else return [t, true] } return Object.fromEntries(strs.map(fromKey)) as RuleActions } } type ActionTransformType = { [type in ItemAction]: (i: RSSItem, f: boolean) => void } const actionTransform: ActionTransformType = { [ItemAction.Read]: (i, f) => { i.hasRead = f }, [ItemAction.Star]: (i, f) => { i.starred = f }, [ItemAction.Hide]: (i, f) => { i.hidden = f }, [ItemAction.Notify]: (i, f) => { i.notify = f }, } export class SourceRule { filter: FeedFilter match: boolean actions: RuleActions constructor( regex: string, actions: string[], filter: FilterType, match: boolean ) { this.filter = new FeedFilter(filter, regex) this.match = match this.actions = RuleActions.fromKeys(actions) } static apply(rule: SourceRule, item: RSSItem) { let result = FeedFilter.testItem(rule.filter, item) if (result === rule.match) { for (let [action, flag] of Object.entries(rule.actions)) { actionTransform[action](item, flag) } } } static applyAll(rules: SourceRule[], item: RSSItem) { for (let rule of rules) { this.apply(rule, item) } } } ================================================ FILE: src/scripts/models/service.ts ================================================ import * as db from "../db" import lf from "lovefield" import { SyncService, ServiceConfigs } from "../../schema-types" import { AppThunk, ActionStatus } from "../utils" import { RSSItem, insertItems, fetchItemsSuccess } from "./item" import { saveSettings, pushNotification } from "./app" import { deleteSource, updateUnreadCounts, RSSSource, insertSource, addSourceSuccess, updateSource, updateFavicon, } from "./source" import { createSourceGroup, addSourceToGroup } from "./group" import { feverServiceHooks } from "./services/fever" import { feedbinServiceHooks } from "./services/feedbin" import { gReaderServiceHooks } from "./services/greader" import { minifluxServiceHooks } from "./services/miniflux" import { nextcloudServiceHooks } from "./services/nextcloud" export interface ServiceHooks { authenticate?: (configs: ServiceConfigs) => Promise reauthenticate?: (configs: ServiceConfigs) => Promise updateSources?: () => AppThunk]>> fetchItems?: () => AppThunk> syncItems?: () => AppThunk, Set]>> markRead?: (item: RSSItem) => AppThunk markUnread?: (item: RSSItem) => AppThunk markAllRead?: ( sids?: number[], date?: Date, before?: boolean ) => AppThunk> star?: (item: RSSItem) => AppThunk unstar?: (item: RSSItem) => AppThunk } export function getServiceHooksFromType(type: SyncService): ServiceHooks { switch (type) { case SyncService.Fever: return feverServiceHooks case SyncService.Feedbin: return feedbinServiceHooks case SyncService.GReader: case SyncService.Inoreader: return gReaderServiceHooks case SyncService.Miniflux: return minifluxServiceHooks case SyncService.Nextcloud: return nextcloudServiceHooks default: return {} } } export function getServiceHooks(): AppThunk { return (_, getState) => { return getServiceHooksFromType(getState().service.type) } } export function syncWithService(background = false): AppThunk> { return async (dispatch, getState) => { const hooks = dispatch(getServiceHooks()) if (hooks.updateSources && hooks.fetchItems && hooks.syncItems) { try { dispatch({ type: SYNC_SERVICE, status: ActionStatus.Request, }) if (hooks.reauthenticate) await dispatch(reauthenticate(hooks)) await dispatch(updateSources(hooks.updateSources)) await dispatch(syncItems(hooks.syncItems)) await dispatch(fetchItems(hooks.fetchItems, background)) dispatch({ type: SYNC_SERVICE, status: ActionStatus.Success, }) } catch (err) { console.log(err) dispatch({ type: SYNC_SERVICE, status: ActionStatus.Failure, err: err, }) } finally { if (getState().app.settings.saving) dispatch(saveSettings()) } } } } function reauthenticate(hooks: ServiceHooks): AppThunk> { return async (dispatch, getState) => { let configs = getState().service if (!(await hooks.authenticate(configs))) { configs = await hooks.reauthenticate(configs) dispatch(saveServiceConfigs(configs)) } } } function updateSources( hook: ServiceHooks["updateSources"] ): AppThunk> { return async (dispatch, getState) => { const [sources, groupsMap] = await dispatch(hook()) const existing = new Map() for (let source of Object.values(getState().sources)) { if (source.serviceRef) { existing.set(source.serviceRef, source) } } const forceSettings = () => { if (!getState().app.settings.saving) dispatch(saveSettings()) } let promises = sources.map(async s => { if (existing.has(s.serviceRef)) { const doc = existing.get(s.serviceRef) existing.delete(s.serviceRef) return doc } else { const docs = (await db.sourcesDB .select() .from(db.sources) .where(db.sources.url.eq(s.url)) .exec()) as RSSSource[] if (docs.length === 0) { // Create a new source forceSettings() const inserted = await dispatch(insertSource(s)) inserted.unreadCount = 0 dispatch(addSourceSuccess(inserted, true)) window.settings.saveGroups(getState().groups) dispatch(updateFavicon([inserted.sid])) return inserted } else if (docs[0].serviceRef !== s.serviceRef) { // Mark an existing source as remote and remove all items const doc = docs[0] forceSettings() doc.serviceRef = s.serviceRef doc.unreadCount = 0 await dispatch(updateSource(doc)) await db.itemsDB .delete() .from(db.items) .where(db.items.source.eq(doc.sid)) .exec() return doc } else { return docs[0] } } }) for (let [_, source] of existing) { // Delete sources removed from the service side forceSettings() promises.push(dispatch(deleteSource(source, true)).then(() => null)) } let sourcesResults = (await Promise.all(promises)).filter(s => s) if (groupsMap) { // Add sources to imported groups forceSettings() for (let source of sourcesResults) { if (groupsMap.has(source.serviceRef)) { const gid = dispatch( createSourceGroup(groupsMap.get(source.serviceRef)) ) dispatch(addSourceToGroup(gid, source.sid)) } } const configs = getState().service delete configs.importGroups dispatch(saveServiceConfigs(configs)) } } } function syncItems(hook: ServiceHooks["syncItems"]): AppThunk> { return async (dispatch, getState) => { const state = getState() const [unreadRefs, starredRefs] = await dispatch(hook()) const unreadCopy = new Set(unreadRefs) const starredCopy = new Set(starredRefs) const rows = await db.itemsDB .select(db.items.serviceRef, db.items.hasRead, db.items.starred) .from(db.items) .where( lf.op.and( db.items.serviceRef.isNotNull(), lf.op.or( db.items.hasRead.eq(false), db.items.starred.eq(true) ) ) ) .exec() const updates = new Array() for (let row of rows) { const serviceRef = row["serviceRef"] if (row["hasRead"] === false && !unreadRefs.delete(serviceRef)) { updates.push( db.itemsDB .update(db.items) .set(db.items.hasRead, true) .where(db.items.serviceRef.eq(serviceRef)) ) } if (row["starred"] === true && !starredRefs.delete(serviceRef)) { updates.push( db.itemsDB .update(db.items) .set(db.items.starred, false) .where(db.items.serviceRef.eq(serviceRef)) ) } } for (let unread of unreadRefs) { updates.push( db.itemsDB .update(db.items) .set(db.items.hasRead, false) .where(db.items.serviceRef.eq(unread)) ) } for (let starred of starredRefs) { updates.push( db.itemsDB .update(db.items) .set(db.items.starred, true) .where(db.items.serviceRef.eq(starred)) ) } if (updates.length > 0) { await db.itemsDB.createTransaction().exec(updates) await dispatch(updateUnreadCounts()) dispatch(syncLocalItems(unreadCopy, starredCopy)) } } } function fetchItems( hook: ServiceHooks["fetchItems"], background: boolean ): AppThunk> { return async (dispatch, getState) => { const [items, configs] = await dispatch(hook()) if (items.length > 0) { const inserted = await insertItems(items) dispatch(fetchItemsSuccess(inserted.reverse(), getState().items)) if (background) { for (let item of inserted) { if (item.notify) dispatch(pushNotification(item)) } if (inserted.length > 0) window.utils.requestAttention() } dispatch(saveServiceConfigs(configs)) } } } export function importGroups(): AppThunk> { return async (dispatch, getState) => { const configs = getState().service if (configs.type !== SyncService.None) { dispatch(saveSettings()) configs.importGroups = true dispatch(saveServiceConfigs(configs)) await dispatch(syncWithService()) } } } export function removeService(): AppThunk> { return async (dispatch, getState) => { dispatch(saveSettings()) const state = getState() const promises = Object.values(state.sources) .filter(s => s.serviceRef) .map(async s => { await dispatch(deleteSource(s, true)) }) await Promise.all(promises) dispatch(saveServiceConfigs({ type: SyncService.None })) dispatch(saveSettings()) } } export const SAVE_SERVICE_CONFIGS = "SAVE_SERVICE_CONFIGS" export const SYNC_SERVICE = "SYNC_SERVICE" export const SYNC_LOCAL_ITEMS = "SYNC_LOCAL_ITEMS" interface SaveServiceConfigsAction { type: typeof SAVE_SERVICE_CONFIGS configs: ServiceConfigs } interface SyncWithServiceAction { type: typeof SYNC_SERVICE status: ActionStatus err? } interface SyncLocalItemsAction { type: typeof SYNC_LOCAL_ITEMS unreadIds: Set starredIds: Set } export type ServiceActionTypes = | SaveServiceConfigsAction | SyncWithServiceAction | SyncLocalItemsAction export function saveServiceConfigs(configs: ServiceConfigs): AppThunk { return dispatch => { window.settings.setServiceConfigs(configs) dispatch({ type: SAVE_SERVICE_CONFIGS, configs: configs, }) } } function syncLocalItems( unread: Set, starred: Set ): ServiceActionTypes { return { type: SYNC_LOCAL_ITEMS, unreadIds: unread, starredIds: starred, } } export function serviceReducer( state = window.settings.getServiceConfigs(), action: ServiceActionTypes ): ServiceConfigs { switch (action.type) { case SAVE_SERVICE_CONFIGS: return action.configs default: return state } } ================================================ FILE: src/scripts/models/services/feedbin.ts ================================================ import intl from "react-intl-universal" import * as db from "../../db" import lf from "lovefield" import { ServiceHooks } from "../service" import { ServiceConfigs, SyncService } from "../../../schema-types" import { createSourceGroup } from "../group" import { RSSSource } from "../source" import { domParser } from "../../utils" import { RSSItem } from "../item" import { SourceRule } from "../rule" export interface FeedbinConfigs extends ServiceConfigs { type: SyncService.Feedbin endpoint: string username: string password: string fetchLimit: number lastId?: number } async function fetchAPI(configs: FeedbinConfigs, params: string) { const headers = new Headers() headers.set( "Authorization", "Basic " + btoa(configs.username + ":" + configs.password) ) return await fetch(configs.endpoint + params, { headers: headers }) } async function markItems( configs: FeedbinConfigs, type: string, method: string, refs: number[] ) { const headers = new Headers() headers.set( "Authorization", "Basic " + btoa(configs.username + ":" + configs.password) ) headers.set("Content-Type", "application/json; charset=utf-8") const promises = new Array>() while (refs.length > 0) { const batch = new Array() while (batch.length < 1000 && refs.length > 0) { batch.push(refs.pop()) } const bodyObject: any = {} bodyObject[`${type}_entries`] = batch promises.push( fetch(configs.endpoint + type + "_entries.json", { method: method, headers: headers, body: JSON.stringify(bodyObject), }) ) } return await Promise.all(promises) } const APIError = () => new Error(intl.get("service.failure")) export const feedbinServiceHooks: ServiceHooks = { authenticate: async (configs: FeedbinConfigs) => { try { const result = await fetchAPI(configs, "authentication.json") return result.status === 200 } catch { return false } }, updateSources: () => async (dispatch, getState) => { const configs = getState().service as FeedbinConfigs const response = await fetchAPI(configs, "subscriptions.json") if (response.status !== 200) throw APIError() const subscriptions: any[] = await response.json() let groupsMap: Map if (configs.importGroups) { const tagsResponse = await fetchAPI(configs, "taggings.json") if (tagsResponse.status !== 200) throw APIError() const tags: any[] = await tagsResponse.json() const tagsSet = new Set() groupsMap = new Map() for (let tag of tags) { const title = tag.name.trim() if (!tagsSet.has(title)) { tagsSet.add(title) dispatch(createSourceGroup(title)) } groupsMap.set(String(tag.feed_id), title) } } const sources = subscriptions.map(s => { const source = new RSSSource(s.feed_url, s.title) source.serviceRef = String(s.feed_id) return source }) return [sources, groupsMap] }, syncItems: () => async (_, getState) => { const configs = getState().service as FeedbinConfigs const [unreadResponse, starredResponse] = await Promise.all([ fetchAPI(configs, "unread_entries.json"), fetchAPI(configs, "starred_entries.json"), ]) if (unreadResponse.status !== 200 || starredResponse.status !== 200) throw APIError() const unread = await unreadResponse.json() const starred = await starredResponse.json() return [ new Set(unread.map(i => String(i))), new Set(starred.map(i => String(i))), ] }, fetchItems: () => async (_, getState) => { const state = getState() const configs = state.service as FeedbinConfigs const items = new Array() configs.lastId = configs.lastId || 0 let page = 1 let min = Number.MAX_SAFE_INTEGER let lastFetched: any[] do { try { const response = await fetchAPI( configs, "entries.json?mode=extended&per_page=125&page=" + page ) if (response.status !== 200) throw APIError() lastFetched = await response.json() items.push( ...lastFetched.filter( i => i.id > configs.lastId && i.id < min ) ) min = lastFetched.reduce((m, n) => Math.min(m, n.id), min) page += 1 } catch { break } } while ( min > configs.lastId && lastFetched && lastFetched.length >= 125 && items.length < configs.fetchLimit ) configs.lastId = items.reduce( (m, n) => Math.max(m, n.id), configs.lastId ) if (items.length > 0) { const fidMap = new Map() for (let source of Object.values(state.sources)) { if (source.serviceRef) { fidMap.set(source.serviceRef, source) } } const [unreadResponse, starredResponse] = await Promise.all([ fetchAPI(configs, "unread_entries.json"), fetchAPI(configs, "starred_entries.json"), ]) if (unreadResponse.status !== 200 || starredResponse.status !== 200) throw APIError() const unread: Set = new Set(await unreadResponse.json()) const starred: Set = new Set(await starredResponse.json()) const parsedItems = new Array() items.forEach(i => { if (i.content === null) return const source = fidMap.get(String(i.feed_id)) const dom = domParser.parseFromString(i.content, "text/html") const item = { source: source.sid, title: i.title, link: i.url, date: new Date(i.published), fetchedDate: new Date(i.created_at), content: i.content, snippet: dom.documentElement.textContent.trim(), creator: i.author, hasRead: !unread.has(i.id), starred: starred.has(i.id), hidden: false, notify: false, serviceRef: String(i.id), } as RSSItem if (i.images && i.images.original_url) { item.thumb = i.images.original_url } else { let baseEl = dom.createElement("base") baseEl.setAttribute( "href", item.link.split("/").slice(0, 3).join("/") ) dom.head.append(baseEl) let img = dom.querySelector("img") if (img && img.src) item.thumb = img.src } // Apply rules and sync back to the service if (source.rules) SourceRule.applyAll(source.rules, item) if (unread.has(i.id) === item.hasRead) markItems( configs, "unread", item.hasRead ? "DELETE" : "POST", [i.id] ) if (starred.has(i.id) !== Boolean(item.starred)) markItems( configs, "starred", item.starred ? "POST" : "DELETE", [i.id] ) parsedItems.push(item) }) return [parsedItems, configs] } else { return [[], configs] } }, markAllRead: (sids, date, before) => async (_, getState) => { const state = getState() const configs = state.service as FeedbinConfigs const predicates: lf.Predicate[] = [ db.items.source.in(sids), db.items.hasRead.eq(false), db.items.serviceRef.isNotNull(), ] if (date) { predicates.push( before ? db.items.date.lte(date) : db.items.date.gte(date) ) } const query = lf.op.and.apply(null, predicates) const rows = await db.itemsDB .select(db.items.serviceRef) .from(db.items) .where(query) .exec() const refs = rows.map(row => parseInt(row["serviceRef"])) markItems(configs, "unread", "DELETE", refs) }, markRead: (item: RSSItem) => async (_, getState) => { await markItems( getState().service as FeedbinConfigs, "unread", "DELETE", [parseInt(item.serviceRef)] ) }, markUnread: (item: RSSItem) => async (_, getState) => { await markItems( getState().service as FeedbinConfigs, "unread", "POST", [parseInt(item.serviceRef)] ) }, star: (item: RSSItem) => async (_, getState) => { await markItems( getState().service as FeedbinConfigs, "starred", "POST", [parseInt(item.serviceRef)] ) }, unstar: (item: RSSItem) => async (_, getState) => { await markItems( getState().service as FeedbinConfigs, "starred", "DELETE", [parseInt(item.serviceRef)] ) }, } ================================================ FILE: src/scripts/models/services/fever.ts ================================================ import intl from "react-intl-universal" import { ServiceHooks } from "../service" import { ServiceConfigs, SyncService } from "../../../schema-types" import { createSourceGroup } from "../group" import { RSSSource } from "../source" import { htmlDecode, domParser } from "../../utils" import { RSSItem } from "../item" import { SourceRule } from "../rule" export interface FeverConfigs extends ServiceConfigs { type: SyncService.Fever endpoint: string username: string apiKey: string fetchLimit: number lastId?: number useInt32?: boolean } async function fetchAPI(configs: FeverConfigs, params = "", postparams = "") { const response = await fetch(configs.endpoint + "?api" + params, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: `api_key=${configs.apiKey}${postparams}`, }) return await response.json() } async function markItem(configs: FeverConfigs, item: RSSItem, as: string) { if (item.serviceRef) { try { await fetchAPI( configs, "", `&mark=item&as=${as}&id=${item.serviceRef}` ) } catch (err) { console.log(err) } } } const APIError = () => new Error(intl.get("service.failure")) export const feverServiceHooks: ServiceHooks = { authenticate: async (configs: FeverConfigs) => { try { return Boolean((await fetchAPI(configs)).auth) } catch { return false } }, updateSources: () => async (dispatch, getState) => { const configs = getState().service as FeverConfigs const response = await fetchAPI(configs, "&feeds") const feeds: any[] = response.feeds const feedGroups: any[] = response.feeds_groups if (feeds === undefined) throw APIError() let groupsMap: Map if (configs.importGroups) { // Import groups on the first sync const groups: any[] = (await fetchAPI(configs, "&groups")).groups if (groups === undefined || feedGroups === undefined) throw APIError() const groupsIdMap = new Map() for (let group of groups) { const title = group.title.trim() dispatch(createSourceGroup(title)) groupsIdMap.set(group.id, title) } groupsMap = new Map() for (let group of feedGroups) { for (let fid of group.feed_ids.split(",")) { groupsMap.set(fid, groupsIdMap.get(group.group_id)) } } } const sources = feeds.map(f => { const source = new RSSSource(f.url, f.title) source.serviceRef = String(f.id) return source }) return [sources, groupsMap] }, fetchItems: () => async (_, getState) => { const state = getState() const configs = state.service as FeverConfigs const items = new Array() configs.lastId = configs.lastId || 0 let min = configs.useInt32 ? 2147483647 : Number.MAX_SAFE_INTEGER let response do { response = await fetchAPI(configs, `&items&max_id=${min}`) if (response.items === undefined) throw APIError() items.push(...response.items.filter(i => i.id > configs.lastId)) if ( response.items.length === 0 && min === Number.MAX_SAFE_INTEGER ) { configs.useInt32 = true min = 2147483647 response = undefined } else { min = response.items.reduce((m, n) => Math.min(m, n.id), min) } } while ( min > configs.lastId && (response === undefined || response.items.length >= 50) && items.length < configs.fetchLimit ) configs.lastId = items.reduce( (m, n) => Math.max(m, n.id), configs.lastId ) if (items.length > 0) { const fidMap = new Map() for (let source of Object.values(state.sources)) { if (source.serviceRef) { fidMap.set(source.serviceRef, source) } } const parsedItems = items.map(i => { const source = fidMap.get(String(i.feed_id)) const item = { source: source.sid, title: i.title, link: i.url, date: new Date(i.created_on_time * 1000), fetchedDate: new Date(), content: i.html, snippet: htmlDecode(i.html).trim(), creator: i.author, hasRead: Boolean(i.is_read), starred: Boolean(i.is_saved), hidden: false, notify: false, serviceRef: String(i.id), } as RSSItem // Try to get the thumbnail of the item let dom = domParser.parseFromString(item.content, "text/html") let baseEl = dom.createElement("base") baseEl.setAttribute( "href", item.link.split("/").slice(0, 3).join("/") ) dom.head.append(baseEl) let img = dom.querySelector("img") if (img && img.src) { item.thumb = img.src } else if (configs.useInt32) { // TTRSS Fever Plugin attachments let a = dom.querySelector( "body>ul>li:first-child>a" ) as HTMLAnchorElement if (a && /, image\/generic$/.test(a.innerText) && a.href) item.thumb = a.href } // Apply rules and sync back to the service if (source.rules) SourceRule.applyAll(source.rules, item) if (Boolean(i.is_read) !== item.hasRead) markItem(configs, item, item.hasRead ? "read" : "unread") if (Boolean(i.is_saved) !== Boolean(item.starred)) markItem(configs, item, item.starred ? "saved" : "unsaved") return item }) return [parsedItems, configs] } else { return [[], configs] } }, syncItems: () => async (_, getState) => { const configs = getState().service as FeverConfigs const [unreadResponse, starredResponse] = await Promise.all([ fetchAPI(configs, "&unread_item_ids"), fetchAPI(configs, "&saved_item_ids"), ]) if ( typeof unreadResponse.unread_item_ids !== "string" || typeof starredResponse.saved_item_ids !== "string" ) { throw APIError() } const unreadFids: string[] = unreadResponse.unread_item_ids.split(",") const starredFids: string[] = starredResponse.saved_item_ids.split(",") return [new Set(unreadFids), new Set(starredFids)] }, markAllRead: (sids, date, before) => async (_, getState) => { const state = getState() const configs = state.service as FeverConfigs if (date && !before) { const iids = state.feeds[state.page.feedId].iids const items = iids .map(iid => state.items[iid]) .filter(i => !i.hasRead && i.date.getTime() >= date.getTime()) for (let item of items) { if (item.serviceRef) { markItem(configs, item, "read") } } } else { const sources = sids.map(sid => state.sources[sid]) const timestamp = Math.floor((date ? date.getTime() : Date.now()) / 1000) + 1 for (let source of sources) { if (source.serviceRef) { fetchAPI( configs, "", `&mark=feed&as=read&id=${source.serviceRef}&before=${timestamp}` ) } } } }, markRead: (item: RSSItem) => async (_, getState) => { await markItem(getState().service as FeverConfigs, item, "read") }, markUnread: (item: RSSItem) => async (_, getState) => { await markItem(getState().service as FeverConfigs, item, "unread") }, star: (item: RSSItem) => async (_, getState) => { await markItem(getState().service as FeverConfigs, item, "saved") }, unstar: (item: RSSItem) => async (_, getState) => { await markItem(getState().service as FeverConfigs, item, "unsaved") }, } ================================================ FILE: src/scripts/models/services/greader.ts ================================================ import intl from "react-intl-universal" import * as db from "../../db" import lf from "lovefield" import { ServiceHooks } from "../service" import { ServiceConfigs, SyncService } from "../../../schema-types" import { createSourceGroup } from "../group" import { RSSSource } from "../source" import { RSSItem } from "../item" import { domParser, htmlDecode } from "../../utils" import { SourceRule } from "../rule" const ALL_TAG = "user/-/state/com.google/reading-list" const READ_TAG = "user/-/state/com.google/read" const STAR_TAG = "user/-/state/com.google/starred" export interface GReaderConfigs extends ServiceConfigs { type: SyncService.GReader | SyncService.Inoreader endpoint: string username: string password: string fetchLimit: number lastFetched?: number lastId?: string auth?: string useInt64: boolean // The Old Reader uses ids longer than 64 bits inoreaderId?: string inoreaderKey?: string removeInoreaderAd?: boolean } async function fetchAPI( configs: GReaderConfigs, params: string, method = "GET", body: BodyInit = null ) { const headers = new Headers() if (configs.auth !== null) headers.set("Authorization", configs.auth) if (configs.type == SyncService.Inoreader) { if (configs.inoreaderId) { headers.set("AppId", configs.inoreaderId) headers.set("AppKey", configs.inoreaderKey) } else { headers.set("AppId", "999999298") headers.set("AppKey", "KPbKYXTfgrKbwmroOeYC7mcW21ZRwF5Y") } } return await fetch(configs.endpoint + params, { method: method, headers: headers, body: body, }) } async function fetchAll( configs: GReaderConfigs, params: string ): Promise> { let results = new Array() let fetched: any[] let continuation: string do { let p = params if (continuation) p += `&c=${continuation}` const response = await fetchAPI(configs, p) const parsed = await response.json() fetched = parsed.itemRefs if (fetched) { for (let i of fetched) { results.push(i.id) } } continuation = parsed.continuation } while (continuation && fetched && fetched.length >= 1000) return new Set(results) } async function editTag( configs: GReaderConfigs, ref: string, tag: string, add = true ) { const body = new URLSearchParams(`i=${ref}&${add ? "a" : "r"}=${tag}`) return await fetchAPI(configs, "/reader/api/0/edit-tag", "POST", body) } function compactId(longId: string, useInt64: boolean) { let parts = longId.split("/") const last = parts[parts.length - 1] if (!useInt64) return last let i = BigInt("0x" + last) return BigInt.asIntN(64, i).toString() } const APIError = () => new Error(intl.get("service.failure")) export const gReaderServiceHooks: ServiceHooks = { authenticate: async (configs: GReaderConfigs) => { if (configs.auth !== null) { try { const result = await fetchAPI( configs, "/reader/api/0/user-info" ) return result.status === 200 } catch { return false } } }, reauthenticate: async ( configs: GReaderConfigs ): Promise => { const body = new URLSearchParams() body.append("Email", configs.username) body.append("Passwd", configs.password) const result = await fetchAPI( configs, "/accounts/ClientLogin", "POST", body ) if (result.status === 200) { const text = await result.text() const matches = text.match(/Auth=(\S+)/) if (matches.length > 1) configs.auth = "GoogleLogin auth=" + matches[1] return configs } else { throw APIError() } }, updateSources: () => async (dispatch, getState) => { const configs = getState().service as GReaderConfigs const response = await fetchAPI( configs, "/reader/api/0/subscription/list?output=json" ) if (response.status !== 200) throw APIError() const subscriptions: any[] = (await response.json()).subscriptions let groupsMap: Map if (configs.importGroups) { groupsMap = new Map() const groupSet = new Set() for (let s of subscriptions) { if (s.categories && s.categories.length > 0) { const group: string = s.categories[0].label if (!groupSet.has(group)) { groupSet.add(group) dispatch(createSourceGroup(group)) } groupsMap.set(s.id, group) } } } const sources = new Array() subscriptions.forEach(s => { const source = new RSSSource(s.url || s.htmlUrl, s.title) source.serviceRef = s.id // Omit duplicate sources in The Old Reader if ( configs.useInt64 || s.url != "http://blog.theoldreader.com/rss" ) { sources.push(source) } }) return [sources, groupsMap] }, syncItems: () => async (_, getState) => { const configs = getState().service as GReaderConfigs if (configs.type == SyncService.Inoreader) { return await Promise.all([ fetchAll( configs, `/reader/api/0/stream/items/ids?output=json&xt=${READ_TAG}&n=1000` ), fetchAll( configs, `/reader/api/0/stream/items/ids?output=json&it=${STAR_TAG}&n=1000` ), ]) } else { return await Promise.all([ fetchAll( configs, `/reader/api/0/stream/items/ids?output=json&s=${ALL_TAG}&xt=${READ_TAG}&n=1000` ), fetchAll( configs, `/reader/api/0/stream/items/ids?output=json&s=${STAR_TAG}&n=1000` ), ]) } }, fetchItems: () => async (_, getState) => { const state = getState() const configs = state.service as GReaderConfigs const items = new Array() let fetchedItems: any[] let continuation: string do { try { const limit = Math.min(configs.fetchLimit - items.length, 1000) let params = `/reader/api/0/stream/contents?output=json&n=${limit}` if (configs.lastFetched) params += `&ot=${configs.lastFetched}` if (continuation) params += `&c=${continuation}` const response = await fetchAPI(configs, params) let fetched = await response.json() fetchedItems = fetched.items for (let i of fetchedItems) { i.id = compactId(i.id, configs.useInt64) if ( i.id === configs.lastId || items.length >= configs.fetchLimit ) { break } else { items.push(i) } } continuation = fetched.continuation } catch { break } } while (continuation && items.length < configs.fetchLimit) if (items.length > 0) { configs.lastId = items[0].id const fidMap = new Map() for (let source of Object.values(state.sources)) { if (source.serviceRef) { fidMap.set(source.serviceRef, source) } } const parsedItems = new Array() items.map(i => { const source = fidMap.get(i.origin.streamId) if (source === undefined) return const dom = domParser.parseFromString( i.summary.content, "text/html" ) if ( configs.type == SyncService.Inoreader && configs.removeInoreaderAd !== false ) { if ( dom.documentElement.textContent .trim() .startsWith("Ads from Inoreader") ) { dom.body.firstChild.remove() } } const item = { source: source.sid, title: i.title, link: i.canonical[0].href, date: new Date(i.published * 1000), fetchedDate: new Date(parseInt(i.crawlTimeMsec)), content: dom.body.innerHTML, snippet: dom.documentElement.textContent.trim(), creator: i.author, hasRead: false, starred: false, hidden: false, notify: false, serviceRef: i.id, } as RSSItem const baseEl = dom.createElement("base") baseEl.setAttribute( "href", item.link.split("/").slice(0, 3).join("/") ) dom.head.append(baseEl) let img = dom.querySelector("img") if (img && img.src) item.thumb = img.src if (configs.type == SyncService.Inoreader) item.title = htmlDecode(item.title) for (let c of i.categories) { if (!item.hasRead && c.endsWith("/state/com.google/read")) item.hasRead = true else if ( !item.starred && c.endsWith("/state/com.google/starred") ) item.starred = true } // Apply rules and sync back to the service if (source.rules) { const hasRead = item.hasRead const starred = item.starred SourceRule.applyAll(source.rules, item) if (item.hasRead !== hasRead) editTag( configs, item.serviceRef, READ_TAG, item.hasRead ) if (item.starred !== starred) editTag( configs, item.serviceRef, STAR_TAG, item.starred ) } parsedItems.push(item) }) if (parsedItems.length > 0) { configs.lastFetched = Math.round( parsedItems[0].fetchedDate.getTime() / 1000 ) } return [parsedItems, configs] } else { return [[], configs] } }, markAllRead: (sids, date, before) => async (_, getState) => { const state = getState() const configs = state.service as GReaderConfigs if (date) { const predicates: lf.Predicate[] = [ db.items.source.in(sids), db.items.hasRead.eq(false), db.items.serviceRef.isNotNull(), ] if (date) { predicates.push( before ? db.items.date.lte(date) : db.items.date.gte(date) ) } const query = lf.op.and.apply(null, predicates) const rows = await db.itemsDB .select(db.items.serviceRef) .from(db.items) .where(query) .exec() const refs = rows.map(row => row["serviceRef"]).join("&i=") if (refs) { editTag(getState().service as GReaderConfigs, refs, READ_TAG) } } else { const sources = sids.map(sid => state.sources[sid]) for (let source of sources) { if (source.serviceRef) { const body = new URLSearchParams() body.set("s", source.serviceRef) fetchAPI( configs, "/reader/api/0/mark-all-as-read", "POST", body ) } } } }, markRead: (item: RSSItem) => async (_, getState) => { await editTag( getState().service as GReaderConfigs, item.serviceRef, READ_TAG ) }, markUnread: (item: RSSItem) => async (_, getState) => { await editTag( getState().service as GReaderConfigs, item.serviceRef, READ_TAG, false ) }, star: (item: RSSItem) => async (_, getState) => { await editTag( getState().service as GReaderConfigs, item.serviceRef, STAR_TAG ) }, unstar: (item: RSSItem) => async (_, getState) => { await editTag( getState().service as GReaderConfigs, item.serviceRef, STAR_TAG, false ) }, } ================================================ FILE: src/scripts/models/services/miniflux.ts ================================================ import intl from "react-intl-universal" import * as db from "../../db" import lf from "lovefield" import { ServiceHooks } from "../service" import { ServiceConfigs, SyncService } from "../../../schema-types" import { createSourceGroup } from "../group" import { RSSSource } from "../source" import { domParser, htmlDecode } from "../../utils" import { RSSItem } from "../item" import { SourceRule } from "../rule" // miniflux service configs export interface MinifluxConfigs extends ServiceConfigs { type: SyncService.Miniflux endpoint: string apiKeyAuth: boolean authKey: string fetchLimit: number lastId?: number } // partial api schema interface Feed { id: number feed_url: string title: string category: { title: string } } interface Category { title: string } interface Entry { id: number status: "unread" | "read" | "removed" title: string url: string published_at: string created_at: string content: string author: string starred: boolean feed: Feed } interface Entries { total: number entries: Entry[] } const APIError = () => new Error(intl.get("service.failure")) // base endpoint, authorization with dedicated token or http basic user/pass pair async function fetchAPI( configs: MinifluxConfigs, endpoint: string = "", method: string = "GET", body: string = null ): Promise { try { const headers = new Headers() headers.append("content-type", "application/x-www-form-urlencoded") configs.apiKeyAuth ? headers.append("X-Auth-Token", configs.authKey) : headers.append("Authorization", `Basic ${configs.authKey}`) let baseUrl = configs.endpoint if (!baseUrl.endsWith("/")) baseUrl = baseUrl + "/" if (!baseUrl.endsWith("/v1/")) baseUrl = baseUrl + "v1/" const response = await fetch(baseUrl + endpoint, { method: method, body: body, headers: headers, }) return response } catch (error) { console.log(error) throw APIError() } } export const minifluxServiceHooks: ServiceHooks = { // poll service info endpoint to verify auth authenticate: async (configs: MinifluxConfigs) => { const response = await fetchAPI(configs, "me") if (await response.json().then(json => json.error_message)) return false return true }, // collect sources from service, along with associated groups/categories updateSources: () => async (dispatch, getState) => { const configs = getState().service as MinifluxConfigs // fetch and create groups in redux if (configs.importGroups) { const groups: Category[] = await fetchAPI( configs, "categories" ).then(response => response.json()) groups.forEach(group => dispatch(createSourceGroup(group.title))) } // fetch all feeds const feedResponse = await fetchAPI(configs, "feeds") const feeds = await feedResponse.json() if (feeds === undefined) throw APIError() // go through feeds, create typed source while also mapping by group let sources: RSSSource[] = new Array() let groupsMap: Map = new Map() for (let feed of feeds) { let source = new RSSSource(feed.feed_url, feed.title) // associate service christened id to match in other request source.serviceRef = feed.id.toString() sources.push(source) groupsMap.set(feed.id.toString(), feed.category.title) } return [sources, configs.importGroups ? groupsMap : undefined] }, // fetch entries from after the last fetched id (if exists) // limit by quantity and maximum safe integer (id) // NOTE: miniflux endpoint /entries default order with "published at", and does not offer "created_at" // but does offer id sort, directly correlated with "created". some feeds give strange published_at. fetchItems: () => async (_, getState) => { const state = getState() const configs = state.service as MinifluxConfigs const items: Entry[] = new Array() let entriesResponse: Entries // parameters configs.lastId = configs.lastId ?? 0 // intermediate const quantity = 125 let continueId: number do { try { if (continueId) { entriesResponse = await fetchAPI( configs, `entries?order=id&direction=desc&after_entry_id=${configs.lastId}&before_entry_id=${continueId}&limit=${quantity}` ).then(response => response.json()) } else { entriesResponse = await fetchAPI( configs, `entries?order=id&direction=desc&after_entry_id=${configs.lastId}&limit=${quantity}` ).then(response => response.json()) } items.push(...entriesResponse.entries) continueId = items[items.length - 1].id } catch { break } } while ( entriesResponse.entries && entriesResponse.total >= quantity && items.length < configs.fetchLimit ) // break/return nothing if no new items acquired if (items.length === 0) return [[], configs] configs.lastId = items[0].id // get sources that possess ref/id given by service, associate new items const sourceMap = new Map() for (let source of Object.values(state.sources)) { if (source.serviceRef) { sourceMap.set(source.serviceRef, source) } } // map item objects to rssitem type while appling rules (if exist) const parsedItems = items.map(item => { const source = sourceMap.get(item.feed.id.toString()) let parsedItem = { source: source.sid, title: item.title, link: item.url, date: new Date(item.published_at ?? item.created_at), fetchedDate: new Date(), content: item.content, snippet: htmlDecode(item.content).trim(), creator: item.author, hasRead: Boolean(item.status === "read"), starred: Boolean(item.starred), hidden: false, notify: false, serviceRef: String(item.id), } as RSSItem // Try to get the thumbnail of the item let dom = domParser.parseFromString(item.content, "text/html") let baseEl = dom.createElement("base") baseEl.setAttribute( "href", parsedItem.link.split("/").slice(0, 3).join("/") ) dom.head.append(baseEl) let img = dom.querySelector("img") if (img && img.src) parsedItem.thumb = img.src if (source.rules) { SourceRule.applyAll(source.rules, parsedItem) if ((item.status === "read") !== parsedItem.hasRead) minifluxServiceHooks.markRead(parsedItem) if (item.starred !== parsedItem.starred) minifluxServiceHooks.markUnread(parsedItem) } return parsedItem }) return [parsedItems, configs] }, // get remote read and star state of articles, for local sync syncItems: () => async (_, getState) => { const configs = getState().service as MinifluxConfigs const unreadPromise: Promise = fetchAPI( configs, "entries?status=unread" ).then(response => response.json()) const starredPromise: Promise = fetchAPI( configs, "entries?starred=true" ).then(response => response.json()) const [unread, starred] = await Promise.all([ unreadPromise, starredPromise, ]) return [ new Set(unread.entries.map((entry: Entry) => String(entry.id))), new Set(starred.entries.map((entry: Entry) => String(entry.id))), ] }, markRead: (item: RSSItem) => async (_, getState) => { if (!item.serviceRef) return const body = `{ "entry_ids": [${item.serviceRef}], "status": "read" }` const response = await fetchAPI( getState().service as MinifluxConfigs, "entries", "PUT", body ) if (response.status !== 204) throw APIError() }, markUnread: (item: RSSItem) => async (_, getState) => { if (!item.serviceRef) return const body = `{ "entry_ids": [${item.serviceRef}], "status": "unread" }` await fetchAPI( getState().service as MinifluxConfigs, "entries", "PUT", body ) }, // mark entries for source ids as read, relative to date, determined by "before" bool // context menu component: // item - null, item date, either // group - group sources, null, true // nav - null, daysago, true // if null, state consulted for context sids markAllRead: (sids, date, before) => async (_, getState) => { const state = getState() const configs = state.service as MinifluxConfigs if (date) { const predicates: lf.Predicate[] = [ db.items.source.in(sids), db.items.hasRead.eq(false), db.items.serviceRef.isNotNull(), before ? db.items.date.lte(date) : db.items.date.gte(date), ] const query = lf.op.and.apply(null, predicates) const rows = await db.itemsDB .select(db.items.serviceRef) .from(db.items) .where(query) .exec() const refs = rows.map(row => row["serviceRef"]) const body = `{ "entry_ids": [${refs}], "status": "read" }` await fetchAPI(configs, "entries", "PUT", body) } else { const sources = state.sources await Promise.all( sids.map(sid => fetchAPI( configs, `feeds/${sources[sid]?.serviceRef}/mark-all-as-read`, "PUT" ) ) ) } }, star: (item: RSSItem) => async (_, getState) => { if (!item.serviceRef) return await fetchAPI( getState().service as MinifluxConfigs, `entries/${item.serviceRef}/bookmark`, "PUT" ) }, unstar: (item: RSSItem) => async (_, getState) => { if (!item.serviceRef) return await fetchAPI( getState().service as MinifluxConfigs, `entries/${item.serviceRef}/bookmark`, "PUT" ) }, } ================================================ FILE: src/scripts/models/services/nextcloud.ts ================================================ import intl from "react-intl-universal" import * as db from "../../db" import lf from "lovefield" import { ServiceHooks } from "../service" import { ServiceConfigs, SyncService } from "../../../schema-types" import { createSourceGroup } from "../group" import { RSSSource } from "../source" import { domParser } from "../../utils" import { RSSItem } from "../item" import { SourceRule } from "../rule" export interface NextcloudConfigs extends ServiceConfigs { type: SyncService.Nextcloud endpoint: string username: string password: string fetchLimit: number lastModified?: number lastId?: number } async function fetchAPI(configs: NextcloudConfigs, params: string) { const headers = new Headers() headers.set( "Authorization", "Basic " + btoa(configs.username + ":" + configs.password) ) return await fetch(configs.endpoint + params, { headers: headers }) } async function markItems( configs: NextcloudConfigs, type: string, method: string, refs: number[] ) { const headers = new Headers() headers.set( "Authorization", "Basic " + btoa(configs.username + ":" + configs.password) ) headers.set("Content-Type", "application/json; charset=utf-8") const promises = new Array>() while (refs.length > 0) { const batch = new Array() while (batch.length < 1000 && refs.length > 0) { batch.push(refs.pop()) } const bodyObject: any = {} bodyObject["itemIds"] = batch promises.push( fetch(configs.endpoint + "/items/" + type + "/multiple", { method: method, headers: headers, body: JSON.stringify(bodyObject), }) ) } return await Promise.all(promises) } const APIError = () => new Error(intl.get("service.failure")) export const nextcloudServiceHooks: ServiceHooks = { authenticate: async (configs: NextcloudConfigs) => { try { const result = await fetchAPI(configs, "/version") return result.status === 200 } catch { return false } }, updateSources: () => async (dispatch, getState) => { const configs = getState().service as NextcloudConfigs const response = await fetchAPI(configs, "/feeds") if (response.status !== 200) throw APIError() const feeds = await response.json() let groupsMap: Map let groupsByTagId: Map = new Map() if (configs.importGroups) { const foldersResponse = await fetchAPI(configs, "/folders") if (foldersResponse.status !== 200) throw APIError() const folders = await foldersResponse.json() const foldersSet = new Set() groupsMap = new Map() for (let folder of folders.folders) { const title = folder.name.trim() if (!foldersSet.has(title)) { foldersSet.add(title) dispatch(createSourceGroup(title)) } groupsByTagId.set(String(folder.id), title) } } const sources = feeds.feeds.map(s => { const source = new RSSSource(s.url, s.title) source.iconurl = s.faviconLink source.serviceRef = String(s.id) if (s.folderId && groupsByTagId.has(String(s.folderId))) { groupsMap.set( String(s.id), groupsByTagId.get(String(s.folderId)) ) } return source }) return [sources, groupsMap] }, syncItems: () => async (_, getState) => { const configs = getState().service as NextcloudConfigs const [unreadResponse, starredResponse] = await Promise.all([ fetchAPI(configs, "/items?getRead=false&type=3&batchSize=-1"), fetchAPI(configs, "/items?getRead=true&type=2&batchSize=-1"), ]) if (unreadResponse.status !== 200 || starredResponse.status !== 200) throw APIError() const unread = await unreadResponse.json() const starred = await starredResponse.json() return [ new Set(unread.items.map(i => String(i.id))), new Set(starred.items.map(i => String(i.id))), ] }, fetchItems: () => async (_, getState) => { const state = getState() const configs = state.service as NextcloudConfigs let items = new Array() configs.lastModified = configs.lastModified || 0 configs.lastId = configs.lastId || 0 let lastFetched: any if (!configs.lastModified || configs.lastModified == 0) { //first sync let min = Number.MAX_SAFE_INTEGER do { const response = await fetchAPI( configs, "/items?getRead=true&type=3&batchSize=125&offset=" + min ) if (response.status !== 200) throw APIError() lastFetched = await response.json() items = [...items, ...lastFetched.items] min = lastFetched.items.reduce((m, n) => Math.min(m, n.id), min) } while ( lastFetched.items && lastFetched.items.length >= 125 && items.length < configs.fetchLimit ) } else { //incremental sync const response = await fetchAPI( configs, "/items/updated?lastModified=" + configs.lastModified + "&type=3" ) if (response.status !== 200) throw APIError() lastFetched = (await response.json()).items items.push(...lastFetched.filter(i => i.id > configs.lastId)) } configs.lastModified = items.reduce( (m, n) => Math.max(m, n.lastModified), configs.lastModified ) configs.lastId = items.reduce( (m, n) => Math.max(m, n.id), configs.lastId ) configs.lastModified++ //+1 to avoid fetching articles with same lastModified next time if (items.length > 0) { const fidMap = new Map() for (let source of Object.values(state.sources)) { if (source.serviceRef) { fidMap.set(source.serviceRef, source) } } const parsedItems = new Array() items.forEach(i => { if (i.body === null || i.url === null) return const unreadItem = i.unread const starredItem = i.starred const source = fidMap.get(String(i.feedId)) const dom = domParser.parseFromString(i.body, "text/html") const item = { source: source.sid, title: i.title, link: i.url, date: new Date(i.pubDate * 1000), fetchedDate: new Date(), content: i.body, snippet: dom.documentElement.textContent.trim(), creator: i.author, hasRead: !i.unread, starred: i.starred, hidden: false, notify: false, serviceRef: String(i.id), } as RSSItem if (i.enclosureLink) { item.thumb = i.enclosureLink } else { let baseEl = dom.createElement("base") baseEl.setAttribute( "href", item.link.split("/").slice(0, 3).join("/") ) dom.head.append(baseEl) let img = dom.querySelector("img") if (img && img.src) item.thumb = img.src } // Apply rules and sync back to the service if (source.rules) SourceRule.applyAll(source.rules, item) if (unreadItem && item.hasRead) markItems( configs, item.hasRead ? "read" : "unread", "POST", [i.id] ) if (starredItem !== Boolean(item.starred)) markItems( configs, item.starred ? "star" : "unstar", "POST", [i.id] ) parsedItems.push(item) }) return [parsedItems, configs] } else { return [[], configs] } }, markAllRead: (sids, date, before) => async (_, getState) => { const state = getState() const configs = state.service as NextcloudConfigs const predicates: lf.Predicate[] = [ db.items.source.in(sids), db.items.hasRead.eq(false), db.items.serviceRef.isNotNull(), ] if (date) { predicates.push( before ? db.items.date.lte(date) : db.items.date.gte(date) ) } const query = lf.op.and.apply(null, predicates) const rows = await db.itemsDB .select(db.items.serviceRef) .from(db.items) .where(query) .exec() const refs = rows.map(row => parseInt(row["serviceRef"])) markItems(configs, "unread", "POST", refs) }, markRead: (item: RSSItem) => async (_, getState) => { await markItems( getState().service as NextcloudConfigs, "read", "POST", [parseInt(item.serviceRef)] ) }, markUnread: (item: RSSItem) => async (_, getState) => { await markItems( getState().service as NextcloudConfigs, "unread", "POST", [parseInt(item.serviceRef)] ) }, star: (item: RSSItem) => async (_, getState) => { await markItems( getState().service as NextcloudConfigs, "star", "POST", [parseInt(item.serviceRef)] ) }, unstar: (item: RSSItem) => async (_, getState) => { await markItems( getState().service as NextcloudConfigs, "unstar", "POST", [parseInt(item.serviceRef)] ) }, } ================================================ FILE: src/scripts/models/source.ts ================================================ import intl from "react-intl-universal" import * as db from "../db" import lf from "lovefield" import { fetchFavicon, ActionStatus, AppThunk, parseRSS, MyParserItem, } from "../utils" import { RSSItem, insertItems, ItemActionTypes, FETCH_ITEMS, MARK_READ, MARK_UNREAD, MARK_ALL_READ, } from "./item" import { saveSettings } from "./app" import { SourceRule } from "./rule" import { fixBrokenGroups } from "./group" export const enum SourceOpenTarget { Local, Webpage, External, FullContent, } export const enum SourceTextDirection { LTR, RTL, Vertical, } export class RSSSource { sid: number url: string iconurl?: string name: string openTarget: SourceOpenTarget unreadCount: number lastFetched: Date serviceRef?: string fetchFrequency: number // in minutes rules?: SourceRule[] textDir: SourceTextDirection hidden: boolean constructor(url: string, name: string = null) { this.url = url this.name = name this.openTarget = SourceOpenTarget.Local this.lastFetched = new Date() this.fetchFrequency = 0 this.textDir = SourceTextDirection.LTR this.hidden = false } static async fetchMetaData(source: RSSSource) { let feed = await parseRSS(source.url) if (!source.name) { if (feed.title) source.name = feed.title.trim() source.name = source.name || intl.get("sources.untitled") } return feed } private static async checkItem( source: RSSSource, item: MyParserItem ): Promise { let i = new RSSItem(item, source) const items = (await db.itemsDB .select() .from(db.items) .where( lf.op.and( db.items.source.eq(i.source), db.items.title.eq(i.title), db.items.date.eq(i.date) ) ) .limit(1) .exec()) as RSSItem[] if (items.length === 0) { RSSItem.parseContent(i, item) if (source.rules) SourceRule.applyAll(source.rules, i) return i } else { return null } } static checkItems( source: RSSSource, items: MyParserItem[] ): Promise { return new Promise((resolve, reject) => { let p = new Array>() for (let item of items) { p.push(this.checkItem(source, item)) } Promise.all(p) .then(values => { resolve(values.filter(v => v != null)) }) .catch(e => { reject(e) }) }) } static async fetchItems(source: RSSSource) { let feed = await parseRSS(source.url) return await this.checkItems(source, feed.items) } } export type SourceState = { [sid: number]: RSSSource } export const INIT_SOURCES = "INIT_SOURCES" export const ADD_SOURCE = "ADD_SOURCE" export const UPDATE_SOURCE = "UPDATE_SOURCE" export const UPDATE_UNREAD_COUNTS = "UPDATE_UNREAD_COUNTS" export const DELETE_SOURCE = "DELETE_SOURCE" export const HIDE_SOURCE = "HIDE_SOURCE" export const UNHIDE_SOURCE = "UNHIDE_SOURCE" interface InitSourcesAction { type: typeof INIT_SOURCES status: ActionStatus sources?: SourceState err? } interface AddSourceAction { type: typeof ADD_SOURCE status: ActionStatus batch: boolean source?: RSSSource err? } interface UpdateSourceAction { type: typeof UPDATE_SOURCE source: RSSSource } interface UpdateUnreadCountsAction { type: typeof UPDATE_UNREAD_COUNTS sources: SourceState } interface DeleteSourceAction { type: typeof DELETE_SOURCE source: RSSSource } interface ToggleSourceHiddenAction { type: typeof HIDE_SOURCE | typeof UNHIDE_SOURCE status: ActionStatus source: RSSSource } export type SourceActionTypes = | InitSourcesAction | AddSourceAction | UpdateSourceAction | UpdateUnreadCountsAction | DeleteSourceAction | ToggleSourceHiddenAction export function initSourcesRequest(): SourceActionTypes { return { type: INIT_SOURCES, status: ActionStatus.Request, } } export function initSourcesSuccess(sources: SourceState): SourceActionTypes { return { type: INIT_SOURCES, status: ActionStatus.Success, sources: sources, } } export function initSourcesFailure(err): SourceActionTypes { return { type: INIT_SOURCES, status: ActionStatus.Failure, err: err, } } async function unreadCount(sources: SourceState): Promise { const rows = await db.itemsDB .select(db.items.source, lf.fn.count(db.items._id)) .from(db.items) .where(db.items.hasRead.eq(false)) .groupBy(db.items.source) .exec() for (let row of rows) { sources[row["source"]].unreadCount = row["COUNT(_id)"] } return sources } export function updateUnreadCounts(): AppThunk> { return async (dispatch, getState) => { const sources: SourceState = {} for (let source of Object.values(getState().sources)) { sources[source.sid] = { ...source, unreadCount: 0, } } dispatch({ type: UPDATE_UNREAD_COUNTS, sources: await unreadCount(sources), }) } } export function initSources(): AppThunk> { return async dispatch => { dispatch(initSourcesRequest()) await db.init() const sources = (await db.sourcesDB .select() .from(db.sources) .exec()) as RSSSource[] const state: SourceState = {} for (let source of sources) { source.unreadCount = 0 state[source.sid] = source } await unreadCount(state) dispatch(fixBrokenGroups(state)) dispatch(initSourcesSuccess(state)) } } export function addSourceRequest(batch: boolean): SourceActionTypes { return { type: ADD_SOURCE, batch: batch, status: ActionStatus.Request, } } export function addSourceSuccess( source: RSSSource, batch: boolean ): SourceActionTypes { return { type: ADD_SOURCE, batch: batch, status: ActionStatus.Success, source: source, } } export function addSourceFailure(err, batch: boolean): SourceActionTypes { return { type: ADD_SOURCE, batch: batch, status: ActionStatus.Failure, err: err, } } let insertPromises = Promise.resolve() export function insertSource(source: RSSSource): AppThunk> { return (_, getState) => { return new Promise((resolve, reject) => { insertPromises = insertPromises.then(async () => { let sids = Object.values(getState().sources).map(s => s.sid) source.sid = Math.max(...sids, -1) + 1 const row = db.sources.createRow(source) try { const inserted = (await db.sourcesDB .insert() .into(db.sources) .values([row]) .exec()) as RSSSource[] resolve(inserted[0]) } catch (err) { if (err.code === 201) reject(intl.get("sources.exist")) else reject(err) } }) }) } } export function addSource( url: string, name: string = null, batch = false ): AppThunk> { return async (dispatch, getState) => { const app = getState().app if (app.sourceInit) { dispatch(addSourceRequest(batch)) const source = new RSSSource(url, name) try { const feed = await RSSSource.fetchMetaData(source) const inserted = await dispatch(insertSource(source)) inserted.unreadCount = feed.items.length dispatch(addSourceSuccess(inserted, batch)) window.settings.saveGroups(getState().groups) dispatch(updateFavicon([inserted.sid])) const items = await RSSSource.checkItems(inserted, feed.items) await insertItems(items) return inserted.sid } catch (e) { dispatch(addSourceFailure(e, batch)) if (!batch) { window.utils.showErrorBox( intl.get("sources.errorAdd"), String(e), intl.get("context.copy") ) } throw e } } throw new Error("Sources not initialized.") } } export function updateSourceDone(source: RSSSource): SourceActionTypes { return { type: UPDATE_SOURCE, source: source, } } export function updateSource(source: RSSSource): AppThunk> { return async dispatch => { let sourceCopy = { ...source } delete sourceCopy.unreadCount const row = db.sources.createRow(sourceCopy) await db.sourcesDB .insertOrReplace() .into(db.sources) .values([row]) .exec() dispatch(updateSourceDone(source)) } } export function deleteSourceDone(source: RSSSource): SourceActionTypes { return { type: DELETE_SOURCE, source: source, } } export function deleteSource( source: RSSSource, batch = false ): AppThunk> { return async (dispatch, getState) => { if (!batch) dispatch(saveSettings()) try { await db.itemsDB .delete() .from(db.items) .where(db.items.source.eq(source.sid)) .exec() await db.sourcesDB .delete() .from(db.sources) .where(db.sources.sid.eq(source.sid)) .exec() dispatch(deleteSourceDone(source)) window.settings.saveGroups(getState().groups) } catch (err) { console.log(err) } finally { if (!batch) dispatch(saveSettings()) } } } export function deleteSources(sources: RSSSource[]): AppThunk> { return async dispatch => { dispatch(saveSettings()) for (let source of sources) { await dispatch(deleteSource(source, true)) } dispatch(saveSettings()) } } export function toggleSourceHidden(source: RSSSource): AppThunk> { return async (dispatch, getState) => { const sourceCopy: RSSSource = { ...getState().sources[source.sid] } sourceCopy.hidden = !sourceCopy.hidden dispatch({ type: sourceCopy.hidden ? HIDE_SOURCE : UNHIDE_SOURCE, status: ActionStatus.Success, source: sourceCopy, }) await dispatch(updateSource(sourceCopy)) } } export function updateFavicon( sids?: number[], force = false ): AppThunk> { return async (dispatch, getState) => { const initSources = getState().sources if (!sids) { sids = Object.values(initSources) .filter(s => s.iconurl === undefined) .map(s => s.sid) } else { sids = sids.filter(sid => sid in initSources) } const promises = sids.map(async sid => { const url = initSources[sid].url let favicon = (await fetchFavicon(url)) || "" const source = getState().sources[sid] if ( source && source.url === url && (force || source.iconurl === undefined) ) { source.iconurl = favicon await dispatch(updateSource(source)) } }) await Promise.all(promises) } } export function sourceReducer( state: SourceState = {}, action: SourceActionTypes | ItemActionTypes ): SourceState { switch (action.type) { case INIT_SOURCES: switch (action.status) { case ActionStatus.Success: return action.sources default: return state } case UPDATE_UNREAD_COUNTS: return action.sources case ADD_SOURCE: switch (action.status) { case ActionStatus.Success: return { ...state, [action.source.sid]: action.source, } default: return state } case UPDATE_SOURCE: return { ...state, [action.source.sid]: action.source, } case DELETE_SOURCE: { delete state[action.source.sid] return { ...state } } case FETCH_ITEMS: { switch (action.status) { case ActionStatus.Success: { let updateMap = new Map() for (let item of action.items) { if (!item.hasRead) { updateMap.set( item.source, updateMap.has(item.source) ? updateMap.get(item.source) + 1 : 1 ) } } let nextState = {} as SourceState for (let [s, source] of Object.entries(state)) { let sid = parseInt(s) if (updateMap.has(sid)) { nextState[sid] = { ...source, unreadCount: source.unreadCount + updateMap.get(sid), } as RSSSource } else { nextState[sid] = source } } return nextState } default: return state } } case MARK_UNREAD: case MARK_READ: return { ...state, [action.item.source]: { ...state[action.item.source], unreadCount: state[action.item.source].unreadCount + (action.type === MARK_UNREAD ? 1 : -1), } as RSSSource, } case MARK_ALL_READ: { let nextState = { ...state } action.sids.forEach(sid => { nextState[sid] = { ...state[sid], unreadCount: action.time ? state[sid].unreadCount : 0, } }) return nextState } default: return state } } ================================================ FILE: src/scripts/reducer.ts ================================================ import { applyMiddleware, combineReducers, createStore } from "redux" import thunkMiddleware from "redux-thunk" import { sourceReducer } from "./models/source" import { itemReducer } from "./models/item" import { feedReducer } from "./models/feed" import { appReducer } from "./models/app" import { groupReducer } from "./models/group" import { pageReducer } from "./models/page" import { serviceReducer } from "./models/service" import { AppDispatch } from "./utils" import { TypedUseSelectorHook, useDispatch, useSelector, useStore, } from "react-redux" export const rootReducer = combineReducers({ sources: sourceReducer, items: itemReducer, feeds: feedReducer, groups: groupReducer, page: pageReducer, service: serviceReducer, app: appReducer, }) export const rootStore = createStore( rootReducer, applyMiddleware(thunkMiddleware) ) export type AppStore = typeof rootStore export type RootState = ReturnType export const useAppDispatch: () => AppDispatch = useDispatch export const useAppSelector: TypedUseSelectorHook = useSelector export const useAppStore: () => AppStore = useStore ================================================ FILE: src/scripts/settings.ts ================================================ import * as db from "./db" import { IPartialTheme, loadTheme } from "@fluentui/react" import locales from "./i18n/_locales" import { ThemeSettings } from "../schema-types" import intl from "react-intl-universal" import { SourceTextDirection } from "./models/source" let lightTheme: IPartialTheme = { defaultFontStyle: { fontFamily: '"Segoe UI", "Source Han Sans Regular", sans-serif', }, } let darkTheme: IPartialTheme = { ...lightTheme, palette: { neutralLighterAlt: "#282828", neutralLighter: "#313131", neutralLight: "#3f3f3f", neutralQuaternaryAlt: "#484848", neutralQuaternary: "#4f4f4f", neutralTertiaryAlt: "#6d6d6d", neutralTertiary: "#c8c8c8", neutralSecondary: "#d0d0d0", neutralSecondaryAlt: "#d2d0ce", neutralPrimaryAlt: "#dadada", neutralPrimary: "#ffffff", neutralDark: "#f4f4f4", black: "#f8f8f8", white: "#1f1f1f", themePrimary: "#3a96dd", themeLighterAlt: "#020609", themeLighter: "#091823", themeLight: "#112d43", themeTertiary: "#235a85", themeSecondary: "#3385c3", themeDarkAlt: "#4ba0e1", themeDark: "#65aee6", themeDarker: "#8ac2ec", accent: "#3a96dd", }, } export function setThemeDefaultFont(locale: string) { switch (locale) { case "zh-CN": lightTheme.defaultFontStyle.fontFamily = '"Segoe UI", "Source Han Sans SC Regular", "Microsoft YaHei", sans-serif' break case "zh-TW": lightTheme.defaultFontStyle.fontFamily = '"Segoe UI", "Source Han Sans TC Regular", "Microsoft JhengHei", sans-serif' break case "ja": lightTheme.defaultFontStyle.fontFamily = '"Segoe UI", "Source Han Sans JP Regular", "Yu Gothic UI", sans-serif' break case "ko": lightTheme.defaultFontStyle.fontFamily = '"Segoe UI", "Source Han Sans KR Regular", "Malgun Gothic", sans-serif' break default: lightTheme.defaultFontStyle.fontFamily = '"Segoe UI", "Source Han Sans Regular", sans-serif' } darkTheme.defaultFontStyle.fontFamily = lightTheme.defaultFontStyle.fontFamily applyThemeSettings() } export function setThemeSettings(theme: ThemeSettings) { window.settings.setThemeSettings(theme) applyThemeSettings() } export function getThemeSettings(): ThemeSettings { return window.settings.getThemeSettings() } export function applyThemeSettings() { loadTheme(window.settings.shouldUseDarkColors() ? darkTheme : lightTheme) } window.settings.addThemeUpdateListener(shouldDark => { loadTheme(shouldDark ? darkTheme : lightTheme) }) export function getCurrentLocale() { let locale = window.settings.getCurrentLocale() if (locale in locales) return locale locale = locale.split("-")[0] return locale in locales ? locale : "en-US" } export async function exportAll() { const filters = [{ name: intl.get("app.frData"), extensions: ["frdata"] }] const write = await window.utils.showSaveDialog( filters, "*/Fluent_Reader_Backup.frdata" ) if (write) { let output = window.settings.getAll() output["lovefield"] = { sources: await db.sourcesDB.select().from(db.sources).exec(), items: await db.itemsDB.select().from(db.items).exec(), } write(JSON.stringify(output), intl.get("settings.writeError")) } } export async function importAll() { const filters = [{ name: intl.get("app.frData"), extensions: ["frdata"] }] let data = await window.utils.showOpenDialog(filters) if (!data) return true let confirmed = await window.utils.showMessageBox( intl.get("app.restore"), intl.get("app.confirmImport"), intl.get("confirm"), intl.get("cancel"), true, "warning" ) if (!confirmed) return true let configs = JSON.parse(data) await db.sourcesDB.delete().from(db.sources).exec() await db.itemsDB.delete().from(db.items).exec() if (configs.nedb) { let openRequest = window.indexedDB.open("NeDB") configs.useNeDB = true openRequest.onsuccess = () => { let db = openRequest.result let objectStore = db .transaction("nedbdata", "readwrite") .objectStore("nedbdata") let requests = Object.entries(configs.nedb).map(([key, value]) => { return objectStore.put(value, key) }) let promises = requests.map( req => new Promise((resolve, reject) => { req.onsuccess = () => resolve() req.onerror = () => reject() }) ) Promise.all(promises).then(() => { delete configs.nedb window.settings.setAll(configs) }) } } else { const sRows = configs.lovefield.sources.map(s => { s.lastFetched = new Date(s.lastFetched) if (!s.textDir) s.textDir = SourceTextDirection.LTR if (!s.hidden) s.hidden = false return db.sources.createRow(s) }) const iRows = configs.lovefield.items.map(i => { i.date = new Date(i.date) i.fetchedDate = new Date(i.fetchedDate) return db.items.createRow(i) }) await db.sourcesDB.insert().into(db.sources).values(sRows).exec() await db.itemsDB.insert().into(db.items).values(iRows).exec() delete configs.lovefield window.settings.setAll(configs) } return false } ================================================ FILE: src/scripts/utils.ts ================================================ import intl from "react-intl-universal" import { ThunkAction, ThunkDispatch } from "redux-thunk" import { AnyAction } from "redux" import { RootState } from "./reducer" import Parser from "rss-parser" import Url from "url" import { SearchEngines } from "../schema-types" export enum ActionStatus { Request, Success, Failure, Intermediate, } export type AppThunk = ThunkAction< ReturnType, RootState, unknown, AnyAction > export type AppDispatch = ThunkDispatch const rssParser = new Parser({ customFields: { item: [ "thumb", "image", ["content:encoded", "fullContent"], ["media:content", "mediaContent", { keepArray: true }], ], }, }) type extractGeneric = Type extends Parser ? U : never export type MyParserItem = extractGeneric & Parser.Item const CHARSET_RE = /charset=([^()<>@,;:\"/[\]?.=\s]*)/i const XML_ENCODING_RE = /^<\?xml.+encoding="(.+?)".*?\?>/i export async function decodeFetchResponse(response: Response, isHTML = false) { const buffer = await response.arrayBuffer() let ctype = response.headers.has("content-type") && response.headers.get("content-type") let charset = ctype && CHARSET_RE.test(ctype) ? CHARSET_RE.exec(ctype)[1] : undefined let content = new TextDecoder(charset).decode(buffer) if (charset === undefined) { if (isHTML) { const dom = domParser.parseFromString(content, "text/html") charset = dom .querySelector("meta[charset]") ?.getAttribute("charset") ?.toLowerCase() if (!charset) { ctype = dom .querySelector("meta[http-equiv='Content-Type']") ?.getAttribute("content") charset = ctype && CHARSET_RE.test(ctype) && CHARSET_RE.exec(ctype)[1].toLowerCase() } } else { charset = XML_ENCODING_RE.test(content) && XML_ENCODING_RE.exec(content)[1].toLowerCase() } if (charset && charset !== "utf-8" && charset !== "utf8") { content = new TextDecoder(charset).decode(buffer) } } return content } export async function parseRSS(url: string) { let result: Response try { result = await fetch(url, { credentials: "omit" }) } catch { throw new Error(intl.get("log.networkError")) } if (result && result.ok) { try { return await rssParser.parseString( await decodeFetchResponse(result) ) } catch { throw new Error(intl.get("log.parseError")) } } else { throw new Error(result.status + " " + result.statusText) } } export const domParser = new DOMParser() export async function fetchFavicon(url: string) { try { url = url.split("/").slice(0, 3).join("/") let result = await fetch(url, { credentials: "omit" }) if (result.ok) { let html = await result.text() let dom = domParser.parseFromString(html, "text/html") let links = dom.getElementsByTagName("link") for (let link of links) { let rel = link.getAttribute("rel") if ( (rel === "icon" || rel === "shortcut icon") && link.hasAttribute("href") ) { let href = link.getAttribute("href") let parsedUrl = Url.parse(url) if (href.startsWith("//")) return parsedUrl.protocol + href else if (href.startsWith("/")) return url + href else return href } } } url = url + "/favicon.ico" if (await validateFavicon(url)) { return url } else { return null } } catch { return null } } export async function validateFavicon(url: string) { let flag = false try { const result = await fetch(url, { credentials: "omit" }) if ( result.status == 200 && result.headers.has("Content-Type") && result.headers.get("Content-Type").startsWith("image") ) { flag = true } } finally { return flag } } export function htmlDecode(input: string) { var doc = domParser.parseFromString(input, "text/html") return doc.documentElement.textContent } export const urlTest = (s: string) => /https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,63}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)/gi.test( s ) export const getWindowBreakpoint = () => window.outerWidth >= 1440 export const cutText = (s: string, length: number) => { return s.length <= length ? s : s.slice(0, length) + "…" } export function getSearchEngineName(engine: SearchEngines) { switch (engine) { case SearchEngines.Google: return intl.get("searchEngine.google") case SearchEngines.Bing: return intl.get("searchEngine.bing") case SearchEngines.Baidu: return intl.get("searchEngine.baidu") case SearchEngines.DuckDuckGo: return intl.get("searchEngine.duckduckgo") } } export function webSearch(text: string, engine = SearchEngines.Google) { switch (engine) { case SearchEngines.Google: return window.utils.openExternal( "https://www.google.com/search?q=" + encodeURIComponent(text) ) case SearchEngines.Bing: return window.utils.openExternal( "https://www.bing.com/search?q=" + encodeURIComponent(text) ) case SearchEngines.Baidu: return window.utils.openExternal( "https://www.baidu.com/s?wd=" + encodeURIComponent(text) ) case SearchEngines.DuckDuckGo: return window.utils.openExternal( "https://duckduckgo.com/?q=" + encodeURIComponent(text) ) } } export function mergeSortedArrays( a: T[], b: T[], cmp: (x: T, y: T) => number ): T[] { let merged = new Array() let i = 0 let j = 0 while (i < a.length && j < b.length) { if (cmp(a[i], b[j]) <= 0) { merged.push(a[i++]) } else { merged.push(b[j++]) } } while (i < a.length) merged.push(a[i++]) while (j < b.length) merged.push(b[j++]) return merged } export function byteToMB(B: number) { let MB = Math.round(B / 1048576) return MB + "MB" } function byteLength(str: string) { var s = str.length for (var i = str.length - 1; i >= 0; i--) { var code = str.charCodeAt(i) if (code > 0x7f && code <= 0x7ff) s++ else if (code > 0x7ff && code <= 0xffff) s += 2 if (code >= 0xdc00 && code <= 0xdfff) i-- //trail surrogate } return s } export function calculateItemSize(): Promise { return new Promise((resolve, reject) => { let result = 0 let openRequest = window.indexedDB.open("itemsDB") openRequest.onsuccess = () => { let db = openRequest.result let objectStore = db.transaction("items").objectStore("items") let cursorRequest = objectStore.openCursor() cursorRequest.onsuccess = () => { let cursor = cursorRequest.result if (cursor) { result += byteLength(JSON.stringify(cursor.value)) cursor.continue() } else { resolve(result) } } cursorRequest.onerror = () => reject() } openRequest.onerror = () => reject() }) } export function validateRegex(regex: string, flags = ""): RegExp { try { return new RegExp(regex, flags) } catch { return null } } export function platformCtrl( e: React.MouseEvent | React.KeyboardEvent | MouseEvent | KeyboardEvent ) { return window.utils.platform === "darwin" ? e.metaKey : e.ctrlKey } export function initTouchBarWithTexts() { window.utils.initTouchBar({ menu: intl.get("nav.menu"), search: intl.get("search"), refresh: intl.get("nav.refresh"), markAll: intl.get("nav.markAllRead"), notifications: intl.get("nav.notifications"), }) } ================================================ FILE: tsconfig.json ================================================ { "compilerOptions": { "jsx": "react", "resolveJsonModule": true, "esModuleInterop": true, "target": "ES2019", "module": "CommonJS" } } ================================================ FILE: webpack.config.js ================================================ const HtmlWebpackPlugin = require("html-webpack-plugin") const NodePolyfillPlugin = require("node-polyfill-webpack-plugin") module.exports = [ { mode: "production", entry: "./src/electron.ts", target: "electron-main", module: { rules: [ { test: /\.ts$/, include: /src/, resolve: { extensions: [".ts", ".js"], }, use: [{ loader: "ts-loader" }], }, ], }, output: { devtoolModuleFilenameTemplate: "[absolute-resource-path]", path: __dirname + "/dist", filename: "electron.js", }, node: { __dirname: false, }, }, { mode: "production", entry: "./src/preload.ts", target: "electron-preload", module: { rules: [ { test: /\.ts$/, include: /src/, resolve: { extensions: [".ts", ".js"], }, use: [{ loader: "ts-loader" }], }, ], }, output: { path: __dirname + "/dist", filename: "preload.js", }, }, { mode: "production", entry: "./src/index.tsx", target: "web", devtool: "source-map", performance: { hints: false, }, module: { rules: [ { test: /\.ts(x?)$/, include: /src/, resolve: { extensions: [".ts", ".tsx", ".js"], }, use: [{ loader: "ts-loader" }], }, ], }, output: { path: __dirname + "/dist", filename: "index.js", }, plugins: [ new NodePolyfillPlugin(), new HtmlWebpackPlugin({ template: "./src/index.html", }), ], }, ]