mirror of
https://github.com/yang991178/fluent-reader.git
synced 2025-02-26 16:37:46 +01:00
commit
541cda3277
@ -25,7 +25,7 @@ If you are using Linux or an older version of Windows, you can [get Fluent Reade
|
||||
</p>
|
||||
|
||||
- A modern UI inspired by Fluent Design System with full dark mode support.
|
||||
- Read locally or sync with self-hosted services through Fever API.
|
||||
- Read locally, or sync with Feedbin or self-hosted services compatible with Fever API.
|
||||
- Importing or exporting OPML files, full application data backup & restoration.
|
||||
- Read the full content with the built-in article view or load webpages by default.
|
||||
- Search for articles with regular expressions or filter by read status.
|
||||
@ -34,7 +34,7 @@ If you are using Linux or an older version of Windows, you can [get Fluent Reade
|
||||
- Hide, mark as read, or star articles automatically as they arrive with regular expression rules.
|
||||
- Fetch articles in the background and send push notifications.
|
||||
|
||||
Support for other RSS services including Inoreader and Feedly are being fundraised through [Open Collective](https://opencollective.com/fluent-reader).
|
||||
Support for other RSS services including Inoreader and Feedly are [under fundraising](https://github.com/yang991178/fluent-reader/issues/23).
|
||||
|
||||
## Development
|
||||
|
||||
|
24
dist/article/article.css
vendored
24
dist/article/article.css
vendored
@ -33,10 +33,29 @@ a:hover, a:active {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
#main {
|
||||
max-width: 700px;
|
||||
margin: 0 auto;
|
||||
display: none;
|
||||
}
|
||||
#main.show {
|
||||
display: block;
|
||||
animation-name: fadeIn;
|
||||
animation-duration: 0.367s;
|
||||
animation-timing-function: cubic-bezier(0.1, 0.9, 0.2, 1);
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
|
||||
#main > p.title {
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.75rem;
|
||||
@ -74,3 +93,8 @@ article code {
|
||||
font-size: .875rem;
|
||||
line-height: 1;
|
||||
}
|
||||
article blockquote {
|
||||
border-left: 2px solid var(--gray);
|
||||
margin: 1em 0;
|
||||
padding: 0 40px;
|
||||
}
|
4
dist/article/article.html
vendored
4
dist/article/article.html
vendored
@ -3,14 +3,14 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta http-equiv="Content-Security-Policy"
|
||||
content="default-src 'none'; script-src-elem 'sha256-sLDWrq1tUAO8IyyqmUckFqxbXYfZ2/3TEUmtxH8Unf0=' 'sha256-q3VKKMMe+ucICfT8N3WHLJdQovcvvHc0HbJ5N9uA3+w='; img-src http: https: data:; style-src 'self' 'unsafe-inline'; frame-src http: https:; media-src http: https:; connect-src https: http:">
|
||||
content="default-src 'none'; script-src-elem 'sha256-sLDWrq1tUAO8IyyqmUckFqxbXYfZ2/3TEUmtxH8Unf0=' 'sha256-9YXu4Ifpt+hDzuBhE+vFtXKt1ZRbo/CkuUY4VX4dZyE='; img-src http: https: data:; style-src 'self' 'unsafe-inline'; frame-src http: https:; media-src http: https:; connect-src https: http:">
|
||||
<title>Article</title>
|
||||
<link rel="stylesheet" href="article.css" />
|
||||
<script integrity="sha256-sLDWrq1tUAO8IyyqmUckFqxbXYfZ2/3TEUmtxH8Unf0=" src="mercury.web.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="main"></div>
|
||||
<script integrity="sha256-q3VKKMMe+ucICfT8N3WHLJdQovcvvHc0HbJ5N9uA3+w=" src="article.js"></script>
|
||||
<script integrity="sha256-9YXu4Ifpt+hDzuBhE+vFtXKt1ZRbo/CkuUY4VX4dZyE=" src="article.js"></script>
|
||||
<!-- Run "cat article.js | openssl dgst -sha256 -binary | openssl enc -base64 -A" for hash -->
|
||||
</body>
|
||||
</html>
|
1
dist/article/article.js
vendored
1
dist/article/article.js
vendored
@ -30,5 +30,6 @@ getArticle(url).then(article => {
|
||||
}
|
||||
let main = document.getElementById("main")
|
||||
main.innerHTML = dom.body.innerHTML
|
||||
main.classList.add("show")
|
||||
})
|
||||
|
||||
|
5
dist/styles/feeds.css
vendored
5
dist/styles/feeds.css
vendored
@ -1,3 +1,7 @@
|
||||
@keyframes slideUp20 {
|
||||
0% { transform: translateY(20px); }
|
||||
100% { transform: translateY(0); }
|
||||
}
|
||||
.article-wrapper {
|
||||
margin: 32px auto 0;
|
||||
width: 860px;
|
||||
@ -6,6 +10,7 @@
|
||||
box-shadow: 0 6.4px 14.4px 0 rgba(0,0,0,.132), 0 1.2px 3.6px 0 rgba(0,0,0,.108);
|
||||
border-radius: 5px;
|
||||
overflow: hidden;
|
||||
animation-name: slideUp20;
|
||||
}
|
||||
.article-container .btn-group .btn {
|
||||
color: #fff;
|
||||
|
5
dist/styles/global.css
vendored
5
dist/styles/global.css
vendored
@ -212,10 +212,7 @@ nav.menu-on .btn-group .btn, nav.hide-btns .btn-group .btn {
|
||||
nav.menu-on .btn-group .btn.system, nav.hide-btns .btn-group .btn.system {
|
||||
display: inline-block;
|
||||
}
|
||||
nav.menu-on .btn-group .btn.system {
|
||||
color: var(--white);
|
||||
}
|
||||
nav.item-on .btn-group .btn.system {
|
||||
nav.menu-on .btn-group .btn.system, nav.item-on .btn-group .btn.system {
|
||||
color: var(--whiteConstant);
|
||||
}
|
||||
.btn-group .btn:hover, .ms-Nav-compositeLink:hover {
|
||||
|
10
dist/styles/main.css
vendored
10
dist/styles/main.css
vendored
@ -5,6 +5,10 @@
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
@keyframes fade {
|
||||
0% { opacity: 0; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
.menu-container, .article-container {
|
||||
position: fixed;
|
||||
z-index: 5;
|
||||
@ -14,6 +18,12 @@
|
||||
height: 100%;
|
||||
background-color: #0008;
|
||||
backdrop-filter: saturate(150%) blur(20px);
|
||||
animation-name: fade;
|
||||
}
|
||||
.menu-container, .article-container, .article-wrapper {
|
||||
animation-duration: 0.5s;
|
||||
animation-timing-function: cubic-bezier(0.1, 0.9, 0.2, 1);
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
.article-container {
|
||||
z-index: 6;
|
||||
|
@ -22,8 +22,8 @@
|
||||
<section class="elevate">
|
||||
<h2>Open & Organized.</h2>
|
||||
<p>
|
||||
Stay in sync with your self-hosted RSS service with Fever API or import
|
||||
your sources from an OPML file and resume reading locally right away.
|
||||
Stay in sync with Feedbin or your self-hosted RSS service compatible with
|
||||
Fever, or import your sources from an OPML file and start reading locally.
|
||||
Easily organize sources with groups. Move between computers with full
|
||||
data backups.
|
||||
</p>
|
||||
|
2
package-lock.json
generated
2
package-lock.json
generated
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "fluent-reader",
|
||||
"version": "0.7.2",
|
||||
"version": "0.7.3",
|
||||
"lockfileVersion": 1,
|
||||
"requires": true,
|
||||
"dependencies": {
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "fluent-reader",
|
||||
"version": "0.7.2",
|
||||
"version": "0.7.3",
|
||||
"description": "Modern desktop RSS reader",
|
||||
"main": "./dist/electron.js",
|
||||
"scripts": {
|
||||
|
@ -42,7 +42,7 @@ class Page extends React.Component<PageProps> {
|
||||
isClickableOutsideFocusTrap={true}
|
||||
className="article-container"
|
||||
onClick={this.props.dismissItem}>
|
||||
<div className={"article-wrapper " + AnimationClassNames.slideUpIn20} onClick={e => e.stopPropagation()}>
|
||||
<div className="article-wrapper" onClick={e => e.stopPropagation()}>
|
||||
<ArticleContainer itemId={this.props.itemId} />
|
||||
</div>
|
||||
{this.props.itemFromFeed && <>
|
||||
|
@ -103,11 +103,11 @@ class AppTab extends React.Component<AppTabProps, AppTabState> {
|
||||
|
||||
languageOptions = (): IDropdownOption[] => [
|
||||
{ key: "default", text: intl.get("followSystem") },
|
||||
{ key: "de", text: "Deutsch" },
|
||||
{ key: "en-US", text: "English" },
|
||||
{ key: "es", text: "Español" },
|
||||
{ key: "fr-FR", text: "Français" },
|
||||
{ key: "zh-CN", text: "中文(简体)" },
|
||||
{ key: "de", text: "Deutsch"}
|
||||
]
|
||||
|
||||
toggleStatus = () => {
|
||||
|
@ -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<ServiceTabProps, ServiceTabState
|
||||
|
||||
serviceOptions = (): IDropdownOption[] => [
|
||||
{ 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<ServiceTabProps, ServiceTabState
|
||||
this.setState({ type: SyncService.None })
|
||||
}
|
||||
|
||||
getConfigsTab = () => {
|
||||
switch (this.state.type) {
|
||||
case SyncService.Fever: return <FeverConfigsTab {...this.props} exit={this.exitConfigsTab} />
|
||||
case SyncService.Feedbin: return <FeedbinConfigsTab {...this.props} exit={this.exitConfigsTab} />
|
||||
default: return null
|
||||
}
|
||||
}
|
||||
|
||||
render = () => (
|
||||
<div className="tab-body">
|
||||
{this.state.type === SyncService.None
|
||||
@ -72,7 +82,7 @@ export class ServiceTab extends React.Component<ServiceTabProps, ServiceTabState
|
||||
style={{marginTop: 32, width: 180}} />
|
||||
</Stack>
|
||||
)
|
||||
: <FeverConfigsTab {...this.props} exit={this.exitConfigsTab} />}
|
||||
: this.getConfigsTab()}
|
||||
</div>
|
||||
)
|
||||
}
|
179
src/components/settings/services/feedbin.tsx
Normal file
179
src/components/settings/services/feedbin.tsx
Normal file
@ -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<ServiceConfigsTabProps, FeedbinConfigsTabState> {
|
||||
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 && (
|
||||
<MessageBar messageBarType={MessageBarType.warning}>{intl.get("service.overwriteWarning")}</MessageBar>
|
||||
)}
|
||||
<Stack horizontalAlign="center" style={{marginTop: 48}}>
|
||||
<svg style={{fill: "var(--black)", width: 32, userSelect: "none"}} viewBox="0 0 120 120"><path d="M116.4,87.2c-22.5-0.1-96.9-0.1-112.4,0c-4.9,0-4.8-22.5,0-23.3c15.6-2.5,60.3,0,60.3,0s16.1,16.3,20.8,16.3 c4.8,0,16.1-16.3,16.1-16.3s12.8-2.3,15.2,0C120.3,67.9,121.2,87.3,116.4,87.2z" /><path d="M110.9,108.8L110.9,108.8c-19.1,2.5-83.6,1.9-103,0c-4.3-0.4-1.5-13.6-1.5-13.6h108.1 C114.4,95.2,116.3,108.1,110.9,108.8z" /><path d="M58.1,9.9C30.6,6.2,7.9,29.1,7.9,51.3l102.6,1C110.6,30.2,85.4,13.6,58.1,9.9z" /></svg>
|
||||
<Label style={{margin: "8px 0 36px"}}>Feedbin</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>Email</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 FeedbinConfigsTab
|
@ -37,7 +37,7 @@ export const enum ImageCallbackTypes {
|
||||
}
|
||||
|
||||
export const enum SyncService {
|
||||
None, Fever
|
||||
None, Fever, Feedbin
|
||||
}
|
||||
export interface ServiceConfigs {
|
||||
type: SyncService
|
||||
|
@ -1,9 +1,9 @@
|
||||
import intl from "react-intl-universal"
|
||||
import { INIT_SOURCES, SourceActionTypes, ADD_SOURCE, UPDATE_SOURCE, DELETE_SOURCE, initSources, SourceOpenTarget } from "./source"
|
||||
import { INIT_SOURCES, SourceActionTypes, ADD_SOURCE, UPDATE_SOURCE, DELETE_SOURCE, initSources, SourceOpenTarget, updateFavicon } from "./source"
|
||||
import { RSSItem, ItemActionTypes, FETCH_ITEMS, fetchItems } from "./item"
|
||||
import { ActionStatus, AppThunk, getWindowBreakpoint, initTouchBarWithTexts } from "../utils"
|
||||
import { INIT_FEEDS, FeedActionTypes, ALL, initFeeds } from "./feed"
|
||||
import { SourceGroupActionTypes, UPDATE_SOURCE_GROUP, ADD_SOURCE_TO_GROUP, DELETE_SOURCE_GROUP, REMOVE_SOURCE_FROM_GROUP, REORDER_SOURCE_GROUPS } from "./group"
|
||||
import { SourceGroupActionTypes, UPDATE_SOURCE_GROUP, ADD_SOURCE_TO_GROUP, DELETE_SOURCE_GROUP, REMOVE_SOURCE_FROM_GROUP, REORDER_SOURCE_GROUPS, fixBrokenGroups } from "./group"
|
||||
import { PageActionTypes, SELECT_PAGE, PageType, selectAllArticles, showItemFromId } from "./page"
|
||||
import { getCurrentLocale } from "../settings"
|
||||
import locales from "../i18n/_locales"
|
||||
@ -140,8 +140,12 @@ export interface SettingsActionTypes {
|
||||
type: typeof TOGGLE_SETTINGS | typeof SAVE_SETTINGS
|
||||
}
|
||||
|
||||
export function closeContextMenu(): ContextMenuActionTypes {
|
||||
return { type: CLOSE_CONTEXT_MENU }
|
||||
export function closeContextMenu(): AppThunk {
|
||||
return (dispatch, getState) => {
|
||||
if (getState().app.contextMenu.type !== ContextMenuType.Hidden) {
|
||||
dispatch({ type: CLOSE_CONTEXT_MENU })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function openItemMenu(item: RSSItem, feedId: string, event: React.MouseEvent): ContextMenuActionTypes {
|
||||
@ -279,14 +283,15 @@ export function initApp(): AppThunk {
|
||||
dispatch(initIntl()).then(async () => {
|
||||
if (window.utils.platform === "darwin") initTouchBarWithTexts()
|
||||
await dispatch(initSources())
|
||||
}).then(() =>
|
||||
dispatch(initFeeds())
|
||||
).then(() => {
|
||||
}).then(() => dispatch(initFeeds()))
|
||||
.then(async () => {
|
||||
dispatch(selectAllArticles())
|
||||
return dispatch(fetchItems())
|
||||
dispatch(fixBrokenGroups())
|
||||
await dispatch(fetchItems())
|
||||
}).then(() => {
|
||||
db.sdb.persistence.compactDatafile()
|
||||
db.idb.persistence.compactDatafile()
|
||||
dispatch(updateFavicon())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -162,6 +162,24 @@ export function toggleGroupExpansion(groupIndex: number): AppThunk {
|
||||
}
|
||||
}
|
||||
|
||||
export function fixBrokenGroups(): AppThunk {
|
||||
return (dispatch, getState) => {
|
||||
const { sources, groups } = getState()
|
||||
const sids = new Set(Object.values(sources).map(s => s.sid))
|
||||
for (let group of groups) {
|
||||
for (let sid of group.sids) {
|
||||
sids.delete(sid)
|
||||
}
|
||||
}
|
||||
if (sids.size > 0) {
|
||||
for (let sid of sids) {
|
||||
groups.push(new SourceGroup([sid]))
|
||||
}
|
||||
dispatch(reorderSourceGroups(groups))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function outlineToSource(outline: Element): [ReturnType<typeof addSource>, string] {
|
||||
let url = outline.getAttribute("xmlUrl")
|
||||
let name = outline.getAttribute("text") || outline.getAttribute("name")
|
||||
|
@ -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<Promise<void>> {
|
||||
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,
|
||||
|
@ -1,19 +1,24 @@
|
||||
import * as db from "../db"
|
||||
import { SyncService, ServiceConfigs } from "../../schema-types"
|
||||
import { AppThunk, ActionStatus } from "../utils"
|
||||
import { RSSItem } from "./item"
|
||||
import { RSSItem, insertItems, fetchItemsSuccess } from "./item"
|
||||
import { saveSettings, pushNotification } from "./app"
|
||||
import { deleteSource, updateUnreadCounts, RSSSource, insertSource, addSourceSuccess,
|
||||
updateSource, updateFavicon } from "./source"
|
||||
import { FilterType, initFeeds } from "./feed"
|
||||
import { createSourceGroup, addSourceToGroup } from "./group"
|
||||
|
||||
import { feverServiceHooks } from "./services/fever"
|
||||
import { saveSettings } from "./app"
|
||||
import { deleteSource } from "./source"
|
||||
import { feedbinServiceHooks } from "./services/feedbin"
|
||||
|
||||
export interface ServiceHooks {
|
||||
authenticate?: (configs: ServiceConfigs) => Promise<boolean>
|
||||
updateSources?: () => AppThunk<Promise<void>>
|
||||
fetchItems?: (background: boolean) => AppThunk<Promise<void>>
|
||||
syncItems?: () => AppThunk<Promise<void>>
|
||||
updateSources?: () => AppThunk<Promise<[RSSSource[], Map<number | string, string>]>>
|
||||
fetchItems?: () => AppThunk<Promise<[RSSItem[], ServiceConfigs]>>
|
||||
syncItems?: () => AppThunk<Promise<[(number | string)[], (number | string)[]]>>
|
||||
markRead?: (item: RSSItem) => AppThunk
|
||||
markUnread?: (item: RSSItem) => AppThunk
|
||||
markAllRead?: (sids?: number[], date?: Date, before?: boolean) => AppThunk
|
||||
markAllRead?: (sids?: number[], date?: Date, before?: boolean) => AppThunk<Promise<void>>
|
||||
star?: (item: RSSItem) => AppThunk
|
||||
unstar?: (item: RSSItem) => AppThunk
|
||||
}
|
||||
@ -21,6 +26,7 @@ export interface ServiceHooks {
|
||||
export function getServiceHooksFromType(type: SyncService): ServiceHooks {
|
||||
switch (type) {
|
||||
case SyncService.Fever: return feverServiceHooks
|
||||
case SyncService.Feedbin: return feedbinServiceHooks
|
||||
default: return {}
|
||||
}
|
||||
}
|
||||
@ -40,9 +46,9 @@ export function syncWithService(background = false): AppThunk<Promise<void>> {
|
||||
type: SYNC_SERVICE,
|
||||
status: ActionStatus.Request
|
||||
})
|
||||
await dispatch(hooks.updateSources())
|
||||
await dispatch(hooks.syncItems())
|
||||
await dispatch(hooks.fetchItems(background))
|
||||
await dispatch(updateSources(hooks.updateSources))
|
||||
await dispatch(syncItems(hooks.syncItems))
|
||||
await dispatch(fetchItems(hooks.fetchItems, background))
|
||||
dispatch({
|
||||
type: SYNC_SERVICE,
|
||||
status: ActionStatus.Success
|
||||
@ -61,6 +67,137 @@ export function syncWithService(background = false): AppThunk<Promise<void>> {
|
||||
}
|
||||
}
|
||||
|
||||
function updateSources(hook: ServiceHooks["updateSources"]): AppThunk<Promise<void>> {
|
||||
return async (dispatch, getState) => {
|
||||
const [sources, groupsMap] = await dispatch(hook())
|
||||
const existing = new Map<number | string, RSSSource>()
|
||||
for (let source of Object.values(getState().sources)) {
|
||||
if (source.serviceRef) {
|
||||
existing.set(source.serviceRef, source)
|
||||
}
|
||||
}
|
||||
const forceSettings = () => {
|
||||
if (!(getState().app.settings.saving)) dispatch(saveSettings())
|
||||
}
|
||||
let promises = sources.map(s => new Promise<RSSSource>((resolve, reject) => {
|
||||
if (existing.has(s.serviceRef)) {
|
||||
const doc = existing.get(s.serviceRef)
|
||||
existing.delete(s.serviceRef)
|
||||
resolve(doc)
|
||||
} else {
|
||||
db.sdb.findOne({ url: s.url }, (err, doc) => {
|
||||
if (err) {
|
||||
reject(err)
|
||||
} else if (doc === null) {
|
||||
// Create a new source
|
||||
forceSettings()
|
||||
dispatch(insertSource(s))
|
||||
.then((inserted) => {
|
||||
inserted.unreadCount = 0
|
||||
resolve(inserted)
|
||||
dispatch(addSourceSuccess(inserted, true))
|
||||
window.settings.saveGroups(getState().groups)
|
||||
dispatch(updateFavicon([inserted.sid]))
|
||||
})
|
||||
.catch((err) => {
|
||||
reject(err)
|
||||
})
|
||||
} else if (doc.serviceRef !== s.serviceRef) {
|
||||
// Mark an existing source as remote and remove all items
|
||||
forceSettings()
|
||||
doc.serviceRef = s.serviceRef
|
||||
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 sourcesResults = (await Promise.all(promises)).filter(s => s)
|
||||
if (groupsMap) {
|
||||
// Add sources to imported groups
|
||||
forceSettings()
|
||||
for (let source of sourcesResults) {
|
||||
if (groupsMap.has(source.serviceRef)) {
|
||||
const gid = dispatch(createSourceGroup(groupsMap.get(source.serviceRef)))
|
||||
dispatch(addSourceToGroup(gid, source.sid))
|
||||
}
|
||||
}
|
||||
const configs = getState().service
|
||||
delete configs.importGroups
|
||||
dispatch(saveServiceConfigs(configs))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function syncItems(hook: ServiceHooks["syncItems"]): AppThunk<Promise<void>> {
|
||||
return async (dispatch, getState) => {
|
||||
const state = getState()
|
||||
const [unreadRefs, starredRefs] = await dispatch(hook())
|
||||
const promises = new Array<Promise<number>>()
|
||||
promises.push(new Promise((resolve) => {
|
||||
db.idb.update({
|
||||
serviceRef: { $exists: true, $in: unreadRefs },
|
||||
hasRead: true
|
||||
}, { $set: { hasRead: false } }, { multi: true }, (_, num) => resolve(num))
|
||||
}))
|
||||
promises.push(new Promise((resolve) => {
|
||||
db.idb.update({
|
||||
serviceRef: { $exists: true, $nin: unreadRefs },
|
||||
hasRead: false
|
||||
}, { $set: { hasRead: true } }, { multi: true }, (_, num) => resolve(num))
|
||||
}))
|
||||
promises.push(new Promise((resolve) => {
|
||||
db.idb.update({
|
||||
serviceRef: { $exists: true, $in: starredRefs },
|
||||
starred: { $exists: false }
|
||||
}, { $set: { starred: true } }, { multi: true }, (_, num) => resolve(num))
|
||||
}))
|
||||
promises.push(new Promise((resolve) => {
|
||||
db.idb.update({
|
||||
serviceRef: { $exists: true, $nin: starredRefs },
|
||||
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(unreadRefs, starredRefs))
|
||||
if (!(state.page.filter.type & FilterType.ShowRead) || !(state.page.filter.type & FilterType.ShowNotStarred)) {
|
||||
dispatch(initFeeds(true))
|
||||
}
|
||||
await dispatch(updateUnreadCounts())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function fetchItems(hook: ServiceHooks["fetchItems"], background: boolean): AppThunk<Promise<void>> {
|
||||
return async (dispatch, getState) => {
|
||||
const [items, configs] = await dispatch(hook())
|
||||
if (items.length > 0) {
|
||||
const inserted = await insertItems(items)
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function importGroups(): AppThunk<Promise<void>> {
|
||||
return async (dispatch, getState) => {
|
||||
const configs = getState().service
|
||||
@ -103,8 +240,8 @@ interface SyncWithServiceAction {
|
||||
|
||||
interface SyncLocalItemsAction {
|
||||
type: typeof SYNC_LOCAL_ITEMS
|
||||
unreadIds: number[]
|
||||
starredIds: number[]
|
||||
unreadIds: (string | number)[]
|
||||
starredIds: (string | number)[]
|
||||
}
|
||||
|
||||
export type ServiceActionTypes = SaveServiceConfigsAction | SyncWithServiceAction | SyncLocalItemsAction
|
||||
@ -119,7 +256,7 @@ export function saveServiceConfigs(configs: ServiceConfigs): AppThunk {
|
||||
}
|
||||
}
|
||||
|
||||
export function syncLocalItems(unread: number[], starred: number[]): ServiceActionTypes {
|
||||
function syncLocalItems(unread: (string | number)[], starred: (string | number)[]): ServiceActionTypes {
|
||||
return {
|
||||
type: SYNC_LOCAL_ITEMS,
|
||||
unreadIds: unread,
|
||||
|
210
src/scripts/models/services/feedbin.ts
Normal file
210
src/scripts/models/services/feedbin.ts
Normal file
@ -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 { 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<Promise<Response>>()
|
||||
while (refs.length > 0) {
|
||||
const batch = new Array<number>()
|
||||
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<number, string>
|
||||
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<string>()
|
||||
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&per_page=125&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 >= 125 &&
|
||||
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 [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<number> = new Set(await unreadResponse.json())
|
||||
const starred: Set<number> = new Set(await starredResponse.json())
|
||||
const parsedItems = items.map(i => {
|
||||
const source = fidMap.get(i.feed_id)
|
||||
const dom = domParser.parseFromString(i.content, "text/html")
|
||||
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: dom.documentElement.textContent.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 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])
|
||||
},
|
||||
}
|
@ -1,13 +1,10 @@
|
||||
import intl from "react-intl-universal"
|
||||
import * as db from "../../db"
|
||||
import { ServiceHooks, saveServiceConfigs, syncLocalItems } from "../service"
|
||||
import { ServiceHooks } 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 { createSourceGroup } from "../group"
|
||||
import { RSSSource } from "../source"
|
||||
import { htmlDecode, domParser } from "../../utils"
|
||||
import { RSSItem } from "../item"
|
||||
import { SourceRule } from "../rule"
|
||||
|
||||
export interface FeverConfigs extends ServiceConfigs {
|
||||
@ -51,8 +48,7 @@ export const feverServiceHooks: ServiceHooks = {
|
||||
},
|
||||
|
||||
updateSources: () => async (dispatch, getState) => {
|
||||
const initState = getState()
|
||||
const configs = initState.service as FeverConfigs
|
||||
const configs = getState().service as FeverConfigs
|
||||
const response = await fetchAPI(configs, "&feeds")
|
||||
const feeds: any[] = response.feeds
|
||||
const feedGroups: any[] = response.feeds_groups
|
||||
@ -62,90 +58,28 @@ export const feverServiceHooks: ServiceHooks = {
|
||||
// Import groups on the first sync
|
||||
const groups: any[] = (await fetchAPI(configs, "&groups")).groups
|
||||
if (groups === undefined || feedGroups === undefined) throw APIError()
|
||||
groupsMap = new Map()
|
||||
const groupsIdMap = new Map<number, string>()
|
||||
for (let group of groups) {
|
||||
dispatch(createSourceGroup(group.title))
|
||||
groupsMap.set(group.id, group.title)
|
||||
const title = group.title.trim()
|
||||
dispatch(createSourceGroup(title))
|
||||
groupsIdMap.set(group.id, 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)
|
||||
groupsMap = new Map()
|
||||
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)))
|
||||
groupsMap.set(fid, groupsIdMap.get(group.group_id))
|
||||
}
|
||||
}
|
||||
}
|
||||
delete configs.importGroups
|
||||
dispatch(saveServiceConfigs(configs))
|
||||
}
|
||||
const sources = feeds.map(f => {
|
||||
const source = new RSSSource(f.url, f.title)
|
||||
source.serviceRef = f.id
|
||||
return source
|
||||
})
|
||||
return [sources, groupsMap]
|
||||
},
|
||||
|
||||
fetchItems: (background) => async (dispatch, getState) => {
|
||||
fetchItems: () => async (_, getState) => {
|
||||
const state = getState()
|
||||
const configs = state.service as FeverConfigs
|
||||
const items = new Array()
|
||||
@ -199,7 +133,7 @@ export const feverServiceHooks: ServiceHooks = {
|
||||
let img = dom.querySelector("img")
|
||||
if (img && img.src) {
|
||||
item.thumb = img.src
|
||||
} else {
|
||||
} else if (configs.useInt32) { // TTRSS Fever Plugin attachments
|
||||
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
|
||||
@ -212,61 +146,24 @@ export const feverServiceHooks: ServiceHooks = {
|
||||
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))
|
||||
return [parsedItems, configs]
|
||||
} else {
|
||||
return [[], 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")
|
||||
syncItems: () => async (_, getState) => {
|
||||
const configs = getState().service as FeverConfigs
|
||||
const [unreadResponse, starredResponse] = await Promise.all([
|
||||
fetchAPI(configs, "&unread_item_ids"),
|
||||
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())
|
||||
}
|
||||
return [unreadFids, starredFids]
|
||||
},
|
||||
|
||||
markAllRead: (sids, date, before) => async (_, getState) => {
|
||||
|
@ -35,14 +35,8 @@ export class RSSSource {
|
||||
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<RSSItem> {
|
||||
return new Promise<RSSItem>((resolve, reject) => {
|
||||
@ -238,34 +232,30 @@ export function insertSource(source: RSSSource): AppThunk<Promise<RSSSource>> {
|
||||
}
|
||||
|
||||
export function addSource(url: string, name: string = null, batch = false): AppThunk<Promise<number>> {
|
||||
return (dispatch, getState) => {
|
||||
let app = getState().app
|
||||
return async (dispatch, getState) => {
|
||||
const 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 => {
|
||||
const source = new RSSSource(url, name)
|
||||
try {
|
||||
const feed = await RSSSource.fetchMetaData(source)
|
||||
const inserted = await dispatch(insertSource(source))
|
||||
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(() => {
|
||||
dispatch(updateFavicon([inserted.sid]))
|
||||
const items = await RSSSource.checkItems(inserted, feed.items)
|
||||
await insertItems(items)
|
||||
return inserted.sid
|
||||
})
|
||||
})
|
||||
})
|
||||
.catch(e => {
|
||||
} catch (e) {
|
||||
dispatch(addSourceFailure(e, batch))
|
||||
if (!batch) {
|
||||
window.utils.showErrorBox(intl.get("sources.errorAdd"), String(e))
|
||||
}
|
||||
return Promise.reject(e)
|
||||
})
|
||||
throw e
|
||||
}
|
||||
return new Promise((_, reject) => { reject("Sources not initialized.") })
|
||||
}
|
||||
throw new Error("Sources not initialized.")
|
||||
}
|
||||
}
|
||||
|
||||
@ -335,6 +325,27 @@ export function deleteSources(sources: RSSSource[]): AppThunk<Promise<void>> {
|
||||
}
|
||||
}
|
||||
|
||||
export function updateFavicon(sids?: number[], force=false): AppThunk<Promise<void>> {
|
||||
return async (dispatch, getState) => {
|
||||
const initSources = getState().sources
|
||||
if (!sids) {
|
||||
sids = Object.values(initSources).filter(s => s.iconurl === undefined).map(s => s.sid)
|
||||
} else {
|
||||
sids = sids.filter(sid => sid in initSources)
|
||||
}
|
||||
const promises = sids.map(async sid => {
|
||||
const url = initSources[sid].url
|
||||
let favicon = (await fetchFavicon(url)) || ""
|
||||
const source = getState().sources[sid]
|
||||
if (source && source.url === url && (force || source.iconurl === undefined)) {
|
||||
source.iconurl = favicon
|
||||
await dispatch(updateSource(source))
|
||||
}
|
||||
})
|
||||
await Promise.all(promises)
|
||||
}
|
||||
}
|
||||
|
||||
export function sourceReducer(
|
||||
state: SourceState = {},
|
||||
action: SourceActionTypes | ItemActionTypes
|
||||
|
@ -52,6 +52,8 @@ window.settings.addThemeUpdateListener((shouldDark) => {
|
||||
|
||||
export function getCurrentLocale() {
|
||||
let locale = window.settings.getCurrentLocale()
|
||||
if (locale in locales) return locale
|
||||
locale = locale.split("-")[0]
|
||||
return (locale in locales) ? locale : "en-US"
|
||||
}
|
||||
|
||||
|
@ -75,6 +75,7 @@ export const domParser = new DOMParser()
|
||||
|
||||
export async function fetchFavicon(url: string) {
|
||||
try {
|
||||
url = url.split("/").slice(0, 3).join("/")
|
||||
let result = await fetch(url, { credentials: "omit" })
|
||||
if (result.ok) {
|
||||
let html = await result.text()
|
||||
|
Loading…
x
Reference in New Issue
Block a user