manage rules

This commit is contained in:
刘浩远 2020-06-24 16:32:06 +08:00
parent 72275241c2
commit a922a2434f
13 changed files with 446 additions and 17 deletions

View File

@ -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.

7
dist/styles.css vendored
View File

@ -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;

View File

@ -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>

View File

@ -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)
}

View File

@ -0,0 +1,339 @@
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"
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[]
}
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: []
}
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.setState({ sid: item.key as string, selectedRules: [], editIndex: -1 })
}
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
}
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("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>
</>)}
</div>
)
}
export default RulesTab

View File

@ -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 }

View File

@ -1,4 +1,3 @@
import { remote } from "electron"
import { connect } from "react-redux"
import { createSelector } from "reselect"
import { RootState } from "../../scripts/reducer"

View 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

View File

@ -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",
@ -74,6 +77,7 @@
"exit": "Exit settings",
"sources": "Sources",
"grouping": "Grouping",
"rules": "Rules",
"app": "Preferences",
"about": "About",
"version": "Version",
@ -123,6 +127,21 @@
"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",
"fullSearch": "Title or content",
"match": "matches",
"notMatch": "doesn't match",
"regex": "Regular expression",
"badRegex": "Invalid regular expression.",
"action": "Actions",
"selectAction": "Select actions"
},
"app": {
"cleanup": "Clean up",
"cache": "Clear cache",

View File

@ -6,6 +6,9 @@
"name": "名称",
"openExternal": "在浏览器中打开",
"emptyName": "名称不得为空",
"emptyField": "此项不得为空",
"edit": "编辑",
"delete": "删除",
"followSystem": "跟随系统",
"more": "更多",
"close": "关闭",
@ -74,6 +77,7 @@
"exit": "退出选项",
"sources": "订阅源",
"grouping": "分组与排序",
"rules": "规则",
"app": "应用偏好",
"about": "关于",
"version": "版本",
@ -123,6 +127,21 @@
"addToGroup": "添加至分组",
"groupHint": "双击分组以修改订阅源,可通过拖拽排序"
},
"rules": {
"source": "订阅源",
"selectSource": "选择一个订阅源",
"new": "新建规则",
"if": "若",
"then": "则",
"title": "标题",
"fullSearch": "标题或正文",
"match": "匹配",
"notMatch": "不匹配",
"regex": "正则表达式",
"badRegex": "正则表达式非法",
"action": "行为",
"selectAction": "选择行为"
},
"app": {
"cleanup": "清理",
"cache": "清空缓存",

View File

@ -10,6 +10,20 @@ export const enum ItemAction {
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
@ -43,11 +57,11 @@ export class SourceRule {
match: boolean
actions: RuleActions
constructor(regex: string, actions: RuleActions, fullSearch: boolean, match: boolean) {
this.filter = new FeedFilter(FilterType.Default, regex)
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 = actions
this.actions = RuleActions.fromKeys(actions)
}
static apply(rule: SourceRule, item: RSSItem) {

View File

@ -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

View File

@ -129,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
}
}