mirror of
https://github.com/yang991178/fluent-reader.git
synced 2025-04-01 04:10:24 +02:00
commit
b2f1a98196
9
package-lock.json
generated
9
package-lock.json
generated
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "fluent-reader",
|
||||
"version": "0.8.1",
|
||||
"version": "0.9.0",
|
||||
"lockfileVersion": 1,
|
||||
"requires": true,
|
||||
"dependencies": {
|
||||
@ -3934,10 +3934,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"ini": {
|
||||
"version": "1.3.5",
|
||||
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.5.tgz",
|
||||
"integrity": "sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw==",
|
||||
"dev": true
|
||||
"version": "1.3.8",
|
||||
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
|
||||
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="
|
||||
},
|
||||
"interpret": {
|
||||
"version": "1.2.0",
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "fluent-reader",
|
||||
"version": "0.8.1",
|
||||
"version": "0.9.0",
|
||||
"description": "Modern desktop RSS reader",
|
||||
"main": "./dist/electron.js",
|
||||
"scripts": {
|
||||
|
@ -4,6 +4,8 @@ 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"
|
||||
|
||||
type ServiceTabProps = {
|
||||
configs: ServiceConfigs
|
||||
@ -12,6 +14,7 @@ type ServiceTabProps = {
|
||||
remove: () => Promise<void>
|
||||
blockActions: () => void
|
||||
authenticate: (configs: ServiceConfigs) => Promise<boolean>
|
||||
reauthenticate: (configs: ServiceConfigs) => Promise<ServiceConfigs>
|
||||
}
|
||||
|
||||
export type ServiceConfigsTabProps = ServiceTabProps & {
|
||||
@ -33,6 +36,8 @@ export class ServiceTab extends React.Component<ServiceTabProps, ServiceTabState
|
||||
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: -1, text: intl.get("service.suggest") },
|
||||
]
|
||||
|
||||
@ -52,6 +57,8 @@ export class ServiceTab extends React.Component<ServiceTabProps, ServiceTabState
|
||||
switch (this.state.type) {
|
||||
case SyncService.Fever: return <FeverConfigsTab {...this.props} exit={this.exitConfigsTab} />
|
||||
case SyncService.Feedbin: return <FeedbinConfigsTab {...this.props} exit={this.exitConfigsTab} />
|
||||
case SyncService.GReader: return <GReaderConfigsTab {...this.props} exit={this.exitConfigsTab} />
|
||||
case SyncService.Inoreader: return <InoreaderConfigsTab {...this.props} exit={this.exitConfigsTab} />
|
||||
default: return null
|
||||
}
|
||||
}
|
||||
|
@ -7,7 +7,6 @@ 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
|
||||
|
180
src/components/settings/services/greader.tsx
Normal file
180
src/components/settings/services/greader.tsx
Normal file
@ -0,0 +1,180 @@
|
||||
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"
|
||||
|
||||
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
|
||||
} 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 && (
|
||||
<MessageBar messageBarType={MessageBarType.warning}>{intl.get("service.overwriteWarning")}</MessageBar>
|
||||
)}
|
||||
<Stack horizontalAlign="center" style={{marginTop: 48}}>
|
||||
<Icon iconName="Communications" style={{color: "var(--black)", transform: "rotate(220deg)", fontSize: 32, userSelect: "none"}} />
|
||||
<Label style={{margin: "8px 0 36px"}}>Google Reader 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 GReaderConfigsTab
|
192
src/components/settings/services/inoreader.tsx
Normal file
192
src/components/settings/services/inoreader.tsx
Normal file
@ -0,0 +1,192 @@
|
||||
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 } from "@fluentui/react"
|
||||
import DangerButton from "../../utils/danger-button"
|
||||
|
||||
type GReaderConfigsTabState = {
|
||||
existing: boolean
|
||||
endpoint: string
|
||||
username: string
|
||||
password: string
|
||||
fetchLimit: number
|
||||
importGroups: boolean
|
||||
}
|
||||
|
||||
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: "",
|
||||
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 })
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
save = async () => {
|
||||
let configs: GReaderConfigs
|
||||
if (this.state.existing) {
|
||||
configs = {
|
||||
...this.props.configs,
|
||||
endpoint: this.state.endpoint,
|
||||
fetchLimit: this.state.fetchLimit
|
||||
} as GReaderConfigs
|
||||
} else {
|
||||
configs = {
|
||||
type: SyncService.Inoreader,
|
||||
endpoint: this.state.endpoint,
|
||||
username: this.state.username,
|
||||
password: this.state.password,
|
||||
fetchLimit: this.state.fetchLimit,
|
||||
useInt64: true
|
||||
}
|
||||
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 <>
|
||||
<MessageBar messageBarType={MessageBarType.severeWarning}
|
||||
isMultiline={false}
|
||||
actions={<MessageBarButton text={intl.get("rules.help")} onClick={openSupport} />}>
|
||||
{intl.get("service.rateLimitWarning")}
|
||||
</MessageBar>
|
||||
{!this.state.existing && (
|
||||
<MessageBar messageBarType={MessageBarType.warning}>{intl.get("service.overwriteWarning")}</MessageBar>
|
||||
)}
|
||||
<Stack horizontalAlign="center" style={{marginTop: 48}}>
|
||||
<svg style={{fill: "var(--black)", width: 36, userSelect: "none"}} viewBox="0 0 72 72"><path transform="translate(-1250.000000, -1834.000000)" d="M1286,1834 C1305.88225,1834 1322,1850.11775 1322,1870 C1322,1889.88225 1305.88225,1906 1286,1906 C1266.11775,1906 1250,1889.88225 1250,1870 C1250,1850.11775 1266.11775,1834 1286,1834 Z M1278.01029,1864.98015 C1270.82534,1864.98015 1265,1870.80399 1265,1877.98875 C1265,1885.17483 1270.82534,1891 1278.01029,1891 C1285.19326,1891 1291.01859,1885.17483 1291.01859,1877.98875 C1291.01859,1870.80399 1285.19326,1864.98015 1278.01029,1864.98015 Z M1281.67908,1870.54455 C1283.73609,1870.54455 1285.40427,1872.21533 1285.40427,1874.2703 C1285.40427,1876.33124 1283.73609,1877.9987 1281.67908,1877.9987 C1279.61941,1877.9987 1277.94991,1876.33124 1277.94991,1874.2703 C1277.94991,1872.21533 1279.61941,1870.54455 1281.67908,1870.54455 Z M1278.01003,1855.78714 L1278.01003,1860.47435 C1287.66605,1860.47435 1295.52584,1868.33193 1295.52584,1877.98901 L1295.52584,1877.98901 L1300.21451,1877.98901 C1300.21451,1865.74746 1290.25391,1855.78714 1278.01003,1855.78714 L1278.01003,1855.78714 Z M1278.01009,1846 L1278.01009,1850.68721 C1285.30188,1850.68721 1292.15771,1853.5278 1297.31618,1858.68479 C1302.47398,1863.84179 1305.31067,1870.69942 1305.31067,1877.98901 L1305.31067,1877.98901 L1310,1877.98901 C1310,1869.44534 1306.67162,1861.41192 1300.6293,1855.36845 C1294.58632,1849.32696 1286.55533,1846 1278.01009,1846 L1278.01009,1846 Z"></path></svg>
|
||||
<Label style={{margin: "8px 0 36px"}}>Inoreader</Label>
|
||||
<Stack className="login-form" horizontal>
|
||||
<Stack.Item>
|
||||
<Label>{intl.get("service.endpoint")}</Label>
|
||||
</Stack.Item>
|
||||
<Stack.Item grow>
|
||||
<Dropdown
|
||||
options={endpointOptions}
|
||||
selectedKey={this.state.endpoint}
|
||||
onChange={this.onEndpointChange} />
|
||||
</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 InoreaderConfigsTab
|
@ -25,6 +25,15 @@ const mapDispatchToProps = (dispatch: AppDispatch) => ({
|
||||
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
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
@ -43,7 +43,7 @@ export const enum ImageCallbackTypes {
|
||||
}
|
||||
|
||||
export const enum SyncService {
|
||||
None, Fever, Feedbin
|
||||
None, Fever, Feedbin, GReader, Inoreader
|
||||
}
|
||||
export interface ServiceConfigs {
|
||||
type: SyncService
|
||||
|
@ -191,6 +191,7 @@
|
||||
"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": "If connection errors persist, the app may have been rate limited by the service.",
|
||||
"endpoint": "Endpoint",
|
||||
"username": "Username",
|
||||
"password": "Password",
|
||||
|
@ -189,6 +189,7 @@
|
||||
"suggest": "建议一项新服务",
|
||||
"overwriteWarning": "若本地与服务端存在URL相同的订阅源,则本地订阅源将被删除",
|
||||
"groupsWarning": "分组不会自动与服务端保持同步",
|
||||
"rateLimitWarning": "若反复出现错误,则原因可能是应用被服务限流",
|
||||
"endpoint": "端点",
|
||||
"username": "用户名",
|
||||
"password": "密码",
|
||||
|
@ -6,14 +6,15 @@ import { RSSItem, insertItems, fetchItemsSuccess } from "./item"
|
||||
import { saveSettings, pushNotification } from "./app"
|
||||
import { deleteSource, updateUnreadCounts, RSSSource, insertSource, addSourceSuccess,
|
||||
updateSource, updateFavicon } from "./source"
|
||||
import { FilterType, initFeeds } from "./feed"
|
||||
import { createSourceGroup, addSourceToGroup } from "./group"
|
||||
|
||||
import { feverServiceHooks } from "./services/fever"
|
||||
import { feedbinServiceHooks } from "./services/feedbin"
|
||||
import { gReaderServiceHooks } from "./services/greader"
|
||||
|
||||
export interface ServiceHooks {
|
||||
authenticate?: (configs: ServiceConfigs) => Promise<boolean>
|
||||
reauthenticate?: (configs: ServiceConfigs) => Promise<ServiceConfigs>
|
||||
updateSources?: () => AppThunk<Promise<[RSSSource[], Map<string, string>]>>
|
||||
fetchItems?: () => AppThunk<Promise<[RSSItem[], ServiceConfigs]>>
|
||||
syncItems?: () => AppThunk<Promise<[Set<string>, Set<string>]>>
|
||||
@ -28,6 +29,9 @@ 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
|
||||
default: return {}
|
||||
}
|
||||
}
|
||||
@ -47,6 +51,7 @@ export function syncWithService(background = false): AppThunk<Promise<void>> {
|
||||
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))
|
||||
@ -68,6 +73,16 @@ export function syncWithService(background = false): AppThunk<Promise<void>> {
|
||||
}
|
||||
}
|
||||
|
||||
function reauthenticate(hooks: ServiceHooks): AppThunk<Promise<void>> {
|
||||
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<Promise<void>> {
|
||||
return async (dispatch, getState) => {
|
||||
const [sources, groupsMap] = await dispatch(hook())
|
||||
|
283
src/scripts/models/services/greader.ts
Normal file
283
src/scripts/models/services/greader.ts
Normal file
@ -0,0 +1,283 @@
|
||||
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
|
||||
}
|
||||
|
||||
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) {
|
||||
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<Set<string>> {
|
||||
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<GReaderConfigs> => {
|
||||
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<string, string>
|
||||
if (configs.importGroups) {
|
||||
groupsMap = new Map()
|
||||
const groupSet = new Set<string>()
|
||||
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<RSSSource>()
|
||||
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<string, RSSSource>()
|
||||
for (let source of Object.values(state.sources)) {
|
||||
if (source.serviceRef) {
|
||||
fidMap.set(source.serviceRef, source)
|
||||
}
|
||||
}
|
||||
const parsedItems = new Array<RSSItem>()
|
||||
items.map(i => {
|
||||
const source = fidMap.get(i.origin.streamId)
|
||||
if (source === undefined) return
|
||||
const dom = domParser.parseFromString(i.summary.content, "text/html")
|
||||
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: i.summary.content,
|
||||
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)
|
||||
},
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user