+
{this.props.feeds.map(fid => (
diff --git a/src/components/utils/article-search.tsx b/src/components/utils/article-search.tsx
new file mode 100644
index 0000000..f9eacee
--- /dev/null
+++ b/src/components/utils/article-search.tsx
@@ -0,0 +1,63 @@
+import * as React from "react"
+import intl = require("react-intl-universal")
+import { connect } from "react-redux"
+import { RootState } from "../../scripts/reducer"
+import { SearchBox, IRefObject, ISearchBox } from "@fluentui/react"
+import { AppDispatch } from "../../scripts/utils"
+import { performSearch } from "../../scripts/models/page"
+
+class Debounced {
+ public use = (func: (...args: any[]) => any, delay: number): ((...args: any[]) => void) => {
+ let timer: NodeJS.Timeout
+ return (...args: any[]) => {
+ clearTimeout(timer)
+ timer = setTimeout(() => {
+ func.apply(this, args)
+ }, delay)
+ }
+ }
+}
+
+type SearchProps = {
+ searchOn: boolean
+ initQuery: string
+ dispatch: AppDispatch
+}
+
+class ArticleSearch extends React.Component {
+ debouncedSearch: (query: string) => void
+ inputRef: React.RefObject
+
+ constructor(props: SearchProps) {
+ super(props)
+ this.debouncedSearch = new Debounced().use((query: string) => props.dispatch(performSearch(query)), 750)
+ this.inputRef = React.createRef()
+ }
+
+ onSearchChange = (_, newValue: string) => {
+ this.debouncedSearch(newValue)
+ }
+
+ componentDidUpdate(prevProps: SearchProps) {
+ if (this.props.searchOn && !prevProps.searchOn) {
+ this.inputRef.current.focus()
+ }
+ }
+
+ render() {
+ return this.props.searchOn && (
+
+ )
+ }
+}
+
+const getSearchProps = (state: RootState) => ({
+ searchOn: state.page.searchOn,
+ initQuery: state.page.filter.search
+})
+export default connect(getSearchProps)(ArticleSearch)
\ No newline at end of file
diff --git a/src/containers/context-menu-container.tsx b/src/containers/context-menu-container.tsx
index 59a43c7..6702346 100644
--- a/src/containers/context-menu-container.tsx
+++ b/src/containers/context-menu-container.tsx
@@ -6,7 +6,7 @@ import { ContextMenu } from "../components/context-menu"
import { RSSItem, markRead, markUnread, toggleStarred, toggleHidden, markAllRead } from "../scripts/models/item"
import { showItem, switchView, ViewType, switchFilter, toggleFilter } from "../scripts/models/page"
import { setDefaultView } from "../scripts/settings"
-import { FeedFilter } from "../scripts/models/feed"
+import { FilterType } from "../scripts/models/feed"
const getContext = (state: RootState) => state.app.contextMenu
const getViewType = (state: RootState) => state.page.viewType
@@ -31,7 +31,7 @@ const mapStateToProps = createSelector(
type: context.type,
event: context.event,
viewType: viewType,
- filter: filter
+ filter: filter.type
}
case ContextMenuType.Group: return {
type: context.type,
@@ -60,8 +60,8 @@ const mapDispatchToProps = dispatch => {
setDefaultView(viewType)
dispatch(switchView(viewType))
},
- switchFilter: (filter: FeedFilter) => dispatch(switchFilter(filter)),
- toggleFilter: (filter: FeedFilter) => dispatch(toggleFilter(filter)),
+ switchFilter: (filter: FilterType) => dispatch(switchFilter(filter)),
+ toggleFilter: (filter: FilterType) => dispatch(toggleFilter(filter)),
markAllRead: (sids: number[]) => dispatch(markAllRead(sids)),
settings: () => dispatch(toggleSettings()),
close: () => dispatch(closeContextMenu())
diff --git a/src/containers/menu-container.tsx b/src/containers/menu-container.tsx
index ce18248..ff7e75b 100644
--- a/src/containers/menu-container.tsx
+++ b/src/containers/menu-container.tsx
@@ -4,22 +4,24 @@ import { RootState } from "../scripts/reducer"
import { Menu } from "../components/menu"
import { toggleMenu, openGroupMenu } from "../scripts/models/app"
import { SourceGroup, toggleGroupExpansion } from "../scripts/models/group"
-import { selectAllArticles, selectSources } from "../scripts/models/page"
+import { selectAllArticles, selectSources, toggleSearch } from "../scripts/models/page"
import { initFeeds } from "../scripts/models/feed"
import { RSSSource } from "../scripts/models/source"
const getApp = (state: RootState) => state.app
const getSources = (state: RootState) => state.sources
const getGroups = (state: RootState) => state.groups
+const getSearchOn = (state: RootState) => state.page.searchOn
const mapStateToProps = createSelector(
- [getApp, getSources, getGroups],
- (app, sources, groups) => ({
+ [getApp, getSources, getGroups, getSearchOn],
+ (app, sources, groups, searchOn) => ({
status: app.sourceInit,
display: app.menu,
selected: app.menuKey,
sources: sources,
- groups: groups
+ groups: groups,
+ searchOn: searchOn,
})
)
@@ -45,7 +47,8 @@ const mapDispatchToProps = dispatch => ({
let [type, index] = key.split("-")
if (type === "g") dispatch(toggleGroupExpansion(parseInt(index)))
}
- }
+ },
+ toggleSearch: () => dispatch(toggleSearch()),
})
const MenuContainer = connect(mapStateToProps, mapDispatchToProps)(Menu)
diff --git a/src/containers/settings/sources-container.tsx b/src/containers/settings/sources-container.tsx
index 78ab96d..8ad7115 100644
--- a/src/containers/settings/sources-container.tsx
+++ b/src/containers/settings/sources-container.tsx
@@ -28,14 +28,15 @@ const mapDispatchToProps = (dispatch: AppDispatch) => {
},
deleteSource: (source: RSSSource) => dispatch(deleteSource(source)),
importOPML: () => {
- let path = remote.dialog.showOpenDialogSync(
+ remote.dialog.showOpenDialog(
remote.getCurrentWindow(),
{
filters: [{ name: intl.get("sources.opmlFile"), extensions: ["xml", "opml"] }],
properties: ["openFile"]
}
- )
- if (path && path.length > 0) dispatch(importOPML(path[0]))
+ ).then(result => {
+ if (!result.canceled && result.filePaths.length > 0) dispatch(importOPML(result.filePaths[0]))
+ })
},
exportOPML: () => {
remote.dialog.showSaveDialog(
diff --git a/src/electron.ts b/src/electron.ts
index 237e73a..9a4a6b9 100644
--- a/src/electron.ts
+++ b/src/electron.ts
@@ -26,7 +26,8 @@ function createWindow() {
show: false,
webPreferences: {
nodeIntegration: true,
- webviewTag: true
+ webviewTag: true,
+ enableRemoteModule: true
}
})
mainWindowState.manage(mainWindow)
diff --git a/src/scripts/i18n/en-US.json b/src/scripts/i18n/en-US.json
index 6b29108..1f5bf82 100644
--- a/src/scripts/i18n/en-US.json
+++ b/src/scripts/i18n/en-US.json
@@ -35,6 +35,7 @@
"subscriptions": "Subscriptions"
},
"article": {
+ "untitled": "(Untitled)",
"hide": "Hide article",
"unhide": "Unhide article",
"markRead": "Mark as read",
@@ -56,6 +57,7 @@
"filter": "Filtering",
"unreadOnly": "Unread only",
"starredOnly": "Starred only",
+ "fullSearch": "Search in full text",
"showHidden": "Show hidden articles",
"manageSources": "Manage sources"
},
@@ -72,6 +74,8 @@
"feedback": "Feedback"
},
"sources": {
+ "untitled": "Source",
+ "errorAdd": "An error has occured when adding the source.",
"errorImport": "Error importing {count, plural, =1 {# source} other {# sources}}.",
"opmlFile": "OPML File",
"name": "Source name",
diff --git a/src/scripts/i18n/zh-CN.json b/src/scripts/i18n/zh-CN.json
index 524b44b..e27d14f 100644
--- a/src/scripts/i18n/zh-CN.json
+++ b/src/scripts/i18n/zh-CN.json
@@ -35,6 +35,7 @@
"subscriptions": "订阅源"
},
"article": {
+ "untitled": "(无标题)",
"hide": "隐藏文章",
"unhide": "取消隐藏",
"markRead": "标为已读",
@@ -56,6 +57,7 @@
"filter": "筛选",
"unreadOnly": "仅未读文章",
"starredOnly": "仅星标文章",
+ "fullSearch": "在正文中搜索",
"showHidden": "显示隐藏文章",
"manageSources": "管理订阅源"
},
@@ -72,6 +74,8 @@
"feedback": "反馈"
},
"sources": {
+ "untitled": "订阅源",
+ "errorAdd": "添加订阅源时出错",
"errorImport": "导入{count}项订阅源时出错",
"opmlFile": "OPML文件",
"name": "订阅源名称",
diff --git a/src/scripts/models/feed.ts b/src/scripts/models/feed.ts
index 0ba8a61..dd22e46 100644
--- a/src/scripts/models/feed.ts
+++ b/src/scripts/models/feed.ts
@@ -4,34 +4,65 @@ import { ItemActionTypes, FETCH_ITEMS, RSSItem, MARK_READ, MARK_UNREAD, TOGGLE_S
import { ActionStatus, AppThunk } from "../utils"
import { PageActionTypes, SELECT_PAGE, PageType, APPLY_FILTER } from "./page"
-export enum FeedFilter {
+export enum FilterType {
None,
ShowRead = 1 << 0,
ShowNotStarred = 1 << 1,
ShowHidden = 1 << 2,
+ FullSearch = 1 << 3,
Default = ShowRead | ShowNotStarred,
UnreadOnly = ShowNotStarred,
- StarredOnly = ShowRead
+ StarredOnly = ShowRead,
+ Toggles = ShowHidden | FullSearch
}
-export namespace FeedFilter {
- export function toQueryObject(filter: FeedFilter) {
+export class FeedFilter {
+ type: FilterType
+ search: string
+
+ constructor(type=FilterType.Default, search="") {
+ this.type = type
+ this.search = search
+ }
+
+ static toQueryObject(filter: FeedFilter) {
+ let type = filter.type
let query = {
hasRead: false,
starred: true,
hidden: { $exists: false }
+ } as any
+ if (type & FilterType.ShowRead) delete query.hasRead
+ if (type & FilterType.ShowNotStarred) delete query.starred
+ if (type & FilterType.ShowHidden) delete query.hidden
+ if (filter.search !== "") {
+ let regex = RegExp(filter.search)
+ if (type & FilterType.FullSearch) {
+ query.$or = [
+ { title: { $regex: regex } },
+ { snippet: { $regex: regex } }
+ ]
+ } else {
+ query.title = { $regex: regex }
+ }
}
- if (filter & FeedFilter.ShowRead) delete query.hasRead
- if (filter & FeedFilter.ShowNotStarred) delete query.starred
- if (filter & FeedFilter.ShowHidden) delete query.hidden
return query
}
- export function testItem(filter: FeedFilter, item: RSSItem) {
+ static testItem(filter: FeedFilter, item: RSSItem) {
+ let type = filter.type
let flag = true
- if (!(filter & FeedFilter.ShowRead)) flag = flag && !item.hasRead
- if (!(filter & FeedFilter.ShowNotStarred)) flag = flag && item.starred
- if (!(filter & FeedFilter.ShowHidden)) flag = flag && !item.hidden
+ if (!(type & FilterType.ShowRead)) flag = flag && !item.hasRead
+ if (!(type & FilterType.ShowNotStarred)) flag = flag && item.starred
+ if (!(type & FilterType.ShowHidden)) flag = flag && !item.hidden
+ if (filter.search !== "") {
+ let regex = RegExp(filter.search)
+ if (type & FilterType.FullSearch) {
+ flag = flag && (regex.test(item.title) || regex.test(item.snippet))
+ } else {
+ flag = flag && regex.test(item.title)
+ }
+ }
return Boolean(flag)
}
}
@@ -50,13 +81,13 @@ export class RSSFeed {
iids: string[]
filter: FeedFilter
- constructor (id: string = null, sids=[], filter=FeedFilter.Default) {
+ constructor (id: string = null, sids=[], filter=null) {
this._id = id
this.sids = sids
this.iids = []
this.loaded = false
this.allLoaded = false
- this.filter = filter
+ this.filter = filter === null ? new FeedFilter() : filter
}
static loadFeed(feed: RSSFeed, init = false): Promise {
diff --git a/src/scripts/models/item.ts b/src/scripts/models/item.ts
index f804d8a..4a6c006 100644
--- a/src/scripts/models/item.ts
+++ b/src/scripts/models/item.ts
@@ -1,4 +1,5 @@
import * as db from "../db"
+import intl = require("react-intl-universal")
import { rssParser, domParser, htmlDecode, ActionStatus, AppThunk } from "../utils"
import { RSSSource } from "./source"
import { FeedActionTypes, INIT_FEED, LOAD_MORE, FeedFilter } from "./feed"
@@ -15,14 +16,13 @@ export class RSSItem {
content: string
snippet: string
creator?: string
- categories?: string[]
hasRead: boolean
starred?: true
hidden?: true
constructor (item: Parser.Item, source: RSSSource) {
this.source = source.sid
- this.title = item.title || ""
+ this.title = item.title || intl.get("article.untitled")
this.link = item.link || ""
this.fetchedDate = new Date()
this.date = item.isoDate ? new Date(item.isoDate) : this.fetchedDate
@@ -44,7 +44,6 @@ export class RSSItem {
this.snippet = htmlDecode(item.contentSnippet || "")
}
this.creator = item.creator
- this.categories = item.categories
this.hasRead = false
}
}
diff --git a/src/scripts/models/page.ts b/src/scripts/models/page.ts
index 4bb5923..406429e 100644
--- a/src/scripts/models/page.ts
+++ b/src/scripts/models/page.ts
@@ -1,8 +1,9 @@
-import { ALL, SOURCE, loadMore, FeedFilter, initFeeds } from "./feed"
+import { ALL, SOURCE, loadMore, FeedFilter, FilterType, initFeeds } from "./feed"
import { getWindowBreakpoint, AppThunk } from "../utils"
import { getDefaultView } from "../settings"
import { RSSItem, markRead } from "./item"
import { SourceActionTypes, DELETE_SOURCE } from "./source"
+import { toggleMenu } from "./app"
export const SELECT_PAGE = "SELECT_PAGE"
export const SWITCH_VIEW = "SWITCH_VIEW"
@@ -10,6 +11,7 @@ export const SHOW_ITEM = "SHOW_ITEM"
export const SHOW_OFFSET_ITEM = "SHOW_OFFSET_ITEM"
export const DISMISS_ITEM = "DISMISS_ITEM"
export const APPLY_FILTER = "APPLY_FILTER"
+export const TOGGLE_SEARCH = "TOGGLE_SEARCH"
export enum PageType {
AllArticles, Sources, Page
@@ -47,8 +49,10 @@ interface ApplyFilterAction {
}
interface DismissItemAction { type: typeof DISMISS_ITEM }
+interface ToggleSearchAction { type: typeof TOGGLE_SEARCH }
-export type PageActionTypes = SelectPageAction | SwitchViewAction | ShowItemAction | DismissItemAction | ApplyFilterAction
+export type PageActionTypes = SelectPageAction | SwitchViewAction | ShowItemAction
+ | DismissItemAction | ApplyFilterAction | ToggleSearchAction
export function selectAllArticles(init = false): AppThunk {
return (dispatch, getState) => {
@@ -96,6 +100,22 @@ export function showItem(feedId: string, item: RSSItem): PageActionTypes {
export const dismissItem = (): PageActionTypes => ({ type: DISMISS_ITEM })
+export const toggleSearch = (): AppThunk => {
+ return (dispatch, getState) => {
+ let state = getState()
+ dispatch(({ type: TOGGLE_SEARCH }))
+ if (!getWindowBreakpoint()) {
+ dispatch(toggleMenu())
+ }
+ if (state.page.searchOn) {
+ dispatch(applyFilter({
+ ...state.page.filter,
+ search: ""
+ }))
+ }
+ }
+}
+
export function showOffsetItem(offset: number): AppThunk {
return (dispatch, getState) => {
let state = getState()
@@ -150,28 +170,46 @@ function applyFilter(filter: FeedFilter): AppThunk {
}
}
-export function switchFilter(filter: FeedFilter): AppThunk {
+export function switchFilter(filter: FilterType): AppThunk {
return (dispatch, getState) => {
let oldFilter = getState().page.filter
- let newFilter = filter | (oldFilter & FeedFilter.ShowHidden)
- if (newFilter != oldFilter) {
- dispatch(applyFilter(newFilter))
+ let oldType = oldFilter.type
+ let newType = filter | (oldType & FilterType.Toggles)
+ if (oldType != newType) {
+ dispatch(applyFilter({
+ ...oldFilter,
+ type: newType
+ }))
}
}
}
-export function toggleFilter(filter: FeedFilter): AppThunk {
+export function toggleFilter(filter: FilterType): AppThunk {
return (dispatch, getState) => {
- let oldFilter = getState().page.filter
- dispatch(applyFilter(oldFilter ^ filter))
+ let nextFilter = { ...getState().page.filter }
+ nextFilter.type ^= filter
+ dispatch(applyFilter(nextFilter))
+ }
+}
+
+export function performSearch(query: string): AppThunk {
+ return (dispatch, getState) => {
+ let state = getState()
+ if (state.page.searchOn) {
+ dispatch(applyFilter({
+ ...state.page.filter,
+ search: query
+ }))
+ }
}
}
export class PageState {
viewType = getDefaultView()
- filter = FeedFilter.Default
+ filter = new FeedFilter()
feedId = ALL
itemId = null as string
+ searchOn = false
}
export function pageReducer(
@@ -209,6 +247,10 @@ export function pageReducer(
...state,
itemId: null
}
+ case TOGGLE_SEARCH: return {
+ ...state,
+ searchOn: !state.searchOn
+ }
default: return state
}
}
\ No newline at end of file
diff --git a/src/scripts/models/source.ts b/src/scripts/models/source.ts
index ef65b60..d680203 100644
--- a/src/scripts/models/source.ts
+++ b/src/scripts/models/source.ts
@@ -1,9 +1,11 @@
import Parser = require("@yang991178/rss-parser")
+import intl = require("react-intl-universal")
import * as db from "../db"
import { rssParser, faviconPromise, ActionStatus, AppThunk } from "../utils"
import { RSSItem, insertItems, ItemActionTypes, FETCH_ITEMS, MARK_READ, MARK_UNREAD, MARK_ALL_READ } from "./item"
import { SourceGroup } from "./group"
import { saveSettings } from "./app"
+import { remote } from "electron"
export enum SourceOpenTarget {
Local, Webpage, External
@@ -14,7 +16,6 @@ export class RSSSource {
url: string
iconurl: string
name: string
- description: string
openTarget: SourceOpenTarget
unreadCount: number
@@ -26,8 +27,10 @@ export class RSSSource {
async fetchMetaData(parser: Parser) {
let feed = await parser.parseURL(this.url)
- if (!this.name && feed.title) this.name = feed.title.trim()
- this.description = feed.description
+ if (!this.name) {
+ if (feed.title) this.name = feed.title.trim()
+ this.name = this.name || intl.get("sources.untitled")
+ }
let domain = this.url.split("/").slice(0, 3).join("/")
let f: string = null
try {
@@ -232,6 +235,9 @@ export function addSource(url: string, name: string = null, batch = false): AppT
.catch(e => {
console.log(e)
dispatch(addSourceFailure(e, batch))
+ if (!batch) {
+ remote.dialog.showErrorBox(intl.get("sources.errorAdd"), String(e))
+ }
return new Promise((_, reject) => { reject(e) })
})
}