add fever api support

This commit is contained in:
刘浩远 2020-08-02 12:59:49 +08:00
parent 3a8f13f5d4
commit fc0183a80d
16 changed files with 670 additions and 56 deletions

View File

@ -154,6 +154,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));

12
package-lock.json generated
View File

@ -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",

View File

@ -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",

View File

@ -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" : ""

View File

@ -2,9 +2,19 @@ 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"
export type ServiceTabProps = {
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 = {
@ -28,10 +38,14 @@ export class ServiceTab extends React.Component<ServiceTabProps, ServiceTabState
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
@ -58,7 +72,7 @@ export class ServiceTab extends React.Component<ServiceTabProps, ServiceTabState
style={{marginTop: 32, width: 180}} />
</Stack>
)
: null}
: <FeverConfigsTab {...this.props} exit={this.exitConfigsTab} />}
</div>
)
}

View File

@ -0,0 +1,185 @@
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 <>
<Stack tokens={{childrenGap: 8}}>
{!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>
<Stack horizontalAlign="center" style={{marginTop: 48}}>
<Icon iconName="Calories" style={{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

View File

@ -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
}))

View File

@ -3,6 +3,9 @@ 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
@ -14,7 +17,15 @@ const mapStateToProps = createSelector(
)
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)

View File

@ -179,7 +179,18 @@
"service": {
"intro": "Sync across devices with RSS services.",
"select": "Select a service",
"suggest": "Suggest a new 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",

View File

@ -177,7 +177,18 @@
"service": {
"intro": "通过 RSS 服务跨设备保持同步",
"select": "选择服务",
"suggest": "建议一项新服务"
"suggest": "建议一项新服务",
"overwriteWarning": "若本地与服务端存在URL相同的订阅源则本地订阅源将被删除",
"groupsWarning": "分组仅在第一次同步时导入而不会与服务端保持同步",
"endpoint": "端点",
"username": "用户名",
"password": "密码",
"unchanged": "未更改",
"fetchLimit": "同步数量",
"fetchLimitNum": "最近 {count} 篇文章",
"importGroups": "导入分组",
"failure": "无法连接到服务",
"failureHint": "请检查服务配置或网络连接"
},
"app": {
"cleanup": "清理",

View File

@ -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,17 @@ export function appReducer(
feedInit: true
}
}
case SYNC_SERVICE:
switch (action.status) {
case ActionStatus.Request: return {
...state,
syncing: true
}
default: return {
...state,
syncing: false
}
}
case FETCH_ITEMS:
switch (action.status) {
case ActionStatus.Request: return {
@ -452,6 +466,7 @@ export function appReducer(
...state,
settings: {
...state.settings,
display: true,
saving: !state.settings.saving
}
}

View File

@ -207,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(() => {
@ -293,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),
{

View File

@ -5,7 +5,7 @@ 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 } from "./service"
import { getServiceHooks, syncWithService, ServiceActionTypes, SYNC_LOCAL_ITEMS } from "./service"
export class RSSItem {
_id: string
@ -22,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"]) {
@ -173,17 +174,14 @@ export function insertItems(items: RSSItem[]): Promise<RSSItem[]> {
export function fetchItems(background = false): AppThunk<Promise<void>> {
return async (dispatch, getState) => {
try {
await dispatch(syncWithService())
} catch (err) {
console.log(err)
}
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 !s.isRemote && ((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)
@ -239,12 +237,12 @@ const markUnreadDone = (item: RSSItem): ItemActionTypes => ({
})
export function markRead(item: RSSItem): AppThunk {
return (dispatch, getState) => {
return (dispatch) => {
if (!item.hasRead) {
db.idb.update({ _id: item._id }, { $set: { hasRead: true } })
dispatch(markReadDone(item))
if (getState().sources[item.source].isRemote) {
dispatch(getServiceHooks()).markRead?.(item)
if (item.serviceRef) {
dispatch(dispatch(getServiceHooks()).markRead?.(item))
}
}
}
@ -257,6 +255,7 @@ export function markAllRead(sids: number[] = null, date: Date = null, before = t
let feed = state.feeds[state.page.feedId]
sids = feed.sids
}
dispatch(dispatch(getServiceHooks()).markAllRead?.(sids, date, before))
let query = {
source: { $in: sids },
hasRead: false,
@ -296,17 +295,16 @@ export function markAllRead(sids: number[] = null, date: Date = null, before = t
if (date) callback(affectedDocuments as unknown as RSSItem[])
})
if (!date) callback()
dispatch(getServiceHooks()).markAllRead?.(sids, date, before)
}
}
export function markUnread(item: RSSItem): AppThunk {
return (dispatch, getState) => {
return (dispatch) => {
if (item.hasRead) {
db.idb.update({ _id: item._id }, { $set: { hasRead: false } })
dispatch(markUnreadDone(item))
if (getState().sources[item.source].isRemote) {
dispatch(getServiceHooks()).markUnread?.(item)
if (item.serviceRef) {
dispatch(dispatch(getServiceHooks()).markUnread?.(item))
}
}
}
@ -318,17 +316,17 @@ const toggleStarredDone = (item: RSSItem): ItemActionTypes => ({
})
export function toggleStarred(item: RSSItem): AppThunk {
return (dispatch, getState) => {
return (dispatch) => {
if (item.starred === true) {
db.idb.update({ _id: item._id }, { $unset: { starred: true } })
} else {
db.idb.update({ _id: item._id }, { $set: { starred: true } })
}
dispatch(toggleStarredDone(item))
if (getState().sources[item.source].isRemote) {
if (item.serviceRef) {
const hooks = dispatch(getServiceHooks())
if (item.starred) hooks.unstar?.(item)
else hooks.star?.(item)
if (item.starred) dispatch(hooks.unstar?.(item))
else dispatch(hooks.star?.(item))
}
}
}
@ -395,7 +393,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:
@ -449,6 +447,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
}
}

View File

@ -1,13 +1,15 @@
import { SyncService, ServiceConfigs } from "../../schema-types"
import { AppThunk } from "../utils"
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?: () => AppThunk<Promise<void>>
fetchItems?: (background: boolean) => AppThunk<Promise<void>>
syncItems?: () => AppThunk<Promise<void>>
markRead?: (item: RSSItem) => AppThunk
markUnread?: (item: RSSItem) => AppThunk
@ -29,25 +31,71 @@ export function getServiceHooks(): AppThunk<ServiceHooks> {
}
}
export function syncWithService(): AppThunk<Promise<void>> {
return async (dispatch) => {
export function syncWithService(background = false): AppThunk<Promise<void>> {
return async (dispatch, getState) => {
const hooks = dispatch(getServiceHooks())
if (hooks.updateSources && hooks.fetchItems && hooks.syncItems) {
await dispatch(hooks.updateSources())
await dispatch(hooks.fetchItems())
await dispatch(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
}
export type ServiceActionTypes = SaveServiceConfigsAction
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) => {
@ -59,6 +107,14 @@ export function saveServiceConfigs(configs: ServiceConfigs): AppThunk {
}
}
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

View File

@ -1,23 +1,46 @@
import { ServiceHooks } from "../service"
import { ServiceConfigs } from "../../../schema-types"
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
password: string
apiKey: string
fetchLimit: number
lastId?: number
importGroups?: boolean
}
async function fetchAPI(configs: FeverConfigs, params="") {
const response = await fetch(configs.endpoint + params, {
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}`
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 {
@ -25,5 +48,252 @@ export const feverServiceHooks: ServiceHooks = {
} 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.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")
},
}

View File

@ -13,12 +13,12 @@ export enum SourceOpenTarget {
export class RSSSource {
sid: number
url: string
iconurl: string
iconurl?: string
name: string
openTarget: SourceOpenTarget
unreadCount: number
lastFetched: Date
isRemote?: true
serviceRef?: string | number
fetchFrequency?: number // in minutes
rules?: SourceRule[]
@ -157,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())
@ -206,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 => {
@ -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 {