import Parser from "@yang991178/rss-parser" import intl from "react-intl-universal" import * as db from "../db" import { fetchFavicon, ActionStatus, AppThunk, parseRSS } from "../utils" import { RSSItem, insertItems, ItemActionTypes, FETCH_ITEMS, MARK_READ, MARK_UNREAD, MARK_ALL_READ } from "./item" import { saveSettings } from "./app" import { SourceRule } from "./rule" export enum SourceOpenTarget { Local, Webpage, External } export class RSSSource { sid: number url: string iconurl: string name: string openTarget: SourceOpenTarget unreadCount: number lastFetched: Date fetchFrequency?: number // in minutes rules?: SourceRule[] constructor(url: string, name: string = null) { this.url = url this.name = name this.openTarget = SourceOpenTarget.Local this.lastFetched = new Date() } static async fetchMetaData(source: RSSSource) { let feed = await parseRSS(source.url) if (!source.name) { if (feed.title) source.name = feed.title.trim() source.name = source.name || intl.get("sources.untitled") } let domain = source.url.split("/").slice(0, 3).join("/") try { let f = await fetchFavicon(domain) if (f !== null) source.iconurl = f } finally { return feed } } private static checkItem(source: RSSSource, item: Parser.Item): Promise { return new Promise((resolve, reject) => { let i = new RSSItem(item, source) db.idb.findOne({ source: i.source, title: i.title, date: i.date }, (err, doc) => { if (err) { reject(err) } else if (doc === null) { RSSItem.parseContent(i, item) if (source.rules) SourceRule.applyAll(source.rules, i) resolve(i) } else { resolve(null) } }) }) } static checkItems(source: RSSSource, items: Parser.Item[]): Promise { return new Promise((resolve, reject) => { let p = new Array>() for (let item of items) { p.push(this.checkItem(source, item)) } Promise.all(p).then(values => { resolve(values.filter(v => v != null)) }).catch(e => { reject(e) }) }) } static async fetchItems(source: RSSSource) { let feed = await parseRSS(source.url) db.sdb.update({ sid: source.sid }, { $set: { lastFetched: new Date() } }) return await this.checkItems(source, feed.items) } } export type SourceState = { [sid: number]: RSSSource } export const INIT_SOURCES = "INIT_SOURCES" export const ADD_SOURCE = "ADD_SOURCE" export const UPDATE_SOURCE = "UPDATE_SOURCE" export const DELETE_SOURCE = "DELETE_SOURCE" interface InitSourcesAction { type: typeof INIT_SOURCES status: ActionStatus sources?: RSSSource[] err? } interface AddSourceAction { type: typeof ADD_SOURCE status: ActionStatus batch: boolean source?: RSSSource err? } interface UpdateSourceAction { type: typeof UPDATE_SOURCE source: RSSSource } interface DeleteSourceAction { type: typeof DELETE_SOURCE, source: RSSSource } export type SourceActionTypes = InitSourcesAction | AddSourceAction | UpdateSourceAction | DeleteSourceAction export function initSourcesRequest(): SourceActionTypes { return { type: INIT_SOURCES, status: ActionStatus.Request } } export function initSourcesSuccess(sources: RSSSource[]): SourceActionTypes { return { type: INIT_SOURCES, status: ActionStatus.Success, sources: sources } } export function initSourcesFailure(err): SourceActionTypes { return { type: INIT_SOURCES, status: ActionStatus.Failure, err: err } } function unreadCount(source: RSSSource): Promise { return new Promise((resolve, reject) => { db.idb.count({ source: source.sid, hasRead: false }, (err, n) => { if (err) { reject(err) } else { source.unreadCount = n resolve(source) } }) }) } export function initSources(): AppThunk> { return (dispatch) => { dispatch(initSourcesRequest()) return new Promise((resolve, reject) => { db.sdb.find({}).sort({ sid: 1 }).exec((err, sources) => { if (err) { dispatch(initSourcesFailure(err)) reject(err) } else { let p = sources.map(s => unreadCount(s)) Promise.all(p) .then(values => { dispatch(initSourcesSuccess(values)) resolve() }) .catch(err => reject(err)) } }) }) } } export function addSourceRequest(batch: boolean): SourceActionTypes { return { type: ADD_SOURCE, batch: batch, status: ActionStatus.Request } } export function addSourceSuccess(source: RSSSource, batch: boolean): SourceActionTypes { return { type: ADD_SOURCE, batch: batch, status: ActionStatus.Success, source: source } } export function addSourceFailure(err, batch: boolean): SourceActionTypes { return { type: ADD_SOURCE, batch: batch, status: ActionStatus.Failure, err: err } } let insertPromises = Promise.resolve() function insertSource(source: RSSSource): AppThunk> { return (_, getState) => { return new Promise((resolve, reject) => { insertPromises = insertPromises.then(() => new Promise(innerResolve => { let sids = Object.values(getState().sources).map(s => s.sid) source.sid = Math.max(...sids, -1) + 1 db.sdb.insert(source, (err, inserted) => { if (err) { if ((new RegExp(`^Can't insert key ${source.url},`)).test(err.message)) { reject(intl.get("sources.exist")) } else { reject(err) } } else { resolve(inserted) } innerResolve() }) })) }) } } export function addSource(url: string, name: string = null, batch = false): AppThunk> { return (dispatch, getState) => { let app = getState().app if (app.sourceInit) { dispatch(addSourceRequest(batch)) let source = new RSSSource(url, name) return RSSSource.fetchMetaData(source) .then(feed => { return dispatch(insertSource(source)) .then(inserted => { inserted.unreadCount = feed.items.length dispatch(addSourceSuccess(inserted, batch)) window.settings.saveGroups(getState().groups) return RSSSource.checkItems(inserted, feed.items) .then(items => insertItems(items)) .then(() => { return inserted.sid }) }) }) .catch(e => { dispatch(addSourceFailure(e, batch)) if (!batch) { window.utils.showErrorBox(intl.get("sources.errorAdd"), String(e)) } return Promise.reject(e) }) } return new Promise((_, reject) => { reject("Sources not initialized.") }) } } export function updateSourceDone(source: RSSSource): SourceActionTypes { return { type: UPDATE_SOURCE, source: source } } export function updateSource(source: RSSSource): AppThunk { return (dispatch) => { let sourceCopy = { ...source } delete sourceCopy.sid delete sourceCopy.unreadCount db.sdb.update({ sid: source.sid }, { $set: { ...sourceCopy }}, {}, err => { if (!err) { dispatch(updateSourceDone(source)) } }) } } export function deleteSourceDone(source: RSSSource): SourceActionTypes { return { type: DELETE_SOURCE, source: source } } export function deleteSource(source: RSSSource, batch = false): AppThunk> { return (dispatch, getState) => { return new Promise((resolve) => { if (!batch) dispatch(saveSettings()) db.idb.remove({ source: source.sid }, { multi: true }, (err) => { if (err) { console.log(err) if (!batch) dispatch(saveSettings()) resolve() } else { db.sdb.remove({ sid: source.sid }, {}, (err) => { if (err) { console.log(err) if (!batch) dispatch(saveSettings()) resolve() } else { dispatch(deleteSourceDone(source)) window.settings.saveGroups(getState().groups) if (!batch) dispatch(saveSettings()) resolve() } }) } }) }) } } export function deleteSources(sources: RSSSource[]): AppThunk> { return async (dispatch) => { dispatch(saveSettings()) for (let source of sources) { await dispatch(deleteSource(source, true)) } dispatch(saveSettings()) } } export function sourceReducer( state: SourceState = {}, action: SourceActionTypes | ItemActionTypes ): SourceState { switch (action.type) { case INIT_SOURCES: switch (action.status) { case ActionStatus.Success: { let newState: SourceState = {} for (let source of action.sources) { newState[source.sid] = source } return newState } default: return state } case ADD_SOURCE: switch (action.status) { case ActionStatus.Success: return { ...state, [action.source.sid]: action.source } default: return state } case UPDATE_SOURCE: return { ...state, [action.source.sid]: action.source } case DELETE_SOURCE: { delete state[action.source.sid] return { ...state } } case FETCH_ITEMS: { switch (action.status) { case ActionStatus.Success: { let updateMap = new Map() for (let item of action.items) { if (!item.hasRead) { updateMap.set( item.source, updateMap.has(item.source) ? (updateMap.get(item.source) + 1) : 1 )} } let nextState = {} as SourceState for (let [s, source] of Object.entries(state)) { let sid = parseInt(s) if (updateMap.has(sid)) { nextState[sid] = { ...source, unreadCount: source.unreadCount + updateMap.get(sid) } as RSSSource } else { nextState[sid] = source } } return nextState } default: return state } } case MARK_UNREAD: case MARK_READ: return { ...state, [action.item.source]: { ...state[action.item.source], unreadCount: state[action.item.source].unreadCount + (action.type === MARK_UNREAD ? 1 : -1) } as RSSSource } case MARK_ALL_READ: { let nextState = { ...state } action.sids.map((sid, i) => { nextState[sid] = { ...state[sid], unreadCount: action.counts ? state[sid].unreadCount - action.counts[i] : 0 } }) return nextState } default: return state } }