mirror of
https://github.com/yang991178/fluent-reader.git
synced 2025-02-27 08:57:44 +01:00
commit
114d1802a3
@ -25,6 +25,7 @@ If you are using Linux or an older version of Windows, you can [get Fluent Reade
|
||||
</p>
|
||||
|
||||
- A modern UI inspired by Fluent Design System with full dark mode support.
|
||||
- Read locally or sync with self-hosted services through Fever API.
|
||||
- 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.
|
||||
@ -33,7 +34,7 @@ If you are using Linux or an older version of Windows, you can [get Fluent Reade
|
||||
- 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 RSS services including Fever API, Inoreader, or Feedly are being fundraised through [Open Collective](https://opencollective.com/fluent-reader).
|
||||
Support for other RSS services including Inoreader and Feedly are being fundraised through [Open Collective](https://opencollective.com/fluent-reader).
|
||||
|
||||
## Development
|
||||
|
||||
@ -44,7 +45,7 @@ Help make Fluent Reader better by reporting bugs or opening feature requests thr
|
||||
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 enjoyed using the app, consider supporting its development by donating through [Open Collective](https://opencollective.com/fluent-reader) or [Paypal](https://www.paypal.me/yang991178).
|
||||
If you enjoy using this app, consider supporting its development by donating through [Open Collective](https://opencollective.com/fluent-reader), [Paypal](https://www.paypal.me/yang991178), or [Alipay](https://hyliu.me/fluent-reader/imgs/alipay.jpg).
|
||||
|
||||
### Build from source
|
||||
```bash
|
||||
|
4
dist/article/article.html
vendored
4
dist/article/article.html
vendored
@ -3,13 +3,13 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta http-equiv="Content-Security-Policy"
|
||||
content="default-src 'none'; script-src-elem 'sha256-iiXdy1GW3jlpYWJzQD+GvmCWg6e+JhQDFjBmllOw8JM='; img-src http://* https://*; style-src 'self' 'unsafe-inline'; frame-src http://* https://*; media-src http://* https://*">
|
||||
content="default-src 'none'; script-src-elem 'sha256-Y47O8EyR7IULmMXvvGsrM43xajwkPmTKvC8AhLDvg/o='; img-src http://* https://*; style-src 'self' 'unsafe-inline'; frame-src http://* https://*; media-src http://* https://*">
|
||||
<title>Article</title>
|
||||
<link rel="stylesheet" href="article.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="main"></div>
|
||||
<script integrity="sha256-iiXdy1GW3jlpYWJzQD+GvmCWg6e+JhQDFjBmllOw8JM=" src="article.js"></script>
|
||||
<script integrity="sha256-Y47O8EyR7IULmMXvvGsrM43xajwkPmTKvC8AhLDvg/o=" src="article.js"></script>
|
||||
<!-- Run "cat article.js | openssl dgst -sha256 -binary | openssl enc -base64 -A" for hash -->
|
||||
</body>
|
||||
</html>
|
4
dist/article/article.js
vendored
4
dist/article/article.js
vendored
@ -9,8 +9,8 @@ let dom = domParser.parseFromString(html, "text/html")
|
||||
let baseEl = dom.createElement('base')
|
||||
baseEl.setAttribute('href', get("u").split("/").slice(0, 3).join("/"))
|
||||
dom.head.append(baseEl)
|
||||
for (let i of dom.querySelectorAll("img")) {
|
||||
i.src = i.src
|
||||
for (let e of dom.querySelectorAll("*[src]")) {
|
||||
e.src = e.src
|
||||
}
|
||||
for (let s of dom.querySelectorAll("script")) {
|
||||
s.parentNode.removeChild(s)
|
||||
|
11
dist/styles/global.css
vendored
11
dist/styles/global.css
vendored
@ -109,6 +109,17 @@ i.ms-Nav-chevron {
|
||||
.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);
|
||||
}
|
||||
|
||||
#root > nav {
|
||||
height: var(--navHeight);
|
||||
|
11
dist/styles/main.css
vendored
11
dist/styles/main.css
vendored
@ -79,7 +79,7 @@ div[role="toolbar"] {
|
||||
height: 100%;
|
||||
}
|
||||
div[role="tabpanel"] {
|
||||
height: calc(100% - 44px);
|
||||
height: calc(100% - 68px);
|
||||
padding: 12px 16px;
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
@ -96,9 +96,6 @@ div[role="tabpanel"] {
|
||||
.settings .loading .ms-Spinner {
|
||||
margin-top: 180px;
|
||||
}
|
||||
.tab-body {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.tab-body .ms-StackItem {
|
||||
margin-right: 6px;
|
||||
margin-bottom: 12px;
|
||||
@ -154,6 +151,12 @@ img.favicon.dropdown {
|
||||
.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));
|
||||
|
14
package-lock.json
generated
14
package-lock.json
generated
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "fluent-reader",
|
||||
"version": "0.6.2",
|
||||
"version": "0.7.0",
|
||||
"lockfileVersion": 1,
|
||||
"requires": true,
|
||||
"dependencies": {
|
||||
@ -2614,9 +2614,9 @@
|
||||
}
|
||||
},
|
||||
"elliptic": {
|
||||
"version": "6.5.2",
|
||||
"resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.5.2.tgz",
|
||||
"integrity": "sha512-f4x70okzZbIQl/NSRLkI/+tteV/9WqL98zx+SQ69KbXxmVrmjwsNUPn/gYJJ0sHvEak24cZgHIPegRePAtA/xw==",
|
||||
"version": "6.5.3",
|
||||
"resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.5.3.tgz",
|
||||
"integrity": "sha512-IMqzv5wNQf+E6aHeIqATs0tOLeOTwj1QKbRcS3jBbYkl5oLAserA8yJTT7/VyHUYG91PRmPyeQDObKLPpeS4dw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"bn.js": "^4.4.0",
|
||||
@ -4058,6 +4058,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"js-md5": {
|
||||
"version": "0.7.3",
|
||||
"resolved": "https://registry.npmjs.org/js-md5/-/js-md5-0.7.3.tgz",
|
||||
"integrity": "sha512-ZC41vPSTLKGwIRjqDh8DfXoCrdQIyBgspJVPXHBGu4nZlAEvG3nf+jO9avM9RmLiGakg7vz974ms99nEV0tmTQ==",
|
||||
"dev": true
|
||||
},
|
||||
"js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
|
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "fluent-reader",
|
||||
"version": "0.6.3",
|
||||
"description": "A simplistic, modern desktop RSS reader",
|
||||
"version": "0.7.0",
|
||||
"description": "Modern desktop RSS reader",
|
||||
"main": "./dist/electron.js",
|
||||
"scripts": {
|
||||
"build": "webpack --config ./webpack.config.js",
|
||||
@ -96,6 +96,7 @@
|
||||
"electron-window-state": "^5.0.3",
|
||||
"hard-source-webpack-plugin": "^0.13.1",
|
||||
"html-webpack-plugin": "^4.3.0",
|
||||
"js-md5": "^0.7.3",
|
||||
"nedb": "^1.8.0",
|
||||
"qrcode.react": "^1.0.0",
|
||||
"react": "^16.13.1",
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { SourceGroup, ViewType, ThemeSettings, SearchEngines } from "../schema-types"
|
||||
import { SourceGroup, ViewType, ThemeSettings, SearchEngines, ServiceConfigs } from "../schema-types"
|
||||
import { ipcRenderer } from "electron"
|
||||
|
||||
const settingsBridge = {
|
||||
@ -82,6 +82,13 @@ const settingsBridge = {
|
||||
ipcRenderer.invoke("set-search-engine", engine)
|
||||
},
|
||||
|
||||
getServiceConfigs: (): ServiceConfigs => {
|
||||
return ipcRenderer.sendSync("get-service-configs")
|
||||
},
|
||||
setServiceConfigs: (configs: ServiceConfigs) => {
|
||||
ipcRenderer.invoke("set-service-configs", configs)
|
||||
},
|
||||
|
||||
getAll: () => {
|
||||
return ipcRenderer.sendSync("get-all-settings") as Object
|
||||
},
|
||||
|
@ -5,6 +5,7 @@ import { RSSItem } from "../scripts/models/item"
|
||||
import { Stack, CommandBarButton, IContextualMenuProps, FocusZone, ContextualMenuItemType, Spinner, Icon, Link } from "@fluentui/react"
|
||||
import { RSSSource, SourceOpenTarget } from "../scripts/models/source"
|
||||
import { shareSubmenu } from "./context-menu"
|
||||
import { platformCtrl } from "../scripts/utils"
|
||||
|
||||
const FONT_SIZE_OPTIONS = [12, 13, 14, 15, 16, 17, 18, 19, 20]
|
||||
|
||||
@ -12,7 +13,7 @@ type ArticleProps = {
|
||||
item: RSSItem
|
||||
source: RSSSource
|
||||
locale: string
|
||||
shortcuts: (item: RSSItem, key: string) => void
|
||||
shortcuts: (item: RSSItem, e: KeyboardEvent) => void
|
||||
dismiss: () => void
|
||||
offsetItem: (offset: number) => void
|
||||
toggleHasRead: (item: RSSItem) => void
|
||||
@ -72,7 +73,7 @@ class Article extends React.Component<ArticleProps, ArticleState> {
|
||||
key: "openInBrowser",
|
||||
text: intl.get("openExternal"),
|
||||
iconProps: { iconName: "NavigateExternalInline" },
|
||||
onClick: e => { window.utils.openExternal(this.props.item.link, window.utils.platform === "darwin" ? e.metaKey : e.ctrlKey) }
|
||||
onClick: e => { window.utils.openExternal(this.props.item.link, platformCtrl(e)) }
|
||||
},
|
||||
{
|
||||
key: "copyURL",
|
||||
@ -117,7 +118,6 @@ class Article extends React.Component<ArticleProps, ArticleState> {
|
||||
this.toggleWebpage()
|
||||
break
|
||||
default:
|
||||
this.props.shortcuts(this.props.item, input.key)
|
||||
const keyboardEvent = new KeyboardEvent("keydown", {
|
||||
code: input.code,
|
||||
key: input.key,
|
||||
@ -128,6 +128,7 @@ class Article extends React.Component<ArticleProps, ArticleState> {
|
||||
repeat: input.isAutoRepeat,
|
||||
bubbles: true
|
||||
})
|
||||
this.props.shortcuts(this.props.item, keyboardEvent)
|
||||
document.dispatchEvent(keyboardEvent)
|
||||
break
|
||||
}
|
||||
|
@ -1,6 +1,7 @@
|
||||
import * as React from "react"
|
||||
import { RSSSource, SourceOpenTarget } from "../../scripts/models/source"
|
||||
import { RSSItem } from "../../scripts/models/item"
|
||||
import { platformCtrl } from "../../scripts/utils"
|
||||
|
||||
export namespace Card {
|
||||
export type Props = {
|
||||
@ -8,7 +9,7 @@ export namespace Card {
|
||||
item: RSSItem
|
||||
source: RSSSource
|
||||
keyword: string
|
||||
shortcuts: (item: RSSItem, key: string) => void
|
||||
shortcuts: (item: RSSItem, e: KeyboardEvent) => void
|
||||
markRead: (item: RSSItem) => void
|
||||
contextMenu: (feedId: string, item: RSSItem, e) => void
|
||||
showItem: (fid: string, item: RSSItem) => void
|
||||
@ -16,7 +17,7 @@ export namespace Card {
|
||||
|
||||
const openInBrowser = (props: Props, e: React.MouseEvent) => {
|
||||
props.markRead(props.item)
|
||||
window.utils.openExternal(props.item.link, window.utils.platform === "darwin" ? e.metaKey : e.ctrlKey)
|
||||
window.utils.openExternal(props.item.link, platformCtrl(e))
|
||||
}
|
||||
|
||||
export const bindEventsToProps = (props: Props) => ({
|
||||
@ -55,6 +56,6 @@ export namespace Card {
|
||||
}
|
||||
|
||||
const onKeyDown = (props: Props, e: React.KeyboardEvent) => {
|
||||
props.shortcuts(props.item, e.key)
|
||||
props.shortcuts(props.item, e.nativeEvent)
|
||||
}
|
||||
}
|
@ -1,7 +1,7 @@
|
||||
import * as React from "react"
|
||||
import intl from "react-intl-universal"
|
||||
import QRCode from "qrcode.react"
|
||||
import { cutText, webSearch, getSearchEngineName } from "../scripts/utils"
|
||||
import { cutText, webSearch, getSearchEngineName, platformCtrl } from "../scripts/utils"
|
||||
import { ContextualMenu, IContextualMenuItem, ContextualMenuItemType, DirectionalHint } from "office-ui-fabric-react/lib/ContextualMenu"
|
||||
import { ContextMenuType } from "../scripts/models/app"
|
||||
import { RSSItem } from "../scripts/models/item"
|
||||
@ -77,7 +77,7 @@ export class ContextMenu extends React.Component<ContextMenuProps> {
|
||||
iconProps: { iconName: "NavigateExternalInline" },
|
||||
onClick: (e) => {
|
||||
this.props.markRead(this.props.item)
|
||||
window.utils.openExternal(this.props.item.link, window.utils.platform === "darwin" ? e.metaKey : e.ctrlKey)
|
||||
window.utils.openExternal(this.props.item.link, platformCtrl(e))
|
||||
}
|
||||
},
|
||||
{
|
||||
@ -158,7 +158,7 @@ export class ContextMenu extends React.Component<ContextMenuProps> {
|
||||
text: intl.get("openExternal"),
|
||||
iconProps: { iconName: "NavigateExternalInline" },
|
||||
onClick: (e) => {
|
||||
if (window.utils.platform === "darwin" ? e.metaKey : e.ctrlKey) {
|
||||
if (platformCtrl(e)) {
|
||||
window.utils.imageCallback(ImageCallbackTypes.OpenExternalBg)
|
||||
} else {
|
||||
window.utils.imageCallback(ImageCallbackTypes.OpenExternal)
|
||||
|
@ -12,7 +12,7 @@ export type FeedProps = FeedReduxProps & {
|
||||
items: RSSItem[]
|
||||
sourceMap: Object
|
||||
keyword: string
|
||||
shortcuts: (item: RSSItem, key: string) => void
|
||||
shortcuts: (item: RSSItem, e: KeyboardEvent) => void
|
||||
markRead: (item: RSSItem) => void
|
||||
contextMenu: (feedId: string, item: RSSItem, e) => void
|
||||
loadMore: (feed: RSSFeed) => void
|
||||
|
@ -82,7 +82,8 @@ class Nav extends React.Component<NavProps, NavState> {
|
||||
window.utils.closeWindow()
|
||||
}
|
||||
|
||||
canFetch = () => this.props.state.sourceInit && this.props.state.feedInit && !this.props.state.fetchingItems
|
||||
canFetch = () => this.props.state.sourceInit && this.props.state.feedInit
|
||||
&& !this.props.state.syncing && !this.props.state.fetchingItems
|
||||
fetching = () => !this.canFetch() ? " fetching" : ""
|
||||
menuOn = () => this.props.state.menu ? " menu-on" : ""
|
||||
itemOn = () => this.props.itemShown ? " item-on" : ""
|
||||
|
@ -8,6 +8,7 @@ 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"
|
||||
|
||||
type SettingsProps = {
|
||||
display: boolean,
|
||||
@ -42,6 +43,9 @@ class Settings extends React.Component<SettingsProps> {
|
||||
<PivotItem headerText={intl.get("settings.rules")} itemIcon="FilterSettings">
|
||||
<RulesTabContainer />
|
||||
</PivotItem>
|
||||
<PivotItem headerText={intl.get("settings.service")} itemIcon="CloudImportExport">
|
||||
<ServiceTabContainer />
|
||||
</PivotItem>
|
||||
<PivotItem headerText={intl.get("settings.app")} itemIcon="Settings">
|
||||
<AppTabContainer />
|
||||
</PivotItem>
|
||||
|
@ -3,12 +3,13 @@ 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, IDragDropContext } from "@fluentui/react"
|
||||
TextField, PrimaryButton, DefaultButton, Dropdown, IDropdownOption, CommandBarButton, MarqueeSelection, IDragDropEvents, MessageBar, MessageBarType } 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,
|
||||
@ -217,10 +218,23 @@ class GroupsTab extends React.Component<GroupsTabProps, GroupsTabState> {
|
||||
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 (trimmed.length > 0) this.props.createGroup(trimmed)
|
||||
if (this.validateNewGroupName(trimmed) === "") this.props.createGroup(trimmed)
|
||||
}
|
||||
|
||||
addToGroup = () => {
|
||||
@ -250,6 +264,9 @@ class GroupsTab extends React.Component<GroupsTabProps, GroupsTabState> {
|
||||
|
||||
render = () => (
|
||||
<div className="tab-body">
|
||||
{this.props.serviceOn && (
|
||||
<MessageBar messageBarType={MessageBarType.info}>{intl.get("service.groupsWarning")}</MessageBar>
|
||||
)}
|
||||
{this.state.manageGroup && this.state.selectedGroup &&
|
||||
<>
|
||||
<Stack horizontal horizontalAlign="space-between" style={{height: 40}}>
|
||||
@ -283,7 +300,7 @@ class GroupsTab extends React.Component<GroupsTabProps, GroupsTabState> {
|
||||
<Stack horizontal>
|
||||
<Stack.Item grow>
|
||||
<TextField
|
||||
onGetErrorMessage={v => v.trim().length == 0 ? intl.get("emptyName") : ""}
|
||||
onGetErrorMessage={this.validateNewGroupName}
|
||||
validateOnLoad={false}
|
||||
placeholder={intl.get("groups.enterName")}
|
||||
value={this.state.newGroupName}
|
||||
@ -293,7 +310,7 @@ class GroupsTab extends React.Component<GroupsTabProps, GroupsTabState> {
|
||||
</Stack.Item>
|
||||
<Stack.Item>
|
||||
<PrimaryButton
|
||||
disabled={this.state.newGroupName.trim().length == 0}
|
||||
disabled={this.validateNewGroupName(this.state.newGroupName) !== ""}
|
||||
type="sumbit"
|
||||
text={intl.get("create")} />
|
||||
</Stack.Item>
|
||||
|
78
src/components/settings/service.tsx
Normal file
78
src/components/settings/service.tsx
Normal file
@ -0,0 +1,78 @@
|
||||
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"
|
||||
|
||||
type ServiceTabProps = {
|
||||
configs: ServiceConfigs
|
||||
save: (configs: ServiceConfigs) => void
|
||||
sync: () => Promise<void>
|
||||
remove: () => Promise<void>
|
||||
blockActions: () => void
|
||||
authenticate: (configs: ServiceConfigs) => Promise<boolean>
|
||||
}
|
||||
|
||||
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: -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 })
|
||||
}
|
||||
|
||||
render = () => (
|
||||
<div className="tab-body">
|
||||
{this.state.type === SyncService.None
|
||||
? (
|
||||
<Stack horizontalAlign="center" style={{marginTop: 64}}>
|
||||
<Stack className="settings-rules-icons" horizontal tokens={{childrenGap: 12}}>
|
||||
<Icon iconName="ThisPC" />
|
||||
<Icon iconName="Sync" />
|
||||
<Icon iconName="Cloud" />
|
||||
</Stack>
|
||||
<span className="settings-hint">
|
||||
{intl.get("service.intro")}
|
||||
<Link
|
||||
onClick={() => window.utils.openExternal("https://github.com/yang991178/fluent-reader/wiki/Support#services")}
|
||||
style={{marginLeft: 6}}>
|
||||
{intl.get("rules.help")}
|
||||
</Link>
|
||||
</span>
|
||||
<Dropdown
|
||||
placeHolder={intl.get("service.select")}
|
||||
options={this.serviceOptions()}
|
||||
selectedKey={null}
|
||||
onChange={this.onServiceOptionChange}
|
||||
style={{marginTop: 32, width: 180}} />
|
||||
</Stack>
|
||||
)
|
||||
: <FeverConfigsTab {...this.props} exit={this.exitConfigsTab} />}
|
||||
</div>
|
||||
)
|
||||
}
|
183
src/components/settings/services/fever.tsx
Normal file
183
src/components/settings/services/fever.tsx
Normal file
@ -0,0 +1,183 @@
|
||||
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 { exists } from "fs"
|
||||
|
||||
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 }) },
|
||||
]
|
||||
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 && (
|
||||
<MessageBar messageBarType={MessageBarType.warning}>{intl.get("service.overwriteWarning")}</MessageBar>
|
||||
)}
|
||||
{!this.state.existing && this.state.importGroups && (
|
||||
<MessageBar messageBarType={MessageBarType.info}>{intl.get("service.groupsWarning")}</MessageBar>
|
||||
)}
|
||||
<Stack horizontalAlign="center" style={{marginTop: 48}}>
|
||||
<Icon iconName="Calories" style={{color: "var(--black)", fontSize: 32, userSelect: "none"}} />
|
||||
<Label style={{margin: "8px 0 36px"}}>Fever API</Label>
|
||||
<Stack className="login-form" horizontal>
|
||||
<Stack.Item>
|
||||
<Label>{intl.get("service.endpoint")}</Label>
|
||||
</Stack.Item>
|
||||
<Stack.Item grow>
|
||||
<TextField
|
||||
onGetErrorMessage={v => urlTest(v.trim()) ? "" : intl.get("sources.badUrl")}
|
||||
validateOnLoad={false}
|
||||
name="endpoint"
|
||||
value={this.state.endpoint}
|
||||
onChange={this.handleInputChange} />
|
||||
</Stack.Item>
|
||||
</Stack>
|
||||
<Stack className="login-form" horizontal>
|
||||
<Stack.Item>
|
||||
<Label>{intl.get("service.username")}</Label>
|
||||
</Stack.Item>
|
||||
<Stack.Item grow>
|
||||
<TextField
|
||||
disabled={this.state.existing}
|
||||
onGetErrorMessage={this.checkNotEmpty}
|
||||
validateOnLoad={false}
|
||||
name="username"
|
||||
value={this.state.username}
|
||||
onChange={this.handleInputChange} />
|
||||
</Stack.Item>
|
||||
</Stack>
|
||||
<Stack className="login-form" horizontal>
|
||||
<Stack.Item>
|
||||
<Label>{intl.get("service.password")}</Label>
|
||||
</Stack.Item>
|
||||
<Stack.Item grow>
|
||||
<TextField
|
||||
type="password"
|
||||
placeholder={this.state.existing ? intl.get("service.unchanged") : ""}
|
||||
onGetErrorMessage={this.checkNotEmpty}
|
||||
validateOnLoad={false}
|
||||
name="password"
|
||||
value={this.state.password}
|
||||
onChange={this.handleInputChange} />
|
||||
</Stack.Item>
|
||||
</Stack>
|
||||
<Stack className="login-form" horizontal>
|
||||
<Stack.Item>
|
||||
<Label>{intl.get("service.fetchLimit")}</Label>
|
||||
</Stack.Item>
|
||||
<Stack.Item grow>
|
||||
<Dropdown
|
||||
options={this.fetchLimitOptions()}
|
||||
selectedKey={this.state.fetchLimit}
|
||||
onChange={this.onFetchLimitOptionChange} />
|
||||
</Stack.Item>
|
||||
</Stack>
|
||||
{!this.state.existing && <Checkbox
|
||||
label={intl.get("service.importGroups")}
|
||||
checked={this.state.importGroups}
|
||||
onChange={(_, c) => this.setState({importGroups: c})} />}
|
||||
<Stack horizontal style={{marginTop: 32}}>
|
||||
<Stack.Item>
|
||||
<PrimaryButton
|
||||
disabled={!this.validateForm()}
|
||||
onClick={this.save}
|
||||
text={this.state.existing ? intl.get("edit") : intl.get("confirm")} />
|
||||
</Stack.Item>
|
||||
<Stack.Item>
|
||||
{this.state.existing
|
||||
? <DangerButton onClick={this.remove} text={intl.get("delete")} />
|
||||
: <DefaultButton onClick={this.props.exit} text={intl.get("cancel")} />
|
||||
}
|
||||
</Stack.Item>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</>
|
||||
}
|
||||
}
|
||||
|
||||
export default FeverConfigsTab
|
@ -1,13 +1,15 @@
|
||||
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 } from "@fluentui/react"
|
||||
IColumn, SelectionMode, Selection, IChoiceGroupOption, ChoiceGroup, IDropdownOption,
|
||||
Dropdown, MessageBar, MessageBarType } 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
|
||||
addSource: (url: string) => void
|
||||
updateSourceName: (source: RSSSource, name: string) => void
|
||||
updateSourceIcon: (source: RSSSource, iconUrl: string) => Promise<void>
|
||||
@ -154,6 +156,9 @@ class SourcesTab extends React.Component<SourcesTabProps, SourcesTabState> {
|
||||
|
||||
render = () => (
|
||||
<div className="tab-body">
|
||||
{this.props.serviceOn && (
|
||||
<MessageBar messageBarType={MessageBarType.info}>{intl.get("sources.serviceWarning")}</MessageBar>
|
||||
)}
|
||||
<Label>{intl.get("sources.opmlFile")}</Label>
|
||||
<Stack horizontal>
|
||||
<Stack.Item>
|
||||
@ -196,6 +201,9 @@ class SourcesTab extends React.Component<SourcesTabProps, SourcesTabState> {
|
||||
selectionMode={SelectionMode.multiple} />
|
||||
|
||||
{this.state.selectedSource && <>
|
||||
{this.state.selectedSource.serviceRef && (
|
||||
<MessageBar messageBarType={MessageBarType.info}>{intl.get("sources.serviceManaged")}</MessageBar>
|
||||
)}
|
||||
<Label>{intl.get("sources.selected")}</Label>
|
||||
<Stack horizontal>
|
||||
<Stack.Item>
|
||||
@ -251,34 +259,39 @@ class SourcesTab extends React.Component<SourcesTabProps, SourcesTabState> {
|
||||
</>}
|
||||
|
||||
</Stack>
|
||||
<Label>{intl.get("sources.fetchFrequency")}</Label>
|
||||
<Stack>
|
||||
<Stack.Item>
|
||||
<Dropdown
|
||||
options={this.fetchFrequencyOptions()}
|
||||
selectedKey={this.state.selectedSource.fetchFrequency ? String(this.state.selectedSource.fetchFrequency) : "0"}
|
||||
onChange={this.onFetchFrequencyChange}
|
||||
style={{width: 200}} />
|
||||
</Stack.Item>
|
||||
</Stack>
|
||||
{!this.state.selectedSource.serviceRef && <>
|
||||
<Label>{intl.get("sources.fetchFrequency")}</Label>
|
||||
<Stack>
|
||||
<Stack.Item>
|
||||
<Dropdown
|
||||
options={this.fetchFrequencyOptions()}
|
||||
selectedKey={this.state.selectedSource.fetchFrequency ? String(this.state.selectedSource.fetchFrequency) : "0"}
|
||||
onChange={this.onFetchFrequencyChange}
|
||||
style={{width: 200}} />
|
||||
</Stack.Item>
|
||||
</Stack>
|
||||
</>}
|
||||
<ChoiceGroup
|
||||
label={intl.get("sources.openTarget")}
|
||||
options={this.sourceOpenTargetChoices()}
|
||||
selectedKey={String(this.state.selectedSource.openTarget)}
|
||||
onChange={this.onOpenTargetChange} />
|
||||
<Stack horizontal>
|
||||
<Stack.Item>
|
||||
<DangerButton
|
||||
onClick={() => this.props.deleteSource(this.state.selectedSource)}
|
||||
key={this.state.selectedSource.sid}
|
||||
text={intl.get("sources.delete")} />
|
||||
</Stack.Item>
|
||||
<Stack.Item>
|
||||
<span className="settings-hint">{intl.get("sources.deleteWarning")}</span>
|
||||
</Stack.Item>
|
||||
</Stack>
|
||||
{!this.state.selectedSource.serviceRef && (
|
||||
<Stack horizontal>
|
||||
<Stack.Item>
|
||||
<DangerButton
|
||||
onClick={() => this.props.deleteSource(this.state.selectedSource)}
|
||||
key={this.state.selectedSource.sid}
|
||||
text={intl.get("sources.delete")} />
|
||||
</Stack.Item>
|
||||
<Stack.Item>
|
||||
<span className="settings-hint">{intl.get("sources.deleteWarning")}</span>
|
||||
</Stack.Item>
|
||||
</Stack>
|
||||
)}
|
||||
</>}
|
||||
{this.state.selectedSources && <>
|
||||
{this.state.selectedSources && (this.state.selectedSources.filter(s => s.serviceRef).length === 0
|
||||
? <>
|
||||
<Label>{intl.get("sources.selectedMulti")}</Label>
|
||||
<Stack horizontal>
|
||||
<Stack.Item>
|
||||
@ -290,7 +303,10 @@ class SourcesTab extends React.Component<SourcesTabProps, SourcesTabState> {
|
||||
<span className="settings-hint">{intl.get("sources.deleteWarning")}</span>
|
||||
</Stack.Item>
|
||||
</Stack>
|
||||
</>}
|
||||
</>
|
||||
: (
|
||||
<MessageBar messageBarType={MessageBarType.info}>{intl.get("sources.serviceManaged")}</MessageBar>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
@ -28,7 +28,7 @@ const makeMapStateToProps = () => {
|
||||
|
||||
const mapDispatchToProps = (dispatch: AppDispatch) => {
|
||||
return {
|
||||
shortcuts: (item: RSSItem, key: string) => dispatch(itemShortcuts(item, key)),
|
||||
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)),
|
||||
|
@ -33,7 +33,7 @@ const makeMapStateToProps = () => {
|
||||
}
|
||||
const mapDispatchToProps = dispatch => {
|
||||
return {
|
||||
shortcuts: (item: RSSItem, key: string) => dispatch(itemShortcuts(item, key)),
|
||||
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)),
|
||||
|
@ -10,7 +10,7 @@ const mapStateToProps = createSelector(
|
||||
[getApp],
|
||||
(app) => ({
|
||||
display: app.settings.display,
|
||||
blocked: !app.sourceInit || app.fetchingItems || app.settings.saving,
|
||||
blocked: !app.sourceInit || app.syncing || app.fetchingItems || app.settings.saving,
|
||||
exitting: app.settings.saving
|
||||
}))
|
||||
|
||||
|
@ -4,16 +4,18 @@ import { RootState } from "../../scripts/reducer"
|
||||
import GroupsTab from "../../components/settings/groups"
|
||||
import { createSourceGroup, updateSourceGroup, addSourceToGroup,
|
||||
deleteSourceGroup, removeSourceFromGroup, reorderSourceGroups } from "../../scripts/models/group"
|
||||
import { SourceGroup } from "../../schema-types"
|
||||
import { SourceGroup, SyncService } from "../../schema-types"
|
||||
|
||||
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],
|
||||
(sources, groups) => ({
|
||||
[getSources, getGroups, getServiceOn],
|
||||
(sources, groups, serviceOn) => ({
|
||||
sources: sources,
|
||||
groups: groups.map((g, i) => ({ ...g, index: i })),
|
||||
serviceOn: serviceOn,
|
||||
key: groups.length
|
||||
})
|
||||
)
|
||||
|
32
src/containers/settings/service-container.tsx
Normal file
32
src/containers/settings/service-container.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
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
|
||||
}
|
||||
})
|
||||
|
||||
const ServiceTabContainer = connect(mapStateToProps, mapDispatchToProps)(ServiceTab)
|
||||
export default ServiceTabContainer
|
@ -7,13 +7,16 @@ import { addSource, RSSSource, updateSource, deleteSource, SourceOpenTarget, del
|
||||
import { importOPML, exportOPML } from "../../scripts/models/group"
|
||||
import { AppDispatch, validateFavicon } from "../../scripts/utils"
|
||||
import { saveSettings } 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 mapStateToProps = createSelector(
|
||||
[getSources],
|
||||
(sources) => ({
|
||||
sources: sources
|
||||
[getSources, getServiceOn],
|
||||
(sources, serviceOn) => ({
|
||||
sources: sources,
|
||||
serviceOn: serviceOn
|
||||
})
|
||||
)
|
||||
|
||||
|
@ -1,5 +1,6 @@
|
||||
import Store = require("electron-store")
|
||||
import { SchemaTypes, SourceGroup, ViewType, ThemeSettings, SearchEngines } from "../schema-types"
|
||||
import { SchemaTypes, SourceGroup, ViewType, ThemeSettings, SearchEngines,
|
||||
SyncService, ServiceConfigs } from "../schema-types"
|
||||
import { ipcMain, session, nativeTheme, app } from "electron"
|
||||
import { WindowManager } from "./window"
|
||||
|
||||
@ -136,3 +137,11 @@ ipcMain.on("get-search-engine", (event) => {
|
||||
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)
|
||||
})
|
||||
|
@ -6,7 +6,7 @@ export class SourceGroup {
|
||||
index?: number // available only from groups tab container
|
||||
|
||||
constructor(sids: number[], name: string = null) {
|
||||
name = (name && name.trim()) || "订阅源组"
|
||||
name = (name && name.trim()) || "Source group"
|
||||
if (sids.length == 1) {
|
||||
this.isMultiple = false
|
||||
} else {
|
||||
@ -36,6 +36,13 @@ export const enum ImageCallbackTypes {
|
||||
OpenExternal, OpenExternalBg, SaveAs, Copy, CopyLink
|
||||
}
|
||||
|
||||
export const enum SyncService {
|
||||
None, Fever
|
||||
}
|
||||
export interface ServiceConfigs {
|
||||
type: SyncService
|
||||
}
|
||||
|
||||
export type SchemaTypes = {
|
||||
version: string
|
||||
theme: ThemeSettings
|
||||
@ -48,4 +55,5 @@ export type SchemaTypes = {
|
||||
menuOn: boolean
|
||||
fetchInterval: number
|
||||
searchEngine: SearchEngines
|
||||
serviceConfigs: ServiceConfigs
|
||||
}
|
||||
|
@ -32,7 +32,8 @@
|
||||
"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."
|
||||
"parseError": "An error has occurred when parsing the XML feed.",
|
||||
"syncFailure": "Failed to sync with service"
|
||||
},
|
||||
"nav": {
|
||||
"menu": "Menu",
|
||||
@ -101,8 +102,9 @@
|
||||
"fetching": "Updating sources, please wait …",
|
||||
"exit": "Exit settings",
|
||||
"sources": "Sources",
|
||||
"grouping": "Grouping",
|
||||
"grouping": "Groups",
|
||||
"rules": "Rules",
|
||||
"service": "Service",
|
||||
"app": "Preferences",
|
||||
"about": "About",
|
||||
"version": "Version",
|
||||
@ -111,6 +113,8 @@
|
||||
"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.",
|
||||
@ -137,6 +141,7 @@
|
||||
"selectedMulti": "Selected multiple sources"
|
||||
},
|
||||
"groups": {
|
||||
"exist": "This group already exists.",
|
||||
"type": "Type",
|
||||
"group": "Group",
|
||||
"source": "Source",
|
||||
@ -174,6 +179,22 @@
|
||||
"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 are only imported on the first sync and will not stay synced.",
|
||||
"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."
|
||||
},
|
||||
"app": {
|
||||
"cleanup": "Clean up",
|
||||
"cache": "Clear cache",
|
||||
|
@ -32,7 +32,8 @@
|
||||
"fetchFailure": "无法加载订阅源“{name}”",
|
||||
"fetchSuccess": "成功加载 {count} 篇文章",
|
||||
"networkError": "连接订阅源时出错",
|
||||
"parseError": "解析XML信息流时出错"
|
||||
"parseError": "解析XML信息流时出错",
|
||||
"syncFailure": "无法与服务同步"
|
||||
},
|
||||
"nav": {
|
||||
"menu": "菜单",
|
||||
@ -101,6 +102,7 @@
|
||||
"sources": "订阅源",
|
||||
"grouping": "分组与排序",
|
||||
"rules": "规则",
|
||||
"service": "服务",
|
||||
"app": "应用偏好",
|
||||
"about": "关于",
|
||||
"version": "版本",
|
||||
@ -109,6 +111,8 @@
|
||||
"feedback": "反馈"
|
||||
},
|
||||
"sources": {
|
||||
"serviceWarning": "此处导入或添加的订阅源将不会与服务端同步",
|
||||
"serviceManaged": "该订阅源由服务端管理",
|
||||
"untitled": "订阅源",
|
||||
"errorAdd": "添加订阅源时出错",
|
||||
"errorParse": "解析OPML文件时出错",
|
||||
@ -135,6 +139,7 @@
|
||||
"selectedMulti": "选中多个订阅源"
|
||||
},
|
||||
"groups": {
|
||||
"exist": "该分组已存在",
|
||||
"type": "类型",
|
||||
"group": "分组",
|
||||
"source": "订阅源",
|
||||
@ -172,6 +177,22 @@
|
||||
"hint": "规则将按顺序执行,拖拽以排序",
|
||||
"test": "测试规则"
|
||||
},
|
||||
"service": {
|
||||
"intro": "通过 RSS 服务跨设备保持同步",
|
||||
"select": "选择服务",
|
||||
"suggest": "建议一项新服务",
|
||||
"overwriteWarning": "若本地与服务端存在URL相同的订阅源,则本地订阅源将被删除",
|
||||
"groupsWarning": "分组仅在第一次同步时导入而不会与服务端保持同步",
|
||||
"endpoint": "端点",
|
||||
"username": "用户名",
|
||||
"password": "密码",
|
||||
"unchanged": "未更改",
|
||||
"fetchLimit": "同步数量",
|
||||
"fetchLimitNum": "最近 {count} 篇文章",
|
||||
"importGroups": "导入分组",
|
||||
"failure": "连接到服务时出错",
|
||||
"failureHint": "请检查服务配置或网络连接"
|
||||
},
|
||||
"app": {
|
||||
"cleanup": "清理",
|
||||
"cache": "清空缓存",
|
||||
|
@ -8,6 +8,7 @@ import { PageActionTypes, SELECT_PAGE, PageType, selectAllArticles, showItemFrom
|
||||
import { getCurrentLocale } from "../settings"
|
||||
import locales from "../i18n/_locales"
|
||||
import * as db from "../db"
|
||||
import { SYNC_SERVICE, ServiceActionTypes } from "./service"
|
||||
|
||||
export const enum ContextMenuType {
|
||||
Hidden, Item, Text, View, Group, Image
|
||||
@ -37,6 +38,7 @@ export class AppState {
|
||||
locale = null as string
|
||||
sourceInit = false
|
||||
feedInit = false
|
||||
syncing = false
|
||||
fetchingItems = false
|
||||
fetchingProgress = 0
|
||||
fetchingTotal = 0
|
||||
@ -292,6 +294,7 @@ 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 {
|
||||
@ -347,6 +350,30 @@ export function appReducer(
|
||||
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 {
|
||||
@ -452,6 +479,8 @@ export function appReducer(
|
||||
...state,
|
||||
settings: {
|
||||
...state.settings,
|
||||
display: true,
|
||||
changed: true,
|
||||
saving: !state.settings.saving
|
||||
}
|
||||
}
|
||||
|
@ -64,9 +64,16 @@ export function createSourceGroupDone(group: SourceGroup): SourceGroupActionType
|
||||
|
||||
export function createSourceGroup(name: string): AppThunk<number> {
|
||||
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))
|
||||
let groups = getState().groups
|
||||
groups = getState().groups
|
||||
window.settings.saveGroups(groups)
|
||||
return groups.length - 1
|
||||
}
|
||||
@ -200,7 +207,7 @@ export function importOPML(): AppThunk {
|
||||
dispatch(fetchItemsRequest(sources.length))
|
||||
let promises = sources.map(([s, gid, url]) => {
|
||||
return dispatch(s).then(sid => {
|
||||
if (sid !== null) dispatch(addSourceToGroup(gid, sid))
|
||||
if (sid !== null && gid > -1) dispatch(addSourceToGroup(gid, sid))
|
||||
}).catch(err => {
|
||||
errors.push([url, err])
|
||||
}).finally(() => {
|
||||
@ -286,10 +293,12 @@ export function groupReducer(
|
||||
})).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) => i == action.groupIndex ? ({
|
||||
case ADD_SOURCE_TO_GROUP: return state.map((g, i) => ({
|
||||
...g,
|
||||
sids: [ ...g.sids, action.sid ]
|
||||
}) : g).filter(g => g.isMultiple || !g.sids.includes(action.sid))
|
||||
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),
|
||||
{
|
||||
|
@ -1,10 +1,11 @@
|
||||
import * as db from "../db"
|
||||
import intl from "react-intl-universal"
|
||||
import { domParser, htmlDecode, ActionStatus, AppThunk } from "../utils"
|
||||
import { domParser, htmlDecode, ActionStatus, AppThunk, platformCtrl } from "../utils"
|
||||
import { RSSSource } from "./source"
|
||||
import { FeedActionTypes, INIT_FEED, LOAD_MORE, FilterType, initFeeds } from "./feed"
|
||||
import Parser from "@yang991178/rss-parser"
|
||||
import { pushNotification, setupAutoFetch } from "./app"
|
||||
import { getServiceHooks, syncWithService, ServiceActionTypes, SYNC_LOCAL_ITEMS } from "./service"
|
||||
|
||||
export class RSSItem {
|
||||
_id: string
|
||||
@ -21,6 +22,7 @@ export class RSSItem {
|
||||
starred?: true
|
||||
hidden?: true
|
||||
notify?: true
|
||||
serviceRef?: string | number
|
||||
|
||||
constructor (item: Parser.Item, source: RSSSource) {
|
||||
for (let field of ["title", "link", "creator"]) {
|
||||
@ -171,13 +173,15 @@ export function insertItems(items: RSSItem[]): Promise<RSSItem[]> {
|
||||
}
|
||||
|
||||
export function fetchItems(background = false): AppThunk<Promise<void>> {
|
||||
return (dispatch, getState) => {
|
||||
return async (dispatch, getState) => {
|
||||
let promises = new Array<Promise<RSSItem[]>>()
|
||||
if (!getState().app.fetchingItems) {
|
||||
const initState = getState()
|
||||
if (!initState.app.fetchingItems && !initState.app.syncing) {
|
||||
await dispatch(syncWithService())
|
||||
let timenow = new Date().getTime()
|
||||
let sources = <RSSSource[]>Object.values(getState().sources).filter(s => {
|
||||
let last = s.lastFetched ? s.lastFetched.getTime() : 0
|
||||
return (last > timenow) || (last + (s.fetchFrequency || 0) * 60000 <= timenow)
|
||||
return !s.serviceRef && ((last > timenow) || (last + (s.fetchFrequency || 0) * 60000 <= timenow))
|
||||
})
|
||||
for (let source of sources) {
|
||||
let promise = RSSSource.fetchItems(source)
|
||||
@ -185,7 +189,8 @@ export function fetchItems(background = false): AppThunk<Promise<void>> {
|
||||
promises.push(promise)
|
||||
}
|
||||
dispatch(fetchItemsRequest(promises.length))
|
||||
return Promise.allSettled(promises).then(results => new Promise<void>((resolve, reject) => {
|
||||
const results = await Promise.allSettled(promises)
|
||||
return await new Promise<void>((resolve, reject) => {
|
||||
let items = new Array<RSSItem>()
|
||||
results.map((r, i) => {
|
||||
if (r.status === "fulfilled") items.push(...r.value)
|
||||
@ -216,9 +221,8 @@ export function fetchItems(background = false): AppThunk<Promise<void>> {
|
||||
console.log(err)
|
||||
reject(err)
|
||||
})
|
||||
}))
|
||||
})
|
||||
}
|
||||
return new Promise((resolve) => { resolve() })
|
||||
}
|
||||
}
|
||||
|
||||
@ -237,6 +241,9 @@ export function markRead(item: RSSItem): AppThunk {
|
||||
if (!item.hasRead) {
|
||||
db.idb.update({ _id: item._id }, { $set: { hasRead: true } })
|
||||
dispatch(markReadDone(item))
|
||||
if (item.serviceRef) {
|
||||
dispatch(dispatch(getServiceHooks()).markRead?.(item))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -248,6 +255,8 @@ export function markAllRead(sids: number[] = null, date: Date = null, before = t
|
||||
let feed = state.feeds[state.page.feedId]
|
||||
sids = feed.sids
|
||||
}
|
||||
const action = dispatch(getServiceHooks()).markAllRead?.(sids, date, before)
|
||||
if (action) dispatch(action)
|
||||
let query = {
|
||||
source: { $in: sids },
|
||||
hasRead: false,
|
||||
@ -295,6 +304,9 @@ export function markUnread(item: RSSItem): AppThunk {
|
||||
if (item.hasRead) {
|
||||
db.idb.update({ _id: item._id }, { $set: { hasRead: false } })
|
||||
dispatch(markUnreadDone(item))
|
||||
if (item.serviceRef) {
|
||||
dispatch(dispatch(getServiceHooks()).markUnread?.(item))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -312,6 +324,11 @@ export function toggleStarred(item: RSSItem): AppThunk {
|
||||
db.idb.update({ _id: item._id }, { $set: { starred: true } })
|
||||
}
|
||||
dispatch(toggleStarredDone(item))
|
||||
if (item.serviceRef) {
|
||||
const hooks = dispatch(getServiceHooks())
|
||||
if (item.starred) dispatch(hooks.unstar?.(item))
|
||||
else dispatch(hooks.star?.(item))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -331,21 +348,22 @@ export function toggleHidden(item: RSSItem): AppThunk {
|
||||
}
|
||||
}
|
||||
|
||||
export function itemShortcuts(item: RSSItem, key: string): AppThunk {
|
||||
export function itemShortcuts(item: RSSItem, e: KeyboardEvent): AppThunk {
|
||||
return (dispatch) => {
|
||||
switch (key) {
|
||||
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)
|
||||
window.utils.openExternal(item.link, platformCtrl(e))
|
||||
break
|
||||
case "s": case "S":
|
||||
dispatch(toggleStarred(item))
|
||||
break
|
||||
case "h": case "H":
|
||||
if (!item.hasRead) dispatch(markRead(item))
|
||||
dispatch(toggleHidden(item))
|
||||
break
|
||||
}
|
||||
@ -376,7 +394,7 @@ export function applyItemReduction(item: RSSItem, type: string) {
|
||||
|
||||
export function itemReducer(
|
||||
state: ItemState = {},
|
||||
action: ItemActionTypes | FeedActionTypes
|
||||
action: ItemActionTypes | FeedActionTypes | ServiceActionTypes
|
||||
): ItemState {
|
||||
switch (action.type) {
|
||||
case FETCH_ITEMS:
|
||||
@ -430,6 +448,24 @@ export function itemReducer(
|
||||
default: return state
|
||||
}
|
||||
}
|
||||
case SYNC_LOCAL_ITEMS: {
|
||||
const unreadSet = new Set(action.unreadIds)
|
||||
const starredSet = new Set(action.starredIds)
|
||||
let nextState = { ...state }
|
||||
for (let [id, item] of Object.entries(state)) {
|
||||
if (item.hasOwnProperty("serviceRef")) {
|
||||
const nextItem = { ...item }
|
||||
nextItem.hasRead = !unreadSet.has(nextItem.serviceRef as number)
|
||||
if (starredSet.has(item.serviceRef as number)) {
|
||||
nextItem.starred = true
|
||||
} else {
|
||||
delete nextItem.starred
|
||||
}
|
||||
nextState[id] = nextItem
|
||||
}
|
||||
}
|
||||
return nextState
|
||||
}
|
||||
default: return state
|
||||
}
|
||||
}
|
@ -86,11 +86,16 @@ export function switchView(viewType: ViewType): PageActionTypes {
|
||||
}
|
||||
}
|
||||
|
||||
export function showItem(feedId: string, item: RSSItem): PageActionTypes {
|
||||
return {
|
||||
type: SHOW_ITEM,
|
||||
feedId: feedId,
|
||||
item: item
|
||||
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: string): AppThunk {
|
||||
|
126
src/scripts/models/service.ts
Normal file
126
src/scripts/models/service.ts
Normal file
@ -0,0 +1,126 @@
|
||||
import { SyncService, ServiceConfigs } from "../../schema-types"
|
||||
import { AppThunk, ActionStatus } from "../utils"
|
||||
import { RSSItem } from "./item"
|
||||
|
||||
import { feverServiceHooks } from "./services/fever"
|
||||
import { saveSettings } from "./app"
|
||||
import { deleteSource } from "./source"
|
||||
|
||||
export interface ServiceHooks {
|
||||
authenticate?: (configs: ServiceConfigs) => Promise<boolean>
|
||||
updateSources?: () => AppThunk<Promise<void>>
|
||||
fetchItems?: (background: boolean) => AppThunk<Promise<void>>
|
||||
syncItems?: () => AppThunk<Promise<void>>
|
||||
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
|
||||
default: return {}
|
||||
}
|
||||
}
|
||||
|
||||
export function getServiceHooks(): AppThunk<ServiceHooks> {
|
||||
return (_, getState) => {
|
||||
return getServiceHooksFromType(getState().service.type)
|
||||
}
|
||||
}
|
||||
|
||||
export function syncWithService(background = false): AppThunk<Promise<void>> {
|
||||
return async (dispatch, getState) => {
|
||||
const hooks = dispatch(getServiceHooks())
|
||||
if (hooks.updateSources && hooks.fetchItems && hooks.syncItems) {
|
||||
try {
|
||||
dispatch({
|
||||
type: SYNC_SERVICE,
|
||||
status: ActionStatus.Request
|
||||
})
|
||||
await dispatch(hooks.updateSources())
|
||||
await dispatch(hooks.syncItems())
|
||||
await dispatch(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())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function removeService(): AppThunk<Promise<void>> {
|
||||
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: number[]
|
||||
starredIds: number[]
|
||||
}
|
||||
|
||||
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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function syncLocalItems(unread: number[], starred: number[]): 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
|
||||
}
|
||||
}
|
299
src/scripts/models/services/fever.ts
Normal file
299
src/scripts/models/services/fever.ts
Normal file
@ -0,0 +1,299 @@
|
||||
import intl from "react-intl-universal"
|
||||
import * as db from "../../db"
|
||||
import { ServiceHooks, saveServiceConfigs, syncLocalItems } from "../service"
|
||||
import { ServiceConfigs, SyncService } from "../../../schema-types"
|
||||
import { createSourceGroup, addSourceToGroup } from "../group"
|
||||
import { RSSSource, insertSource, addSourceSuccess, updateSource, deleteSource, updateUnreadCounts } from "../source"
|
||||
import { fetchFavicon, htmlDecode, domParser } from "../../utils"
|
||||
import { saveSettings, pushNotification } from "../app"
|
||||
import { initFeeds, FilterType } from "../feed"
|
||||
import { RSSItem, insertItems, fetchItemsSuccess } from "../item"
|
||||
import { SourceRule } from "../rule"
|
||||
|
||||
export interface FeverConfigs extends ServiceConfigs {
|
||||
type: SyncService.Fever
|
||||
endpoint: string
|
||||
username: string
|
||||
apiKey: string
|
||||
fetchLimit: number
|
||||
lastId?: number
|
||||
importGroups?: 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 initState = getState()
|
||||
const configs = initState.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<number, string>
|
||||
if (configs.importGroups) {
|
||||
// Import groups on the first sync
|
||||
const groups: any[] = (await fetchAPI(configs, "&groups")).groups
|
||||
if (groups === undefined || feedGroups === undefined) throw APIError()
|
||||
groupsMap = new Map()
|
||||
for (let group of groups) {
|
||||
dispatch(createSourceGroup(group.title))
|
||||
groupsMap.set(group.id, group.title)
|
||||
}
|
||||
}
|
||||
const existing = new Map<number, RSSSource>()
|
||||
for (let source of Object.values(initState.sources)) {
|
||||
if (source.serviceRef) {
|
||||
existing.set(source.serviceRef as number, source)
|
||||
}
|
||||
}
|
||||
const forceSettings = () => {
|
||||
if (!(getState().app.settings.saving)) dispatch(saveSettings())
|
||||
}
|
||||
let promises = feeds.map(f => new Promise<RSSSource>((resolve, reject) => {
|
||||
if (existing.has(f.id)) {
|
||||
const doc = existing.get(f.id)
|
||||
existing.delete(f.id)
|
||||
resolve(doc)
|
||||
} else {
|
||||
db.sdb.findOne({ url: f.url }, (err, doc) => {
|
||||
if (err) {
|
||||
reject(err)
|
||||
} else if (doc === null) {
|
||||
// Create a new source
|
||||
forceSettings()
|
||||
let source = new RSSSource(f.url, f.title)
|
||||
source.serviceRef = f.id
|
||||
const domain = source.url.split("/").slice(0, 3).join("/")
|
||||
fetchFavicon(domain).then(favicon => {
|
||||
if (favicon) source.iconurl = favicon
|
||||
dispatch(insertSource(source))
|
||||
.then((inserted) => {
|
||||
inserted.unreadCount = 0
|
||||
resolve(inserted)
|
||||
dispatch(addSourceSuccess(inserted, true))
|
||||
})
|
||||
.catch((err) => {
|
||||
reject(err)
|
||||
})
|
||||
})
|
||||
} else if (doc.serviceRef !== f.id) {
|
||||
// Mark an existing source as remote and remove all items
|
||||
forceSettings()
|
||||
doc.serviceRef = f.id
|
||||
doc.unreadCount = 0
|
||||
dispatch(updateSource(doc)).finally(() => {
|
||||
db.idb.remove({ source: doc.sid }, { multi: true }, (err) => {
|
||||
if (err) reject(err)
|
||||
else resolve(doc)
|
||||
})
|
||||
})
|
||||
} else {
|
||||
resolve(doc)
|
||||
}
|
||||
})
|
||||
}
|
||||
}))
|
||||
for (let [_, source] of existing) {
|
||||
// Delete sources removed from the service side
|
||||
forceSettings()
|
||||
promises.push(dispatch(deleteSource(source, true)).then(() => null))
|
||||
}
|
||||
let sources = (await Promise.all(promises)).filter(s => s)
|
||||
if (groupsMap) {
|
||||
// Add sources to imported groups
|
||||
forceSettings()
|
||||
let sourcesMap = new Map<number, number>()
|
||||
for (let source of sources) sourcesMap.set(source.serviceRef as number, source.sid)
|
||||
for (let group of feedGroups) {
|
||||
for (let fid of group.feed_ids.split(",").map(s => parseInt(s))) {
|
||||
if (sourcesMap.has(fid)) {
|
||||
const gid = dispatch(createSourceGroup(groupsMap.get(group.group_id)))
|
||||
dispatch(addSourceToGroup(gid, sourcesMap.get(fid)))
|
||||
}
|
||||
}
|
||||
}
|
||||
delete configs.importGroups
|
||||
dispatch(saveServiceConfigs(configs))
|
||||
}
|
||||
},
|
||||
|
||||
fetchItems: (background) => async (dispatch, getState) => {
|
||||
const state = getState()
|
||||
const configs = state.service as FeverConfigs
|
||||
const items = new Array()
|
||||
configs.lastId = configs.lastId || 0
|
||||
let min = 2147483647
|
||||
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))
|
||||
min = response.items.reduce((m, n) => Math.min(m, n.id), min)
|
||||
} while (min > configs.lastId && 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<number, RSSSource>()
|
||||
for (let source of Object.values(state.sources)) {
|
||||
if (source.serviceRef) {
|
||||
fidMap.set(source.serviceRef as number, source)
|
||||
}
|
||||
}
|
||||
const parsedItems = items.map(i => {
|
||||
const source = fidMap.get(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),
|
||||
serviceRef: i.id
|
||||
} as RSSItem
|
||||
if (i.is_saved) item.starred = true
|
||||
// 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 {
|
||||
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
|
||||
})
|
||||
const inserted = await insertItems(parsedItems)
|
||||
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))
|
||||
}
|
||||
},
|
||||
|
||||
syncItems: () => async (dispatch, getState) => {
|
||||
const state = getState()
|
||||
const configs = state.service as FeverConfigs
|
||||
const unreadResponse = await fetchAPI(configs, "&unread_item_ids")
|
||||
const starredResponse = await fetchAPI(configs, "&saved_item_ids")
|
||||
if (typeof unreadResponse.unread_item_ids !== "string" || typeof starredResponse.saved_item_ids !== "string") {
|
||||
throw APIError()
|
||||
}
|
||||
const unreadFids: number[] = unreadResponse.unread_item_ids.split(",").map(s => parseInt(s))
|
||||
const starredFids: number[] = starredResponse.saved_item_ids.split(",").map(s => parseInt(s))
|
||||
const promises = new Array<Promise<number>>()
|
||||
promises.push(new Promise((resolve) => {
|
||||
db.idb.update({
|
||||
serviceRef: { $exists: true, $in: unreadFids },
|
||||
hasRead: true
|
||||
}, { $set: { hasRead: false } }, { multi: true }, (_, num) => resolve(num))
|
||||
}))
|
||||
promises.push(new Promise((resolve) => {
|
||||
db.idb.update({
|
||||
serviceRef: { $exists: true, $nin: unreadFids },
|
||||
hasRead: false
|
||||
}, { $set: { hasRead: true } }, { multi: true }, (_, num) => resolve(num))
|
||||
}))
|
||||
promises.push(new Promise((resolve) => {
|
||||
db.idb.update({
|
||||
serviceRef: { $exists: true, $in: starredFids },
|
||||
starred: { $exists: false }
|
||||
}, { $set: { starred: true } }, { multi: true }, (_, num) => resolve(num))
|
||||
}))
|
||||
promises.push(new Promise((resolve) => {
|
||||
db.idb.update({
|
||||
serviceRef: { $exists: true, $nin: starredFids },
|
||||
starred: true
|
||||
}, { $unset: { starred: true } }, { multi: true }, (_, num) => resolve(num))
|
||||
}))
|
||||
const affected = (await Promise.all(promises)).reduce((a, b) => a + b, 0)
|
||||
if (affected > 0) {
|
||||
dispatch(syncLocalItems(unreadFids, starredFids))
|
||||
if (!(state.page.filter.type & FilterType.ShowRead) || !(state.page.filter.type & FilterType.ShowNotStarred)) {
|
||||
dispatch(initFeeds(true))
|
||||
}
|
||||
await dispatch(updateUnreadCounts())
|
||||
}
|
||||
},
|
||||
|
||||
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")
|
||||
},
|
||||
}
|
@ -13,11 +13,12 @@ export enum SourceOpenTarget {
|
||||
export class RSSSource {
|
||||
sid: number
|
||||
url: string
|
||||
iconurl: string
|
||||
iconurl?: string
|
||||
name: string
|
||||
openTarget: SourceOpenTarget
|
||||
unreadCount: number
|
||||
lastFetched: Date
|
||||
serviceRef?: string | number
|
||||
fetchFrequency?: number // in minutes
|
||||
rules?: SourceRule[]
|
||||
|
||||
@ -156,6 +157,14 @@ function unreadCount(source: RSSSource): Promise<RSSSource> {
|
||||
})
|
||||
}
|
||||
|
||||
export function updateUnreadCounts(): AppThunk<Promise<void>> {
|
||||
return async (dispatch, getState) => {
|
||||
await Promise.all(Object.values(getState().sources).map(async s => {
|
||||
dispatch(updateSourceDone(await unreadCount(s)))
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
export function initSources(): AppThunk<Promise<void>> {
|
||||
return (dispatch) => {
|
||||
dispatch(initSourcesRequest())
|
||||
@ -205,7 +214,7 @@ export function addSourceFailure(err, batch: boolean): SourceActionTypes {
|
||||
}
|
||||
|
||||
let insertPromises = Promise.resolve()
|
||||
function insertSource(source: RSSSource): AppThunk<Promise<RSSSource>> {
|
||||
export function insertSource(source: RSSSource): AppThunk<Promise<RSSSource>> {
|
||||
return (_, getState) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
insertPromises = insertPromises.then(() => new Promise(innerResolve => {
|
||||
@ -226,7 +235,6 @@ function insertSource(source: RSSSource): AppThunk<Promise<RSSSource>> {
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export function addSource(url: string, name: string = null, batch = false): AppThunk<Promise<number>> {
|
||||
@ -268,8 +276,8 @@ export function updateSourceDone(source: RSSSource): SourceActionTypes {
|
||||
}
|
||||
}
|
||||
|
||||
export function updateSource(source: RSSSource): AppThunk {
|
||||
return (dispatch) => {
|
||||
export function updateSource(source: RSSSource): AppThunk<Promise<void>> {
|
||||
return (dispatch) => new Promise((resolve) => {
|
||||
let sourceCopy = { ...source }
|
||||
delete sourceCopy.sid
|
||||
delete sourceCopy.unreadCount
|
||||
@ -277,8 +285,9 @@ export function updateSource(source: RSSSource): AppThunk {
|
||||
if (!err) {
|
||||
dispatch(updateSourceDone(source))
|
||||
}
|
||||
resolve()
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteSourceDone(source: RSSSource): SourceActionTypes {
|
||||
|
@ -6,6 +6,7 @@ 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"
|
||||
|
||||
export const rootReducer = combineReducers({
|
||||
sources: sourceReducer,
|
||||
@ -13,6 +14,7 @@ export const rootReducer = combineReducers({
|
||||
feeds: feedReducer,
|
||||
groups: groupReducer,
|
||||
page: pageReducer,
|
||||
service: serviceReducer,
|
||||
app: appReducer
|
||||
})
|
||||
|
||||
|
@ -189,3 +189,7 @@ export function validateRegex(regex: string, flags = ""): RegExp {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function platformCtrl(e: React.MouseEvent | React.KeyboardEvent | MouseEvent | KeyboardEvent) {
|
||||
return window.utils.platform === "darwin" ? e.metaKey : e.ctrlKey
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user