commit
eeb5c53e9f
|
@ -1,4 +0,0 @@
|
|||
### We respect your privacy.
|
||||
|
||||
- We do not under any form collect, store, or share any personal imformation from users of the app.
|
||||
- A temporary cookies session that would be destoyed when the app closes is used to minimize third-party tracking when displaying third party contents.
|
|
@ -1,5 +1,5 @@
|
|||
html, body {
|
||||
font-family: "Segoe UI Regular", "Source Han Sans SC Regular", "Microsoft YaHei", sans-serif;
|
||||
font-family: "Segoe UI", "Source Han Sans SC Regular", "Microsoft YaHei", sans-serif;
|
||||
}
|
||||
html {
|
||||
overflow: hidden scroll;
|
||||
|
@ -19,6 +19,9 @@ html {
|
|||
}
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6, b, strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
a {
|
||||
color: var(--primary);
|
||||
text-decoration: none;
|
||||
|
@ -63,4 +66,9 @@ article figure figcaption {
|
|||
}
|
||||
article iframe {
|
||||
width: 100%;
|
||||
}
|
||||
article code {
|
||||
font-family: Monaco, Consolas, monospace;
|
||||
font-size: .875rem;
|
||||
line-height: 1;
|
||||
}
|
|
@ -40,7 +40,7 @@
|
|||
|
||||
html, body {
|
||||
background-color: transparent;
|
||||
font-family: "Segoe UI Regular", "Source Han Sans SC Regular", "Microsoft YaHei", sans-serif;
|
||||
font-family: "Segoe UI", "Source Han Sans SC Regular", "Microsoft YaHei", sans-serif;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
|
@ -316,12 +316,19 @@ div[role="tabpanel"] {
|
|||
.tab-body .ms-ChoiceFieldGroup {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.tab-body .ms-CommandBar {
|
||||
padding: 0;
|
||||
}
|
||||
img.favicon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
vertical-align: middle;
|
||||
user-select: none;
|
||||
}
|
||||
img.favicon.dropdown {
|
||||
margin-right: 8px;
|
||||
vertical-align: sub;
|
||||
}
|
||||
.ms-DetailsList-contentWrapper {
|
||||
max-height: 400px;
|
||||
overflow-x: hidden;
|
||||
|
@ -379,6 +386,10 @@ img.favicon {
|
|||
height: 28px;
|
||||
box-shadow: 0 1.6px 3.6px 0 rgba(0,0,0,.132), 0 0.3px 0.9px 0 rgba(0,0,0,.108);
|
||||
}
|
||||
.list-main .article-search {
|
||||
max-width: 294px;
|
||||
margin: 4px 10px;
|
||||
}
|
||||
|
||||
@media (min-width: 1441px) {
|
||||
#root > nav.menu-on {
|
||||
|
@ -425,6 +436,10 @@ img.favicon {
|
|||
left: 280px;
|
||||
max-width: calc(100% - 728px);
|
||||
}
|
||||
.list-main.menu-on .article-search {
|
||||
left: 0;
|
||||
width: 330px;
|
||||
}
|
||||
|
||||
nav.hide-btns .btn-group .btn, nav.menu-on .btn-group .btn.hide-wide, .menu .btn-group .btn.hide-wide {
|
||||
display: none;
|
||||
|
@ -539,11 +554,6 @@ img.favicon {
|
|||
overflow: hidden;
|
||||
background: var(--white);
|
||||
}
|
||||
.list-main .article-search {
|
||||
left: 0;
|
||||
max-width: 330px;
|
||||
margin: 4px 10px;
|
||||
}
|
||||
.list-feed-container {
|
||||
width: 350px;
|
||||
background-color: var(--neutralLighterAlt);
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "fluent-reader",
|
||||
"version": "0.3.3",
|
||||
"version": "0.4.0",
|
||||
"description": "A simplistic, modern desktop RSS reader",
|
||||
"main": "./dist/electron.js",
|
||||
"scripts": {
|
||||
|
@ -13,7 +13,7 @@
|
|||
},
|
||||
"keywords": [],
|
||||
"author": "Haoyuan Liu",
|
||||
"license": "BSD",
|
||||
"license": "BSD-3-Clause",
|
||||
"build": {
|
||||
"appId": "me.hyliu.fluentreader",
|
||||
"productName": "Fluent Reader",
|
||||
|
@ -25,7 +25,8 @@
|
|||
"win": {
|
||||
"target": [
|
||||
"nsis",
|
||||
"appx"
|
||||
"appx",
|
||||
"zip"
|
||||
]
|
||||
},
|
||||
"appx": {
|
||||
|
|
|
@ -7,6 +7,7 @@ import { Pivot, PivotItem, Spinner } from "@fluentui/react"
|
|||
import SourcesTabContainer from "../containers/settings/sources-container"
|
||||
import GroupsTabContainer from "../containers/settings/groups-container"
|
||||
import AppTabContainer from "../containers/settings/app-container"
|
||||
import RulesTabContainer from "../containers/settings/rules-container"
|
||||
|
||||
type SettingsProps = {
|
||||
display: boolean,
|
||||
|
@ -38,6 +39,9 @@ class Settings extends React.Component<SettingsProps> {
|
|||
<PivotItem headerText={intl.get("settings.grouping")} itemIcon="GroupList">
|
||||
<GroupsTabContainer />
|
||||
</PivotItem>
|
||||
<PivotItem headerText={intl.get("settings.rules")} itemIcon="FilterSettings">
|
||||
<RulesTabContainer />
|
||||
</PivotItem>
|
||||
<PivotItem headerText={intl.get("settings.app")} itemIcon="Settings">
|
||||
<AppTabContainer />
|
||||
</PivotItem>
|
||||
|
|
|
@ -9,7 +9,7 @@ class AboutTab extends React.Component {
|
|||
<div className="tab-body">
|
||||
<Stack className="settings-about" horizontalAlign="center">
|
||||
<img src="icons/logo.svg" style={{width: 120, height: 120}} />
|
||||
<h3>Fluent Reader</h3>
|
||||
<h3 style={{fontWeight: 600}}>Fluent Reader</h3>
|
||||
<small>{intl.get("settings.version")} {remote.app.getVersion()}</small>
|
||||
<p className="settings-hint">Copyright © 2020 Haoyuan Liu. All rights reserved.</p>
|
||||
<Stack horizontal horizontalAlign="center" tokens={{childrenGap: 12}}>
|
||||
|
|
|
@ -80,8 +80,8 @@ class AppTab extends React.Component<AppTabProps, AppTabState> {
|
|||
languageOptions = (): IDropdownOption[] => [
|
||||
{ key: "default", text: intl.get("followSystem") },
|
||||
{ key: "en-US", text: "English" },
|
||||
{ key: "zh-CN", text: "中文(简体)"},
|
||||
{ key: "fr-FR", text: "Français"},
|
||||
{ key: "zh-CN", text: "中文(简体)"},
|
||||
]
|
||||
|
||||
toggleStatus = () => {
|
||||
|
|
|
@ -160,7 +160,7 @@ class GroupsTab extends React.Component<GroupsTabProps, GroupsTabState> {
|
|||
let groups = this.props.groups.filter(g => g.index != draggedItem.index)
|
||||
|
||||
groups.splice(insertIndex, 0, draggedItem)
|
||||
|
||||
this.groupSelection.setAllSelected(false)
|
||||
this.props.reorderGroups(groups)
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,391 @@
|
|||
import * as React from "react"
|
||||
import intl = require("react-intl-universal")
|
||||
import { SourceState, RSSSource } from "../../scripts/models/source"
|
||||
import { Stack, Label, Dropdown, IDropdownOption, TextField, PrimaryButton, Icon, DropdownMenuItemType,
|
||||
DefaultButton, DetailsList, IColumn, CommandBar, ICommandBarItemProps, Selection, SelectionMode, MarqueeSelection, IDragDropEvents } from "@fluentui/react"
|
||||
import { SourceRule, RuleActions } from "../../scripts/models/rule"
|
||||
import { FilterType } from "../../scripts/models/feed"
|
||||
import { validateRegex } from "../../scripts/utils"
|
||||
import { RSSItem } from "../../scripts/models/item"
|
||||
import Parser = require("@yang991178/rss-parser")
|
||||
|
||||
const actionKeyMap = {
|
||||
"r-true": "article.markRead",
|
||||
"r-false": "article.markUnread",
|
||||
"s-true": "article.star",
|
||||
"s-false": "article.unstar",
|
||||
"h-true": "article.hide",
|
||||
"h-false": "article.unhide",
|
||||
}
|
||||
|
||||
type RulesTabProps = {
|
||||
sources: SourceState
|
||||
updateSourceRules: (source: RSSSource, rules: SourceRule[]) => void
|
||||
}
|
||||
|
||||
type RulesTabState = {
|
||||
sid: string
|
||||
selectedRules: number[]
|
||||
editIndex: number
|
||||
regex: string
|
||||
fullSearch: boolean
|
||||
match: boolean
|
||||
actionKeys: string[]
|
||||
mockTitle: string
|
||||
mockContent: string
|
||||
mockResult: string
|
||||
}
|
||||
|
||||
class RulesTab extends React.Component<RulesTabProps, RulesTabState> {
|
||||
rulesSelection: Selection
|
||||
rulesDragDropEvents: IDragDropEvents
|
||||
rulesDraggedItem: SourceRule
|
||||
rulesDraggedIndex = -1
|
||||
|
||||
constructor(props) {
|
||||
super(props)
|
||||
this.state = {
|
||||
sid: null,
|
||||
selectedRules: [],
|
||||
editIndex: -1,
|
||||
regex: "",
|
||||
fullSearch: false,
|
||||
match: true,
|
||||
actionKeys: [],
|
||||
mockTitle: "",
|
||||
mockContent: "",
|
||||
mockResult: ""
|
||||
}
|
||||
this.rulesSelection = new Selection({
|
||||
getKey: (_, i) => i,
|
||||
onSelectionChanged: () => {
|
||||
this.setState({selectedRules: this.rulesSelection.getSelectedIndices()})
|
||||
}
|
||||
})
|
||||
this.rulesDragDropEvents = this.getRulesDragDropEvents()
|
||||
}
|
||||
|
||||
getRulesDragDropEvents = (): IDragDropEvents => ({
|
||||
canDrop: () => true,
|
||||
canDrag: () => true,
|
||||
onDrop: (item?: SourceRule) => {
|
||||
if (this.rulesDraggedItem) {
|
||||
this.reorderRules(item)
|
||||
}
|
||||
},
|
||||
onDragStart: (item?: SourceRule, itemIndex?: number) => {
|
||||
this.rulesDraggedItem = item
|
||||
this.rulesDraggedIndex = itemIndex!
|
||||
},
|
||||
onDragEnd: () => {
|
||||
this.rulesDraggedItem = undefined
|
||||
this.rulesDraggedIndex = -1
|
||||
},
|
||||
})
|
||||
|
||||
reorderRules = (item: SourceRule) => {
|
||||
let rules = this.getSourceRules()
|
||||
let draggedItems = this.rulesSelection.isIndexSelected(this.rulesDraggedIndex)
|
||||
? this.rulesSelection.getSelection() as SourceRule[]
|
||||
: [this.rulesDraggedItem]
|
||||
|
||||
let insertIndex = rules.indexOf(item)
|
||||
let items = rules.filter(r => !draggedItems.includes(r))
|
||||
|
||||
items.splice(insertIndex, 0, ...draggedItems)
|
||||
this.rulesSelection.setAllSelected(false)
|
||||
let source = this.props.sources[parseInt(this.state.sid)]
|
||||
this.props.updateSourceRules(source, items)
|
||||
}
|
||||
|
||||
initRuleEdit = (rule: SourceRule = null) => {
|
||||
this.setState({
|
||||
regex: rule ? rule.filter.search : "",
|
||||
fullSearch: rule ? Boolean(rule.filter.type & FilterType.FullSearch) : false,
|
||||
match: rule ? rule.match : true,
|
||||
actionKeys: rule ? RuleActions.toKeys(rule.actions) : []
|
||||
})
|
||||
}
|
||||
|
||||
getSourceRules = () => this.props.sources[parseInt(this.state.sid)].rules
|
||||
|
||||
ruleColumns = (): IColumn[] => [
|
||||
{
|
||||
isRowHeader: true,
|
||||
key: "regex",
|
||||
name: intl.get("rules.regex"),
|
||||
minWidth: 100,
|
||||
maxWidth: 200,
|
||||
onRender: (rule: SourceRule) => rule.filter.search
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
name: intl.get("rules.action"),
|
||||
minWidth: 100,
|
||||
onRender: (rule: SourceRule) => RuleActions.toKeys(rule.actions).map(k => intl.get(actionKeyMap[k])).join(", ")
|
||||
}
|
||||
]
|
||||
|
||||
handleInputChange = (event) => {
|
||||
const name = event.target.name as "regex"
|
||||
this.setState({[name]: event.target.value})
|
||||
}
|
||||
|
||||
sourceOptions = (): IDropdownOption[] => Object.entries(this.props.sources).map(([sid, s]) => ({
|
||||
key: sid,
|
||||
text: s.name,
|
||||
data: { icon: s.iconurl }
|
||||
}))
|
||||
onRenderSourceOption = (option: IDropdownOption) => (
|
||||
<div>
|
||||
{option.data && option.data.icon && (
|
||||
<img src={option.data.icon} className="favicon dropdown"/>
|
||||
)}
|
||||
<span>{option.text}</span>
|
||||
</div>
|
||||
)
|
||||
onRenderSourceTitle = (options: IDropdownOption[]) => {
|
||||
return this.onRenderSourceOption(options[0])
|
||||
}
|
||||
onSourceOptionChange = (_, item: IDropdownOption) => {
|
||||
this.initRuleEdit()
|
||||
this.rulesSelection.setAllSelected(false)
|
||||
this.setState({
|
||||
sid: item.key as string, selectedRules: [], editIndex: -1,
|
||||
mockTitle: "", mockContent: "", mockResult: ""
|
||||
})
|
||||
}
|
||||
|
||||
searchOptions = (): IDropdownOption[] => [
|
||||
{ key: 0, text: intl.get("rules.title") },
|
||||
{ key: 1, text: intl.get("rules.fullSearch") }
|
||||
]
|
||||
onSearchOptionChange = (_, item: IDropdownOption) => {
|
||||
this.setState({ fullSearch: Boolean(item.key) })
|
||||
}
|
||||
|
||||
matchOptions = (): IDropdownOption[] => [
|
||||
{ key: 1, text: intl.get("rules.match") },
|
||||
{ key: 0, text: intl.get("rules.notMatch") }
|
||||
]
|
||||
onMatchOptionChange = (_, item: IDropdownOption) => {
|
||||
this.setState({ match: Boolean(item.key) })
|
||||
}
|
||||
|
||||
actionOptions = (): IDropdownOption[] => [
|
||||
...Object.entries(actionKeyMap).map(([k, t], i) => {
|
||||
if (k.includes("-false")) {
|
||||
return [{ key: k, text: intl.get(t) }, { key: i, text: "-", itemType: DropdownMenuItemType.Divider }]
|
||||
} else {
|
||||
return [{ key: k, text: intl.get(t) }]
|
||||
}
|
||||
})
|
||||
].flat(1)
|
||||
|
||||
onActionOptionChange = (_, item: IDropdownOption) => {
|
||||
if (item.selected) {
|
||||
this.setState(prevState => {
|
||||
let [a, f] = (item.key as string).split("-")
|
||||
let keys = prevState.actionKeys.filter(k => !k.startsWith(`${a}-`))
|
||||
keys.push(item.key as string)
|
||||
return { actionKeys: keys }
|
||||
})
|
||||
} else {
|
||||
this.setState(prevState => ({ actionKeys: prevState.actionKeys.filter(k => k !== item.key) }))
|
||||
}
|
||||
}
|
||||
|
||||
validateRegexField = (value: string) => {
|
||||
if (value.length === 0) return intl.get("emptyField")
|
||||
else if (validateRegex(value) === null) return intl.get("rules.badRegex")
|
||||
else return ""
|
||||
}
|
||||
|
||||
saveRule = () => {
|
||||
let rule = new SourceRule(this.state.regex, this.state.actionKeys, this.state.fullSearch, this.state.match)
|
||||
let source = this.props.sources[parseInt(this.state.sid)]
|
||||
let rules = source.rules ? [ ...source.rules ] : []
|
||||
if (this.state.editIndex === -1) {
|
||||
rules.push(rule)
|
||||
} else {
|
||||
rules.splice(this.state.editIndex, 1, rule)
|
||||
}
|
||||
this.props.updateSourceRules(source, rules)
|
||||
this.setState({ editIndex: -1 })
|
||||
this.initRuleEdit()
|
||||
}
|
||||
newRule = () => {
|
||||
this.initRuleEdit()
|
||||
this.setState({ editIndex: this.getSourceRules().length })
|
||||
}
|
||||
editRule = (rule: SourceRule, index: number) => {
|
||||
this.initRuleEdit(rule)
|
||||
this.setState({ editIndex: index })
|
||||
}
|
||||
deleteRules = () => {
|
||||
let rules = this.getSourceRules()
|
||||
for (let i of this.state.selectedRules) rules[i] = null
|
||||
let source = this.props.sources[parseInt(this.state.sid)]
|
||||
this.props.updateSourceRules(source, rules.filter(r => r !== null))
|
||||
this.initRuleEdit()
|
||||
}
|
||||
|
||||
commandBarItems = (): ICommandBarItemProps[] => [{
|
||||
key: "new", text: intl.get("rules.new"), iconProps: { iconName: "Add" },
|
||||
onClick: this.newRule
|
||||
}]
|
||||
commandBarFarItems = (): ICommandBarItemProps[] => {
|
||||
let items = []
|
||||
if (this.state.selectedRules.length === 1) {
|
||||
let index = this.state.selectedRules[0]
|
||||
items.push({
|
||||
key: "edit", text: intl.get("edit"), iconProps: { iconName: "Edit" },
|
||||
onClick: () => this.editRule(this.getSourceRules()[index], index)
|
||||
})
|
||||
}
|
||||
if (this.state.selectedRules.length > 0) {
|
||||
items.push({
|
||||
key: "del", text: intl.get("delete"),
|
||||
iconProps: { iconName: "Delete", style: { color: "#d13438" } },
|
||||
onClick: this.deleteRules
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
testMockItem = () => {
|
||||
let parsed = { title: this.state.mockTitle }
|
||||
let source = this.props.sources[parseInt(this.state.sid)]
|
||||
let item = new RSSItem(parsed as Parser.Item, source)
|
||||
item.snippet = this.state.mockContent
|
||||
SourceRule.applyAll(this.getSourceRules(), item)
|
||||
let result = []
|
||||
result.push(intl.get(item.hasRead ? "article.markRead" : "article.markUnread"))
|
||||
if (item.starred) result.push(intl.get("article.star"))
|
||||
if (item.hidden) result.push(intl.get("article.hide"))
|
||||
this.setState({ mockResult: result.join(", ") })
|
||||
}
|
||||
|
||||
render = () => (
|
||||
<div className="tab-body">
|
||||
<Stack horizontal tokens={{childrenGap: 16}}>
|
||||
<Stack.Item>
|
||||
<Label>{intl.get("rules.source")}</Label>
|
||||
</Stack.Item>
|
||||
<Stack.Item grow>
|
||||
<Dropdown
|
||||
placeholder={intl.get("rules.selectSource")}
|
||||
options={this.sourceOptions()}
|
||||
onRenderOption={this.onRenderSourceOption}
|
||||
onRenderTitle={this.onRenderSourceTitle}
|
||||
selectedKey={this.state.sid}
|
||||
onChange={this.onSourceOptionChange} />
|
||||
</Stack.Item>
|
||||
</Stack>
|
||||
|
||||
{this.state.sid && (
|
||||
this.state.editIndex > -1 || !this.getSourceRules() || this.getSourceRules().length === 0
|
||||
? <>
|
||||
<Label>
|
||||
{intl.get((this.state.editIndex >= 0 && this.state.editIndex < this.getSourceRules().length) ? "edit" : "rules.new")}
|
||||
</Label>
|
||||
<Stack horizontal>
|
||||
<Stack.Item>
|
||||
<Label>{intl.get("rules.if")}</Label>
|
||||
</Stack.Item>
|
||||
<Stack.Item>
|
||||
<Dropdown
|
||||
options={this.searchOptions()}
|
||||
selectedKey={this.state.fullSearch ? 1 : 0}
|
||||
onChange={this.onSearchOptionChange}
|
||||
style={{width: 140}} />
|
||||
</Stack.Item>
|
||||
<Stack.Item>
|
||||
<Dropdown
|
||||
options={this.matchOptions()}
|
||||
selectedKey={this.state.match ? 1 : 0}
|
||||
onChange={this.onMatchOptionChange}
|
||||
style={{width: 130}} />
|
||||
</Stack.Item>
|
||||
<Stack.Item grow>
|
||||
<TextField
|
||||
name="regex"
|
||||
placeholder={intl.get("rules.regex")}
|
||||
value={this.state.regex}
|
||||
onGetErrorMessage={this.validateRegexField}
|
||||
validateOnLoad={false}
|
||||
onChange={this.handleInputChange} />
|
||||
</Stack.Item>
|
||||
</Stack>
|
||||
<Stack horizontal>
|
||||
<Stack.Item>
|
||||
<Label>{intl.get("rules.then")}</Label>
|
||||
</Stack.Item>
|
||||
<Stack.Item grow>
|
||||
<Dropdown multiSelect
|
||||
placeholder={intl.get("rules.selectAction")}
|
||||
options={this.actionOptions()}
|
||||
selectedKeys={this.state.actionKeys}
|
||||
onChange={this.onActionOptionChange}
|
||||
onRenderCaretDown={() => <Icon iconName="CirclePlus" />} />
|
||||
</Stack.Item>
|
||||
</Stack>
|
||||
<Stack horizontal>
|
||||
<Stack.Item>
|
||||
<PrimaryButton
|
||||
disabled={this.state.regex.length == 0 || validateRegex(this.state.regex) === null || this.state.actionKeys.length == 0}
|
||||
text={intl.get("confirm")}
|
||||
onClick={this.saveRule} />
|
||||
</Stack.Item>
|
||||
{this.state.editIndex > -1 && <Stack.Item>
|
||||
<DefaultButton
|
||||
text={intl.get("cancel")}
|
||||
onClick={() => this.setState({ editIndex: -1 })} />
|
||||
</Stack.Item>}
|
||||
</Stack>
|
||||
</>
|
||||
: <>
|
||||
<CommandBar
|
||||
items={this.commandBarItems()}
|
||||
farItems={this.commandBarFarItems()} />
|
||||
<MarqueeSelection selection={this.rulesSelection} isDraggingConstrainedToRoot>
|
||||
<DetailsList compact
|
||||
columns={this.ruleColumns()}
|
||||
items={this.getSourceRules()}
|
||||
onItemInvoked={this.editRule}
|
||||
dragDropEvents={this.rulesDragDropEvents}
|
||||
setKey="selected"
|
||||
selection={this.rulesSelection}
|
||||
selectionMode={SelectionMode.multiple} />
|
||||
</MarqueeSelection>
|
||||
<span className="settings-hint up">{intl.get("rules.hint")}</span>
|
||||
|
||||
<Label>{intl.get("rules.test")}</Label>
|
||||
<Stack horizontal>
|
||||
<Stack.Item grow>
|
||||
<TextField
|
||||
name="mockTitle"
|
||||
placeholder={intl.get("rules.title")}
|
||||
value={this.state.mockTitle}
|
||||
onChange={this.handleInputChange} />
|
||||
</Stack.Item>
|
||||
<Stack.Item grow>
|
||||
<TextField
|
||||
name="mockContent"
|
||||
placeholder={intl.get("rules.content")}
|
||||
value={this.state.mockContent}
|
||||
onChange={this.handleInputChange} />
|
||||
</Stack.Item>
|
||||
<Stack.Item>
|
||||
<PrimaryButton
|
||||
text={intl.get("confirm")}
|
||||
onClick={this.testMockItem} />
|
||||
</Stack.Item>
|
||||
</Stack>
|
||||
<span className="settings-hint up">{this.state.mockResult}</span>
|
||||
</>)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default RulesTab
|
|
@ -13,6 +13,7 @@ type SourcesTabProps = {
|
|||
updateSourceOpenTarget: (source: RSSSource, target: SourceOpenTarget) => void
|
||||
updateFetchFrequency: (source: RSSSource, frequency: number) => void
|
||||
deleteSource: (source: RSSSource) => void
|
||||
deleteSources: (sources: RSSSource[]) => void
|
||||
importOPML: () => void
|
||||
exportOPML: () => void
|
||||
}
|
||||
|
@ -20,7 +21,8 @@ type SourcesTabProps = {
|
|||
type SourcesTabState = {
|
||||
[formName: string]: string
|
||||
} & {
|
||||
selectedSource: RSSSource
|
||||
selectedSource: RSSSource,
|
||||
selectedSources: RSSSource[]
|
||||
}
|
||||
|
||||
class SourcesTab extends React.Component<SourcesTabProps, SourcesTabState> {
|
||||
|
@ -31,15 +33,18 @@ class SourcesTab extends React.Component<SourcesTabProps, SourcesTabState> {
|
|||
this.state = {
|
||||
newUrl: "",
|
||||
newSourceName: "",
|
||||
selectedSource: null
|
||||
selectedSource: null,
|
||||
selectedSources: null
|
||||
}
|
||||
this.selection = new Selection({
|
||||
getKey: s => (s as RSSSource).sid,
|
||||
onSelectionChanged: () => {
|
||||
let source = this.selection.getSelectedCount() ? this.selection.getSelection()[0] as RSSSource : null
|
||||
let count = this.selection.getSelectedCount()
|
||||
let sources = count ? this.selection.getSelection() as RSSSource[] : null
|
||||
this.setState({
|
||||
selectedSource: source,
|
||||
newSourceName: source ? source.name : ""
|
||||
selectedSource: count === 1 ? sources[0] : null,
|
||||
selectedSources: count > 1 ? sources : null,
|
||||
newSourceName: count === 1 ? sources[0].name : ""
|
||||
})
|
||||
}
|
||||
})
|
||||
|
@ -99,6 +104,12 @@ class SourcesTab extends React.Component<SourcesTabProps, SourcesTabState> {
|
|||
{ key: String(SourceOpenTarget.External), text: intl.get("openExternal") }
|
||||
]
|
||||
|
||||
updateSourceName = () => {
|
||||
let newName = this.state.newSourceName.trim()
|
||||
this.props.updateSourceName(this.state.selectedSource, newName)
|
||||
this.setState({selectedSource: {...this.state.selectedSource, name: newName} as RSSSource})
|
||||
}
|
||||
|
||||
handleInputChange = (event) => {
|
||||
const name: string = event.target.name
|
||||
this.setState({[name]: event.target.value})
|
||||
|
@ -151,12 +162,13 @@ class SourcesTab extends React.Component<SourcesTabProps, SourcesTabState> {
|
|||
</form>
|
||||
|
||||
<DetailsList
|
||||
compact={Object.keys(this.props.sources).length >= 10}
|
||||
items={Object.values(this.props.sources)}
|
||||
columns={this.columns()}
|
||||
getKey={s => s.sid}
|
||||
setKey="selected"
|
||||
selection={this.selection}
|
||||
selectionMode={SelectionMode.single} />
|
||||
selectionMode={SelectionMode.multiple} />
|
||||
|
||||
{this.state.selectedSource && <>
|
||||
<Label>{intl.get("sources.selected")}</Label>
|
||||
|
@ -173,7 +185,7 @@ class SourcesTab extends React.Component<SourcesTabProps, SourcesTabState> {
|
|||
<Stack.Item>
|
||||
<DefaultButton
|
||||
disabled={this.state.newSourceName.trim().length == 0}
|
||||
onClick={() => this.props.updateSourceName(this.state.selectedSource, this.state.newSourceName.trim())}
|
||||
onClick={this.updateSourceName}
|
||||
text={intl.get("sources.editName")} />
|
||||
</Stack.Item>
|
||||
</Stack>
|
||||
|
@ -204,6 +216,19 @@ class SourcesTab extends React.Component<SourcesTabProps, SourcesTabState> {
|
|||
</Stack.Item>
|
||||
</Stack>
|
||||
</>}
|
||||
{this.state.selectedSources && <>
|
||||
<Label>{intl.get("sources.selectedMulti")}</Label>
|
||||
<Stack horizontal>
|
||||
<Stack.Item>
|
||||
<DangerButton
|
||||
onClick={() => this.props.deleteSources(this.state.selectedSources)}
|
||||
text={intl.get("sources.delete")} />
|
||||
</Stack.Item>
|
||||
<Stack.Item>
|
||||
<span className="settings-hint">{intl.get("sources.deleteWarning")}</span>
|
||||
</Stack.Item>
|
||||
</Stack>
|
||||
</>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -3,7 +3,7 @@ import intl = require("react-intl-universal")
|
|||
import { connect } from "react-redux"
|
||||
import { RootState } from "../../scripts/reducer"
|
||||
import { SearchBox, ISearchBox, Async } from "@fluentui/react"
|
||||
import { AppDispatch } from "../../scripts/utils"
|
||||
import { AppDispatch, validateRegex } from "../../scripts/utils"
|
||||
import { performSearch } from "../../scripts/models/page"
|
||||
|
||||
type SearchProps = {
|
||||
|
@ -23,12 +23,8 @@ class ArticleSearch extends React.Component<SearchProps, SearchState> {
|
|||
constructor(props: SearchProps) {
|
||||
super(props)
|
||||
this.debouncedSearch = new Async().debounce((query: string) => {
|
||||
try {
|
||||
RegExp(query)
|
||||
props.dispatch(performSearch(query))
|
||||
} catch {
|
||||
// console.log("Invalid regex")
|
||||
}
|
||||
let regex = validateRegex(query)
|
||||
if (regex !== null) props.dispatch(performSearch(query))
|
||||
}, 750)
|
||||
this.inputRef = React.createRef<ISearchBox>()
|
||||
this.state = { query: props.initQuery }
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
import { remote } from "electron"
|
||||
import { connect } from "react-redux"
|
||||
import { createSelector } from "reselect"
|
||||
import { RootState } from "../../scripts/reducer"
|
||||
|
|
|
@ -0,0 +1,26 @@
|
|||
import { connect } from "react-redux"
|
||||
import { createSelector } from "reselect"
|
||||
import { RootState } from "../../scripts/reducer"
|
||||
import RulesTab from "../../components/settings/rules"
|
||||
import { AppDispatch } from "../../scripts/utils"
|
||||
import { RSSSource, updateSource } from "../../scripts/models/source"
|
||||
import { SourceRule } from "../../scripts/models/rule"
|
||||
|
||||
const getSources = (state: RootState) => state.sources
|
||||
|
||||
const mapStateToProps = createSelector(
|
||||
[getSources],
|
||||
(sources) => ({
|
||||
sources: sources
|
||||
})
|
||||
)
|
||||
|
||||
const mapDispatchToProps = (dispatch: AppDispatch) => ({
|
||||
updateSourceRules: (source: RSSSource, rules: SourceRule[]) => {
|
||||
source.rules = rules
|
||||
dispatch(updateSource(source))
|
||||
}
|
||||
})
|
||||
|
||||
const RulesTabContainer = connect(mapStateToProps, mapDispatchToProps)(RulesTab)
|
||||
export default RulesTabContainer
|
|
@ -4,7 +4,7 @@ import { connect } from "react-redux"
|
|||
import { createSelector } from "reselect"
|
||||
import { RootState } from "../../scripts/reducer"
|
||||
import SourcesTab from "../../components/settings/sources"
|
||||
import { addSource, RSSSource, updateSource, deleteSource, SourceOpenTarget } from "../../scripts/models/source"
|
||||
import { addSource, RSSSource, updateSource, deleteSource, SourceOpenTarget, deleteSources } from "../../scripts/models/source"
|
||||
import { importOPML, exportOPML } from "../../scripts/models/group"
|
||||
import { AppDispatch } from "../../scripts/utils"
|
||||
|
||||
|
@ -30,6 +30,7 @@ const mapDispatchToProps = (dispatch: AppDispatch) => {
|
|||
dispatch(updateSource({ ...source, fetchFrequency: frequency } as RSSSource))
|
||||
},
|
||||
deleteSource: (source: RSSSource) => dispatch(deleteSource(source)),
|
||||
deleteSources: (sources: RSSSource[]) => dispatch(deleteSources(sources)),
|
||||
importOPML: () => {
|
||||
remote.dialog.showOpenDialog(
|
||||
remote.getCurrentWindow(),
|
||||
|
|
|
@ -6,6 +6,9 @@
|
|||
"name": "Name",
|
||||
"openExternal": "Open externally",
|
||||
"emptyName": "This field cannot be empty.",
|
||||
"emptyField": "This field cannot be empty.",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete",
|
||||
"followSystem": "Follow system",
|
||||
"more": "More",
|
||||
"close": "Close",
|
||||
|
@ -78,6 +81,7 @@
|
|||
"exit": "Exit settings",
|
||||
"sources": "Sources",
|
||||
"grouping": "Grouping",
|
||||
"rules": "Rules",
|
||||
"app": "Preferences",
|
||||
"about": "About",
|
||||
"version": "Version",
|
||||
|
@ -88,6 +92,8 @@
|
|||
"sources": {
|
||||
"untitled": "Source",
|
||||
"errorAdd": "An error has occured when adding the source.",
|
||||
"errorParse": "An error has occured when parsing the OPML file.",
|
||||
"errorParseHint": "Please ensure that the file isn't corrupted and is encoded with UTF-8.",
|
||||
"errorImport": "Error importing {count, plural, =1 {# source} other {# sources}}.",
|
||||
"opmlFile": "OPML File",
|
||||
"name": "Source name",
|
||||
|
@ -104,7 +110,8 @@
|
|||
"inputUrl": "Enter URL",
|
||||
"badUrl": "Invalid URL",
|
||||
"deleteWarning": "The source and all saved articles will be removed.",
|
||||
"selected": "Selected source"
|
||||
"selected": "Selected source",
|
||||
"selectedMulti": "Selected multiple sources"
|
||||
},
|
||||
"groups": {
|
||||
"type": "Type",
|
||||
|
@ -124,6 +131,24 @@
|
|||
"addToGroup": "Add to ...",
|
||||
"groupHint": "Double click on group to edit sources. Drag and drop to reorder."
|
||||
},
|
||||
"rules": {
|
||||
"source": "Source",
|
||||
"selectSource": "Select a source",
|
||||
"new": "New Rule",
|
||||
"if": "If",
|
||||
"then": "Then",
|
||||
"title": "Title",
|
||||
"content": "Content",
|
||||
"fullSearch": "Title or content",
|
||||
"match": "matches",
|
||||
"notMatch": "doesn't match",
|
||||
"regex": "Regular expression",
|
||||
"badRegex": "Invalid regular expression.",
|
||||
"action": "Actions",
|
||||
"selectAction": "Select actions",
|
||||
"hint": "Rules will be applied in order. Drag and drop to reorder.",
|
||||
"test": "Test rules"
|
||||
},
|
||||
"app": {
|
||||
"cleanup": "Clean up",
|
||||
"cache": "Clear cache",
|
||||
|
|
|
@ -6,7 +6,10 @@
|
|||
"name": "Nom",
|
||||
"openExternal": "Ouvrir dans le navigateur",
|
||||
"emptyName": "Ce champ ne peut pas être vide.",
|
||||
"emptyField": "Ce champ ne peut pas être vide.",
|
||||
"followSystem": "Suivre le système",
|
||||
"edit": "Modifier",
|
||||
"delete": "Supprimer",
|
||||
"more": "Plus",
|
||||
"close": "Fermer",
|
||||
"search": "Rechercher",
|
||||
|
|
|
@ -6,6 +6,9 @@
|
|||
"name": "名称",
|
||||
"openExternal": "在浏览器中打开",
|
||||
"emptyName": "名称不得为空",
|
||||
"emptyField": "此项不得为空",
|
||||
"edit": "编辑",
|
||||
"delete": "删除",
|
||||
"followSystem": "跟随系统",
|
||||
"more": "更多",
|
||||
"close": "关闭",
|
||||
|
@ -78,6 +81,7 @@
|
|||
"exit": "退出选项",
|
||||
"sources": "订阅源",
|
||||
"grouping": "分组与排序",
|
||||
"rules": "规则",
|
||||
"app": "应用偏好",
|
||||
"about": "关于",
|
||||
"version": "版本",
|
||||
|
@ -88,6 +92,8 @@
|
|||
"sources": {
|
||||
"untitled": "订阅源",
|
||||
"errorAdd": "添加订阅源时出错",
|
||||
"errorParse": "解析OPML文件时出错",
|
||||
"errorParseHint": "请确保OPML文件完整且使用UTF-8编码。",
|
||||
"errorImport": "导入{count}项订阅源时出错",
|
||||
"opmlFile": "OPML文件",
|
||||
"name": "订阅源名称",
|
||||
|
@ -103,8 +109,9 @@
|
|||
"loadWebpage": "加载网页",
|
||||
"inputUrl": "输入URL",
|
||||
"badUrl": "请正确输入URL",
|
||||
"deleteWarning": "这将移除此订阅源与所有已保存的文章",
|
||||
"selected": "选中订阅源"
|
||||
"deleteWarning": "这将移除订阅源与所有已保存的文章",
|
||||
"selected": "选中订阅源",
|
||||
"selectedMulti": "选中多个订阅源"
|
||||
},
|
||||
"groups": {
|
||||
"type": "类型",
|
||||
|
@ -124,6 +131,24 @@
|
|||
"addToGroup": "添加至分组",
|
||||
"groupHint": "双击分组以修改订阅源,可通过拖拽排序"
|
||||
},
|
||||
"rules": {
|
||||
"source": "订阅源",
|
||||
"selectSource": "选择一个订阅源",
|
||||
"new": "新建规则",
|
||||
"if": "若",
|
||||
"then": "则",
|
||||
"title": "标题",
|
||||
"content": "正文",
|
||||
"fullSearch": "标题或正文",
|
||||
"match": "匹配",
|
||||
"notMatch": "不匹配",
|
||||
"regex": "正则表达式",
|
||||
"badRegex": "正则表达式非法",
|
||||
"action": "行为",
|
||||
"selectAction": "选择行为",
|
||||
"hint": "规则将按顺序执行,拖拽以排序",
|
||||
"test": "测试规则"
|
||||
},
|
||||
"app": {
|
||||
"cleanup": "清理",
|
||||
"cache": "清空缓存",
|
||||
|
|
|
@ -210,6 +210,12 @@ export function importOPML(path: string): AppThunk {
|
|||
dispatch(saveSettings())
|
||||
return
|
||||
}
|
||||
let parseError = doc[0].getElementsByTagName("parsererror")
|
||||
if (parseError.length > 0) {
|
||||
dispatch(saveSettings())
|
||||
remote.dialog.showErrorBox(intl.get("sources.errorParse"), intl.get("sources.errorParseHint"))
|
||||
return
|
||||
}
|
||||
let sources: [ReturnType<typeof addSource>, number, string][] = []
|
||||
let errors: [string, any][] = []
|
||||
for (let el of doc[0].children) {
|
||||
|
|
|
@ -26,26 +26,33 @@ export class RSSItem {
|
|||
this.link = item.link || ""
|
||||
this.fetchedDate = new Date()
|
||||
this.date = item.isoDate ? new Date(item.isoDate) : this.fetchedDate
|
||||
if (item.fullContent) {
|
||||
this.content = item.fullContent
|
||||
this.snippet = htmlDecode(item.fullContent)
|
||||
} else {
|
||||
this.content = item.content || ""
|
||||
this.snippet = htmlDecode(item.contentSnippet || "")
|
||||
}
|
||||
if (item.thumb) this.thumb = item.thumb
|
||||
else if (item.image) this.thumb = item.image
|
||||
else {
|
||||
let dom = domParser.parseFromString(this.content, "text/html")
|
||||
let baseEl = dom.createElement('base')
|
||||
baseEl.setAttribute('href', this.link.split("/").slice(0, 3).join("/"))
|
||||
dom.head.append(baseEl)
|
||||
let img = dom.querySelector("img")
|
||||
if (img && img.src) this.thumb = img.src
|
||||
}
|
||||
this.creator = item.creator
|
||||
this.hasRead = false
|
||||
}
|
||||
|
||||
static parseContent(item: RSSItem, parsed: Parser.Item) {
|
||||
if (parsed.fullContent) {
|
||||
item.content = parsed.fullContent
|
||||
item.snippet = htmlDecode(parsed.fullContent)
|
||||
} else {
|
||||
item.content = parsed.content || ""
|
||||
item.snippet = htmlDecode(parsed.contentSnippet || "")
|
||||
}
|
||||
if (parsed.thumb) item.thumb = parsed.thumb
|
||||
else if (parsed.image) item.thumb = parsed.image
|
||||
else if (parsed.mediaContent) {
|
||||
let images = parsed.mediaContent.filter(c => c.$ && c.$.medium === "image" && c.$.url)
|
||||
if (images.length > 0) item.thumb = images[0].$.url
|
||||
}
|
||||
if(!item.thumb) {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type ItemState = {
|
||||
|
|
|
@ -0,0 +1,81 @@
|
|||
import { FeedFilter, FilterType } from "./feed"
|
||||
import { RSSItem } from "./item"
|
||||
|
||||
export const enum ItemAction {
|
||||
Read = "r",
|
||||
Star = "s",
|
||||
Hide = "h",
|
||||
}
|
||||
|
||||
export type RuleActions = {
|
||||
[type in ItemAction]: boolean
|
||||
}
|
||||
export namespace RuleActions {
|
||||
export function toKeys(actions: RuleActions): string[] {
|
||||
return Object.entries(actions).map(([t, f]) => `${t}-${f}`)
|
||||
}
|
||||
|
||||
export function fromKeys(strs: string[]): RuleActions {
|
||||
const fromKey = (str: string): [ItemAction, boolean] => {
|
||||
let [t, f] = str.split("-") as [ItemAction, string]
|
||||
if (f) return [t, f === "true"]
|
||||
else return [t, true]
|
||||
}
|
||||
return Object.fromEntries(strs.map(fromKey)) as RuleActions
|
||||
}
|
||||
}
|
||||
|
||||
type ActionTransformType = {
|
||||
[type in ItemAction]: (i: RSSItem, f: boolean) => void
|
||||
}
|
||||
const actionTransform: ActionTransformType = {
|
||||
[ItemAction.Read]: (i, f) => {
|
||||
if (f) {
|
||||
i.hasRead = true
|
||||
} else {
|
||||
i.hasRead = false
|
||||
}
|
||||
},
|
||||
[ItemAction.Star]: (i, f) => {
|
||||
if (f) {
|
||||
i.starred = true
|
||||
} else if (i.starred) {
|
||||
delete i.starred
|
||||
}
|
||||
},
|
||||
[ItemAction.Hide]: (i, f) => {
|
||||
if (f) {
|
||||
i.hidden = true
|
||||
} else if (i.hidden) {
|
||||
delete i.hidden
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class SourceRule {
|
||||
filter: FeedFilter
|
||||
match: boolean
|
||||
actions: RuleActions
|
||||
|
||||
constructor(regex: string, actions: string[], fullSearch: boolean, match: boolean) {
|
||||
this.filter = new FeedFilter(FilterType.Default | FilterType.ShowHidden, regex)
|
||||
if (fullSearch) this.filter.type |= FilterType.FullSearch
|
||||
this.match = match
|
||||
this.actions = RuleActions.fromKeys(actions)
|
||||
}
|
||||
|
||||
static apply(rule: SourceRule, item: RSSItem) {
|
||||
let result = FeedFilter.testItem(rule.filter, item)
|
||||
if (result === rule.match) {
|
||||
for (let [action, flag] of Object.entries(rule.actions)) {
|
||||
actionTransform[action](item, flag)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static applyAll(rules: SourceRule[], item: RSSItem) {
|
||||
for (let rule of rules) {
|
||||
this.apply(rule, item)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -6,6 +6,7 @@ import { RSSItem, insertItems, ItemActionTypes, FETCH_ITEMS, MARK_READ, MARK_UNR
|
|||
import { SourceGroup } from "./group"
|
||||
import { saveSettings } from "./app"
|
||||
import { remote } from "electron"
|
||||
import { SourceRule } from "./rule"
|
||||
|
||||
export enum SourceOpenTarget {
|
||||
Local, Webpage, External
|
||||
|
@ -20,6 +21,7 @@ export class RSSSource {
|
|||
unreadCount: number
|
||||
lastFetched: Date
|
||||
fetchFrequency?: number // in minutes
|
||||
rules?: SourceRule[]
|
||||
|
||||
constructor(url: string, name: string = null) {
|
||||
this.url = url
|
||||
|
@ -55,6 +57,8 @@ export class RSSSource {
|
|||
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)
|
||||
|
@ -289,29 +293,44 @@ export function deleteSourceDone(source: RSSSource): SourceActionTypes {
|
|||
}
|
||||
}
|
||||
|
||||
export function deleteSource(source: RSSSource): AppThunk {
|
||||
export function deleteSource(source: RSSSource, batch = false): AppThunk<Promise<void>> {
|
||||
return (dispatch, getState) => {
|
||||
dispatch(saveSettings())
|
||||
db.idb.remove({ source: source.sid }, { multi: true }, (err) => {
|
||||
if (err) {
|
||||
console.log(err)
|
||||
dispatch(saveSettings())
|
||||
} else {
|
||||
db.sdb.remove({ sid: source.sid }, {}, (err) => {
|
||||
if (err) {
|
||||
console.log(err)
|
||||
dispatch(saveSettings())
|
||||
} else {
|
||||
dispatch(deleteSourceDone(source))
|
||||
SourceGroup.save(getState().groups)
|
||||
dispatch(saveSettings())
|
||||
}
|
||||
})
|
||||
}
|
||||
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))
|
||||
SourceGroup.save(getState().groups)
|
||||
if (!batch) dispatch(saveSettings())
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function deleteSources(sources: RSSSource[]): AppThunk<Promise<void>> {
|
||||
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
|
||||
|
@ -349,9 +368,10 @@ export function sourceReducer(
|
|||
case ActionStatus.Success: {
|
||||
let updateMap = new Map<number, number>()
|
||||
for (let item of action.items) {
|
||||
updateMap.set(
|
||||
if (!item.hasRead) { updateMap.set(
|
||||
item.source,
|
||||
updateMap.has(item.source) ? (updateMap.get(item.source) + 1) : 1)
|
||||
updateMap.has(item.source) ? (updateMap.get(item.source) + 1) : 1
|
||||
)}
|
||||
}
|
||||
let nextState = {} as SourceState
|
||||
for (let [s, source] of Object.entries(state)) {
|
||||
|
|
|
@ -19,7 +19,10 @@ export type AppDispatch = ThunkDispatch<RootState, undefined, AnyAction>
|
|||
import Parser = require("@yang991178/rss-parser")
|
||||
const rssParser = new Parser({
|
||||
customFields: {
|
||||
item: ["thumb", "image", ["content:encoded", "fullContent"]] as Parser.CustomFieldItem[]
|
||||
item: [
|
||||
"thumb", "image", ["content:encoded", "fullContent"],
|
||||
['media:content', 'mediaContent', {keepArray: true}],
|
||||
] as Parser.CustomFieldItem[]
|
||||
}
|
||||
})
|
||||
|
||||
|
@ -126,4 +129,12 @@ export function calculateItemSize(): Promise<number> {
|
|||
}
|
||||
openRequest.onerror = () => reject()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function validateRegex(regex: string): RegExp {
|
||||
try {
|
||||
return new RegExp(regex)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue