diff --git a/dist/styles/main.css b/dist/styles/main.css index aa38e44..c338a8e 100644 --- a/dist/styles/main.css +++ b/dist/styles/main.css @@ -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)); diff --git a/package-lock.json b/package-lock.json index 7514e5b..b21a718 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 8620b3c..118326f 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/nav.tsx b/src/components/nav.tsx index 6011be8..0580822 100644 --- a/src/components/nav.tsx +++ b/src/components/nav.tsx @@ -82,7 +82,8 @@ class Nav extends React.Component { 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" : "" diff --git a/src/components/settings/service.tsx b/src/components/settings/service.tsx index 684c675..18643a7 100644 --- a/src/components/settings/service.tsx +++ b/src/components/settings/service.tsx @@ -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 + remove: () => Promise + blockActions: () => void + authenticate: (configs: ServiceConfigs) => Promise +} + +export type ServiceConfigsTabProps = ServiceTabProps & { + exit: () => void } type ServiceTabState = { @@ -28,10 +38,14 @@ export class ServiceTab extends React.Component { + this.setState({ type: SyncService.None }) + } + render = () => (
{this.state.type === SyncService.None @@ -58,7 +72,7 @@ export class ServiceTab extends React.Component ) - : null} + : }
) } \ No newline at end of file diff --git a/src/components/settings/services/fever.tsx b/src/components/settings/services/fever.tsx new file mode 100644 index 0000000..036802c --- /dev/null +++ b/src/components/settings/services/fever.tsx @@ -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 { + 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 && ( + {intl.get("service.overwriteWarning")} + )} + {!this.state.existing && this.state.importGroups && ( + {intl.get("service.groupsWarning")} + )} + + + + + + + + + + urlTest(v.trim()) ? "" : intl.get("sources.badUrl")} + validateOnLoad={false} + name="endpoint" + value={this.state.endpoint} + onChange={this.handleInputChange} /> + + + + + + + + + + + + + + + + + + + + + + + + + + + {!this.state.existing && this.setState({importGroups: c})} />} + + + + + + {this.state.existing + ? + : + } + + + + + } +} + +export default FeverConfigsTab \ No newline at end of file diff --git a/src/containers/settings-container.tsx b/src/containers/settings-container.tsx index c4f18ee..aad26cd 100644 --- a/src/containers/settings-container.tsx +++ b/src/containers/settings-container.tsx @@ -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 })) diff --git a/src/containers/settings/service-container.tsx b/src/containers/settings/service-container.tsx index 463dfc1..df5670b 100644 --- a/src/containers/settings/service-container.tsx +++ b/src/containers/settings/service-container.tsx @@ -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) diff --git a/src/scripts/i18n/en-US.json b/src/scripts/i18n/en-US.json index 0e61a28..cf10048 100644 --- a/src/scripts/i18n/en-US.json +++ b/src/scripts/i18n/en-US.json @@ -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", diff --git a/src/scripts/i18n/zh-CN.json b/src/scripts/i18n/zh-CN.json index 5ad176e..263e268 100644 --- a/src/scripts/i18n/zh-CN.json +++ b/src/scripts/i18n/zh-CN.json @@ -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": "清理", diff --git a/src/scripts/models/app.ts b/src/scripts/models/app.ts index 7e4718e..7932042 100644 --- a/src/scripts/models/app.ts +++ b/src/scripts/models/app.ts @@ -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 } } diff --git a/src/scripts/models/group.ts b/src/scripts/models/group.ts index ff50bea..7eaceb7 100644 --- a/src/scripts/models/group.ts +++ b/src/scripts/models/group.ts @@ -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), { diff --git a/src/scripts/models/item.ts b/src/scripts/models/item.ts index 6dd1547..c81fb0d 100644 --- a/src/scripts/models/item.ts +++ b/src/scripts/models/item.ts @@ -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 { export function fetchItems(background = false): AppThunk> { return async (dispatch, getState) => { - try { - await dispatch(syncWithService()) - } catch (err) { - console.log(err) - } let promises = new Array>() - if (!getState().app.fetchingItems) { + const initState = getState() + if (!initState.app.fetchingItems && !initState.app.syncing) { + await dispatch(syncWithService()) let timenow = new Date().getTime() let sources = 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 } } \ No newline at end of file diff --git a/src/scripts/models/service.ts b/src/scripts/models/service.ts index 8d840e1..e1e83c2 100644 --- a/src/scripts/models/service.ts +++ b/src/scripts/models/service.ts @@ -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 updateSources?: () => AppThunk> - fetchItems?: () => AppThunk> + fetchItems?: (background: boolean) => AppThunk> syncItems?: () => AppThunk> markRead?: (item: RSSItem) => AppThunk markUnread?: (item: RSSItem) => AppThunk @@ -29,25 +31,71 @@ export function getServiceHooks(): AppThunk { } } -export function syncWithService(): AppThunk> { - return async (dispatch) => { +export function syncWithService(background = false): AppThunk> { + 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> { + 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 diff --git a/src/scripts/models/services/fever.ts b/src/scripts/models/services/fever.ts index 0d8305b..62432ab 100644 --- a/src/scripts/models/services/fever.ts +++ b/src/scripts/models/services/fever.ts @@ -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 + 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() + 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((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() + 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() + 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>() + 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") + }, } \ No newline at end of file diff --git a/src/scripts/models/source.ts b/src/scripts/models/source.ts index e9c8dca..ecf8603 100644 --- a/src/scripts/models/source.ts +++ b/src/scripts/models/source.ts @@ -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 { }) } +export function updateUnreadCounts(): AppThunk> { + return async (dispatch, getState) => { + await Promise.all(Object.values(getState().sources).map(async s => { + dispatch(updateSourceDone(await unreadCount(s))) + })) + } +} + export function initSources(): AppThunk> { return (dispatch) => { dispatch(initSourcesRequest()) @@ -206,7 +214,7 @@ export function addSourceFailure(err, batch: boolean): SourceActionTypes { } let insertPromises = Promise.resolve() -function insertSource(source: RSSSource): AppThunk> { +export function insertSource(source: RSSSource): AppThunk> { 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> { + 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 {