diff --git a/src/components/settings/sources.tsx b/src/components/settings/sources.tsx index a74fb1e..de84dd4 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 deleteSource: (source: RSSSource) => void importOPML: () => void + exportOPML: () => void } type SourcesTabState = { @@ -103,7 +104,7 @@ class SourcesTab extends React.Component { - + diff --git a/src/containers/settings/sources-container.tsx b/src/containers/settings/sources-container.tsx index b588b62..78ab96d 100644 --- a/src/containers/settings/sources-container.tsx +++ b/src/containers/settings/sources-container.tsx @@ -5,7 +5,7 @@ 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 { importOPML } from "../../scripts/models/group" +import { importOPML, exportOPML } from "../../scripts/models/group" import { AppDispatch } from "../../scripts/utils" const getSources = (state: RootState) => state.sources @@ -36,6 +36,16 @@ const mapDispatchToProps = (dispatch: AppDispatch) => { } ) if (path && path.length > 0) dispatch(importOPML(path[0])) + }, + exportOPML: () => { + remote.dialog.showSaveDialog( + remote.getCurrentWindow(), + { + filters: [{ name: intl.get("sources.opmlFile"), extensions: ["opml"] }] + } + ).then(result => { + if (!result.canceled) dispatch(exportOPML(result.filePath)) + }) } } } diff --git a/src/scripts/i18n/en-US.json b/src/scripts/i18n/en-US.json index d621efc..6b29108 100644 --- a/src/scripts/i18n/en-US.json +++ b/src/scripts/i18n/en-US.json @@ -72,6 +72,7 @@ "feedback": "Feedback" }, "sources": { + "errorImport": "Error importing {count, plural, =1 {# source} other {# sources}}.", "opmlFile": "OPML File", "name": "Source name", "editName": "Edit name", diff --git a/src/scripts/i18n/zh-CN.json b/src/scripts/i18n/zh-CN.json index 4bde838..524b44b 100644 --- a/src/scripts/i18n/zh-CN.json +++ b/src/scripts/i18n/zh-CN.json @@ -72,6 +72,7 @@ "feedback": "反馈" }, "sources": { + "errorImport": "导入{count}项订阅源时出错", "opmlFile": "OPML文件", "name": "订阅源名称", "editName": "修改名称", diff --git a/src/scripts/models/app.ts b/src/scripts/models/app.ts index 6bf5764..d722179 100644 --- a/src/scripts/models/app.ts +++ b/src/scripts/models/app.ts @@ -185,7 +185,7 @@ export function initIntl(): AppThunk> { return intl.init({ currentLocale: locale, locales: locales, - fallbackLocale: "zh-CN" + fallbackLocale: "en-US" }).then(() => { dispatch(initIntlDone(locale)) }) } } @@ -234,7 +234,7 @@ export function appReducer( } default: return { ...state, - fetchingItems: false, + fetchingItems: state.fetchingTotal !== 0, settings: { ...state.settings, saving: action.batch diff --git a/src/scripts/models/group.ts b/src/scripts/models/group.ts index 68f9b27..1dce9ab 100644 --- a/src/scripts/models/group.ts +++ b/src/scripts/models/group.ts @@ -1,9 +1,12 @@ import fs = require("fs") -import { SourceActionTypes, ADD_SOURCE, DELETE_SOURCE, addSource } from "./source" +import intl = require("react-intl-universal") +import { SourceActionTypes, ADD_SOURCE, DELETE_SOURCE, addSource, RSSSource } from "./source" import { ActionStatus, AppThunk, domParser, AppDispatch } from "../utils" import { saveSettings } from "./app" import { store } from "../settings" +import { fetchItemsIntermediate, fetchItemsRequest, fetchItemsSuccess } from "./item" +import { remote } from "electron" const GROUPS_STORE_KEY = "sourceGroups" @@ -21,6 +24,7 @@ export class SourceGroup { } else { this.isMultiple = true this.name = name + this.expanded = true } this.sids = sids } @@ -184,15 +188,11 @@ export function toggleGroupExpansion(groupIndex: number): AppThunk { } } -async function outlineToSource(dispatch: AppDispatch, outline: Element): Promise { +function outlineToSource(outline: Element): [ReturnType, string] { let url = outline.getAttribute("xmlUrl") let name = outline.getAttribute("text") || outline.getAttribute("name") if (url) { - try { - return await dispatch(addSource(url.trim(), name, true)) - } catch (e) { - return null - } + return [addSource(url.trim(), name, true), url] } else { return null } @@ -205,38 +205,91 @@ export function importOPML(path: string): AppThunk { console.log(err) } else { dispatch(saveSettings()) - let successes: number = 0, failures: number = 0 let doc = domParser.parseFromString(data, "text/xml").getElementsByTagName("body") if (doc.length == 0) { dispatch(saveSettings()) return } + let sources: [ReturnType, number, string][] = [] + let errors: [string, any][] = [] for (let el of doc[0].children) { if (el.getAttribute("type") === "rss") { - let sid = await outlineToSource(dispatch, el) - if (sid === null) failures += 1 - else successes += 1 + let source = outlineToSource(el) + if (source) sources.push([source[0], -1, source[1]]) } else if (el.hasAttribute("text") || el.hasAttribute("title")) { let groupName = el.getAttribute("text") || el.getAttribute("title") let gid = dispatch(createSourceGroup(groupName)) for (let child of el.children) { - let sid = await outlineToSource(dispatch, child) - if (sid === null) { - failures += 1 - } else { - successes += 1 - dispatch(addSourceToGroup(gid, sid)) - } + let source = outlineToSource(child) + if (source) sources.push([source[0], gid, source[1]]) } } } - console.log(failures, successes) - dispatch(saveSettings()) + dispatch(fetchItemsRequest(sources.length)) + let promises = sources.map(([s, gid, url]) => { + return dispatch(s).then(sid => { + if (sid !== null) dispatch(addSourceToGroup(gid, sid)) + }).catch(err => { + errors.push([url, err]) + }).finally(() => { + dispatch(fetchItemsIntermediate()) + }) + }) + Promise.allSettled(promises).then(() => { + dispatch(fetchItemsSuccess([])) + dispatch(saveSettings()) + if (errors.length > 0) { + remote.dialog.showErrorBox( + intl.get("sources.errorImport", { count: errors.length }), + errors.map(e => { + return e[0] + "\n" + String(e[1]) + }).join("\n") + ) + } + }) } }) } } +function sourceToOutline(source: RSSSource, xml: Document) { + let outline = xml.createElement("outline") + outline.setAttribute("text", source.name) + outline.setAttribute("name", source.name) + outline.setAttribute("type", "rss") + outline.setAttribute("xmlUrl", source.url) + return outline +} + +export function exportOPML(path: string): AppThunk { + return (_, getState) => { + let state = getState() + let xml = domParser.parseFromString( + "Fluent Reader Export", + "text/xml" + ) + let body = xml.getElementsByTagName("body")[0] + for (let group of state.groups) { + if (group.isMultiple) { + let outline = xml.createElement("outline") + outline.setAttribute("text", group.name) + outline.setAttribute("name", group.name) + for (let sid of group.sids) { + outline.appendChild(sourceToOutline(state.sources[sid], xml)) + } + body.appendChild(outline) + } else { + body.appendChild(sourceToOutline(state.sources[group.sids[0]], xml)) + } + } + let serializer = new XMLSerializer() + fs.writeFile(path, serializer.serializeToString(xml), (err) => { + if (err) console.log(err) + }) + } + +} + export type GroupState = SourceGroup[] export function groupReducer( diff --git a/src/scripts/models/source.ts b/src/scripts/models/source.ts index fb1807a..ef65b60 100644 --- a/src/scripts/models/source.ts +++ b/src/scripts/models/source.ts @@ -204,7 +204,7 @@ export function addSourceFailure(err, batch: boolean): SourceActionTypes { export function addSource(url: string, name: string = null, batch = false): AppThunk> { return (dispatch, getState) => { let app = getState().app - if (app.sourceInit && !app.fetchingItems) { + if (app.sourceInit) { dispatch(addSourceRequest(batch)) let source = new RSSSource(url, name) return source.fetchMetaData(rssParser) @@ -235,7 +235,7 @@ export function addSource(url: string, name: string = null, batch = false): AppT return new Promise((_, reject) => { reject(e) }) }) } - return new Promise((_, reject) => { reject("Sources not initialized or fetching items.") }) + return new Promise((_, reject) => { reject("Sources not initialized.") }) } } diff --git a/src/scripts/utils.ts b/src/scripts/utils.ts index 8c0391b..455150c 100644 --- a/src/scripts/utils.ts +++ b/src/scripts/utils.ts @@ -24,6 +24,7 @@ const customFields = { import ElectronProxyAgent = require("@yang991178/electron-proxy-agent") import { ViewType } from "./models/page" import { IPartialTheme } from "@fluentui/react" +import { SourceGroup } from "./models/group" let agent = new ElectronProxyAgent(remote.getCurrentWebContents().session) export const rssParser = new Parser({ customFields: customFields,