Merge pull request #463 from blu3mangroup/minifluxBranch

added miniflux API service, accompanying settings component
This commit is contained in:
Haoyuan Liu 2022-09-11 12:43:00 -07:00 committed by GitHub
commit e4b42fe4fd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 642 additions and 1 deletions

2
package-lock.json generated
View File

@ -6,7 +6,7 @@
"packages": {
"": {
"name": "fluent-reader",
"version": "1.1.1",
"version": "1.1.2",
"license": "BSD-3-Clause",
"devDependencies": {
"@fluentui/react": "^7.126.2",

View File

@ -6,6 +6,7 @@ import FeverConfigsTab from "./services/fever"
import FeedbinConfigsTab from "./services/feedbin"
import GReaderConfigsTab from "./services/greader"
import InoreaderConfigsTab from "./services/inoreader"
import MinifluxConfigsTab from "./services/miniflux"
type ServiceTabProps = {
configs: ServiceConfigs
@ -41,6 +42,7 @@ export class ServiceTab extends React.Component<
{ key: SyncService.Feedbin, text: "Feedbin" },
{ key: SyncService.GReader, text: "Google Reader API (Beta)" },
{ key: SyncService.Inoreader, text: "Inoreader" },
{ key: SyncService.Miniflux, text: "Miniflux" },
{ key: -1, text: intl.get("service.suggest") },
]
@ -88,6 +90,13 @@ export class ServiceTab extends React.Component<
exit={this.exitConfigsTab}
/>
)
case SyncService.Miniflux:
return (
<MinifluxConfigsTab
{...this.props}
exit={this.exitConfigsTab}
/>
)
default:
return null
}

View File

@ -0,0 +1,307 @@
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 LiteExporter from "./lite-exporter"
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>
{this.state.existing && (
<LiteExporter serviceConfigs={this.props.configs} />
)}
</Stack>
</>
)
}
}
export default MinifluxConfigsTab

View File

@ -59,6 +59,7 @@ export const enum SyncService {
Feedbin,
GReader,
Inoreader,
Miniflux,
}
export interface ServiceConfigs {
type: SyncService

View File

@ -18,6 +18,7 @@ import { createSourceGroup, addSourceToGroup } from "./group"
import { feverServiceHooks } from "./services/fever"
import { feedbinServiceHooks } from "./services/feedbin"
import { gReaderServiceHooks } from "./services/greader"
import { minifluxServiceHooks} from "./services/miniflux"
export interface ServiceHooks {
authenticate?: (configs: ServiceConfigs) => Promise<boolean>
@ -45,6 +46,8 @@ export function getServiceHooksFromType(type: SyncService): ServiceHooks {
case SyncService.GReader:
case SyncService.Inoreader:
return gReaderServiceHooks
case SyncService.Miniflux:
return minifluxServiceHooks
default:
return {}
}

View File

@ -0,0 +1,321 @@
import intl from "react-intl-universal"
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}`)
const response = await fetch(configs.endpoint + "/v1/" + 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, groupsMap]
},
// 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
let items: Entry[] = new Array()
let entriesResponse: Entries
// parameters
let min = Number.MAX_SAFE_INTEGER
configs.lastId ? configs.lastId : 0
// intermediate
const quantity = 100;
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 = entriesResponse.entries.concat(items)
continueId = items[items.length-1].id
}
catch
{
break;
}
}
while (min > configs.lastId &&
entriesResponse.entries &&
entriesResponse.total == 100 &&
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.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 (Boolean(item.status == "read") !== parsedItem.hasRead)
minifluxServiceHooks.markRead(parsedItem)
if (Boolean(item.starred) !== Boolean(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 unread: Entries = await fetchAPI(configs, "entries?status=unread")
.then(response => response.json());
const starred: Entries = await fetchAPI(configs, "entries?starred=true")
.then(response => response.json());
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()
let items = state.feeds[state.page.feedId].iids
.map(iid => state.items[iid])
.filter(item => item.serviceRef && !item.hasRead)
if (date) items = items.filter(i => before ? i.date < date : i.date > date)
const refs = items.map(item => item.serviceRef)
const body = `{
"entry_ids": [${refs}],
"status": "read"
}`
await fetchAPI(getState().service as MinifluxConfigs, "entries", "PUT", body)
},
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");
}
}