diff --git a/src/components/settings/service.tsx b/src/components/settings/service.tsx index 18643a7..2d220c3 100644 --- a/src/components/settings/service.tsx +++ b/src/components/settings/service.tsx @@ -3,6 +3,7 @@ import intl from "react-intl-universal" import { ServiceConfigs, SyncService } from "../../schema-types" import { Stack, Icon, Link, Dropdown, IDropdownOption } from "@fluentui/react" import FeverConfigsTab from "./services/fever" +import FeedbinConfigsTab from "./services/feedbin" type ServiceTabProps = { configs: ServiceConfigs @@ -31,6 +32,7 @@ export class ServiceTab extends React.Component [ { key: SyncService.Fever, text: "Fever API" }, + { key: SyncService.Feedbin, text: "Feedbin" }, { key: -1, text: intl.get("service.suggest") }, ] @@ -46,6 +48,14 @@ export class ServiceTab extends React.Component { + switch (this.state.type) { + case SyncService.Fever: return + case SyncService.Feedbin: return + default: return null + } + } + render = () => (
{this.state.type === SyncService.None @@ -72,7 +82,7 @@ export class ServiceTab extends React.Component ) - : } + : this.getConfigsTab()}
) } \ No newline at end of file diff --git a/src/components/settings/services/feedbin.tsx b/src/components/settings/services/feedbin.tsx new file mode 100644 index 0000000..d7493f1 --- /dev/null +++ b/src/components/settings/services/feedbin.tsx @@ -0,0 +1,179 @@ +import * as React from "react" +import intl from "react-intl-universal" +import { ServiceConfigsTabProps } from "../service" +import { FeedbinConfigs } from "../../../scripts/models/services/feedbin" +import { SyncService } from "../../../schema-types" +import { Stack, Icon, Label, TextField, PrimaryButton, DefaultButton, Checkbox, + MessageBar, MessageBarType, Dropdown, IDropdownOption } from "@fluentui/react" +import DangerButton from "../../utils/danger-button" +import { urlTest } from "../../../scripts/utils" + +type FeedbinConfigsTabState = { + existing: boolean + endpoint: string + username: string + password: string + fetchLimit: number + importGroups: boolean +} + +class FeedbinConfigsTab extends React.Component { + constructor(props: ServiceConfigsTabProps) { + super(props) + const configs = props.configs as FeedbinConfigs + this.state = { + existing: configs.type === SyncService.Feedbin, + endpoint: configs.endpoint || "https://api.feedbin.me/v2/", + username: configs.username || "", + password: "", + fetchLimit: configs.fetchLimit || 250, + importGroups: true, + } + } + + fetchLimitOptions = (): IDropdownOption[] => [ + { key: 250, text: intl.get("service.fetchLimitNum", { count: 250 }) }, + { key: 500, text: intl.get("service.fetchLimitNum", { count: 500 }) }, + { key: 750, text: intl.get("service.fetchLimitNum", { count: 750 }) }, + { key: 1000, text: intl.get("service.fetchLimitNum", { count: 1000 }) }, + ] + onFetchLimitOptionChange = (_, option: IDropdownOption) => { + this.setState({ fetchLimit: option.key as number }) + } + + handleInputChange = (event) => { + const name: string = event.target.name + // @ts-expect-error + this.setState({[name]: event.target.value}) + } + + checkNotEmpty = (v: string) => { + return (!this.state.existing && v.length == 0) ? intl.get("emptyField") : "" + } + + validateForm = () => { + return urlTest(this.state.endpoint.trim()) && (this.state.existing || (this.state.username && this.state.password)) + } + + save = async () => { + let configs: FeedbinConfigs + if (this.state.existing) { + configs = { + ...this.props.configs, + endpoint: this.state.endpoint, + fetchLimit: this.state.fetchLimit + } as FeedbinConfigs + if (this.state.password) + configs.password = this.state.password + } else { + configs = { + type: SyncService.Feedbin, + endpoint: this.state.endpoint, + username: this.state.username, + password: this.state.password, + fetchLimit: this.state.fetchLimit, + } + if (this.state.importGroups) configs.importGroups = true + } + this.props.blockActions() + const valid = await this.props.authenticate(configs) + if (valid) { + this.props.save(configs) + this.setState({ existing: true }) + this.props.sync() + } else { + this.props.blockActions() + window.utils.showErrorBox(intl.get("service.failure"), intl.get("service.failureHint")) + } + } + + remove = async () => { + this.props.exit() + await this.props.remove() + } + + render() { + return <> + {!this.state.existing && ( + {intl.get("service.overwriteWarning")} + )} + + + + + + + + + urlTest(v.trim()) ? "" : intl.get("sources.badUrl")} + validateOnLoad={false} + name="endpoint" + value={this.state.endpoint} + onChange={this.handleInputChange} /> + + + + + + + + + + + + + + + + + + + + + + + + + + + {!this.state.existing && this.setState({importGroups: c})} />} + + + + + + {this.state.existing + ? + : + } + + + + + } +} + +export default FeedbinConfigsTab \ No newline at end of file diff --git a/src/schema-types.ts b/src/schema-types.ts index 7eeb158..76a228d 100644 --- a/src/schema-types.ts +++ b/src/schema-types.ts @@ -37,7 +37,7 @@ export const enum ImageCallbackTypes { } export const enum SyncService { - None, Fever + None, Fever, Feedbin } export interface ServiceConfigs { type: SyncService diff --git a/src/scripts/models/item.ts b/src/scripts/models/item.ts index 5e13fc3..a5cca09 100644 --- a/src/scripts/models/item.ts +++ b/src/scripts/models/item.ts @@ -248,15 +248,15 @@ export function markRead(item: RSSItem): AppThunk { } } -export function markAllRead(sids: number[] = null, date: Date = null, before = true): AppThunk { - return (dispatch, getState) => { +export function markAllRead(sids: number[] = null, date: Date = null, before = true): AppThunk> { + return async (dispatch, getState) => { let state = getState() if (sids === null) { let feed = state.feeds[state.page.feedId] sids = feed.sids } const action = dispatch(getServiceHooks()).markAllRead?.(sids, date, before) - if (action) dispatch(action) + if (action) await dispatch(action) let query = { source: { $in: sids }, hasRead: false, diff --git a/src/scripts/models/service.ts b/src/scripts/models/service.ts index 1c10950..40eebad 100644 --- a/src/scripts/models/service.ts +++ b/src/scripts/models/service.ts @@ -2,13 +2,14 @@ import * as db from "../db" import { SyncService, ServiceConfigs } from "../../schema-types" import { AppThunk, ActionStatus, fetchFavicon } from "../utils" import { RSSItem, insertItems, fetchItemsSuccess } from "./item" - -import { feverServiceHooks } from "./services/fever" import { saveSettings, pushNotification } from "./app" import { deleteSource, updateUnreadCounts, RSSSource, insertSource, addSourceSuccess, updateSource } from "./source" import { FilterType, initFeeds } from "./feed" import { createSourceGroup, addSourceToGroup } from "./group" +import { feverServiceHooks } from "./services/fever" +import { feedbinServiceHooks } from "./services/feedbin" + export interface ServiceHooks { authenticate?: (configs: ServiceConfigs) => Promise updateSources?: () => AppThunk]>> @@ -16,7 +17,7 @@ export interface ServiceHooks { syncItems?: () => AppThunk> markRead?: (item: RSSItem) => AppThunk markUnread?: (item: RSSItem) => AppThunk - markAllRead?: (sids?: number[], date?: Date, before?: boolean) => AppThunk + markAllRead?: (sids?: number[], date?: Date, before?: boolean) => AppThunk> star?: (item: RSSItem) => AppThunk unstar?: (item: RSSItem) => AppThunk } @@ -24,6 +25,7 @@ export interface ServiceHooks { export function getServiceHooksFromType(type: SyncService): ServiceHooks { switch (type) { case SyncService.Fever: return feverServiceHooks + case SyncService.Feedbin: return feedbinServiceHooks default: return {} } } @@ -96,6 +98,7 @@ function updateSources(hook: ServiceHooks["updateSources"]): AppThunk { reject(err) diff --git a/src/scripts/models/services/feedbin.ts b/src/scripts/models/services/feedbin.ts new file mode 100644 index 0000000..0cfede5 --- /dev/null +++ b/src/scripts/models/services/feedbin.ts @@ -0,0 +1,210 @@ +import intl from "react-intl-universal" +import * as db from "../../db" +import { ServiceHooks } from "../service" +import { ServiceConfigs, SyncService } from "../../../schema-types" +import { createSourceGroup } from "../group" +import { RSSSource } from "../source" +import { htmlDecode, domParser } from "../../utils" +import { RSSItem } from "../item" +import { SourceRule } from "../rule" + +export interface FeedbinConfigs extends ServiceConfigs { + type: SyncService.Feedbin + endpoint: string + username: string + password: string + fetchLimit: number + lastId?: number +} + +async function fetchAPI(configs: FeedbinConfigs, params: string) { + const headers = new Headers() + headers.set("Authorization", "Basic " + btoa(configs.username + ":" + configs.password)) + return await fetch(configs.endpoint + params, { headers: headers }) +} + +async function markItems(configs: FeedbinConfigs, type: string, method: string, refs: number[]) { + const headers = new Headers() + headers.set("Authorization", "Basic " + btoa(configs.username + ":" + configs.password)) + headers.set("Content-Type", "application/json; charset=utf-8") + const promises = new Array>() + while (refs.length > 0) { + const batch = new Array() + while (batch.length < 1000 && refs.length > 0) { + batch.push(refs.pop()) + } + const bodyObject: any = {} + bodyObject[`${type}_entries`] = batch + promises.push(fetch(configs.endpoint + type + "_entries.json", { + method: method, + headers: headers, + body: JSON.stringify(bodyObject) + })) + } + return await Promise.all(promises) +} + +const APIError = () => new Error(intl.get("service.failure")) + +export const feedbinServiceHooks: ServiceHooks = { + authenticate: async (configs: FeedbinConfigs) => { + try { + const result = await fetchAPI(configs, "authentication.json") + return result.status === 200 + } catch { + return false + } + }, + + updateSources: () => async (dispatch, getState) => { + const configs = getState().service as FeedbinConfigs + const response = await fetchAPI(configs, "subscriptions.json") + if (response.status !== 200) throw APIError() + const subscriptions: any[] = await response.json() + let groupsMap: Map + if (configs.importGroups) { + const tagsResponse = await fetchAPI(configs, "taggings.json") + if (tagsResponse.status !== 200) throw APIError() + const tags: any[] = await tagsResponse.json() + const tagsSet = new Set() + groupsMap = new Map() + for (let tag of tags) { + const title = tag.name.trim() + if (!tagsSet.has(title)) { + tagsSet.add(title) + dispatch(createSourceGroup(title)) + } + groupsMap.set(tag.feed_id, title) + } + } + const sources = subscriptions.map(s => { + const source = new RSSSource(s.feed_url, s.title) + source.serviceRef = s.feed_id + return source + }) + return [sources, groupsMap] + }, + + syncItems: () => async (_, getState) => { + const configs = getState().service as FeedbinConfigs + const [unreadResponse, starredResponse] = await Promise.all([ + fetchAPI(configs, "unread_entries.json"), + fetchAPI(configs, "starred_entries.json") + ]) + if (unreadResponse.status !== 200 || starredResponse.status !== 200) throw APIError() + const unread = await unreadResponse.json() + const starred = await starredResponse.json() + return [unread, starred] + }, + + fetchItems: () => async (_, getState) => { + const state = getState() + const configs = state.service as FeedbinConfigs + const items = new Array() + configs.lastId = configs.lastId || 0 + let page = 1 + let min = Number.MAX_SAFE_INTEGER + let lastFetched: any[] + do { + const response = await fetchAPI(configs, "entries.json?mode=extended&page=" + page) + if (response.status !== 200) throw APIError() + lastFetched = await response.json() + items.push(...lastFetched.filter(i => i.id > configs.lastId && i.id < min)) + min = lastFetched.reduce((m, n) => Math.min(m, n.id), min) + page += 1 + } while ( + min > configs.lastId && + lastFetched && lastFetched.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 [unreadResponse, starredResponse] = await Promise.all([ + fetchAPI(configs, "unread_entries.json"), + fetchAPI(configs, "starred_entries.json") + ]) + if (unreadResponse.status !== 200 || starredResponse.status !== 200) throw APIError() + const unread: Set = new Set(await unreadResponse.json()) + const starred: Set = new Set(await starredResponse.json()) + const parsedItems = 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.published), + fetchedDate: new Date(i.created_at), + content: i.content, + snippet: htmlDecode(i.content).trim(), + creator: i.author, + hasRead: !unread.has(i.id), + serviceRef: i.id, + } as RSSItem + if (starred.has(i.id)) item.starred = true + if (i.images && i.images.original_url) { + item.thumb = i.images.original_url + } else { + 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 + } + // Apply rules and sync back to the service + if (source.rules) SourceRule.applyAll(source.rules, item) + if (unread.has(i.id) === item.hasRead) + markItems(configs, "unread", item.hasRead ? "DELETE" : "POST", [i.id]) + if (starred.has(i.id) !== Boolean(item.starred)) + markItems(configs, "starred", item.starred ? "POST" : "DELETE", [i.id]) + return item + }) + return [parsedItems, configs] + } else { + return [[], configs] + } + }, + + markAllRead: (sids, date, before) => (_, getState) => new Promise(resolve => { + const state = getState() + const configs = state.service as FeedbinConfigs + const query: any = { + source: { $in: sids }, + hasRead: false, + serviceRef: { $exists: true } + } + if (date) { + query.date = before ? { $lte: date } : { $gte: date } + } + // @ts-ignore + db.idb.find(query, { serviceRef: 1 }, (err, docs) => { + resolve() + if (!err) { + const refs = docs.map(i => i.serviceRef as number) + markItems(configs, "unread", "DELETE", refs) + } + }) + }), + + markRead: (item: RSSItem) => async (_, getState) => { + await markItems(getState().service as FeedbinConfigs, "unread", "DELETE", [item.serviceRef as number]) + }, + + markUnread: (item: RSSItem) => async (_, getState) => { + await markItems(getState().service as FeedbinConfigs, "unread", "POST", [item.serviceRef as number]) + }, + + star: (item: RSSItem) => async (_, getState) => { + await markItems(getState().service as FeedbinConfigs, "starred", "POST", [item.serviceRef as number]) + }, + + unstar: (item: RSSItem) => async (_, getState) => { + await markItems(getState().service as FeedbinConfigs, "starred", "DELETE", [item.serviceRef as number]) + }, +} \ No newline at end of file