FluentReader/src/scripts/models/feed.ts

504 lines
15 KiB
TypeScript

import * as db from "../db"
import lf from "lovefield"
import {
SourceActionTypes,
INIT_SOURCES,
ADD_SOURCE,
DELETE_SOURCE,
UNHIDE_SOURCE,
HIDE_SOURCE,
} from "./source"
import {
ItemActionTypes,
FETCH_ITEMS,
RSSItem,
TOGGLE_HIDDEN,
applyItemReduction,
} from "./item"
import { ActionStatus, AppThunk, mergeSortedArrays } from "../utils"
import { PageActionTypes, SELECT_PAGE, PageType, APPLY_FILTER } from "./page"
export enum FilterType {
None,
ShowRead = 1 << 0,
ShowNotStarred = 1 << 1,
ShowHidden = 1 << 2,
FullSearch = 1 << 3,
CaseInsensitive = 1 << 4,
CreatorSearch = 1 << 5,
Default = ShowRead | ShowNotStarred,
UnreadOnly = ShowNotStarred,
StarredOnly = ShowRead,
Toggles = ShowHidden | FullSearch | CaseInsensitive,
}
export class FeedFilter {
type: FilterType
search: string
constructor(type: FilterType = null, search = "") {
if (
type === null &&
(type = window.settings.getFilterType()) === null
) {
type = FilterType.Default | FilterType.CaseInsensitive
}
this.type = type
this.search = search
}
static toPredicates(filter: FeedFilter) {
let type = filter.type
const predicates = new Array<lf.Predicate>()
if (!(type & FilterType.ShowRead))
predicates.push(db.items.hasRead.eq(false))
if (!(type & FilterType.ShowNotStarred))
predicates.push(db.items.starred.eq(true))
if (!(type & FilterType.ShowHidden))
predicates.push(db.items.hidden.eq(false))
if (filter.search !== "") {
const flags = type & FilterType.CaseInsensitive ? "i" : ""
const regex = RegExp(filter.search, flags)
if (type & FilterType.FullSearch) {
predicates.push(
lf.op.or(
db.items.title.match(regex),
db.items.snippet.match(regex)
)
)
} else {
predicates.push(db.items.title.match(regex))
}
}
return predicates
}
static testItem(filter: FeedFilter, item: RSSItem) {
let type = filter.type
let flag = true
if (!(type & FilterType.ShowRead)) flag = flag && !item.hasRead
if (!(type & FilterType.ShowNotStarred)) flag = flag && item.starred
if (!(type & FilterType.ShowHidden)) flag = flag && !item.hidden
if (filter.search !== "") {
const flags = type & FilterType.CaseInsensitive ? "i" : ""
const regex = RegExp(filter.search, flags)
if (type & FilterType.FullSearch) {
flag =
flag && (regex.test(item.title) || regex.test(item.snippet))
} else if (type & FilterType.CreatorSearch) {
flag = flag && regex.test(item.creator || "")
} else {
flag = flag && regex.test(item.title)
}
}
return Boolean(flag)
}
}
export const ALL = "ALL"
export const SOURCE = "SOURCE"
const LOAD_QUANTITY = 50
export class RSSFeed {
_id: string
loaded: boolean
loading: boolean
allLoaded: boolean
sids: number[]
iids: number[]
filter: FeedFilter
constructor(id: string = null, sids = [], filter = null) {
this._id = id
this.sids = sids
this.iids = []
this.loaded = false
this.allLoaded = false
this.filter = filter === null ? new FeedFilter() : filter
}
static async loadFeed(feed: RSSFeed, skip = 0): Promise<RSSItem[]> {
const predicates = FeedFilter.toPredicates(feed.filter)
predicates.push(db.items.source.in(feed.sids))
return (await db.itemsDB
.select()
.from(db.items)
.where(lf.op.and.apply(null, predicates))
.orderBy(db.items.date, lf.Order.DESC)
.skip(skip)
.limit(LOAD_QUANTITY)
.exec()) as RSSItem[]
}
}
export type FeedState = {
[_id: string]: RSSFeed
}
export const INIT_FEEDS = "INIT_FEEDS"
export const INIT_FEED = "INIT_FEED"
export const LOAD_MORE = "LOAD_MORE"
export const DISMISS_ITEMS = "DISMISS_ITEMS"
interface initFeedsAction {
type: typeof INIT_FEEDS
status: ActionStatus
}
interface initFeedAction {
type: typeof INIT_FEED
status: ActionStatus
feed?: RSSFeed
items?: RSSItem[]
err?
}
interface loadMoreAction {
type: typeof LOAD_MORE
status: ActionStatus
feed: RSSFeed
items?: RSSItem[]
err?
}
interface dismissItemsAction {
type: typeof DISMISS_ITEMS
fid: string
iids: Set<number>
}
export type FeedActionTypes =
| initFeedAction
| initFeedsAction
| loadMoreAction
| dismissItemsAction
export function dismissItems(): AppThunk {
return (dispatch, getState) => {
const state = getState()
let fid = state.page.feedId
let filter = state.feeds[fid].filter
let iids = new Set<number>()
for (let iid of state.feeds[fid].iids) {
let item = state.items[iid]
if (!FeedFilter.testItem(filter, item)) {
iids.add(iid)
}
}
dispatch({
type: DISMISS_ITEMS,
fid: fid,
iids: iids,
})
}
}
export function initFeedsRequest(): FeedActionTypes {
return {
type: INIT_FEEDS,
status: ActionStatus.Request,
}
}
export function initFeedsSuccess(): FeedActionTypes {
return {
type: INIT_FEEDS,
status: ActionStatus.Success,
}
}
export function initFeedSuccess(
feed: RSSFeed,
items: RSSItem[]
): FeedActionTypes {
return {
type: INIT_FEED,
status: ActionStatus.Success,
items: items,
feed: feed,
}
}
export function initFeedFailure(err): FeedActionTypes {
return {
type: INIT_FEED,
status: ActionStatus.Failure,
err: err,
}
}
export function initFeeds(force = false): AppThunk<Promise<void>> {
return (dispatch, getState) => {
dispatch(initFeedsRequest())
let promises = new Array<Promise<void>>()
for (let feed of Object.values(getState().feeds)) {
if (!feed.loaded || force) {
let p = RSSFeed.loadFeed(feed)
.then(items => {
dispatch(initFeedSuccess(feed, items))
})
.catch(err => {
console.log(err)
dispatch(initFeedFailure(err))
})
promises.push(p)
}
}
return Promise.allSettled(promises).then(() => {
dispatch(initFeedsSuccess())
})
}
}
export function loadMoreRequest(feed: RSSFeed): FeedActionTypes {
return {
type: LOAD_MORE,
status: ActionStatus.Request,
feed: feed,
}
}
export function loadMoreSuccess(
feed: RSSFeed,
items: RSSItem[]
): FeedActionTypes {
return {
type: LOAD_MORE,
status: ActionStatus.Success,
feed: feed,
items: items,
}
}
export function loadMoreFailure(feed: RSSFeed, err): FeedActionTypes {
return {
type: LOAD_MORE,
status: ActionStatus.Failure,
feed: feed,
err: err,
}
}
export function loadMore(feed: RSSFeed): AppThunk<Promise<void>> {
return (dispatch, getState) => {
if (feed.loaded && !feed.loading && !feed.allLoaded) {
dispatch(loadMoreRequest(feed))
const state = getState()
const skipNum = feed.iids.filter(i =>
FeedFilter.testItem(feed.filter, state.items[i])
).length
return RSSFeed.loadFeed(feed, skipNum)
.then(items => {
dispatch(loadMoreSuccess(feed, items))
})
.catch(e => {
console.log(e)
dispatch(loadMoreFailure(feed, e))
})
}
return new Promise((_, reject) => {
reject()
})
}
}
export function feedReducer(
state: FeedState = { [ALL]: new RSSFeed(ALL) },
action:
| SourceActionTypes
| ItemActionTypes
| FeedActionTypes
| PageActionTypes
): FeedState {
switch (action.type) {
case INIT_SOURCES:
switch (action.status) {
case ActionStatus.Success:
return {
...state,
[ALL]: new RSSFeed(
ALL,
Object.values(action.sources)
.filter(s => !s.hidden)
.map(s => s.sid)
),
}
default:
return state
}
case ADD_SOURCE:
case UNHIDE_SOURCE:
switch (action.status) {
case ActionStatus.Success:
return {
...state,
[ALL]: new RSSFeed(
ALL,
[...state[ALL].sids, action.source.sid],
state[ALL].filter
),
}
default:
return state
}
case DELETE_SOURCE:
case HIDE_SOURCE: {
let nextState = {}
for (let [id, feed] of Object.entries(state)) {
nextState[id] = new RSSFeed(
id,
feed.sids.filter(sid => sid != action.source.sid),
feed.filter
)
}
return nextState
}
case APPLY_FILTER: {
let nextState = {}
for (let [id, feed] of Object.entries(state)) {
nextState[id] = {
...feed,
filter: action.filter,
}
}
return nextState
}
case FETCH_ITEMS:
switch (action.status) {
case ActionStatus.Success: {
let nextState = { ...state }
for (let feed of Object.values(state)) {
if (feed.loaded) {
let items = action.items.filter(
i =>
feed.sids.includes(i.source) &&
FeedFilter.testItem(feed.filter, i)
)
if (items.length > 0) {
let oldItems = feed.iids.map(
id => action.itemState[id]
)
let nextItems = mergeSortedArrays(
oldItems,
items,
(a, b) =>
b.date.getTime() - a.date.getTime()
)
nextState[feed._id] = {
...feed,
iids: nextItems.map(i => i._id),
}
}
}
}
return nextState
}
default:
return state
}
case DISMISS_ITEMS:
let nextState = { ...state }
let feed = state[action.fid]
nextState[action.fid] = {
...feed,
iids: feed.iids.filter(iid => !action.iids.has(iid)),
}
return nextState
case INIT_FEED:
switch (action.status) {
case ActionStatus.Success:
return {
...state,
[action.feed._id]: {
...action.feed,
loaded: true,
allLoaded: action.items.length < LOAD_QUANTITY,
iids: action.items.map(i => i._id),
},
}
default:
return state
}
case LOAD_MORE:
switch (action.status) {
case ActionStatus.Request:
return {
...state,
[action.feed._id]: {
...action.feed,
loading: true,
},
}
case ActionStatus.Success:
return {
...state,
[action.feed._id]: {
...action.feed,
loading: false,
allLoaded: action.items.length < LOAD_QUANTITY,
iids: [
...action.feed.iids,
...action.items.map(i => i._id),
],
},
}
case ActionStatus.Failure:
return {
...state,
[action.feed._id]: {
...action.feed,
loading: false,
},
}
default:
return state
}
case TOGGLE_HIDDEN: {
let nextItem = applyItemReduction(action.item, action.type)
let filteredFeeds = Object.values(state).filter(
feed =>
feed.loaded && !FeedFilter.testItem(feed.filter, nextItem)
)
if (filteredFeeds.length > 0) {
let nextState = { ...state }
for (let feed of filteredFeeds) {
nextState[feed._id] = {
...feed,
iids: feed.iids.filter(id => id != nextItem._id),
}
}
return nextState
} else {
return state
}
}
case SELECT_PAGE:
switch (action.pageType) {
case PageType.Sources:
return {
...state,
[SOURCE]: new RSSFeed(
SOURCE,
action.sids,
action.filter
),
}
case PageType.AllArticles:
return action.init
? {
...state,
[ALL]: {
...state[ALL],
loaded: false,
filter: action.filter,
},
}
: state
default:
return state
}
default:
return state
}
}