mirror of
https://github.com/yang991178/fluent-reader.git
synced 2025-03-02 10:27:46 +01:00
alpha release build 0.4.0
Version 0.4.0
This commit is contained in:
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.
|
|
10
dist/article/article.css
vendored
10
dist/article/article.css
vendored
@ -1,5 +1,5 @@
|
|||||||
html, body {
|
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 {
|
html {
|
||||||
overflow: hidden scroll;
|
overflow: hidden scroll;
|
||||||
@ -19,6 +19,9 @@ html {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
h1, h2, h3, h4, h5, h6, b, strong {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
a {
|
a {
|
||||||
color: var(--primary);
|
color: var(--primary);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
@ -64,3 +67,8 @@ article figure figcaption {
|
|||||||
article iframe {
|
article iframe {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
article code {
|
||||||
|
font-family: Monaco, Consolas, monospace;
|
||||||
|
font-size: .875rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
22
dist/styles.css
vendored
22
dist/styles.css
vendored
@ -40,7 +40,7 @@
|
|||||||
|
|
||||||
html, body {
|
html, body {
|
||||||
background-color: transparent;
|
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%;
|
height: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@ -316,12 +316,19 @@ div[role="tabpanel"] {
|
|||||||
.tab-body .ms-ChoiceFieldGroup {
|
.tab-body .ms-ChoiceFieldGroup {
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
.tab-body .ms-CommandBar {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
img.favicon {
|
img.favicon {
|
||||||
width: 16px;
|
width: 16px;
|
||||||
height: 16px;
|
height: 16px;
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
img.favicon.dropdown {
|
||||||
|
margin-right: 8px;
|
||||||
|
vertical-align: sub;
|
||||||
|
}
|
||||||
.ms-DetailsList-contentWrapper {
|
.ms-DetailsList-contentWrapper {
|
||||||
max-height: 400px;
|
max-height: 400px;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
@ -379,6 +386,10 @@ img.favicon {
|
|||||||
height: 28px;
|
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);
|
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) {
|
@media (min-width: 1441px) {
|
||||||
#root > nav.menu-on {
|
#root > nav.menu-on {
|
||||||
@ -425,6 +436,10 @@ img.favicon {
|
|||||||
left: 280px;
|
left: 280px;
|
||||||
max-width: calc(100% - 728px);
|
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 {
|
nav.hide-btns .btn-group .btn, nav.menu-on .btn-group .btn.hide-wide, .menu .btn-group .btn.hide-wide {
|
||||||
display: none;
|
display: none;
|
||||||
@ -539,11 +554,6 @@ img.favicon {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: var(--white);
|
background: var(--white);
|
||||||
}
|
}
|
||||||
.list-main .article-search {
|
|
||||||
left: 0;
|
|
||||||
max-width: 330px;
|
|
||||||
margin: 4px 10px;
|
|
||||||
}
|
|
||||||
.list-feed-container {
|
.list-feed-container {
|
||||||
width: 350px;
|
width: 350px;
|
||||||
background-color: var(--neutralLighterAlt);
|
background-color: var(--neutralLighterAlt);
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "fluent-reader",
|
"name": "fluent-reader",
|
||||||
"version": "0.3.3",
|
"version": "0.4.0",
|
||||||
"description": "A simplistic, modern desktop RSS reader",
|
"description": "A simplistic, modern desktop RSS reader",
|
||||||
"main": "./dist/electron.js",
|
"main": "./dist/electron.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@ -13,7 +13,7 @@
|
|||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "Haoyuan Liu",
|
"author": "Haoyuan Liu",
|
||||||
"license": "BSD",
|
"license": "BSD-3-Clause",
|
||||||
"build": {
|
"build": {
|
||||||
"appId": "me.hyliu.fluentreader",
|
"appId": "me.hyliu.fluentreader",
|
||||||
"productName": "Fluent Reader",
|
"productName": "Fluent Reader",
|
||||||
@ -25,7 +25,8 @@
|
|||||||
"win": {
|
"win": {
|
||||||
"target": [
|
"target": [
|
||||||
"nsis",
|
"nsis",
|
||||||
"appx"
|
"appx",
|
||||||
|
"zip"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"appx": {
|
"appx": {
|
||||||
|
@ -7,6 +7,7 @@ import { Pivot, PivotItem, Spinner } from "@fluentui/react"
|
|||||||
import SourcesTabContainer from "../containers/settings/sources-container"
|
import SourcesTabContainer from "../containers/settings/sources-container"
|
||||||
import GroupsTabContainer from "../containers/settings/groups-container"
|
import GroupsTabContainer from "../containers/settings/groups-container"
|
||||||
import AppTabContainer from "../containers/settings/app-container"
|
import AppTabContainer from "../containers/settings/app-container"
|
||||||
|
import RulesTabContainer from "../containers/settings/rules-container"
|
||||||
|
|
||||||
type SettingsProps = {
|
type SettingsProps = {
|
||||||
display: boolean,
|
display: boolean,
|
||||||
@ -38,6 +39,9 @@ class Settings extends React.Component<SettingsProps> {
|
|||||||
<PivotItem headerText={intl.get("settings.grouping")} itemIcon="GroupList">
|
<PivotItem headerText={intl.get("settings.grouping")} itemIcon="GroupList">
|
||||||
<GroupsTabContainer />
|
<GroupsTabContainer />
|
||||||
</PivotItem>
|
</PivotItem>
|
||||||
|
<PivotItem headerText={intl.get("settings.rules")} itemIcon="FilterSettings">
|
||||||
|
<RulesTabContainer />
|
||||||
|
</PivotItem>
|
||||||
<PivotItem headerText={intl.get("settings.app")} itemIcon="Settings">
|
<PivotItem headerText={intl.get("settings.app")} itemIcon="Settings">
|
||||||
<AppTabContainer />
|
<AppTabContainer />
|
||||||
</PivotItem>
|
</PivotItem>
|
||||||
|
@ -9,7 +9,7 @@ class AboutTab extends React.Component {
|
|||||||
<div className="tab-body">
|
<div className="tab-body">
|
||||||
<Stack className="settings-about" horizontalAlign="center">
|
<Stack className="settings-about" horizontalAlign="center">
|
||||||
<img src="icons/logo.svg" style={{width: 120, height: 120}} />
|
<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>
|
<small>{intl.get("settings.version")} {remote.app.getVersion()}</small>
|
||||||
<p className="settings-hint">Copyright © 2020 Haoyuan Liu. All rights reserved.</p>
|
<p className="settings-hint">Copyright © 2020 Haoyuan Liu. All rights reserved.</p>
|
||||||
<Stack horizontal horizontalAlign="center" tokens={{childrenGap: 12}}>
|
<Stack horizontal horizontalAlign="center" tokens={{childrenGap: 12}}>
|
||||||
|
@ -80,8 +80,8 @@ class AppTab extends React.Component<AppTabProps, AppTabState> {
|
|||||||
languageOptions = (): IDropdownOption[] => [
|
languageOptions = (): IDropdownOption[] => [
|
||||||
{ key: "default", text: intl.get("followSystem") },
|
{ key: "default", text: intl.get("followSystem") },
|
||||||
{ key: "en-US", text: "English" },
|
{ key: "en-US", text: "English" },
|
||||||
{ key: "zh-CN", text: "中文(简体)"},
|
|
||||||
{ key: "fr-FR", text: "Français"},
|
{ key: "fr-FR", text: "Français"},
|
||||||
|
{ key: "zh-CN", text: "中文(简体)"},
|
||||||
]
|
]
|
||||||
|
|
||||||
toggleStatus = () => {
|
toggleStatus = () => {
|
||||||
|
@ -160,7 +160,7 @@ class GroupsTab extends React.Component<GroupsTabProps, GroupsTabState> {
|
|||||||
let groups = this.props.groups.filter(g => g.index != draggedItem.index)
|
let groups = this.props.groups.filter(g => g.index != draggedItem.index)
|
||||||
|
|
||||||
groups.splice(insertIndex, 0, draggedItem)
|
groups.splice(insertIndex, 0, draggedItem)
|
||||||
|
this.groupSelection.setAllSelected(false)
|
||||||
this.props.reorderGroups(groups)
|
this.props.reorderGroups(groups)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
391
src/components/settings/rules.tsx
Normal file
391
src/components/settings/rules.tsx
Normal file
@ -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
|
updateSourceOpenTarget: (source: RSSSource, target: SourceOpenTarget) => void
|
||||||
updateFetchFrequency: (source: RSSSource, frequency: number) => void
|
updateFetchFrequency: (source: RSSSource, frequency: number) => void
|
||||||
deleteSource: (source: RSSSource) => void
|
deleteSource: (source: RSSSource) => void
|
||||||
|
deleteSources: (sources: RSSSource[]) => void
|
||||||
importOPML: () => void
|
importOPML: () => void
|
||||||
exportOPML: () => void
|
exportOPML: () => void
|
||||||
}
|
}
|
||||||
@ -20,7 +21,8 @@ type SourcesTabProps = {
|
|||||||
type SourcesTabState = {
|
type SourcesTabState = {
|
||||||
[formName: string]: string
|
[formName: string]: string
|
||||||
} & {
|
} & {
|
||||||
selectedSource: RSSSource
|
selectedSource: RSSSource,
|
||||||
|
selectedSources: RSSSource[]
|
||||||
}
|
}
|
||||||
|
|
||||||
class SourcesTab extends React.Component<SourcesTabProps, SourcesTabState> {
|
class SourcesTab extends React.Component<SourcesTabProps, SourcesTabState> {
|
||||||
@ -31,15 +33,18 @@ class SourcesTab extends React.Component<SourcesTabProps, SourcesTabState> {
|
|||||||
this.state = {
|
this.state = {
|
||||||
newUrl: "",
|
newUrl: "",
|
||||||
newSourceName: "",
|
newSourceName: "",
|
||||||
selectedSource: null
|
selectedSource: null,
|
||||||
|
selectedSources: null
|
||||||
}
|
}
|
||||||
this.selection = new Selection({
|
this.selection = new Selection({
|
||||||
getKey: s => (s as RSSSource).sid,
|
getKey: s => (s as RSSSource).sid,
|
||||||
onSelectionChanged: () => {
|
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({
|
this.setState({
|
||||||
selectedSource: source,
|
selectedSource: count === 1 ? sources[0] : null,
|
||||||
newSourceName: source ? source.name : ""
|
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") }
|
{ 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) => {
|
handleInputChange = (event) => {
|
||||||
const name: string = event.target.name
|
const name: string = event.target.name
|
||||||
this.setState({[name]: event.target.value})
|
this.setState({[name]: event.target.value})
|
||||||
@ -151,12 +162,13 @@ class SourcesTab extends React.Component<SourcesTabProps, SourcesTabState> {
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
<DetailsList
|
<DetailsList
|
||||||
|
compact={Object.keys(this.props.sources).length >= 10}
|
||||||
items={Object.values(this.props.sources)}
|
items={Object.values(this.props.sources)}
|
||||||
columns={this.columns()}
|
columns={this.columns()}
|
||||||
getKey={s => s.sid}
|
getKey={s => s.sid}
|
||||||
setKey="selected"
|
setKey="selected"
|
||||||
selection={this.selection}
|
selection={this.selection}
|
||||||
selectionMode={SelectionMode.single} />
|
selectionMode={SelectionMode.multiple} />
|
||||||
|
|
||||||
{this.state.selectedSource && <>
|
{this.state.selectedSource && <>
|
||||||
<Label>{intl.get("sources.selected")}</Label>
|
<Label>{intl.get("sources.selected")}</Label>
|
||||||
@ -173,7 +185,7 @@ class SourcesTab extends React.Component<SourcesTabProps, SourcesTabState> {
|
|||||||
<Stack.Item>
|
<Stack.Item>
|
||||||
<DefaultButton
|
<DefaultButton
|
||||||
disabled={this.state.newSourceName.trim().length == 0}
|
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")} />
|
text={intl.get("sources.editName")} />
|
||||||
</Stack.Item>
|
</Stack.Item>
|
||||||
</Stack>
|
</Stack>
|
||||||
@ -204,6 +216,19 @@ class SourcesTab extends React.Component<SourcesTabProps, SourcesTabState> {
|
|||||||
</Stack.Item>
|
</Stack.Item>
|
||||||
</Stack>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -3,7 +3,7 @@ import intl = require("react-intl-universal")
|
|||||||
import { connect } from "react-redux"
|
import { connect } from "react-redux"
|
||||||
import { RootState } from "../../scripts/reducer"
|
import { RootState } from "../../scripts/reducer"
|
||||||
import { SearchBox, ISearchBox, Async } from "@fluentui/react"
|
import { SearchBox, ISearchBox, Async } from "@fluentui/react"
|
||||||
import { AppDispatch } from "../../scripts/utils"
|
import { AppDispatch, validateRegex } from "../../scripts/utils"
|
||||||
import { performSearch } from "../../scripts/models/page"
|
import { performSearch } from "../../scripts/models/page"
|
||||||
|
|
||||||
type SearchProps = {
|
type SearchProps = {
|
||||||
@ -23,12 +23,8 @@ class ArticleSearch extends React.Component<SearchProps, SearchState> {
|
|||||||
constructor(props: SearchProps) {
|
constructor(props: SearchProps) {
|
||||||
super(props)
|
super(props)
|
||||||
this.debouncedSearch = new Async().debounce((query: string) => {
|
this.debouncedSearch = new Async().debounce((query: string) => {
|
||||||
try {
|
let regex = validateRegex(query)
|
||||||
RegExp(query)
|
if (regex !== null) props.dispatch(performSearch(query))
|
||||||
props.dispatch(performSearch(query))
|
|
||||||
} catch {
|
|
||||||
// console.log("Invalid regex")
|
|
||||||
}
|
|
||||||
}, 750)
|
}, 750)
|
||||||
this.inputRef = React.createRef<ISearchBox>()
|
this.inputRef = React.createRef<ISearchBox>()
|
||||||
this.state = { query: props.initQuery }
|
this.state = { query: props.initQuery }
|
||||||
|
@ -1,4 +1,3 @@
|
|||||||
import { remote } from "electron"
|
|
||||||
import { connect } from "react-redux"
|
import { connect } from "react-redux"
|
||||||
import { createSelector } from "reselect"
|
import { createSelector } from "reselect"
|
||||||
import { RootState } from "../../scripts/reducer"
|
import { RootState } from "../../scripts/reducer"
|
||||||
|
26
src/containers/settings/rules-container.tsx
Normal file
26
src/containers/settings/rules-container.tsx
Normal file
@ -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 { createSelector } from "reselect"
|
||||||
import { RootState } from "../../scripts/reducer"
|
import { RootState } from "../../scripts/reducer"
|
||||||
import SourcesTab from "../../components/settings/sources"
|
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 { importOPML, exportOPML } from "../../scripts/models/group"
|
||||||
import { AppDispatch } from "../../scripts/utils"
|
import { AppDispatch } from "../../scripts/utils"
|
||||||
|
|
||||||
@ -30,6 +30,7 @@ const mapDispatchToProps = (dispatch: AppDispatch) => {
|
|||||||
dispatch(updateSource({ ...source, fetchFrequency: frequency } as RSSSource))
|
dispatch(updateSource({ ...source, fetchFrequency: frequency } as RSSSource))
|
||||||
},
|
},
|
||||||
deleteSource: (source: RSSSource) => dispatch(deleteSource(source)),
|
deleteSource: (source: RSSSource) => dispatch(deleteSource(source)),
|
||||||
|
deleteSources: (sources: RSSSource[]) => dispatch(deleteSources(sources)),
|
||||||
importOPML: () => {
|
importOPML: () => {
|
||||||
remote.dialog.showOpenDialog(
|
remote.dialog.showOpenDialog(
|
||||||
remote.getCurrentWindow(),
|
remote.getCurrentWindow(),
|
||||||
|
@ -6,6 +6,9 @@
|
|||||||
"name": "Name",
|
"name": "Name",
|
||||||
"openExternal": "Open externally",
|
"openExternal": "Open externally",
|
||||||
"emptyName": "This field cannot be empty.",
|
"emptyName": "This field cannot be empty.",
|
||||||
|
"emptyField": "This field cannot be empty.",
|
||||||
|
"edit": "Edit",
|
||||||
|
"delete": "Delete",
|
||||||
"followSystem": "Follow system",
|
"followSystem": "Follow system",
|
||||||
"more": "More",
|
"more": "More",
|
||||||
"close": "Close",
|
"close": "Close",
|
||||||
@ -78,6 +81,7 @@
|
|||||||
"exit": "Exit settings",
|
"exit": "Exit settings",
|
||||||
"sources": "Sources",
|
"sources": "Sources",
|
||||||
"grouping": "Grouping",
|
"grouping": "Grouping",
|
||||||
|
"rules": "Rules",
|
||||||
"app": "Preferences",
|
"app": "Preferences",
|
||||||
"about": "About",
|
"about": "About",
|
||||||
"version": "Version",
|
"version": "Version",
|
||||||
@ -88,6 +92,8 @@
|
|||||||
"sources": {
|
"sources": {
|
||||||
"untitled": "Source",
|
"untitled": "Source",
|
||||||
"errorAdd": "An error has occured when adding the 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}}.",
|
"errorImport": "Error importing {count, plural, =1 {# source} other {# sources}}.",
|
||||||
"opmlFile": "OPML File",
|
"opmlFile": "OPML File",
|
||||||
"name": "Source name",
|
"name": "Source name",
|
||||||
@ -104,7 +110,8 @@
|
|||||||
"inputUrl": "Enter URL",
|
"inputUrl": "Enter URL",
|
||||||
"badUrl": "Invalid URL",
|
"badUrl": "Invalid URL",
|
||||||
"deleteWarning": "The source and all saved articles will be removed.",
|
"deleteWarning": "The source and all saved articles will be removed.",
|
||||||
"selected": "Selected source"
|
"selected": "Selected source",
|
||||||
|
"selectedMulti": "Selected multiple sources"
|
||||||
},
|
},
|
||||||
"groups": {
|
"groups": {
|
||||||
"type": "Type",
|
"type": "Type",
|
||||||
@ -124,6 +131,24 @@
|
|||||||
"addToGroup": "Add to ...",
|
"addToGroup": "Add to ...",
|
||||||
"groupHint": "Double click on group to edit sources. Drag and drop to reorder."
|
"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": {
|
"app": {
|
||||||
"cleanup": "Clean up",
|
"cleanup": "Clean up",
|
||||||
"cache": "Clear cache",
|
"cache": "Clear cache",
|
||||||
|
@ -6,7 +6,10 @@
|
|||||||
"name": "Nom",
|
"name": "Nom",
|
||||||
"openExternal": "Ouvrir dans le navigateur",
|
"openExternal": "Ouvrir dans le navigateur",
|
||||||
"emptyName": "Ce champ ne peut pas être vide.",
|
"emptyName": "Ce champ ne peut pas être vide.",
|
||||||
|
"emptyField": "Ce champ ne peut pas être vide.",
|
||||||
"followSystem": "Suivre le système",
|
"followSystem": "Suivre le système",
|
||||||
|
"edit": "Modifier",
|
||||||
|
"delete": "Supprimer",
|
||||||
"more": "Plus",
|
"more": "Plus",
|
||||||
"close": "Fermer",
|
"close": "Fermer",
|
||||||
"search": "Rechercher",
|
"search": "Rechercher",
|
||||||
|
@ -6,6 +6,9 @@
|
|||||||
"name": "名称",
|
"name": "名称",
|
||||||
"openExternal": "在浏览器中打开",
|
"openExternal": "在浏览器中打开",
|
||||||
"emptyName": "名称不得为空",
|
"emptyName": "名称不得为空",
|
||||||
|
"emptyField": "此项不得为空",
|
||||||
|
"edit": "编辑",
|
||||||
|
"delete": "删除",
|
||||||
"followSystem": "跟随系统",
|
"followSystem": "跟随系统",
|
||||||
"more": "更多",
|
"more": "更多",
|
||||||
"close": "关闭",
|
"close": "关闭",
|
||||||
@ -78,6 +81,7 @@
|
|||||||
"exit": "退出选项",
|
"exit": "退出选项",
|
||||||
"sources": "订阅源",
|
"sources": "订阅源",
|
||||||
"grouping": "分组与排序",
|
"grouping": "分组与排序",
|
||||||
|
"rules": "规则",
|
||||||
"app": "应用偏好",
|
"app": "应用偏好",
|
||||||
"about": "关于",
|
"about": "关于",
|
||||||
"version": "版本",
|
"version": "版本",
|
||||||
@ -88,6 +92,8 @@
|
|||||||
"sources": {
|
"sources": {
|
||||||
"untitled": "订阅源",
|
"untitled": "订阅源",
|
||||||
"errorAdd": "添加订阅源时出错",
|
"errorAdd": "添加订阅源时出错",
|
||||||
|
"errorParse": "解析OPML文件时出错",
|
||||||
|
"errorParseHint": "请确保OPML文件完整且使用UTF-8编码。",
|
||||||
"errorImport": "导入{count}项订阅源时出错",
|
"errorImport": "导入{count}项订阅源时出错",
|
||||||
"opmlFile": "OPML文件",
|
"opmlFile": "OPML文件",
|
||||||
"name": "订阅源名称",
|
"name": "订阅源名称",
|
||||||
@ -103,8 +109,9 @@
|
|||||||
"loadWebpage": "加载网页",
|
"loadWebpage": "加载网页",
|
||||||
"inputUrl": "输入URL",
|
"inputUrl": "输入URL",
|
||||||
"badUrl": "请正确输入URL",
|
"badUrl": "请正确输入URL",
|
||||||
"deleteWarning": "这将移除此订阅源与所有已保存的文章",
|
"deleteWarning": "这将移除订阅源与所有已保存的文章",
|
||||||
"selected": "选中订阅源"
|
"selected": "选中订阅源",
|
||||||
|
"selectedMulti": "选中多个订阅源"
|
||||||
},
|
},
|
||||||
"groups": {
|
"groups": {
|
||||||
"type": "类型",
|
"type": "类型",
|
||||||
@ -124,6 +131,24 @@
|
|||||||
"addToGroup": "添加至分组",
|
"addToGroup": "添加至分组",
|
||||||
"groupHint": "双击分组以修改订阅源,可通过拖拽排序"
|
"groupHint": "双击分组以修改订阅源,可通过拖拽排序"
|
||||||
},
|
},
|
||||||
|
"rules": {
|
||||||
|
"source": "订阅源",
|
||||||
|
"selectSource": "选择一个订阅源",
|
||||||
|
"new": "新建规则",
|
||||||
|
"if": "若",
|
||||||
|
"then": "则",
|
||||||
|
"title": "标题",
|
||||||
|
"content": "正文",
|
||||||
|
"fullSearch": "标题或正文",
|
||||||
|
"match": "匹配",
|
||||||
|
"notMatch": "不匹配",
|
||||||
|
"regex": "正则表达式",
|
||||||
|
"badRegex": "正则表达式非法",
|
||||||
|
"action": "行为",
|
||||||
|
"selectAction": "选择行为",
|
||||||
|
"hint": "规则将按顺序执行,拖拽以排序",
|
||||||
|
"test": "测试规则"
|
||||||
|
},
|
||||||
"app": {
|
"app": {
|
||||||
"cleanup": "清理",
|
"cleanup": "清理",
|
||||||
"cache": "清空缓存",
|
"cache": "清空缓存",
|
||||||
|
@ -210,6 +210,12 @@ export function importOPML(path: string): AppThunk {
|
|||||||
dispatch(saveSettings())
|
dispatch(saveSettings())
|
||||||
return
|
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 sources: [ReturnType<typeof addSource>, number, string][] = []
|
||||||
let errors: [string, any][] = []
|
let errors: [string, any][] = []
|
||||||
for (let el of doc[0].children) {
|
for (let el of doc[0].children) {
|
||||||
|
@ -26,26 +26,33 @@ export class RSSItem {
|
|||||||
this.link = item.link || ""
|
this.link = item.link || ""
|
||||||
this.fetchedDate = new Date()
|
this.fetchedDate = new Date()
|
||||||
this.date = item.isoDate ? new Date(item.isoDate) : this.fetchedDate
|
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.creator = item.creator
|
||||||
this.hasRead = false
|
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 = {
|
export type ItemState = {
|
||||||
|
81
src/scripts/models/rule.ts
Normal file
81
src/scripts/models/rule.ts
Normal file
@ -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 { SourceGroup } from "./group"
|
||||||
import { saveSettings } from "./app"
|
import { saveSettings } from "./app"
|
||||||
import { remote } from "electron"
|
import { remote } from "electron"
|
||||||
|
import { SourceRule } from "./rule"
|
||||||
|
|
||||||
export enum SourceOpenTarget {
|
export enum SourceOpenTarget {
|
||||||
Local, Webpage, External
|
Local, Webpage, External
|
||||||
@ -20,6 +21,7 @@ export class RSSSource {
|
|||||||
unreadCount: number
|
unreadCount: number
|
||||||
lastFetched: Date
|
lastFetched: Date
|
||||||
fetchFrequency?: number // in minutes
|
fetchFrequency?: number // in minutes
|
||||||
|
rules?: SourceRule[]
|
||||||
|
|
||||||
constructor(url: string, name: string = null) {
|
constructor(url: string, name: string = null) {
|
||||||
this.url = url
|
this.url = url
|
||||||
@ -55,6 +57,8 @@ export class RSSSource {
|
|||||||
if (err) {
|
if (err) {
|
||||||
reject(err)
|
reject(err)
|
||||||
} else if (doc === null) {
|
} else if (doc === null) {
|
||||||
|
RSSItem.parseContent(i, item)
|
||||||
|
if (source.rules) SourceRule.applyAll(source.rules, i)
|
||||||
resolve(i)
|
resolve(i)
|
||||||
} else {
|
} else {
|
||||||
resolve(null)
|
resolve(null)
|
||||||
@ -289,26 +293,41 @@ 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) => {
|
return (dispatch, getState) => {
|
||||||
dispatch(saveSettings())
|
return new Promise((resolve) => {
|
||||||
|
if (!batch) dispatch(saveSettings())
|
||||||
db.idb.remove({ source: source.sid }, { multi: true }, (err) => {
|
db.idb.remove({ source: source.sid }, { multi: true }, (err) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
console.log(err)
|
console.log(err)
|
||||||
dispatch(saveSettings())
|
if (!batch) dispatch(saveSettings())
|
||||||
|
resolve()
|
||||||
} else {
|
} else {
|
||||||
db.sdb.remove({ sid: source.sid }, {}, (err) => {
|
db.sdb.remove({ sid: source.sid }, {}, (err) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
console.log(err)
|
console.log(err)
|
||||||
dispatch(saveSettings())
|
if (!batch) dispatch(saveSettings())
|
||||||
|
resolve()
|
||||||
} else {
|
} else {
|
||||||
dispatch(deleteSourceDone(source))
|
dispatch(deleteSourceDone(source))
|
||||||
SourceGroup.save(getState().groups)
|
SourceGroup.save(getState().groups)
|
||||||
|
if (!batch) dispatch(saveSettings())
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteSources(sources: RSSSource[]): AppThunk<Promise<void>> {
|
||||||
|
return async (dispatch) => {
|
||||||
dispatch(saveSettings())
|
dispatch(saveSettings())
|
||||||
|
for (let source of sources) {
|
||||||
|
await dispatch(deleteSource(source, true))
|
||||||
}
|
}
|
||||||
})
|
dispatch(saveSettings())
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -349,9 +368,10 @@ export function sourceReducer(
|
|||||||
case ActionStatus.Success: {
|
case ActionStatus.Success: {
|
||||||
let updateMap = new Map<number, number>()
|
let updateMap = new Map<number, number>()
|
||||||
for (let item of action.items) {
|
for (let item of action.items) {
|
||||||
updateMap.set(
|
if (!item.hasRead) { updateMap.set(
|
||||||
item.source,
|
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
|
let nextState = {} as SourceState
|
||||||
for (let [s, source] of Object.entries(state)) {
|
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")
|
import Parser = require("@yang991178/rss-parser")
|
||||||
const rssParser = new Parser({
|
const rssParser = new Parser({
|
||||||
customFields: {
|
customFields: {
|
||||||
item: ["thumb", "image", ["content:encoded", "fullContent"]] as Parser.CustomFieldItem[]
|
item: [
|
||||||
|
"thumb", "image", ["content:encoded", "fullContent"],
|
||||||
|
['media:content', 'mediaContent', {keepArray: true}],
|
||||||
|
] as Parser.CustomFieldItem[]
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -127,3 +130,11 @@ export function calculateItemSize(): Promise<number> {
|
|||||||
openRequest.onerror = () => reject()
|
openRequest.onerror = () => reject()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function validateRegex(regex: string): RegExp {
|
||||||
|
try {
|
||||||
|
return new RegExp(regex)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user