From 5fda3cac6057c78f1b5522f47c770c410a0530bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E6=B5=A9=E8=BF=9C?= Date: Mon, 22 Jun 2020 20:41:12 +0800 Subject: [PATCH 1/5] add rules class --- src/scripts/models/rule.ts | 67 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 src/scripts/models/rule.ts diff --git a/src/scripts/models/rule.ts b/src/scripts/models/rule.ts new file mode 100644 index 0000000..c6276be --- /dev/null +++ b/src/scripts/models/rule.ts @@ -0,0 +1,67 @@ +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 +} + +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: RuleActions, fullSearch: boolean, match: boolean) { + this.filter = new FeedFilter(FilterType.Default, regex) + if (fullSearch) this.filter.type |= FilterType.FullSearch + this.match = match + this.actions = 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) + } + } +} \ No newline at end of file From 72275241c2dd4b4601d5c0e2fb8fb6adac4f8d9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E6=B5=A9=E8=BF=9C?= Date: Tue, 23 Jun 2020 14:08:09 +0800 Subject: [PATCH 2/5] delete multiple sources --- src/components/settings/sources.tsx | 39 +++++++++++--- src/containers/settings/sources-container.tsx | 3 +- src/scripts/i18n/en-US.json | 5 +- src/scripts/i18n/zh-CN.json | 7 ++- src/scripts/models/group.ts | 6 +++ src/scripts/models/item.ts | 41 +++++++++------ src/scripts/models/source.ts | 52 ++++++++++++------- src/scripts/utils.ts | 5 +- 8 files changed, 111 insertions(+), 47 deletions(-) diff --git a/src/components/settings/sources.tsx b/src/components/settings/sources.tsx index ac60248..7d866ba 100644 --- a/src/components/settings/sources.tsx +++ b/src/components/settings/sources.tsx @@ -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 { @@ -31,15 +33,18 @@ class SourcesTab extends React.Component { 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 { { 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 { = 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 && <> @@ -173,7 +185,7 @@ class SourcesTab extends React.Component { this.props.updateSourceName(this.state.selectedSource, this.state.newSourceName.trim())} + onClick={this.updateSourceName} text={intl.get("sources.editName")} /> @@ -204,6 +216,19 @@ class SourcesTab extends React.Component { } + {this.state.selectedSources && <> + + + + this.props.deleteSources(this.state.selectedSources)} + text={intl.get("sources.delete")} /> + + + {intl.get("sources.deleteWarning")} + + + } ) } diff --git a/src/containers/settings/sources-container.tsx b/src/containers/settings/sources-container.tsx index 9c297b6..9873d43 100644 --- a/src/containers/settings/sources-container.tsx +++ b/src/containers/settings/sources-container.tsx @@ -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(), diff --git a/src/scripts/i18n/en-US.json b/src/scripts/i18n/en-US.json index c4ab21d..5eb8306 100644 --- a/src/scripts/i18n/en-US.json +++ b/src/scripts/i18n/en-US.json @@ -84,6 +84,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", @@ -100,7 +102,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", diff --git a/src/scripts/i18n/zh-CN.json b/src/scripts/i18n/zh-CN.json index 7ab0844..a28aa03 100644 --- a/src/scripts/i18n/zh-CN.json +++ b/src/scripts/i18n/zh-CN.json @@ -84,6 +84,8 @@ "sources": { "untitled": "订阅源", "errorAdd": "添加订阅源时出错", + "errorParse": "解析OPML文件时出错", + "errorParseHint": "请确保OPML文件完整且使用UTF-8编码。", "errorImport": "导入{count}项订阅源时出错", "opmlFile": "OPML文件", "name": "订阅源名称", @@ -99,8 +101,9 @@ "loadWebpage": "加载网页", "inputUrl": "输入URL", "badUrl": "请正确输入URL", - "deleteWarning": "这将移除此订阅源与所有已保存的文章", - "selected": "选中订阅源" + "deleteWarning": "这将移除订阅源与所有已保存的文章", + "selected": "选中订阅源", + "selectedMulti": "选中多个订阅源" }, "groups": { "type": "类型", diff --git a/src/scripts/models/group.ts b/src/scripts/models/group.ts index b07946d..c213709 100644 --- a/src/scripts/models/group.ts +++ b/src/scripts/models/group.ts @@ -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, number, string][] = [] let errors: [string, any][] = [] for (let el of doc[0].children) { diff --git a/src/scripts/models/item.ts b/src/scripts/models/item.ts index c5f9c88..a39a2f9 100644 --- a/src/scripts/models/item.ts +++ b/src/scripts/models/item.ts @@ -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 = { diff --git a/src/scripts/models/source.ts b/src/scripts/models/source.ts index 48cea54..ae6e7a8 100644 --- a/src/scripts/models/source.ts +++ b/src/scripts/models/source.ts @@ -55,6 +55,7 @@ export class RSSSource { if (err) { reject(err) } else if (doc === null) { + RSSItem.parseContent(i, item) resolve(i) } else { resolve(null) @@ -289,29 +290,44 @@ export function deleteSourceDone(source: RSSSource): SourceActionTypes { } } -export function deleteSource(source: RSSSource): AppThunk { +export function deleteSource(source: RSSSource, batch = false): AppThunk> { 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> { + 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 diff --git a/src/scripts/utils.ts b/src/scripts/utils.ts index d697d73..3fdbb5d 100644 --- a/src/scripts/utils.ts +++ b/src/scripts/utils.ts @@ -19,7 +19,10 @@ export type AppDispatch = ThunkDispatch 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[] } }) From a922a2434f904a92345d67bddf28f68ed7cb286e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E6=B5=A9=E8=BF=9C?= Date: Wed, 24 Jun 2020 16:32:06 +0800 Subject: [PATCH 3/5] manage rules --- PRIVACY.md | 4 - dist/styles.css | 7 + src/components/settings.tsx | 4 + src/components/settings/groups.tsx | 2 +- src/components/settings/rules.tsx | 339 +++++++++++++++++++ src/components/utils/article-search.tsx | 10 +- src/containers/settings/groups-container.tsx | 1 - src/containers/settings/rules-container.tsx | 26 ++ src/scripts/i18n/en-US.json | 19 ++ src/scripts/i18n/zh-CN.json | 19 ++ src/scripts/models/rule.ts | 20 +- src/scripts/models/source.ts | 2 + src/scripts/utils.ts | 10 +- 13 files changed, 446 insertions(+), 17 deletions(-) delete mode 100644 PRIVACY.md create mode 100644 src/components/settings/rules.tsx create mode 100644 src/containers/settings/rules-container.tsx diff --git a/PRIVACY.md b/PRIVACY.md deleted file mode 100644 index 126d648..0000000 --- a/PRIVACY.md +++ /dev/null @@ -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. diff --git a/dist/styles.css b/dist/styles.css index e29572a..3eaf32c 100644 --- a/dist/styles.css +++ b/dist/styles.css @@ -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; diff --git a/src/components/settings.tsx b/src/components/settings.tsx index 6bcbdff..4e39c2f 100644 --- a/src/components/settings.tsx +++ b/src/components/settings.tsx @@ -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 { + + + diff --git a/src/components/settings/groups.tsx b/src/components/settings/groups.tsx index 147b97c..a2b7b86 100644 --- a/src/components/settings/groups.tsx +++ b/src/components/settings/groups.tsx @@ -160,7 +160,7 @@ class GroupsTab extends React.Component { let groups = this.props.groups.filter(g => g.index != draggedItem.index) groups.splice(insertIndex, 0, draggedItem) - + this.groupSelection.setAllSelected(false) this.props.reorderGroups(groups) } diff --git a/src/components/settings/rules.tsx b/src/components/settings/rules.tsx new file mode 100644 index 0000000..df9b04b --- /dev/null +++ b/src/components/settings/rules.tsx @@ -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 { + 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) => ( +
+ {option.data && option.data.icon && ( + + )} + {option.text} +
+ ) + 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 = () => ( +
+ + + + + + + + + + {this.state.sid && ( + this.state.editIndex > -1 || !this.getSourceRules() || this.getSourceRules().length === 0 + ? <> + + + + + + + + + + + + + + + + + + + + + } /> + + + + + + + {this.state.editIndex > -1 && + this.setState({ editIndex: -1 })} /> + } + + + : <> + + + + + )} +
+ ) +} + +export default RulesTab \ No newline at end of file diff --git a/src/components/utils/article-search.tsx b/src/components/utils/article-search.tsx index b2a68eb..e973124 100644 --- a/src/components/utils/article-search.tsx +++ b/src/components/utils/article-search.tsx @@ -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 { 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() this.state = { query: props.initQuery } diff --git a/src/containers/settings/groups-container.tsx b/src/containers/settings/groups-container.tsx index d7ed689..0917939 100644 --- a/src/containers/settings/groups-container.tsx +++ b/src/containers/settings/groups-container.tsx @@ -1,4 +1,3 @@ -import { remote } from "electron" import { connect } from "react-redux" import { createSelector } from "reselect" import { RootState } from "../../scripts/reducer" diff --git a/src/containers/settings/rules-container.tsx b/src/containers/settings/rules-container.tsx new file mode 100644 index 0000000..ff2c642 --- /dev/null +++ b/src/containers/settings/rules-container.tsx @@ -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 \ No newline at end of file diff --git a/src/scripts/i18n/en-US.json b/src/scripts/i18n/en-US.json index 5eb8306..7a5c49d 100644 --- a/src/scripts/i18n/en-US.json +++ b/src/scripts/i18n/en-US.json @@ -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", diff --git a/src/scripts/i18n/zh-CN.json b/src/scripts/i18n/zh-CN.json index a28aa03..dc959f0 100644 --- a/src/scripts/i18n/zh-CN.json +++ b/src/scripts/i18n/zh-CN.json @@ -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": "清空缓存", diff --git a/src/scripts/models/rule.ts b/src/scripts/models/rule.ts index c6276be..edb0c4a 100644 --- a/src/scripts/models/rule.ts +++ b/src/scripts/models/rule.ts @@ -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) { diff --git a/src/scripts/models/source.ts b/src/scripts/models/source.ts index ae6e7a8..d5df8b9 100644 --- a/src/scripts/models/source.ts +++ b/src/scripts/models/source.ts @@ -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 diff --git a/src/scripts/utils.ts b/src/scripts/utils.ts index 3fdbb5d..9e2f117 100644 --- a/src/scripts/utils.ts +++ b/src/scripts/utils.ts @@ -129,4 +129,12 @@ export function calculateItemSize(): Promise { } openRequest.onerror = () => reject() }) -} \ No newline at end of file +} + +export function validateRegex(regex: string): RegExp { + try { + return new RegExp(regex) + } catch { + return null + } +} From eba23e606f0449f3f2c9970cd078b2d6f4dc41f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E6=B5=A9=E8=BF=9C?= Date: Wed, 24 Jun 2020 21:46:26 +0800 Subject: [PATCH 4/5] test & apply rules --- src/components/settings/rules.tsx | 53 +++++++++++++++++++++++++++++-- src/scripts/i18n/en-US.json | 5 ++- src/scripts/i18n/zh-CN.json | 5 ++- src/scripts/models/source.ts | 6 ++-- 4 files changed, 63 insertions(+), 6 deletions(-) diff --git a/src/components/settings/rules.tsx b/src/components/settings/rules.tsx index df9b04b..dd08e3b 100644 --- a/src/components/settings/rules.tsx +++ b/src/components/settings/rules.tsx @@ -6,6 +6,8 @@ import { Stack, Label, Dropdown, IDropdownOption, TextField, PrimaryButton, Icon 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", @@ -29,6 +31,9 @@ type RulesTabState = { fullSearch: boolean match: boolean actionKeys: string[] + mockTitle: string + mockContent: string + mockResult: string } class RulesTab extends React.Component { @@ -46,7 +51,10 @@ class RulesTab extends React.Component { regex: "", fullSearch: false, match: true, - actionKeys: [] + actionKeys: [], + mockTitle: "", + mockContent: "", + mockResult: "" } this.rulesSelection = new Selection({ getKey: (_, i) => i, @@ -141,7 +149,10 @@ class RulesTab extends React.Component { } onSourceOptionChange = (_, item: IDropdownOption) => { this.initRuleEdit() - this.setState({ sid: item.key as string, selectedRules: [], editIndex: -1 }) + this.setState({ + sid: item.key as string, selectedRules: [], editIndex: -1, + mockTitle: "", mockContent: "", mockResult: "" + }) } searchOptions = (): IDropdownOption[] => [ @@ -241,6 +252,19 @@ class RulesTab extends React.Component { 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 = () => (
@@ -331,6 +355,31 @@ class RulesTab extends React.Component { selection={this.rulesSelection} selectionMode={SelectionMode.multiple} /> + {intl.get("rules.hint")} + + + + + + + + + + + + + + {this.state.mockResult} )}
) diff --git a/src/scripts/i18n/en-US.json b/src/scripts/i18n/en-US.json index 7a5c49d..f027d11 100644 --- a/src/scripts/i18n/en-US.json +++ b/src/scripts/i18n/en-US.json @@ -134,13 +134,16 @@ "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" + "selectAction": "Select actions", + "hint": "Rules will be applied in order. Drag and drop to reorder.", + "test": "Test rules" }, "app": { "cleanup": "Clean up", diff --git a/src/scripts/i18n/zh-CN.json b/src/scripts/i18n/zh-CN.json index dc959f0..da77fc9 100644 --- a/src/scripts/i18n/zh-CN.json +++ b/src/scripts/i18n/zh-CN.json @@ -134,13 +134,16 @@ "if": "若", "then": "则", "title": "标题", + "content": "正文", "fullSearch": "标题或正文", "match": "匹配", "notMatch": "不匹配", "regex": "正则表达式", "badRegex": "正则表达式非法", "action": "行为", - "selectAction": "选择行为" + "selectAction": "选择行为", + "hint": "规则将按顺序执行,拖拽以排序", + "test": "测试规则" }, "app": { "cleanup": "清理", diff --git a/src/scripts/models/source.ts b/src/scripts/models/source.ts index d5df8b9..b7cf831 100644 --- a/src/scripts/models/source.ts +++ b/src/scripts/models/source.ts @@ -58,6 +58,7 @@ export class RSSSource { reject(err) } else if (doc === null) { RSSItem.parseContent(i, item) + if (source.rules) SourceRule.applyAll(source.rules, i) resolve(i) } else { resolve(null) @@ -367,9 +368,10 @@ export function sourceReducer( case ActionStatus.Success: { let updateMap = new Map() 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)) { From 2e4e4008110aaa72c36023d1f8a1d18a016e4761 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E6=B5=A9=E8=BF=9C?= Date: Thu, 25 Jun 2020 10:18:49 +0800 Subject: [PATCH 5/5] styles updates --- dist/article/article.css | 10 +++++++++- dist/styles.css | 15 +++++++++------ package.json | 7 ++++--- src/components/settings/about.tsx | 2 +- src/components/settings/app.tsx | 2 +- src/components/settings/rules.tsx | 5 ++++- src/scripts/i18n/fr-FR.json | 3 +++ 7 files changed, 31 insertions(+), 13 deletions(-) diff --git a/dist/article/article.css b/dist/article/article.css index 5893fc0..0de6099 100644 --- a/dist/article/article.css +++ b/dist/article/article.css @@ -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; } \ No newline at end of file diff --git a/dist/styles.css b/dist/styles.css index 3eaf32c..c446e06 100644 --- a/dist/styles.css +++ b/dist/styles.css @@ -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; @@ -386,6 +386,10 @@ img.favicon.dropdown { 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 { @@ -432,6 +436,10 @@ img.favicon.dropdown { 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; @@ -546,11 +554,6 @@ img.favicon.dropdown { 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); diff --git a/package.json b/package.json index 92d7ae2..937e8bc 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/components/settings/about.tsx b/src/components/settings/about.tsx index 4baa065..9e46785 100644 --- a/src/components/settings/about.tsx +++ b/src/components/settings/about.tsx @@ -9,7 +9,7 @@ class AboutTab extends React.Component {
-

Fluent Reader

+

Fluent Reader

{intl.get("settings.version")} {remote.app.getVersion()}

Copyright © 2020 Haoyuan Liu. All rights reserved.

diff --git a/src/components/settings/app.tsx b/src/components/settings/app.tsx index 9d02b78..6ec3f0d 100644 --- a/src/components/settings/app.tsx +++ b/src/components/settings/app.tsx @@ -80,8 +80,8 @@ class AppTab extends React.Component { 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 = () => { diff --git a/src/components/settings/rules.tsx b/src/components/settings/rules.tsx index dd08e3b..b56ece5 100644 --- a/src/components/settings/rules.tsx +++ b/src/components/settings/rules.tsx @@ -149,6 +149,7 @@ class RulesTab extends React.Component { } onSourceOptionChange = (_, item: IDropdownOption) => { this.initRuleEdit() + this.rulesSelection.setAllSelected(false) this.setState({ sid: item.key as string, selectedRules: [], editIndex: -1, mockTitle: "", mockContent: "", mockResult: "" @@ -285,7 +286,9 @@ class RulesTab extends React.Component { {this.state.sid && ( this.state.editIndex > -1 || !this.getSourceRules() || this.getSourceRules().length === 0 ? <> - + diff --git a/src/scripts/i18n/fr-FR.json b/src/scripts/i18n/fr-FR.json index 33be189..57a4143 100644 --- a/src/scripts/i18n/fr-FR.json +++ b/src/scripts/i18n/fr-FR.json @@ -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",