From 5786561dc3ed2139f97ecad20bc303738c229f6a Mon Sep 17 00:00:00 2001 From: lyle Date: Tue, 6 Sep 2022 02:45:27 +0100 Subject: [PATCH] added miniflux API service, accompanying settings component --- package-lock.json | 2 +- src/components/settings/service.tsx | 9 + src/components/settings/services/miniflux.tsx | 307 +++++++++++++++++ src/schema-types.ts | 1 + src/scripts/models/service.ts | 3 + src/scripts/models/services/miniflux.ts | 321 ++++++++++++++++++ 6 files changed, 642 insertions(+), 1 deletion(-) create mode 100644 src/components/settings/services/miniflux.tsx create mode 100644 src/scripts/models/services/miniflux.ts diff --git a/package-lock.json b/package-lock.json index 4ff4276..d8e9c97 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/src/components/settings/service.tsx b/src/components/settings/service.tsx index 59097d0..8fd3ae2 100644 --- a/src/components/settings/service.tsx +++ b/src/components/settings/service.tsx @@ -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 ( + + ) default: return null } diff --git a/src/components/settings/services/miniflux.tsx b/src/components/settings/services/miniflux.tsx new file mode 100644 index 0000000..6271af0 --- /dev/null +++ b/src/components/settings/services/miniflux.tsx @@ -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 && ( + + {intl.get("service.overwriteWarning")} + + )} + + + + + + + + + + urlTest(v.trim()) + ? "" + : intl.get("sources.badUrl") + } + validateOnLoad={false} + name="endpoint" + value={this.state.endpoint} + onChange={this.handleInputChange} + /> + + + + + + + + + + + { this.state.apiKeyAuth && + + + + + + + } + { !this.state.apiKeyAuth && + + + + + + + } + { !this.state.apiKeyAuth && + + + + + + + } + + + + + + + + + {!this.state.existing && ( + + this.setState({ importGroups: c }) + } + /> + )} + + + + + + {this.state.existing ? ( + + ) : ( + + )} + + + {this.state.existing && ( + + )} + + + ) + } +} + +export default MinifluxConfigsTab diff --git a/src/schema-types.ts b/src/schema-types.ts index 45dcc77..a6572f0 100644 --- a/src/schema-types.ts +++ b/src/schema-types.ts @@ -59,6 +59,7 @@ export const enum SyncService { Feedbin, GReader, Inoreader, + Miniflux, } export interface ServiceConfigs { type: SyncService diff --git a/src/scripts/models/service.ts b/src/scripts/models/service.ts index cc62d57..6eee7c9 100644 --- a/src/scripts/models/service.ts +++ b/src/scripts/models/service.ts @@ -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 @@ -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 {} } diff --git a/src/scripts/models/services/miniflux.ts b/src/scripts/models/services/miniflux.ts new file mode 100644 index 0000000..afff5b9 --- /dev/null +++ b/src/scripts/models/services/miniflux.ts @@ -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 +{ + 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(); + let groupsMap: Map = new Map() + 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() + 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"); + } + +} + + + + + + +