export to OPML & better import

This commit is contained in:
刘浩远 2020-06-13 16:04:21 +08:00
parent 9878386085
commit d649bde776
8 changed files with 93 additions and 26 deletions

View File

@ -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<SourcesTabProps, SourcesTabState> {
<PrimaryButton onClick={this.props.importOPML} text={intl.get("sources.import")} />
</Stack.Item>
<Stack.Item>
<DefaultButton text={intl.get("sources.export")} />
<DefaultButton onClick={this.props.exportOPML} text={intl.get("sources.export")} />
</Stack.Item>
</Stack>

View File

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

View File

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

View File

@ -72,6 +72,7 @@
"feedback": "反馈"
},
"sources": {
"errorImport": "导入{count}项订阅源时出错",
"opmlFile": "OPML文件",
"name": "订阅源名称",
"editName": "修改名称",

View File

@ -185,7 +185,7 @@ export function initIntl(): AppThunk<Promise<void>> {
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

View File

@ -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<number> {
function outlineToSource(outline: Element): [ReturnType<typeof addSource>, 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<typeof addSource>, 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(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?><opml version=\"1.0\"><head><title>Fluent Reader Export</title></head><body></body></opml>",
"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(

View File

@ -204,7 +204,7 @@ export function addSourceFailure(err, batch: boolean): SourceActionTypes {
export function addSource(url: string, name: string = null, batch = false): AppThunk<Promise<number>> {
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.") })
}
}

View File

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