alpha release build 0.4.0

Version 0.4.0
This commit is contained in:
Haoyuan Liu 2020-06-25 10:46:11 +08:00 committed by GitHub
commit eeb5c53e9f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 710 additions and 75 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.

View File

@ -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;
@ -64,3 +67,8 @@ article figure figcaption {
article iframe {
width: 100%;
}
article code {
font-family: Monaco, Consolas, monospace;
font-size: .875rem;
line-height: 1;
}

22
dist/styles.css vendored
View File

@ -40,7 +40,7 @@
html, body {
background-color: transparent;
font-family: "Segoe UI Regular", "Source Han Sans SC Regular", "Microsoft YaHei", sans-serif;
font-family: "Segoe UI", "Source Han Sans SC Regular", "Microsoft YaHei", sans-serif;
height: 100%;
overflow: hidden;
margin: 0;
@ -316,12 +316,19 @@ div[role="tabpanel"] {
.tab-body .ms-ChoiceFieldGroup {
margin-bottom: 20px;
}
.tab-body .ms-CommandBar {
padding: 0;
}
img.favicon {
width: 16px;
height: 16px;
vertical-align: middle;
user-select: none;
}
img.favicon.dropdown {
margin-right: 8px;
vertical-align: sub;
}
.ms-DetailsList-contentWrapper {
max-height: 400px;
overflow-x: hidden;
@ -379,6 +386,10 @@ img.favicon {
height: 28px;
box-shadow: 0 1.6px 3.6px 0 rgba(0,0,0,.132), 0 0.3px 0.9px 0 rgba(0,0,0,.108);
}
.list-main .article-search {
max-width: 294px;
margin: 4px 10px;
}
@media (min-width: 1441px) {
#root > nav.menu-on {
@ -425,6 +436,10 @@ img.favicon {
left: 280px;
max-width: calc(100% - 728px);
}
.list-main.menu-on .article-search {
left: 0;
width: 330px;
}
nav.hide-btns .btn-group .btn, nav.menu-on .btn-group .btn.hide-wide, .menu .btn-group .btn.hide-wide {
display: none;
@ -539,11 +554,6 @@ img.favicon {
overflow: hidden;
background: var(--white);
}
.list-main .article-search {
left: 0;
max-width: 330px;
margin: 4px 10px;
}
.list-feed-container {
width: 350px;
background-color: var(--neutralLighterAlt);

View File

@ -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": {

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

@ -9,7 +9,7 @@ class AboutTab extends React.Component {
<div className="tab-body">
<Stack className="settings-about" horizontalAlign="center">
<img src="icons/logo.svg" style={{width: 120, height: 120}} />
<h3>Fluent Reader</h3>
<h3 style={{fontWeight: 600}}>Fluent Reader</h3>
<small>{intl.get("settings.version")} {remote.app.getVersion()}</small>
<p className="settings-hint">Copyright © 2020 Haoyuan Liu. All rights reserved.</p>
<Stack horizontal horizontalAlign="center" tokens={{childrenGap: 12}}>

View File

@ -80,8 +80,8 @@ class AppTab extends React.Component<AppTabProps, AppTabState> {
languageOptions = (): IDropdownOption[] => [
{ key: "default", text: intl.get("followSystem") },
{ key: "en-US", text: "English" },
{ key: "zh-CN", text: "中文(简体)"},
{ key: "fr-FR", text: "Français"},
{ key: "zh-CN", text: "中文(简体)"},
]
toggleStatus = () => {

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

View File

@ -13,6 +13,7 @@ type SourcesTabProps = {
updateSourceOpenTarget: (source: RSSSource, target: SourceOpenTarget) => void
updateFetchFrequency: (source: RSSSource, frequency: number) => void
deleteSource: (source: RSSSource) => void
deleteSources: (sources: RSSSource[]) => void
importOPML: () => void
exportOPML: () => void
}
@ -20,7 +21,8 @@ type SourcesTabProps = {
type SourcesTabState = {
[formName: string]: string
} & {
selectedSource: RSSSource
selectedSource: RSSSource,
selectedSources: RSSSource[]
}
class SourcesTab extends React.Component<SourcesTabProps, SourcesTabState> {
@ -31,15 +33,18 @@ class SourcesTab extends React.Component<SourcesTabProps, SourcesTabState> {
this.state = {
newUrl: "",
newSourceName: "",
selectedSource: null
selectedSource: null,
selectedSources: null
}
this.selection = new Selection({
getKey: s => (s as RSSSource).sid,
onSelectionChanged: () => {
let source = this.selection.getSelectedCount() ? this.selection.getSelection()[0] as RSSSource : null
let count = this.selection.getSelectedCount()
let sources = count ? this.selection.getSelection() as RSSSource[] : null
this.setState({
selectedSource: source,
newSourceName: source ? source.name : ""
selectedSource: count === 1 ? sources[0] : null,
selectedSources: count > 1 ? sources : null,
newSourceName: count === 1 ? sources[0].name : ""
})
}
})
@ -99,6 +104,12 @@ class SourcesTab extends React.Component<SourcesTabProps, SourcesTabState> {
{ key: String(SourceOpenTarget.External), text: intl.get("openExternal") }
]
updateSourceName = () => {
let newName = this.state.newSourceName.trim()
this.props.updateSourceName(this.state.selectedSource, newName)
this.setState({selectedSource: {...this.state.selectedSource, name: newName} as RSSSource})
}
handleInputChange = (event) => {
const name: string = event.target.name
this.setState({[name]: event.target.value})
@ -151,12 +162,13 @@ class SourcesTab extends React.Component<SourcesTabProps, SourcesTabState> {
</form>
<DetailsList
compact={Object.keys(this.props.sources).length >= 10}
items={Object.values(this.props.sources)}
columns={this.columns()}
getKey={s => s.sid}
setKey="selected"
selection={this.selection}
selectionMode={SelectionMode.single} />
selectionMode={SelectionMode.multiple} />
{this.state.selectedSource && <>
<Label>{intl.get("sources.selected")}</Label>
@ -173,7 +185,7 @@ class SourcesTab extends React.Component<SourcesTabProps, SourcesTabState> {
<Stack.Item>
<DefaultButton
disabled={this.state.newSourceName.trim().length == 0}
onClick={() => this.props.updateSourceName(this.state.selectedSource, this.state.newSourceName.trim())}
onClick={this.updateSourceName}
text={intl.get("sources.editName")} />
</Stack.Item>
</Stack>
@ -204,6 +216,19 @@ class SourcesTab extends React.Component<SourcesTabProps, SourcesTabState> {
</Stack.Item>
</Stack>
</>}
{this.state.selectedSources && <>
<Label>{intl.get("sources.selectedMulti")}</Label>
<Stack horizontal>
<Stack.Item>
<DangerButton
onClick={() => this.props.deleteSources(this.state.selectedSources)}
text={intl.get("sources.delete")} />
</Stack.Item>
<Stack.Item>
<span className="settings-hint">{intl.get("sources.deleteWarning")}</span>
</Stack.Item>
</Stack>
</>}
</div>
)
}

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

@ -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(),

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",
@ -78,6 +81,7 @@
"exit": "Exit settings",
"sources": "Sources",
"grouping": "Grouping",
"rules": "Rules",
"app": "Preferences",
"about": "About",
"version": "Version",
@ -88,6 +92,8 @@
"sources": {
"untitled": "Source",
"errorAdd": "An error has occured when adding the source.",
"errorParse": "An error has occured when parsing the OPML file.",
"errorParseHint": "Please ensure that the file isn't corrupted and is encoded with UTF-8.",
"errorImport": "Error importing {count, plural, =1 {# source} other {# sources}}.",
"opmlFile": "OPML File",
"name": "Source name",
@ -104,7 +110,8 @@
"inputUrl": "Enter URL",
"badUrl": "Invalid URL",
"deleteWarning": "The source and all saved articles will be removed.",
"selected": "Selected source"
"selected": "Selected source",
"selectedMulti": "Selected multiple sources"
},
"groups": {
"type": "Type",
@ -124,6 +131,24 @@
"addToGroup": "Add to ...",
"groupHint": "Double click on group to edit sources. Drag and drop to reorder."
},
"rules": {
"source": "Source",
"selectSource": "Select a source",
"new": "New Rule",
"if": "If",
"then": "Then",
"title": "Title",
"content": "Content",
"fullSearch": "Title or content",
"match": "matches",
"notMatch": "doesn't match",
"regex": "Regular expression",
"badRegex": "Invalid regular expression.",
"action": "Actions",
"selectAction": "Select actions",
"hint": "Rules will be applied in order. Drag and drop to reorder.",
"test": "Test rules"
},
"app": {
"cleanup": "Clean up",
"cache": "Clear cache",

View File

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

View File

@ -6,6 +6,9 @@
"name": "名称",
"openExternal": "在浏览器中打开",
"emptyName": "名称不得为空",
"emptyField": "此项不得为空",
"edit": "编辑",
"delete": "删除",
"followSystem": "跟随系统",
"more": "更多",
"close": "关闭",
@ -78,6 +81,7 @@
"exit": "退出选项",
"sources": "订阅源",
"grouping": "分组与排序",
"rules": "规则",
"app": "应用偏好",
"about": "关于",
"version": "版本",
@ -88,6 +92,8 @@
"sources": {
"untitled": "订阅源",
"errorAdd": "添加订阅源时出错",
"errorParse": "解析OPML文件时出错",
"errorParseHint": "请确保OPML文件完整且使用UTF-8编码。",
"errorImport": "导入{count}项订阅源时出错",
"opmlFile": "OPML文件",
"name": "订阅源名称",
@ -103,8 +109,9 @@
"loadWebpage": "加载网页",
"inputUrl": "输入URL",
"badUrl": "请正确输入URL",
"deleteWarning": "这将移除此订阅源与所有已保存的文章",
"selected": "选中订阅源"
"deleteWarning": "这将移除订阅源与所有已保存的文章",
"selected": "选中订阅源",
"selectedMulti": "选中多个订阅源"
},
"groups": {
"type": "类型",
@ -124,6 +131,24 @@
"addToGroup": "添加至分组",
"groupHint": "双击分组以修改订阅源,可通过拖拽排序"
},
"rules": {
"source": "订阅源",
"selectSource": "选择一个订阅源",
"new": "新建规则",
"if": "若",
"then": "则",
"title": "标题",
"content": "正文",
"fullSearch": "标题或正文",
"match": "匹配",
"notMatch": "不匹配",
"regex": "正则表达式",
"badRegex": "正则表达式非法",
"action": "行为",
"selectAction": "选择行为",
"hint": "规则将按顺序执行,拖拽以排序",
"test": "测试规则"
},
"app": {
"cleanup": "清理",
"cache": "清空缓存",

View File

@ -210,6 +210,12 @@ export function importOPML(path: string): AppThunk {
dispatch(saveSettings())
return
}
let parseError = doc[0].getElementsByTagName("parsererror")
if (parseError.length > 0) {
dispatch(saveSettings())
remote.dialog.showErrorBox(intl.get("sources.errorParse"), intl.get("sources.errorParseHint"))
return
}
let sources: [ReturnType<typeof addSource>, number, string][] = []
let errors: [string, any][] = []
for (let el of doc[0].children) {

View File

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

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

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
@ -55,6 +57,8 @@ export class RSSSource {
if (err) {
reject(err)
} else if (doc === null) {
RSSItem.parseContent(i, item)
if (source.rules) SourceRule.applyAll(source.rules, i)
resolve(i)
} else {
resolve(null)
@ -289,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) => {
dispatch(saveSettings())
return new Promise((resolve) => {
if (!batch) dispatch(saveSettings())
db.idb.remove({ source: source.sid }, { multi: true }, (err) => {
if (err) {
console.log(err)
dispatch(saveSettings())
if (!batch) dispatch(saveSettings())
resolve()
} else {
db.sdb.remove({ sid: source.sid }, {}, (err) => {
if (err) {
console.log(err)
dispatch(saveSettings())
if (!batch) dispatch(saveSettings())
resolve()
} else {
dispatch(deleteSourceDone(source))
SourceGroup.save(getState().groups)
if (!batch) dispatch(saveSettings())
resolve()
}
})
}
})
})
}
}
export function deleteSources(sources: RSSSource[]): AppThunk<Promise<void>> {
return async (dispatch) => {
dispatch(saveSettings())
for (let source of sources) {
await dispatch(deleteSource(source, true))
}
})
}
})
dispatch(saveSettings())
}
}
@ -349,9 +368,10 @@ export function sourceReducer(
case ActionStatus.Success: {
let updateMap = new Map<number, number>()
for (let item of action.items) {
updateMap.set(
if (!item.hasRead) { updateMap.set(
item.source,
updateMap.has(item.source) ? (updateMap.get(item.source) + 1) : 1)
updateMap.has(item.source) ? (updateMap.get(item.source) + 1) : 1
)}
}
let nextState = {} as SourceState
for (let [s, source] of Object.entries(state)) {

View File

@ -19,7 +19,10 @@ export type AppDispatch = ThunkDispatch<RootState, undefined, AnyAction>
import Parser = require("@yang991178/rss-parser")
const rssParser = new Parser({
customFields: {
item: ["thumb", "image", ["content:encoded", "fullContent"]] as Parser.CustomFieldItem[]
item: [
"thumb", "image", ["content:encoded", "fullContent"],
['media:content', 'mediaContent', {keepArray: true}],
] as Parser.CustomFieldItem[]
}
})
@ -127,3 +130,11 @@ export function calculateItemSize(): Promise<number> {
openRequest.onerror = () => reject()
})
}
export function validateRegex(regex: string): RegExp {
try {
return new RegExp(regex)
} catch {
return null
}
}