commit
7e9cff26ab
2
dist/styles/global.css
vendored
2
dist/styles/global.css
vendored
@ -237,7 +237,7 @@ body.darwin .btn-group .seperator {
|
|||||||
height: var(--navHeight);
|
height: var(--navHeight);
|
||||||
line-height: var(--navHeight);
|
line-height: var(--navHeight);
|
||||||
}
|
}
|
||||||
body.darwin.not-fullscreen #root > nav .btn-group .btn:first-of-type {
|
body.darwin.not-fullscreen #root > nav .btn-group:first-of-type {
|
||||||
margin-left: 72px;
|
margin-left: 72px;
|
||||||
}
|
}
|
||||||
#root > nav .btn-group .btn.system {
|
#root > nav .btn-group .btn.system {
|
||||||
|
1848
package-lock.json
generated
1848
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "fluent-reader",
|
"name": "fluent-reader",
|
||||||
"version": "1.1.2",
|
"version": "1.1.3",
|
||||||
"description": "Modern desktop RSS reader",
|
"description": "Modern desktop RSS reader",
|
||||||
"main": "./dist/electron.js",
|
"main": "./dist/electron.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@ -26,7 +26,7 @@
|
|||||||
"@types/react-dom": "^16.9.8",
|
"@types/react-dom": "^16.9.8",
|
||||||
"@types/react-redux": "^7.1.9",
|
"@types/react-redux": "^7.1.9",
|
||||||
"@yang991178/rss-parser": "^3.8.1",
|
"@yang991178/rss-parser": "^3.8.1",
|
||||||
"electron": "^19.0.0",
|
"electron": "^21.0.1",
|
||||||
"electron-builder": "^23.0.3",
|
"electron-builder": "^23.0.3",
|
||||||
"electron-react-devtools": "^0.5.3",
|
"electron-react-devtools": "^0.5.3",
|
||||||
"electron-store": "^5.2.0",
|
"electron-store": "^5.2.0",
|
||||||
|
@ -131,6 +131,7 @@ class AppTab extends React.Component<AppTabProps, AppTabState> {
|
|||||||
{ key: "de", text: "Deutsch" },
|
{ key: "de", text: "Deutsch" },
|
||||||
{ key: "en-US", text: "English" },
|
{ key: "en-US", text: "English" },
|
||||||
{ key: "es", text: "Español" },
|
{ key: "es", text: "Español" },
|
||||||
|
{ key: "cs", text: "Čeština" },
|
||||||
{ key: "fr-FR", text: "Français" },
|
{ key: "fr-FR", text: "Français" },
|
||||||
{ key: "it", text: "Italiano" },
|
{ key: "it", text: "Italiano" },
|
||||||
{ key: "nl", text: "Nederlands" },
|
{ key: "nl", text: "Nederlands" },
|
||||||
|
@ -6,6 +6,8 @@ import FeverConfigsTab from "./services/fever"
|
|||||||
import FeedbinConfigsTab from "./services/feedbin"
|
import FeedbinConfigsTab from "./services/feedbin"
|
||||||
import GReaderConfigsTab from "./services/greader"
|
import GReaderConfigsTab from "./services/greader"
|
||||||
import InoreaderConfigsTab from "./services/inoreader"
|
import InoreaderConfigsTab from "./services/inoreader"
|
||||||
|
import MinifluxConfigsTab from "./services/miniflux"
|
||||||
|
import NextcloudConfigsTab from "./services/nextcloud"
|
||||||
|
|
||||||
type ServiceTabProps = {
|
type ServiceTabProps = {
|
||||||
configs: ServiceConfigs
|
configs: ServiceConfigs
|
||||||
@ -41,6 +43,8 @@ export class ServiceTab extends React.Component<
|
|||||||
{ key: SyncService.Feedbin, text: "Feedbin" },
|
{ key: SyncService.Feedbin, text: "Feedbin" },
|
||||||
{ key: SyncService.GReader, text: "Google Reader API (Beta)" },
|
{ key: SyncService.GReader, text: "Google Reader API (Beta)" },
|
||||||
{ key: SyncService.Inoreader, text: "Inoreader" },
|
{ key: SyncService.Inoreader, text: "Inoreader" },
|
||||||
|
{ key: SyncService.Miniflux, text: "Miniflux" },
|
||||||
|
{ key: SyncService.Nextcloud, text: "Nextcloud News API" },
|
||||||
{ key: -1, text: intl.get("service.suggest") },
|
{ key: -1, text: intl.get("service.suggest") },
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -88,6 +92,20 @@ export class ServiceTab extends React.Component<
|
|||||||
exit={this.exitConfigsTab}
|
exit={this.exitConfigsTab}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
case SyncService.Miniflux:
|
||||||
|
return (
|
||||||
|
<MinifluxConfigsTab
|
||||||
|
{...this.props}
|
||||||
|
exit={this.exitConfigsTab}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
case SyncService.Nextcloud:
|
||||||
|
return (
|
||||||
|
<NextcloudConfigsTab
|
||||||
|
{...this.props}
|
||||||
|
exit={this.exitConfigsTab}
|
||||||
|
/>
|
||||||
|
)
|
||||||
default:
|
default:
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
323
src/components/settings/services/miniflux.tsx
Normal file
323
src/components/settings/services/miniflux.tsx
Normal file
@ -0,0 +1,323 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import intl from "react-intl-universal"
|
||||||
|
import { ServiceConfigsTabProps } from "../service"
|
||||||
|
import { SyncService } from "../../../schema-types"
|
||||||
|
import {
|
||||||
|
Stack,
|
||||||
|
Icon,
|
||||||
|
Label,
|
||||||
|
TextField,
|
||||||
|
PrimaryButton,
|
||||||
|
DefaultButton,
|
||||||
|
Checkbox,
|
||||||
|
MessageBar,
|
||||||
|
MessageBarType,
|
||||||
|
Dropdown,
|
||||||
|
IDropdownOption,
|
||||||
|
} from "@fluentui/react"
|
||||||
|
import DangerButton from "../../utils/danger-button"
|
||||||
|
import { urlTest } from "../../../scripts/utils"
|
||||||
|
import { MinifluxConfigs } from "../../../scripts/models/services/miniflux"
|
||||||
|
|
||||||
|
type MinifluxConfigsTabState = {
|
||||||
|
existing: boolean
|
||||||
|
endpoint: string
|
||||||
|
apiKeyAuth: boolean
|
||||||
|
username: string
|
||||||
|
password: string
|
||||||
|
apiKey: string
|
||||||
|
fetchLimit: number
|
||||||
|
importGroups: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
class MinifluxConfigsTab extends React.Component<
|
||||||
|
ServiceConfigsTabProps,
|
||||||
|
MinifluxConfigsTabState
|
||||||
|
> {
|
||||||
|
constructor(props: ServiceConfigsTabProps) {
|
||||||
|
super(props)
|
||||||
|
const configs = props.configs as MinifluxConfigs
|
||||||
|
this.state = {
|
||||||
|
existing: configs.type === SyncService.Miniflux,
|
||||||
|
endpoint: configs.endpoint || "",
|
||||||
|
apiKeyAuth: true,
|
||||||
|
username: "",
|
||||||
|
password: "",
|
||||||
|
apiKey: "",
|
||||||
|
fetchLimit: configs.fetchLimit || 250,
|
||||||
|
importGroups: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchLimitOptions = (): IDropdownOption[] => [
|
||||||
|
{ key: 250, text: intl.get("service.fetchLimitNum", { count: 250 }) },
|
||||||
|
{ key: 500, text: intl.get("service.fetchLimitNum", { count: 500 }) },
|
||||||
|
{ key: 750, text: intl.get("service.fetchLimitNum", { count: 750 }) },
|
||||||
|
{ key: 1000, text: intl.get("service.fetchLimitNum", { count: 1000 }) },
|
||||||
|
{ key: 1500, text: intl.get("service.fetchLimitNum", { count: 1500 }) },
|
||||||
|
{
|
||||||
|
key: Number.MAX_SAFE_INTEGER,
|
||||||
|
text: intl.get("service.fetchUnlimited"),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
onFetchLimitOptionChange = (_, option: IDropdownOption) => {
|
||||||
|
this.setState({ fetchLimit: option.key as number })
|
||||||
|
}
|
||||||
|
|
||||||
|
authenticationOptions = (): IDropdownOption[] => [
|
||||||
|
{ key: "apiKey", text: "API Key" /*intl.get("service.password")*/ },
|
||||||
|
{
|
||||||
|
key: "userPass",
|
||||||
|
text:
|
||||||
|
intl.get("service.username") +
|
||||||
|
"/" +
|
||||||
|
intl.get("service.password"),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
onAuthenticationOptionsChange = (_, option: IDropdownOption) => {
|
||||||
|
this.setState({ apiKeyAuth: option.key == "apiKey" })
|
||||||
|
}
|
||||||
|
|
||||||
|
handleInputChange = event => {
|
||||||
|
const name: string = event.target.name
|
||||||
|
// @ts-expect-error
|
||||||
|
this.setState({ [name]: event.target.value })
|
||||||
|
}
|
||||||
|
|
||||||
|
checkNotEmpty = (v: string) => {
|
||||||
|
return !this.state.existing && v.length == 0
|
||||||
|
? intl.get("emptyField")
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
|
||||||
|
validateForm = () => {
|
||||||
|
return (
|
||||||
|
urlTest(this.state.endpoint.trim()) &&
|
||||||
|
(this.state.existing ||
|
||||||
|
this.state.apiKey ||
|
||||||
|
(this.state.username && this.state.password))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
save = async () => {
|
||||||
|
let configs: MinifluxConfigs
|
||||||
|
|
||||||
|
if (this.state.existing) {
|
||||||
|
configs = {
|
||||||
|
...this.props.configs,
|
||||||
|
endpoint: this.state.endpoint,
|
||||||
|
fetchLimit: this.state.fetchLimit,
|
||||||
|
} as MinifluxConfigs
|
||||||
|
|
||||||
|
if (this.state.apiKey || this.state.password)
|
||||||
|
configs.authKey = this.state.apiKeyAuth
|
||||||
|
? this.state.apiKey
|
||||||
|
: Buffer.from(
|
||||||
|
this.state.username + ":" + this.state.password,
|
||||||
|
"binary"
|
||||||
|
).toString("base64")
|
||||||
|
} else {
|
||||||
|
configs = {
|
||||||
|
type: SyncService.Miniflux,
|
||||||
|
endpoint: this.state.endpoint,
|
||||||
|
apiKeyAuth: this.state.apiKeyAuth,
|
||||||
|
authKey: this.state.apiKeyAuth
|
||||||
|
? this.state.apiKey
|
||||||
|
: Buffer.from(
|
||||||
|
this.state.username + ":" + this.state.password,
|
||||||
|
"binary"
|
||||||
|
).toString("base64"),
|
||||||
|
fetchLimit: this.state.fetchLimit,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.state.importGroups) configs.importGroups = true
|
||||||
|
}
|
||||||
|
|
||||||
|
this.props.blockActions()
|
||||||
|
const valid = await this.props.authenticate(configs)
|
||||||
|
|
||||||
|
if (valid) {
|
||||||
|
this.props.save(configs)
|
||||||
|
this.setState({ existing: true })
|
||||||
|
this.props.sync()
|
||||||
|
} else {
|
||||||
|
this.props.blockActions()
|
||||||
|
window.utils.showErrorBox(
|
||||||
|
intl.get("service.failure"),
|
||||||
|
intl.get("service.failureHint")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
remove = async () => {
|
||||||
|
this.props.exit()
|
||||||
|
await this.props.remove()
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{!this.state.existing && (
|
||||||
|
<MessageBar messageBarType={MessageBarType.warning}>
|
||||||
|
{intl.get("service.overwriteWarning")}
|
||||||
|
</MessageBar>
|
||||||
|
)}
|
||||||
|
<Stack horizontalAlign="center" style={{ marginTop: 48 }}>
|
||||||
|
<Icon
|
||||||
|
iconName="MarkDownLanguage"
|
||||||
|
style={{
|
||||||
|
color: "var(--black)",
|
||||||
|
fontSize: 32,
|
||||||
|
userSelect: "none",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Label style={{ margin: "8px 0 36px" }}>Miniflux</Label>
|
||||||
|
<Stack className="login-form" horizontal>
|
||||||
|
<Stack.Item>
|
||||||
|
<Label>{intl.get("service.endpoint")}</Label>
|
||||||
|
</Stack.Item>
|
||||||
|
<Stack.Item grow>
|
||||||
|
<TextField
|
||||||
|
onGetErrorMessage={v =>
|
||||||
|
urlTest(v.trim())
|
||||||
|
? ""
|
||||||
|
: intl.get("sources.badUrl")
|
||||||
|
}
|
||||||
|
validateOnLoad={false}
|
||||||
|
name="endpoint"
|
||||||
|
value={this.state.endpoint}
|
||||||
|
onChange={this.handleInputChange}
|
||||||
|
/>
|
||||||
|
</Stack.Item>
|
||||||
|
</Stack>
|
||||||
|
<Stack className="login-form" horizontal>
|
||||||
|
<Stack.Item>
|
||||||
|
<Label>{intl.get("groups.type")}</Label>
|
||||||
|
</Stack.Item>
|
||||||
|
<Stack.Item grow>
|
||||||
|
<Dropdown
|
||||||
|
options={this.authenticationOptions()}
|
||||||
|
selectedKey={
|
||||||
|
this.state.apiKeyAuth
|
||||||
|
? "apiKey"
|
||||||
|
: "userPass"
|
||||||
|
}
|
||||||
|
onChange={this.onAuthenticationOptionsChange}
|
||||||
|
/>
|
||||||
|
</Stack.Item>
|
||||||
|
</Stack>
|
||||||
|
{this.state.apiKeyAuth && (
|
||||||
|
<Stack className="login-form" horizontal>
|
||||||
|
<Stack.Item>
|
||||||
|
<Label>{intl.get("service.password")}</Label>
|
||||||
|
</Stack.Item>
|
||||||
|
<Stack.Item grow>
|
||||||
|
<TextField
|
||||||
|
type="password"
|
||||||
|
placeholder={
|
||||||
|
this.state.existing
|
||||||
|
? intl.get("service.unchanged")
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
onGetErrorMessage={this.checkNotEmpty}
|
||||||
|
validateOnLoad={false}
|
||||||
|
name="apiKey"
|
||||||
|
value={this.state.apiKey}
|
||||||
|
onChange={this.handleInputChange}
|
||||||
|
/>
|
||||||
|
</Stack.Item>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
{!this.state.apiKeyAuth && (
|
||||||
|
<Stack className="login-form" horizontal>
|
||||||
|
<Stack.Item>
|
||||||
|
<Label>{intl.get("service.username")}</Label>
|
||||||
|
</Stack.Item>
|
||||||
|
<Stack.Item grow>
|
||||||
|
<TextField
|
||||||
|
disabled={this.state.existing}
|
||||||
|
onGetErrorMessage={this.checkNotEmpty}
|
||||||
|
validateOnLoad={false}
|
||||||
|
name="username"
|
||||||
|
value={this.state.username}
|
||||||
|
onChange={this.handleInputChange}
|
||||||
|
/>
|
||||||
|
</Stack.Item>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
{!this.state.apiKeyAuth && (
|
||||||
|
<Stack className="login-form" horizontal>
|
||||||
|
<Stack.Item>
|
||||||
|
<Label>{intl.get("service.password")}</Label>
|
||||||
|
</Stack.Item>
|
||||||
|
<Stack.Item grow>
|
||||||
|
<TextField
|
||||||
|
type="password"
|
||||||
|
placeholder={
|
||||||
|
this.state.existing
|
||||||
|
? intl.get("service.unchanged")
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
onGetErrorMessage={this.checkNotEmpty}
|
||||||
|
validateOnLoad={false}
|
||||||
|
name="password"
|
||||||
|
value={this.state.password}
|
||||||
|
onChange={this.handleInputChange}
|
||||||
|
/>
|
||||||
|
</Stack.Item>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
<Stack className="login-form" horizontal>
|
||||||
|
<Stack.Item>
|
||||||
|
<Label>{intl.get("service.fetchLimit")}</Label>
|
||||||
|
</Stack.Item>
|
||||||
|
<Stack.Item grow>
|
||||||
|
<Dropdown
|
||||||
|
options={this.fetchLimitOptions()}
|
||||||
|
selectedKey={this.state.fetchLimit}
|
||||||
|
onChange={this.onFetchLimitOptionChange}
|
||||||
|
/>
|
||||||
|
</Stack.Item>
|
||||||
|
</Stack>
|
||||||
|
{!this.state.existing && (
|
||||||
|
<Checkbox
|
||||||
|
label={intl.get("service.importGroups")}
|
||||||
|
checked={this.state.importGroups}
|
||||||
|
onChange={(_, c) =>
|
||||||
|
this.setState({ importGroups: c })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Stack horizontal style={{ marginTop: 32 }}>
|
||||||
|
<Stack.Item>
|
||||||
|
<PrimaryButton
|
||||||
|
disabled={!this.validateForm()}
|
||||||
|
onClick={this.save}
|
||||||
|
text={
|
||||||
|
this.state.existing
|
||||||
|
? intl.get("edit")
|
||||||
|
: intl.get("confirm")
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Stack.Item>
|
||||||
|
<Stack.Item>
|
||||||
|
{this.state.existing ? (
|
||||||
|
<DangerButton
|
||||||
|
onClick={this.remove}
|
||||||
|
text={intl.get("delete")}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<DefaultButton
|
||||||
|
onClick={this.props.exit}
|
||||||
|
text={intl.get("cancel")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Stack.Item>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MinifluxConfigsTab
|
246
src/components/settings/services/nextcloud.tsx
Normal file
246
src/components/settings/services/nextcloud.tsx
Normal file
@ -0,0 +1,246 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import intl from "react-intl-universal"
|
||||||
|
import { ServiceConfigsTabProps } from "../service"
|
||||||
|
import { NextcloudConfigs } from "../../../scripts/models/services/nextcloud"
|
||||||
|
import { SyncService } from "../../../schema-types"
|
||||||
|
import {
|
||||||
|
Stack,
|
||||||
|
Icon,
|
||||||
|
Label,
|
||||||
|
TextField,
|
||||||
|
PrimaryButton,
|
||||||
|
DefaultButton,
|
||||||
|
Checkbox,
|
||||||
|
MessageBar,
|
||||||
|
MessageBarType,
|
||||||
|
Dropdown,
|
||||||
|
IDropdownOption,
|
||||||
|
} from "@fluentui/react"
|
||||||
|
import DangerButton from "../../utils/danger-button"
|
||||||
|
import { urlTest } from "../../../scripts/utils"
|
||||||
|
|
||||||
|
type NextcloudConfigsTabState = {
|
||||||
|
existing: boolean
|
||||||
|
endpoint: string
|
||||||
|
username: string
|
||||||
|
password: string
|
||||||
|
fetchLimit: number
|
||||||
|
importGroups: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
class NextcloudConfigsTab extends React.Component<
|
||||||
|
ServiceConfigsTabProps,
|
||||||
|
NextcloudConfigsTabState
|
||||||
|
> {
|
||||||
|
constructor(props: ServiceConfigsTabProps) {
|
||||||
|
super(props)
|
||||||
|
const configs = props.configs as NextcloudConfigs
|
||||||
|
this.state = {
|
||||||
|
existing: configs.type === SyncService.Nextcloud,
|
||||||
|
endpoint: configs.endpoint || "https://nextcloud.com/",
|
||||||
|
username: configs.username || "",
|
||||||
|
password: "",
|
||||||
|
fetchLimit: configs.fetchLimit || 250,
|
||||||
|
importGroups: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchLimitOptions = (): IDropdownOption[] => [
|
||||||
|
{ key: 250, text: intl.get("service.fetchLimitNum", { count: 250 }) },
|
||||||
|
{ key: 500, text: intl.get("service.fetchLimitNum", { count: 500 }) },
|
||||||
|
{ key: 750, text: intl.get("service.fetchLimitNum", { count: 750 }) },
|
||||||
|
{ key: 1000, text: intl.get("service.fetchLimitNum", { count: 1000 }) },
|
||||||
|
{ key: 1500, text: intl.get("service.fetchLimitNum", { count: 1500 }) },
|
||||||
|
{
|
||||||
|
key: Number.MAX_SAFE_INTEGER,
|
||||||
|
text: intl.get("service.fetchUnlimited"),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
onFetchLimitOptionChange = (_, option: IDropdownOption) => {
|
||||||
|
this.setState({ fetchLimit: option.key as number })
|
||||||
|
}
|
||||||
|
|
||||||
|
handleInputChange = event => {
|
||||||
|
const name: string = event.target.name
|
||||||
|
// @ts-expect-error
|
||||||
|
this.setState({ [name]: event.target.value })
|
||||||
|
}
|
||||||
|
|
||||||
|
checkNotEmpty = (v: string) => {
|
||||||
|
return !this.state.existing && v.length == 0
|
||||||
|
? intl.get("emptyField")
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
|
||||||
|
validateForm = () => {
|
||||||
|
return (
|
||||||
|
urlTest(this.state.endpoint.trim()) &&
|
||||||
|
(this.state.existing ||
|
||||||
|
(this.state.username && this.state.password))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
save = async () => {
|
||||||
|
let configs: NextcloudConfigs
|
||||||
|
if (this.state.existing) {
|
||||||
|
configs = {
|
||||||
|
...this.props.configs,
|
||||||
|
endpoint: this.state.endpoint,
|
||||||
|
fetchLimit: this.state.fetchLimit,
|
||||||
|
} as NextcloudConfigs
|
||||||
|
if (this.state.password) configs.password = this.state.password
|
||||||
|
} else {
|
||||||
|
configs = {
|
||||||
|
type: SyncService.Nextcloud,
|
||||||
|
endpoint: this.state.endpoint + "index.php/apps/news/api/v1-3",
|
||||||
|
username: this.state.username,
|
||||||
|
password: this.state.password,
|
||||||
|
fetchLimit: this.state.fetchLimit,
|
||||||
|
}
|
||||||
|
if (this.state.importGroups) configs.importGroups = true
|
||||||
|
}
|
||||||
|
this.props.blockActions()
|
||||||
|
const valid = await this.props.authenticate(configs)
|
||||||
|
if (valid) {
|
||||||
|
this.props.save(configs)
|
||||||
|
this.setState({ existing: true })
|
||||||
|
this.props.sync()
|
||||||
|
} else {
|
||||||
|
this.props.blockActions()
|
||||||
|
window.utils.showErrorBox(
|
||||||
|
intl.get("service.failure"),
|
||||||
|
intl.get("service.failureHint")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
remove = async () => {
|
||||||
|
this.props.exit()
|
||||||
|
await this.props.remove()
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{!this.state.existing && (
|
||||||
|
<MessageBar messageBarType={MessageBarType.warning}>
|
||||||
|
{intl.get("service.overwriteWarning")}
|
||||||
|
</MessageBar>
|
||||||
|
)}
|
||||||
|
<Stack horizontalAlign="center" style={{ marginTop: 48 }}>
|
||||||
|
<Icon
|
||||||
|
iconName="AlignLeft"
|
||||||
|
style={{
|
||||||
|
color: "var(--black)",
|
||||||
|
fontSize: 32,
|
||||||
|
userSelect: "none",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Label style={{ margin: "8px 0 36px" }}>Nextcloud</Label>
|
||||||
|
<Stack className="login-form" horizontal>
|
||||||
|
<Stack.Item>
|
||||||
|
<Label>{intl.get("service.endpoint")}</Label>
|
||||||
|
</Stack.Item>
|
||||||
|
<Stack.Item grow>
|
||||||
|
<TextField
|
||||||
|
onGetErrorMessage={v =>
|
||||||
|
urlTest(v.trim())
|
||||||
|
? ""
|
||||||
|
: intl.get("sources.badUrl")
|
||||||
|
}
|
||||||
|
validateOnLoad={false}
|
||||||
|
name="endpoint"
|
||||||
|
value={this.state.endpoint}
|
||||||
|
onChange={this.handleInputChange}
|
||||||
|
/>
|
||||||
|
</Stack.Item>
|
||||||
|
</Stack>
|
||||||
|
<Stack className="login-form" horizontal>
|
||||||
|
<Stack.Item>
|
||||||
|
<Label>{intl.get("service.username")}</Label>
|
||||||
|
</Stack.Item>
|
||||||
|
<Stack.Item grow>
|
||||||
|
<TextField
|
||||||
|
disabled={this.state.existing}
|
||||||
|
onGetErrorMessage={this.checkNotEmpty}
|
||||||
|
validateOnLoad={false}
|
||||||
|
name="username"
|
||||||
|
value={this.state.username}
|
||||||
|
onChange={this.handleInputChange}
|
||||||
|
/>
|
||||||
|
</Stack.Item>
|
||||||
|
</Stack>
|
||||||
|
<Stack className="login-form" horizontal>
|
||||||
|
<Stack.Item>
|
||||||
|
<Label>{intl.get("service.password")}</Label>
|
||||||
|
</Stack.Item>
|
||||||
|
<Stack.Item grow>
|
||||||
|
<TextField
|
||||||
|
type="password"
|
||||||
|
placeholder={
|
||||||
|
this.state.existing
|
||||||
|
? intl.get("service.unchanged")
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
onGetErrorMessage={this.checkNotEmpty}
|
||||||
|
validateOnLoad={false}
|
||||||
|
name="password"
|
||||||
|
value={this.state.password}
|
||||||
|
onChange={this.handleInputChange}
|
||||||
|
/>
|
||||||
|
</Stack.Item>
|
||||||
|
</Stack>
|
||||||
|
<Stack className="login-form" horizontal>
|
||||||
|
<Stack.Item>
|
||||||
|
<Label>{intl.get("service.fetchLimit")}</Label>
|
||||||
|
</Stack.Item>
|
||||||
|
<Stack.Item grow>
|
||||||
|
<Dropdown
|
||||||
|
options={this.fetchLimitOptions()}
|
||||||
|
selectedKey={this.state.fetchLimit}
|
||||||
|
onChange={this.onFetchLimitOptionChange}
|
||||||
|
/>
|
||||||
|
</Stack.Item>
|
||||||
|
</Stack>
|
||||||
|
{!this.state.existing && (
|
||||||
|
<Checkbox
|
||||||
|
label={intl.get("service.importGroups")}
|
||||||
|
checked={this.state.importGroups}
|
||||||
|
onChange={(_, c) =>
|
||||||
|
this.setState({ importGroups: c })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Stack horizontal style={{ marginTop: 32 }}>
|
||||||
|
<Stack.Item>
|
||||||
|
<PrimaryButton
|
||||||
|
disabled={!this.validateForm()}
|
||||||
|
onClick={this.save}
|
||||||
|
text={
|
||||||
|
this.state.existing
|
||||||
|
? intl.get("edit")
|
||||||
|
: intl.get("confirm")
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Stack.Item>
|
||||||
|
<Stack.Item>
|
||||||
|
{this.state.existing ? (
|
||||||
|
<DangerButton
|
||||||
|
onClick={this.remove}
|
||||||
|
text={intl.get("delete")}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<DefaultButton
|
||||||
|
onClick={this.props.exit}
|
||||||
|
text={intl.get("cancel")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Stack.Item>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default NextcloudConfigsTab
|
@ -59,6 +59,8 @@ export const enum SyncService {
|
|||||||
Feedbin,
|
Feedbin,
|
||||||
GReader,
|
GReader,
|
||||||
Inoreader,
|
Inoreader,
|
||||||
|
Miniflux,
|
||||||
|
Nextcloud,
|
||||||
}
|
}
|
||||||
export interface ServiceConfigs {
|
export interface ServiceConfigs {
|
||||||
type: SyncService
|
type: SyncService
|
||||||
|
@ -5,6 +5,7 @@ Currently, Fluent Reader supports the following languages.
|
|||||||
| Locale | Language | Credit |
|
| Locale | Language | Credit |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| en-US | English | [@yang991178](https://github.com/yang991178) |
|
| en-US | English | [@yang991178](https://github.com/yang991178) |
|
||||||
|
| cs | Čeština | [@vikdevelop](https://github.com/vikdevelop) |
|
||||||
| es | Español | [@kant](https://github.com/kant) |
|
| es | Español | [@kant](https://github.com/kant) |
|
||||||
| fr-FR | Français | [@Toinane](https://github.com/Toinane) |
|
| fr-FR | Français | [@Toinane](https://github.com/Toinane) |
|
||||||
| fi-FI | Suomi | [@SUPERHAMSTERI](https://github.com/SUPERHAMSTERI) |
|
| fi-FI | Suomi | [@SUPERHAMSTERI](https://github.com/SUPERHAMSTERI) |
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import en_US from "./en-US.json"
|
import en_US from "./en-US.json"
|
||||||
|
import cs from "./cs.json"
|
||||||
import zh_CN from "./zh-CN.json"
|
import zh_CN from "./zh-CN.json"
|
||||||
import zh_TW from "./zh-TW.json"
|
import zh_TW from "./zh-TW.json"
|
||||||
import ja from "./ja.json"
|
import ja from "./ja.json"
|
||||||
@ -18,6 +19,7 @@ import pt_PT from "./pt-PT.json"
|
|||||||
|
|
||||||
const locales = {
|
const locales = {
|
||||||
"en-US": en_US,
|
"en-US": en_US,
|
||||||
|
"cs": cs,
|
||||||
"zh-CN": zh_CN,
|
"zh-CN": zh_CN,
|
||||||
"zh-TW": zh_TW,
|
"zh-TW": zh_TW,
|
||||||
"ja": ja,
|
"ja": ja,
|
||||||
|
242
src/scripts/i18n/cs.json
Normal file
242
src/scripts/i18n/cs.json
Normal file
@ -0,0 +1,242 @@
|
|||||||
|
{
|
||||||
|
"allArticles": "Všechny články",
|
||||||
|
"add": "Přidat",
|
||||||
|
"create": "Vytvořit",
|
||||||
|
"icon": "Ikona",
|
||||||
|
"name": "Název",
|
||||||
|
"openExternal": "Otevřít externě",
|
||||||
|
"emptyName": "Toto pole nesmí být prázdné.",
|
||||||
|
"emptyField": "Toto pole nesmí být prázdné.",
|
||||||
|
"edit": "Upravit",
|
||||||
|
"delete": "Odstranit",
|
||||||
|
"followSystem": "Podle systému",
|
||||||
|
"more": "Více",
|
||||||
|
"close": "Zavřít",
|
||||||
|
"search": "Hledat",
|
||||||
|
"loadMore": "Načíst více",
|
||||||
|
"dangerButton": "Potvrdit {action}?",
|
||||||
|
"confirmMarkAll": "Opravdu chcete označit všechny články na této stránce jako přečtené?",
|
||||||
|
"confirm": "Potvrdit",
|
||||||
|
"cancel": "Zrušit",
|
||||||
|
"default": "Výchozí",
|
||||||
|
"time": {
|
||||||
|
"now": "nyní",
|
||||||
|
"m": "m",
|
||||||
|
"h": "h",
|
||||||
|
"d": "d",
|
||||||
|
"minute": "{m, plural, =1 {# minute} other {# minutes}}",
|
||||||
|
"hour": "{h, plural, =1 {# hour} other {# hours}}",
|
||||||
|
"day": "{d, plural, =1 {# day} other {# days}}"
|
||||||
|
},
|
||||||
|
"log": {
|
||||||
|
"empty": "Žádné notifikace",
|
||||||
|
"fetchFailure": "Nepodařilo se načíst zdroj \"{name}\".",
|
||||||
|
"fetchSuccess": "Úspěšně načten {count, plural, =1 {# article} other {# articles}}.",
|
||||||
|
"networkError": "Došlo k chybě sítě.",
|
||||||
|
"parseError": "Při analýze kanálu XML došlo k chybě.",
|
||||||
|
"syncFailure": "Došlo k chybě při synchronizaci se službou"
|
||||||
|
},
|
||||||
|
"nav": {
|
||||||
|
"menu": "Menu",
|
||||||
|
"refresh": "Načíst znovu",
|
||||||
|
"markAllRead": "Označit vše jako přečtené",
|
||||||
|
"notifications": "Notifikace",
|
||||||
|
"view": "Zobrazit",
|
||||||
|
"settings": "Nastavení",
|
||||||
|
"minimize": "Maximalizovat",
|
||||||
|
"maximize": "Minimalizovat"
|
||||||
|
},
|
||||||
|
"menu": {
|
||||||
|
"close": "Zavřít menu",
|
||||||
|
"subscriptions": "Odběry"
|
||||||
|
},
|
||||||
|
"article": {
|
||||||
|
"error": "Nepodařilo se načíst článek.",
|
||||||
|
"reload": "Načíst znovu?",
|
||||||
|
"empty": "Žádné články",
|
||||||
|
"untitled": "(Bez titulku)",
|
||||||
|
"hide": "Skrýt článek",
|
||||||
|
"unhide": "Odkrýt článek",
|
||||||
|
"markRead": "Označit jako přečtené",
|
||||||
|
"markUnread": "Označit jako nepřečtené",
|
||||||
|
"markAbove": "Označte výše uvedené jako přečtené",
|
||||||
|
"markBelow": "Označte níže uvedené jako přečtené",
|
||||||
|
"star": "Ohvězdičkovat",
|
||||||
|
"unstar": "Odstranit hvězdu",
|
||||||
|
"fontSize": "Velikost písma",
|
||||||
|
"loadWebpage": "Načíst webovou stránku",
|
||||||
|
"loadFull": "Načíst všechen obsah",
|
||||||
|
"notify": "Upozornit, pokud je načteno na pozadí",
|
||||||
|
"dontNotify": "Neupozorňovat",
|
||||||
|
"textDir": "Směr textu",
|
||||||
|
"LTR": "Zleva doprava",
|
||||||
|
"RTL": "Zprava doleva",
|
||||||
|
"Vertical": "Vertikálně",
|
||||||
|
"font": "Písmo"
|
||||||
|
},
|
||||||
|
"context": {
|
||||||
|
"share": "Sdílet",
|
||||||
|
"read": "Číst",
|
||||||
|
"copyTitle": "Kopírovat titulek",
|
||||||
|
"copyURL": "Kopírovat odkaz",
|
||||||
|
"copy": "Kopírovat",
|
||||||
|
"search": "Hledat \"{text}\" on {engine}",
|
||||||
|
"view": "Zobrazit",
|
||||||
|
"cardView": "Karty",
|
||||||
|
"listView": "Seznam",
|
||||||
|
"magazineView": "Časopis",
|
||||||
|
"compactView": "Kompaktní",
|
||||||
|
"filter": "Filtrování",
|
||||||
|
"unreadOnly": "Jen nepřečtené",
|
||||||
|
"starredOnly": "Jen ohvězdičkované",
|
||||||
|
"fullSearch": "Vyhledat v plném textu",
|
||||||
|
"showHidden": "Zobrazit skryté články",
|
||||||
|
"manageSources": "Spravovat zdroje",
|
||||||
|
"saveImageAs": "Uložit obrázek jako …",
|
||||||
|
"copyImage": "Kopírovat obrázek",
|
||||||
|
"copyImageURL": "Kopírovat adresu obrázku",
|
||||||
|
"caseSensitive": "Rozlišovat velká a malá písmena",
|
||||||
|
"showCover": "Zobrazit kryt",
|
||||||
|
"showSnippet": "Zobrazit úryvek",
|
||||||
|
"fadeRead": " Vyblednout čtenné články"
|
||||||
|
},
|
||||||
|
"searchEngine": {
|
||||||
|
"name": "Vyhledávač",
|
||||||
|
"google": "Google",
|
||||||
|
"bing": "Bing",
|
||||||
|
"baidu": "Baidu",
|
||||||
|
"duckduckgo": "DuckDuckGo"
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"writeError": "Došlo k chybě při zapisování souboru.",
|
||||||
|
"name": "Nastavení",
|
||||||
|
"fetching": "Probíhá aktualizace zdrojů, prosím vyčkejte …",
|
||||||
|
"exit": "Opustit nastavení",
|
||||||
|
"sources": "Zdroje",
|
||||||
|
"grouping": "Skupiny",
|
||||||
|
"rules": "Pravidla",
|
||||||
|
"service": "Služba",
|
||||||
|
"app": "Předvolby",
|
||||||
|
"about": "O aplikaci",
|
||||||
|
"version": "Verze",
|
||||||
|
"shortcuts": "Zkratky",
|
||||||
|
"openSource": "Otevřený zdrojový kód",
|
||||||
|
"feedback": "Zpětná vazba"
|
||||||
|
},
|
||||||
|
"sources": {
|
||||||
|
"serviceWarning": "Zde importované nebo přidané zdroje nebudou synchronizovány s vaší službou.",
|
||||||
|
"serviceManaged": "Tento zdroj spravuje vaše služba.",
|
||||||
|
"untitled": "Zdroj",
|
||||||
|
"errorAdd": "Došlo k chybě při přidávání zdroje.",
|
||||||
|
"errorParse": "Při analýze souboru OPML došlo k chybě.",
|
||||||
|
"errorParseHint": "Ujistěte se, že soubor není poškozený a je kódován pomocí UTF-8.",
|
||||||
|
"errorImport": "Objevila se chyba při importování {count, plural, =1 {# source} other {# sources}}.",
|
||||||
|
"exist": "Tento zdroj již existuje.",
|
||||||
|
"opmlFile": "Soubor OPML",
|
||||||
|
"name": "Název zdroje",
|
||||||
|
"editName": "Upravit název",
|
||||||
|
"fetchFrequency": "Limit frekvence načítání",
|
||||||
|
"unlimited": "Neomezené",
|
||||||
|
"openTarget": "Výchozí otevřený cíl pro články",
|
||||||
|
"delete": "Odstranit zdroj",
|
||||||
|
"add": "Přidat zdroj",
|
||||||
|
"import": "Importovat",
|
||||||
|
"export": "Exportovat",
|
||||||
|
"rssText": "Celý text RSS",
|
||||||
|
"loadWebpage": "Načíst webovou stránku",
|
||||||
|
"inputUrl": "Zadejte URL adresu",
|
||||||
|
"badIcon": "Nesprávná ikona",
|
||||||
|
"badUrl": "Nesprávná URL adresa",
|
||||||
|
"deleteWarning": "Zdroj a všechny uložené články budou odstraněny.",
|
||||||
|
"selected": "Vybraný zdroj",
|
||||||
|
"selectedMulti": "Vybrat více zdrojů",
|
||||||
|
"hidden": "Ukryto v \"all articles\""
|
||||||
|
},
|
||||||
|
"groups": {
|
||||||
|
"exist": "Tato skupina již existuje",
|
||||||
|
"type": "Typ",
|
||||||
|
"group": "Skupina",
|
||||||
|
"source": "Zdroj",
|
||||||
|
"capacity": "Kapacita",
|
||||||
|
"exitGroup": "Zpět na skupiny",
|
||||||
|
"deleteSource": "Odstranit ze skupiny",
|
||||||
|
"sourceHint": "Přetažením zdrojů změníte jejich pořadí.",
|
||||||
|
"create": "Vytvořit skupinu",
|
||||||
|
"selectedGroup": "Vybraná skupina",
|
||||||
|
"selectedSource": "Vybraný zdroj",
|
||||||
|
"enterName": "Zadejte název",
|
||||||
|
"editName": "Editovat název",
|
||||||
|
"deleteGroup": "Odstranit skupinu",
|
||||||
|
"chooseGroup": "Vybrat skupinu",
|
||||||
|
"addToGroup": "Přidat do ...",
|
||||||
|
"groupHint": "Dvojklikem na skupinu upravíte zdroje. Přetažením změníte pořadí."
|
||||||
|
},
|
||||||
|
"rules": {
|
||||||
|
"intro": "Automatické označování článků nebo odesílání oznámení pomocí regulárních výrazů.",
|
||||||
|
"help": "Zjistit více",
|
||||||
|
"source": "Zdroj",
|
||||||
|
"selectSource": "Vybrat zdroj",
|
||||||
|
"new": "Nové pravidlo",
|
||||||
|
"if": "Pokud",
|
||||||
|
"then": "Poté",
|
||||||
|
"title": "Titulek",
|
||||||
|
"content": "Obsah",
|
||||||
|
"fullSearch": "Titulek nebo obsah",
|
||||||
|
"creator": "Autor",
|
||||||
|
"match": "odpovídá",
|
||||||
|
"notMatch": "neodpovídá",
|
||||||
|
"regex": "Pravidelný výraz",
|
||||||
|
"badRegex": "Nesprávný pravidelný výraz.",
|
||||||
|
"action": "Akce",
|
||||||
|
"selectAction": "Vybrat akce",
|
||||||
|
"hint": "Pravidla se použijí v pořadí. Tažením myši můžete změnit pořadí.",
|
||||||
|
"test": "Testovací pravidla"
|
||||||
|
},
|
||||||
|
"service": {
|
||||||
|
"intro": "Synchronizace mezi zařízeními pomocí služeb RSS.",
|
||||||
|
"select": "Vyberte službu",
|
||||||
|
"suggest": "Navrhnout novou službu",
|
||||||
|
"overwriteWarning": "Místní zdroje budou odstraněny, pokud ve službě existují.",
|
||||||
|
"groupsWarning": "Skupiny nejsou automaticky synchronizovány se službou.",
|
||||||
|
"rateLimitWarning": "Abyste se vyhnuli omezování rychlosti, musíte si vytvořit vlastní klíč API.",
|
||||||
|
"removeAd": "Odstranit reklamu",
|
||||||
|
"endpoint": "Koncový bod",
|
||||||
|
"username": "Uživatelské jméno",
|
||||||
|
"password": "Heslo",
|
||||||
|
"unchanged": "Nezměněno",
|
||||||
|
"fetchLimit": "Limit synchronizace",
|
||||||
|
"fetchLimitNum": "{count} nejnovějších článků",
|
||||||
|
"importGroups": "Importovat skupiny",
|
||||||
|
"failure": "Nedaří se připojit ke službě",
|
||||||
|
"failureHint": "Zkontrolujte konfiguraci služby nebo stav sítě.",
|
||||||
|
"fetchUnlimited": "Neomezený (nedoporučuje se)",
|
||||||
|
"exportToLite": "Exportovat do Fluent reader Lite"
|
||||||
|
},
|
||||||
|
"app": {
|
||||||
|
"cleanup": "Vyčistit",
|
||||||
|
"cache": "Vyčistit mezipaměť",
|
||||||
|
"cacheSize": "Velikost {size} dat v mezipaměti",
|
||||||
|
"deleteChoices": "Odstranit články před ... dny",
|
||||||
|
"confirmDelete": "Odstranit",
|
||||||
|
"daysAgo": "{days, plural, =1 {# day} other {# days}} ago",
|
||||||
|
"deleteAll": "Odstranit věechny články",
|
||||||
|
"calculatingSize": "Probíhá výpočet velikosti...",
|
||||||
|
"itemSize": "Přibližně {size} místního úložiště zabírají články",
|
||||||
|
"confirmImport": "Opravdu chcete importovat data ze záložního souboru? Všechna aktuální data budou vymazána.",
|
||||||
|
"data": "Data aplikace",
|
||||||
|
"backup": "Záloha",
|
||||||
|
"restore": "Obnovit",
|
||||||
|
"frData": "Data aplikace Fluent Reader",
|
||||||
|
"language": "Zobrazovaný jazyk",
|
||||||
|
"theme": "Motiv",
|
||||||
|
"lightTheme": "Světlý motiv",
|
||||||
|
"darkTheme": "Tmavý motiv",
|
||||||
|
"enableProxy": "Povolit Proxy",
|
||||||
|
"badUrl": "Nesprávná URL",
|
||||||
|
"pac": "PAC Adresa",
|
||||||
|
"setPac": "Nastavit PAC",
|
||||||
|
"pacHint": "U proxy serverů Socks se doporučuje, aby PAC vracel \"SOCKS5\" pro DNS na straně proxy serveru. Vypnutí proxy vyžaduje restart.",
|
||||||
|
"fetchInterval": "Automatický interval načítání",
|
||||||
|
"never": "Nikdy"
|
||||||
|
}
|
||||||
|
}
|
@ -9,7 +9,7 @@
|
|||||||
"emptyField": "Bu alan boş olamaz.",
|
"emptyField": "Bu alan boş olamaz.",
|
||||||
"edit": "Düzenle",
|
"edit": "Düzenle",
|
||||||
"delete": "Sil",
|
"delete": "Sil",
|
||||||
"followSystem": "Sistem dili",
|
"followSystem": "Sistem ayarlarını kullan",
|
||||||
"more": "Daha fazla",
|
"more": "Daha fazla",
|
||||||
"close": "Kapat",
|
"close": "Kapat",
|
||||||
"search": "Ara",
|
"search": "Ara",
|
||||||
|
@ -18,6 +18,8 @@ import { createSourceGroup, addSourceToGroup } from "./group"
|
|||||||
import { feverServiceHooks } from "./services/fever"
|
import { feverServiceHooks } from "./services/fever"
|
||||||
import { feedbinServiceHooks } from "./services/feedbin"
|
import { feedbinServiceHooks } from "./services/feedbin"
|
||||||
import { gReaderServiceHooks } from "./services/greader"
|
import { gReaderServiceHooks } from "./services/greader"
|
||||||
|
import { minifluxServiceHooks } from "./services/miniflux"
|
||||||
|
import { nextcloudServiceHooks } from "./services/nextcloud"
|
||||||
|
|
||||||
export interface ServiceHooks {
|
export interface ServiceHooks {
|
||||||
authenticate?: (configs: ServiceConfigs) => Promise<boolean>
|
authenticate?: (configs: ServiceConfigs) => Promise<boolean>
|
||||||
@ -45,6 +47,10 @@ export function getServiceHooksFromType(type: SyncService): ServiceHooks {
|
|||||||
case SyncService.GReader:
|
case SyncService.GReader:
|
||||||
case SyncService.Inoreader:
|
case SyncService.Inoreader:
|
||||||
return gReaderServiceHooks
|
return gReaderServiceHooks
|
||||||
|
case SyncService.Miniflux:
|
||||||
|
return minifluxServiceHooks
|
||||||
|
case SyncService.Nextcloud:
|
||||||
|
return nextcloudServiceHooks
|
||||||
default:
|
default:
|
||||||
return {}
|
return {}
|
||||||
}
|
}
|
||||||
|
348
src/scripts/models/services/miniflux.ts
Normal file
348
src/scripts/models/services/miniflux.ts
Normal file
@ -0,0 +1,348 @@
|
|||||||
|
import intl from "react-intl-universal"
|
||||||
|
import * as db from "../../db"
|
||||||
|
import lf from "lovefield"
|
||||||
|
import { ServiceHooks } from "../service"
|
||||||
|
import { ServiceConfigs, SyncService } from "../../../schema-types"
|
||||||
|
import { createSourceGroup } from "../group"
|
||||||
|
import { RSSSource } from "../source"
|
||||||
|
import { domParser, htmlDecode } from "../../utils"
|
||||||
|
import { RSSItem } from "../item"
|
||||||
|
import { SourceRule } from "../rule"
|
||||||
|
|
||||||
|
// miniflux service configs
|
||||||
|
export interface MinifluxConfigs extends ServiceConfigs {
|
||||||
|
type: SyncService.Miniflux
|
||||||
|
endpoint: string
|
||||||
|
apiKeyAuth: boolean
|
||||||
|
authKey: string
|
||||||
|
fetchLimit: number
|
||||||
|
lastId?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// partial api schema
|
||||||
|
interface Feed {
|
||||||
|
id: number
|
||||||
|
feed_url: string
|
||||||
|
title: string
|
||||||
|
category: { title: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Category {
|
||||||
|
title: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Entry {
|
||||||
|
id: number
|
||||||
|
status: "unread" | "read" | "removed"
|
||||||
|
title: string
|
||||||
|
url: string
|
||||||
|
published_at: string
|
||||||
|
created_at: string
|
||||||
|
content: string
|
||||||
|
author: string
|
||||||
|
starred: boolean
|
||||||
|
feed: Feed
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Entries {
|
||||||
|
total: number
|
||||||
|
entries: Entry[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const APIError = () => new Error(intl.get("service.failure"))
|
||||||
|
|
||||||
|
// base endpoint, authorization with dedicated token or http basic user/pass pair
|
||||||
|
async function fetchAPI(
|
||||||
|
configs: MinifluxConfigs,
|
||||||
|
endpoint: string = "",
|
||||||
|
method: string = "GET",
|
||||||
|
body: string = null
|
||||||
|
): Promise<Response> {
|
||||||
|
try {
|
||||||
|
const headers = new Headers()
|
||||||
|
headers.append("content-type", "application/x-www-form-urlencoded")
|
||||||
|
|
||||||
|
configs.apiKeyAuth
|
||||||
|
? headers.append("X-Auth-Token", configs.authKey)
|
||||||
|
: headers.append("Authorization", `Basic ${configs.authKey}`)
|
||||||
|
|
||||||
|
let baseUrl = configs.endpoint
|
||||||
|
if (!baseUrl.endsWith("/")) baseUrl = baseUrl + "/"
|
||||||
|
if (!baseUrl.endsWith("/v1/")) baseUrl = baseUrl + "v1/"
|
||||||
|
const response = await fetch(baseUrl + endpoint, {
|
||||||
|
method: method,
|
||||||
|
body: body,
|
||||||
|
headers: headers,
|
||||||
|
})
|
||||||
|
|
||||||
|
return response
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error)
|
||||||
|
throw APIError()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const minifluxServiceHooks: ServiceHooks = {
|
||||||
|
// poll service info endpoint to verify auth
|
||||||
|
authenticate: async (configs: MinifluxConfigs) => {
|
||||||
|
const response = await fetchAPI(configs, "me")
|
||||||
|
|
||||||
|
if (await response.json().then(json => json.error_message)) return false
|
||||||
|
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
|
||||||
|
// collect sources from service, along with associated groups/categories
|
||||||
|
updateSources: () => async (dispatch, getState) => {
|
||||||
|
const configs = getState().service as MinifluxConfigs
|
||||||
|
|
||||||
|
// fetch and create groups in redux
|
||||||
|
if (configs.importGroups) {
|
||||||
|
const groups: Category[] = await fetchAPI(
|
||||||
|
configs,
|
||||||
|
"categories"
|
||||||
|
).then(response => response.json())
|
||||||
|
groups.forEach(group => dispatch(createSourceGroup(group.title)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetch all feeds
|
||||||
|
const feedResponse = await fetchAPI(configs, "feeds")
|
||||||
|
const feeds = await feedResponse.json()
|
||||||
|
|
||||||
|
if (feeds === undefined) throw APIError()
|
||||||
|
|
||||||
|
// go through feeds, create typed source while also mapping by group
|
||||||
|
let sources: RSSSource[] = new Array<RSSSource>()
|
||||||
|
let groupsMap: Map<string, string> = new Map<string, string>()
|
||||||
|
for (let feed of feeds) {
|
||||||
|
let source = new RSSSource(feed.feed_url, feed.title)
|
||||||
|
// associate service christened id to match in other request
|
||||||
|
source.serviceRef = feed.id.toString()
|
||||||
|
sources.push(source)
|
||||||
|
groupsMap.set(feed.id.toString(), feed.category.title)
|
||||||
|
}
|
||||||
|
|
||||||
|
return [sources, configs.importGroups ? groupsMap : undefined]
|
||||||
|
},
|
||||||
|
|
||||||
|
// fetch entries from after the last fetched id (if exists)
|
||||||
|
// limit by quantity and maximum safe integer (id)
|
||||||
|
// NOTE: miniflux endpoint /entries default order with "published at", and does not offer "created_at"
|
||||||
|
// but does offer id sort, directly correlated with "created". some feeds give strange published_at.
|
||||||
|
|
||||||
|
fetchItems: () => async (_, getState) => {
|
||||||
|
const state = getState()
|
||||||
|
const configs = state.service as MinifluxConfigs
|
||||||
|
const items: Entry[] = new Array()
|
||||||
|
let entriesResponse: Entries
|
||||||
|
|
||||||
|
// parameters
|
||||||
|
configs.lastId = configs.lastId ?? 0
|
||||||
|
// intermediate
|
||||||
|
const quantity = 125
|
||||||
|
let continueId: number
|
||||||
|
|
||||||
|
do {
|
||||||
|
try {
|
||||||
|
if (continueId) {
|
||||||
|
entriesResponse = await fetchAPI(
|
||||||
|
configs,
|
||||||
|
`entries?order=id&direction=desc&after_entry_id=${configs.lastId}&before_entry_id=${continueId}&limit=${quantity}`
|
||||||
|
).then(response => response.json())
|
||||||
|
} else {
|
||||||
|
entriesResponse = await fetchAPI(
|
||||||
|
configs,
|
||||||
|
`entries?order=id&direction=desc&after_entry_id=${configs.lastId}&limit=${quantity}`
|
||||||
|
).then(response => response.json())
|
||||||
|
}
|
||||||
|
|
||||||
|
items.push(...entriesResponse.entries)
|
||||||
|
continueId = items[items.length - 1].id
|
||||||
|
} catch {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} while (
|
||||||
|
entriesResponse.entries &&
|
||||||
|
entriesResponse.total >= quantity &&
|
||||||
|
items.length < configs.fetchLimit
|
||||||
|
)
|
||||||
|
|
||||||
|
// break/return nothing if no new items acquired
|
||||||
|
if (items.length === 0) return [[], configs]
|
||||||
|
configs.lastId = items[0].id
|
||||||
|
|
||||||
|
// get sources that possess ref/id given by service, associate new items
|
||||||
|
const sourceMap = new Map<string, RSSSource>()
|
||||||
|
for (let source of Object.values(state.sources)) {
|
||||||
|
if (source.serviceRef) {
|
||||||
|
sourceMap.set(source.serviceRef, source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// map item objects to rssitem type while appling rules (if exist)
|
||||||
|
const parsedItems = items.map(item => {
|
||||||
|
const source = sourceMap.get(item.feed.id.toString())
|
||||||
|
|
||||||
|
let parsedItem = {
|
||||||
|
source: source.sid,
|
||||||
|
title: item.title,
|
||||||
|
link: item.url,
|
||||||
|
date: new Date(item.published_at ?? item.created_at),
|
||||||
|
fetchedDate: new Date(),
|
||||||
|
content: item.content,
|
||||||
|
snippet: htmlDecode(item.content).trim(),
|
||||||
|
creator: item.author,
|
||||||
|
hasRead: Boolean(item.status === "read"),
|
||||||
|
starred: Boolean(item.starred),
|
||||||
|
hidden: false,
|
||||||
|
notify: false,
|
||||||
|
serviceRef: String(item.id),
|
||||||
|
} as RSSItem
|
||||||
|
|
||||||
|
// Try to get the thumbnail of the item
|
||||||
|
let dom = domParser.parseFromString(item.content, "text/html")
|
||||||
|
let baseEl = dom.createElement("base")
|
||||||
|
baseEl.setAttribute(
|
||||||
|
"href",
|
||||||
|
parsedItem.link.split("/").slice(0, 3).join("/")
|
||||||
|
)
|
||||||
|
dom.head.append(baseEl)
|
||||||
|
let img = dom.querySelector("img")
|
||||||
|
if (img && img.src) parsedItem.thumb = img.src
|
||||||
|
|
||||||
|
if (source.rules) {
|
||||||
|
SourceRule.applyAll(source.rules, parsedItem)
|
||||||
|
if ((item.status === "read") !== parsedItem.hasRead)
|
||||||
|
minifluxServiceHooks.markRead(parsedItem)
|
||||||
|
if (item.starred !== parsedItem.starred)
|
||||||
|
minifluxServiceHooks.markUnread(parsedItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsedItem
|
||||||
|
})
|
||||||
|
|
||||||
|
return [parsedItems, configs]
|
||||||
|
},
|
||||||
|
|
||||||
|
// get remote read and star state of articles, for local sync
|
||||||
|
syncItems: () => async (_, getState) => {
|
||||||
|
const configs = getState().service as MinifluxConfigs
|
||||||
|
|
||||||
|
const unreadPromise: Promise<Entries> = fetchAPI(
|
||||||
|
configs,
|
||||||
|
"entries?status=unread"
|
||||||
|
).then(response => response.json())
|
||||||
|
const starredPromise: Promise<Entries> = fetchAPI(
|
||||||
|
configs,
|
||||||
|
"entries?starred=true"
|
||||||
|
).then(response => response.json())
|
||||||
|
const [unread, starred] = await Promise.all([
|
||||||
|
unreadPromise,
|
||||||
|
starredPromise,
|
||||||
|
])
|
||||||
|
|
||||||
|
return [
|
||||||
|
new Set(unread.entries.map((entry: Entry) => String(entry.id))),
|
||||||
|
new Set(starred.entries.map((entry: Entry) => String(entry.id))),
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
markRead: (item: RSSItem) => async (_, getState) => {
|
||||||
|
if (!item.serviceRef) return
|
||||||
|
|
||||||
|
const body = `{
|
||||||
|
"entry_ids": [${item.serviceRef}],
|
||||||
|
"status": "read"
|
||||||
|
}`
|
||||||
|
|
||||||
|
const response = await fetchAPI(
|
||||||
|
getState().service as MinifluxConfigs,
|
||||||
|
"entries",
|
||||||
|
"PUT",
|
||||||
|
body
|
||||||
|
)
|
||||||
|
|
||||||
|
if (response.status !== 204) throw APIError()
|
||||||
|
},
|
||||||
|
|
||||||
|
markUnread: (item: RSSItem) => async (_, getState) => {
|
||||||
|
if (!item.serviceRef) return
|
||||||
|
|
||||||
|
const body = `{
|
||||||
|
"entry_ids": [${item.serviceRef}],
|
||||||
|
"status": "unread"
|
||||||
|
}`
|
||||||
|
await fetchAPI(
|
||||||
|
getState().service as MinifluxConfigs,
|
||||||
|
"entries",
|
||||||
|
"PUT",
|
||||||
|
body
|
||||||
|
)
|
||||||
|
},
|
||||||
|
|
||||||
|
// mark entries for source ids as read, relative to date, determined by "before" bool
|
||||||
|
|
||||||
|
// context menu component:
|
||||||
|
// item - null, item date, either
|
||||||
|
// group - group sources, null, true
|
||||||
|
// nav - null, daysago, true
|
||||||
|
|
||||||
|
// if null, state consulted for context sids
|
||||||
|
|
||||||
|
markAllRead: (sids, date, before) => async (_, getState) => {
|
||||||
|
const state = getState()
|
||||||
|
const configs = state.service as MinifluxConfigs
|
||||||
|
|
||||||
|
if (date) {
|
||||||
|
const predicates: lf.Predicate[] = [
|
||||||
|
db.items.source.in(sids),
|
||||||
|
db.items.hasRead.eq(false),
|
||||||
|
db.items.serviceRef.isNotNull(),
|
||||||
|
before ? db.items.date.lte(date) : db.items.date.gte(date),
|
||||||
|
]
|
||||||
|
const query = lf.op.and.apply(null, predicates)
|
||||||
|
const rows = await db.itemsDB
|
||||||
|
.select(db.items.serviceRef)
|
||||||
|
.from(db.items)
|
||||||
|
.where(query)
|
||||||
|
.exec()
|
||||||
|
const refs = rows.map(row => row["serviceRef"])
|
||||||
|
const body = `{
|
||||||
|
"entry_ids": [${refs}],
|
||||||
|
"status": "read"
|
||||||
|
}`
|
||||||
|
await fetchAPI(configs, "entries", "PUT", body)
|
||||||
|
} else {
|
||||||
|
const sources = state.sources
|
||||||
|
await Promise.all(
|
||||||
|
sids.map(sid =>
|
||||||
|
fetchAPI(
|
||||||
|
configs,
|
||||||
|
`feeds/${sources[sid]?.serviceRef}/mark-all-as-read`,
|
||||||
|
"PUT"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
star: (item: RSSItem) => async (_, getState) => {
|
||||||
|
if (!item.serviceRef) return
|
||||||
|
|
||||||
|
await fetchAPI(
|
||||||
|
getState().service as MinifluxConfigs,
|
||||||
|
`entries/${item.serviceRef}/bookmark`,
|
||||||
|
"PUT"
|
||||||
|
)
|
||||||
|
},
|
||||||
|
|
||||||
|
unstar: (item: RSSItem) => async (_, getState) => {
|
||||||
|
if (!item.serviceRef) return
|
||||||
|
|
||||||
|
await fetchAPI(
|
||||||
|
getState().service as MinifluxConfigs,
|
||||||
|
`entries/${item.serviceRef}/bookmark`,
|
||||||
|
"PUT"
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
298
src/scripts/models/services/nextcloud.ts
Normal file
298
src/scripts/models/services/nextcloud.ts
Normal file
@ -0,0 +1,298 @@
|
|||||||
|
import intl from "react-intl-universal"
|
||||||
|
import * as db from "../../db"
|
||||||
|
import lf from "lovefield"
|
||||||
|
import { ServiceHooks } from "../service"
|
||||||
|
import { ServiceConfigs, SyncService } from "../../../schema-types"
|
||||||
|
import { createSourceGroup } from "../group"
|
||||||
|
import { RSSSource } from "../source"
|
||||||
|
import { domParser } from "../../utils"
|
||||||
|
import { RSSItem } from "../item"
|
||||||
|
import { SourceRule } from "../rule"
|
||||||
|
|
||||||
|
export interface NextcloudConfigs extends ServiceConfigs {
|
||||||
|
type: SyncService.Nextcloud
|
||||||
|
endpoint: string
|
||||||
|
username: string
|
||||||
|
password: string
|
||||||
|
fetchLimit: number
|
||||||
|
lastModified?: number
|
||||||
|
lastId?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAPI(configs: NextcloudConfigs, params: string) {
|
||||||
|
const headers = new Headers()
|
||||||
|
headers.set(
|
||||||
|
"Authorization",
|
||||||
|
"Basic " + btoa(configs.username + ":" + configs.password)
|
||||||
|
)
|
||||||
|
return await fetch(configs.endpoint + params, { headers: headers })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markItems(
|
||||||
|
configs: NextcloudConfigs,
|
||||||
|
type: string,
|
||||||
|
method: string,
|
||||||
|
refs: number[]
|
||||||
|
) {
|
||||||
|
const headers = new Headers()
|
||||||
|
headers.set(
|
||||||
|
"Authorization",
|
||||||
|
"Basic " + btoa(configs.username + ":" + configs.password)
|
||||||
|
)
|
||||||
|
headers.set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
const promises = new Array<Promise<Response>>()
|
||||||
|
while (refs.length > 0) {
|
||||||
|
const batch = new Array<number>()
|
||||||
|
while (batch.length < 1000 && refs.length > 0) {
|
||||||
|
batch.push(refs.pop())
|
||||||
|
}
|
||||||
|
const bodyObject: any = {}
|
||||||
|
bodyObject["itemIds"] = batch
|
||||||
|
promises.push(
|
||||||
|
fetch(configs.endpoint + "/items/" + type + "/multiple", {
|
||||||
|
method: method,
|
||||||
|
headers: headers,
|
||||||
|
body: JSON.stringify(bodyObject),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return await Promise.all(promises)
|
||||||
|
}
|
||||||
|
|
||||||
|
const APIError = () => new Error(intl.get("service.failure"))
|
||||||
|
|
||||||
|
export const nextcloudServiceHooks: ServiceHooks = {
|
||||||
|
authenticate: async (configs: NextcloudConfigs) => {
|
||||||
|
try {
|
||||||
|
const result = await fetchAPI(configs, "/version")
|
||||||
|
return result.status === 200
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
updateSources: () => async (dispatch, getState) => {
|
||||||
|
const configs = getState().service as NextcloudConfigs
|
||||||
|
const response = await fetchAPI(configs, "/feeds")
|
||||||
|
if (response.status !== 200) throw APIError()
|
||||||
|
const feeds = await response.json()
|
||||||
|
let groupsMap: Map<string, string>
|
||||||
|
let groupsByTagId: Map<string, string> = new Map()
|
||||||
|
if (configs.importGroups) {
|
||||||
|
const foldersResponse = await fetchAPI(configs, "/folders")
|
||||||
|
if (foldersResponse.status !== 200) throw APIError()
|
||||||
|
const folders = await foldersResponse.json()
|
||||||
|
const foldersSet = new Set<string>()
|
||||||
|
groupsMap = new Map()
|
||||||
|
for (let folder of folders.folders) {
|
||||||
|
const title = folder.name.trim()
|
||||||
|
if (!foldersSet.has(title)) {
|
||||||
|
foldersSet.add(title)
|
||||||
|
dispatch(createSourceGroup(title))
|
||||||
|
}
|
||||||
|
groupsByTagId.set(String(folder.id), title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const sources = feeds.feeds.map(s => {
|
||||||
|
const source = new RSSSource(s.url, s.title)
|
||||||
|
source.iconurl = s.faviconLink
|
||||||
|
source.serviceRef = String(s.id)
|
||||||
|
if (s.folderId && groupsByTagId.has(String(s.folderId))) {
|
||||||
|
groupsMap.set(
|
||||||
|
String(s.id),
|
||||||
|
groupsByTagId.get(String(s.folderId))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return source
|
||||||
|
})
|
||||||
|
return [sources, groupsMap]
|
||||||
|
},
|
||||||
|
|
||||||
|
syncItems: () => async (_, getState) => {
|
||||||
|
const configs = getState().service as NextcloudConfigs
|
||||||
|
const [unreadResponse, starredResponse] = await Promise.all([
|
||||||
|
fetchAPI(configs, "/items?getRead=false&type=3&batchSize=-1"),
|
||||||
|
fetchAPI(configs, "/items?getRead=true&type=2&batchSize=-1"),
|
||||||
|
])
|
||||||
|
if (unreadResponse.status !== 200 || starredResponse.status !== 200)
|
||||||
|
throw APIError()
|
||||||
|
const unread = await unreadResponse.json()
|
||||||
|
const starred = await starredResponse.json()
|
||||||
|
return [
|
||||||
|
new Set(unread.items.map(i => String(i.id))),
|
||||||
|
new Set(starred.items.map(i => String(i.id))),
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
fetchItems: () => async (_, getState) => {
|
||||||
|
const state = getState()
|
||||||
|
const configs = state.service as NextcloudConfigs
|
||||||
|
let items = new Array()
|
||||||
|
configs.lastModified = configs.lastModified || 0
|
||||||
|
configs.lastId = configs.lastId || 0
|
||||||
|
let lastFetched: any
|
||||||
|
|
||||||
|
if (!configs.lastModified || configs.lastModified == 0) {
|
||||||
|
//first sync
|
||||||
|
let min = Number.MAX_SAFE_INTEGER
|
||||||
|
do {
|
||||||
|
const response = await fetchAPI(
|
||||||
|
configs,
|
||||||
|
"/items?getRead=true&type=3&batchSize=125&offset=" + min
|
||||||
|
)
|
||||||
|
if (response.status !== 200) throw APIError()
|
||||||
|
lastFetched = await response.json()
|
||||||
|
items = [...items, ...lastFetched.items]
|
||||||
|
min = lastFetched.items.reduce((m, n) => Math.min(m, n.id), min)
|
||||||
|
} while (
|
||||||
|
lastFetched.items &&
|
||||||
|
lastFetched.items.length >= 125 &&
|
||||||
|
items.length < configs.fetchLimit
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
//incremental sync
|
||||||
|
const response = await fetchAPI(
|
||||||
|
configs,
|
||||||
|
"/items/updated?lastModified=" +
|
||||||
|
configs.lastModified +
|
||||||
|
"&type=3"
|
||||||
|
)
|
||||||
|
if (response.status !== 200) throw APIError()
|
||||||
|
lastFetched = (await response.json()).items
|
||||||
|
items.push(...lastFetched.filter(i => i.id > configs.lastId))
|
||||||
|
}
|
||||||
|
configs.lastModified = items.reduce(
|
||||||
|
(m, n) => Math.max(m, n.lastModified),
|
||||||
|
configs.lastModified
|
||||||
|
)
|
||||||
|
configs.lastId = items.reduce(
|
||||||
|
(m, n) => Math.max(m, n.id),
|
||||||
|
configs.lastId
|
||||||
|
)
|
||||||
|
configs.lastModified++ //+1 to avoid fetching articles with same lastModified next time
|
||||||
|
if (items.length > 0) {
|
||||||
|
const fidMap = new Map<string, RSSSource>()
|
||||||
|
for (let source of Object.values(state.sources)) {
|
||||||
|
if (source.serviceRef) {
|
||||||
|
fidMap.set(source.serviceRef, source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedItems = new Array<RSSItem>()
|
||||||
|
items.forEach(i => {
|
||||||
|
if (i.body === null || i.url === null) return
|
||||||
|
const unreadItem = i.unread
|
||||||
|
const starredItem = i.starred
|
||||||
|
const source = fidMap.get(String(i.feedId))
|
||||||
|
const dom = domParser.parseFromString(i.body, "text/html")
|
||||||
|
const item = {
|
||||||
|
source: source.sid,
|
||||||
|
title: i.title,
|
||||||
|
link: i.url,
|
||||||
|
date: new Date(i.pubDate * 1000),
|
||||||
|
fetchedDate: new Date(),
|
||||||
|
content: i.body,
|
||||||
|
snippet: dom.documentElement.textContent.trim(),
|
||||||
|
creator: i.author,
|
||||||
|
hasRead: !i.unread,
|
||||||
|
starred: i.starred,
|
||||||
|
hidden: false,
|
||||||
|
notify: false,
|
||||||
|
serviceRef: String(i.id),
|
||||||
|
} as RSSItem
|
||||||
|
if (i.enclosureLink) {
|
||||||
|
item.thumb = i.enclosureLink
|
||||||
|
} else {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
// Apply rules and sync back to the service
|
||||||
|
if (source.rules) SourceRule.applyAll(source.rules, item)
|
||||||
|
if (unreadItem && item.hasRead)
|
||||||
|
markItems(
|
||||||
|
configs,
|
||||||
|
item.hasRead ? "read" : "unread",
|
||||||
|
"POST",
|
||||||
|
[i.id]
|
||||||
|
)
|
||||||
|
if (starredItem !== Boolean(item.starred))
|
||||||
|
markItems(
|
||||||
|
configs,
|
||||||
|
item.starred ? "star" : "unstar",
|
||||||
|
"POST",
|
||||||
|
[i.id]
|
||||||
|
)
|
||||||
|
|
||||||
|
parsedItems.push(item)
|
||||||
|
})
|
||||||
|
return [parsedItems, configs]
|
||||||
|
} else {
|
||||||
|
return [[], configs]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
markAllRead: (sids, date, before) => async (_, getState) => {
|
||||||
|
const state = getState()
|
||||||
|
const configs = state.service as NextcloudConfigs
|
||||||
|
const predicates: lf.Predicate[] = [
|
||||||
|
db.items.source.in(sids),
|
||||||
|
db.items.hasRead.eq(false),
|
||||||
|
db.items.serviceRef.isNotNull(),
|
||||||
|
]
|
||||||
|
if (date) {
|
||||||
|
predicates.push(
|
||||||
|
before ? db.items.date.lte(date) : db.items.date.gte(date)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const query = lf.op.and.apply(null, predicates)
|
||||||
|
const rows = await db.itemsDB
|
||||||
|
.select(db.items.serviceRef)
|
||||||
|
.from(db.items)
|
||||||
|
.where(query)
|
||||||
|
.exec()
|
||||||
|
const refs = rows.map(row => parseInt(row["serviceRef"]))
|
||||||
|
markItems(configs, "unread", "POST", refs)
|
||||||
|
},
|
||||||
|
|
||||||
|
markRead: (item: RSSItem) => async (_, getState) => {
|
||||||
|
await markItems(
|
||||||
|
getState().service as NextcloudConfigs,
|
||||||
|
"read",
|
||||||
|
"POST",
|
||||||
|
[parseInt(item.serviceRef)]
|
||||||
|
)
|
||||||
|
},
|
||||||
|
|
||||||
|
markUnread: (item: RSSItem) => async (_, getState) => {
|
||||||
|
await markItems(
|
||||||
|
getState().service as NextcloudConfigs,
|
||||||
|
"unread",
|
||||||
|
"POST",
|
||||||
|
[parseInt(item.serviceRef)]
|
||||||
|
)
|
||||||
|
},
|
||||||
|
|
||||||
|
star: (item: RSSItem) => async (_, getState) => {
|
||||||
|
await markItems(
|
||||||
|
getState().service as NextcloudConfigs,
|
||||||
|
"star",
|
||||||
|
"POST",
|
||||||
|
[parseInt(item.serviceRef)]
|
||||||
|
)
|
||||||
|
},
|
||||||
|
|
||||||
|
unstar: (item: RSSItem) => async (_, getState) => {
|
||||||
|
await markItems(
|
||||||
|
getState().service as NextcloudConfigs,
|
||||||
|
"unstar",
|
||||||
|
"POST",
|
||||||
|
[parseInt(item.serviceRef)]
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user