mirror of
https://github.com/yang991178/fluent-reader.git
synced 2025-02-11 17:20:41 +01:00
commit
178cb0a204
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
@ -0,0 +1 @@
|
||||
dist/article/article.js text eol=lf
|
33
.github/workflows/release-linux.yml
vendored
Normal file
33
.github/workflows/release-linux.yml
vendored
Normal file
@ -0,0 +1,33 @@
|
||||
name: CI/CD Release Linux
|
||||
|
||||
on:
|
||||
release:
|
||||
types:
|
||||
- published
|
||||
|
||||
jobs:
|
||||
release-linux:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- name: Build and package the app
|
||||
run: |
|
||||
sudo npm install --unsafe-perm=true --allow-root
|
||||
npm run build
|
||||
npm run package-linux
|
||||
|
||||
- name: Get app version
|
||||
id: package-version
|
||||
uses: martinbeentjes/npm-get-version-action@master
|
||||
|
||||
- name: Upload AppImage to release assets
|
||||
uses: actions/upload-release-asset@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: https://uploads.github.com/repos/yang991178/fluent-reader/releases/${{ github.ref }}/assets
|
||||
asset_path: ./bin/linux/x64/Fluent Reader-${{ steps.package-version.outputs.current-version }}.AppImage
|
||||
asset_name: Fluent.Reader.${{ steps.package-version.outputs.current-version }}.AppImage
|
||||
asset_content_type: application/octet-stream
|
76
.github/workflows/release-main.yml
vendored
Normal file
76
.github/workflows/release-main.yml
vendored
Normal file
@ -0,0 +1,76 @@
|
||||
name: CI/CD Release Main
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: windows-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- name: Build and package the app
|
||||
run: |
|
||||
npm install
|
||||
npm run build
|
||||
npm run package-win
|
||||
|
||||
- name: Get app version
|
||||
id: package-version
|
||||
uses: martinbeentjes/npm-get-version-action@master
|
||||
|
||||
- name: Create release
|
||||
id: create_release
|
||||
uses: actions/create-release@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
tag_name: ${{ github.ref }}
|
||||
release_name: Fluent Reader v${{ steps.package-version.outputs.current-version }} Beta
|
||||
draft: true
|
||||
prerelease: false
|
||||
|
||||
- name: Upload x64 exe to release assets
|
||||
uses: actions/upload-release-asset@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
asset_path: ./bin/win32/x64/Fluent Reader Setup ${{ steps.package-version.outputs.current-version }}.exe
|
||||
asset_name: Fluent.Reader.Setup.${{ steps.package-version.outputs.current-version }}.x64.exe
|
||||
asset_content_type: application/vnd.microsoft.portable-executable
|
||||
|
||||
- name: Upload x86 exe to release assets
|
||||
uses: actions/upload-release-asset@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
asset_path: ./bin/win32/ia32/Fluent Reader Setup ${{ steps.package-version.outputs.current-version }}.exe
|
||||
asset_name: Fluent.Reader.Setup.${{ steps.package-version.outputs.current-version }}.x86.exe
|
||||
asset_content_type: application/vnd.microsoft.portable-executable
|
||||
|
||||
- name: Upload x64 zip to release assets
|
||||
uses: actions/upload-release-asset@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
asset_path: ./bin/win32/x64/Fluent Reader-${{ steps.package-version.outputs.current-version }}-win.zip
|
||||
asset_name: Fluent.Reader.Unpacked.${{ steps.package-version.outputs.current-version }}.x64.zip
|
||||
asset_content_type: application/zip
|
||||
|
||||
- name: Upload x86 zip to release assets
|
||||
uses: actions/upload-release-asset@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
asset_path: ./bin/win32/ia32/Fluent Reader-${{ steps.package-version.outputs.current-version }}-ia32-win.zip
|
||||
asset_name: Fluent.Reader.Unpacked.${{ steps.package-version.outputs.current-version }}.x86.zip
|
||||
asset_content_type: application/zip
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "fluent-reader",
|
||||
"version": "0.5.0",
|
||||
"version": "0.5.1",
|
||||
"description": "A simplistic, modern desktop RSS reader",
|
||||
"main": "./dist/electron.js",
|
||||
"scripts": {
|
||||
@ -37,8 +37,8 @@
|
||||
"backgroundColor": "transparent",
|
||||
"languages": [
|
||||
"zh-CN",
|
||||
"en-US",
|
||||
"fr-FR",
|
||||
"en",
|
||||
"fr",
|
||||
"es"
|
||||
],
|
||||
"showNameOnTiles": true,
|
||||
|
@ -10,6 +10,7 @@ type SourcesTabProps = {
|
||||
sources: SourceState
|
||||
addSource: (url: string) => void
|
||||
updateSourceName: (source: RSSSource, name: string) => void
|
||||
updateSourceIcon: (source: RSSSource, iconUrl: string) => Promise<void>
|
||||
updateSourceOpenTarget: (source: RSSSource, target: SourceOpenTarget) => void
|
||||
updateFetchFrequency: (source: RSSSource, frequency: number) => void
|
||||
deleteSource: (source: RSSSource) => void
|
||||
@ -25,6 +26,12 @@ type SourcesTabState = {
|
||||
selectedSources: RSSSource[]
|
||||
}
|
||||
|
||||
const enum EditDropdownKeys {
|
||||
Name = "n",
|
||||
Icon = "i",
|
||||
Url = "u",
|
||||
}
|
||||
|
||||
class SourcesTab extends React.Component<SourcesTabProps, SourcesTabState> {
|
||||
selection: Selection
|
||||
|
||||
@ -44,7 +51,9 @@ class SourcesTab extends React.Component<SourcesTabProps, SourcesTabState> {
|
||||
this.setState({
|
||||
selectedSource: count === 1 ? sources[0] : null,
|
||||
selectedSources: count > 1 ? sources : null,
|
||||
newSourceName: count === 1 ? sources[0].name : ""
|
||||
newSourceName: count === 1 ? sources[0].name : "",
|
||||
newSourceIcon: count === 1 ? (sources[0].iconurl || "") : "",
|
||||
sourceEditOption: EditDropdownKeys.Name,
|
||||
})
|
||||
}
|
||||
})
|
||||
@ -80,6 +89,16 @@ class SourcesTab extends React.Component<SourcesTabProps, SourcesTabState> {
|
||||
}
|
||||
]
|
||||
|
||||
sourceEditOptions = (): IDropdownOption[] => [
|
||||
{ key: EditDropdownKeys.Name, text: intl.get("name") },
|
||||
{ key: EditDropdownKeys.Icon, text: intl.get("icon") },
|
||||
{ key: EditDropdownKeys.Url, text: "URL" },
|
||||
]
|
||||
|
||||
onSourceEditOptionChange = (_, option: IDropdownOption) => {
|
||||
this.setState({sourceEditOption: option.key as string})
|
||||
}
|
||||
|
||||
fetchFrequencyOptions = (): IDropdownOption[] => [
|
||||
{ key: "0", text: intl.get("sources.unlimited") },
|
||||
{ key: "15", text: intl.get("time.minute", { m: 15 }) },
|
||||
@ -110,6 +129,12 @@ class SourcesTab extends React.Component<SourcesTabProps, SourcesTabState> {
|
||||
this.setState({selectedSource: {...this.state.selectedSource, name: newName} as RSSSource})
|
||||
}
|
||||
|
||||
updateSourceIcon = () => {
|
||||
let newIcon = this.state.newSourceIcon.trim()
|
||||
this.props.updateSourceIcon(this.state.selectedSource, newIcon)
|
||||
this.setState({selectedSource: {...this.state.selectedSource, iconurl: newIcon}})
|
||||
}
|
||||
|
||||
handleInputChange = (event) => {
|
||||
const name: string = event.target.name
|
||||
this.setState({[name]: event.target.value})
|
||||
@ -173,21 +198,58 @@ class SourcesTab extends React.Component<SourcesTabProps, SourcesTabState> {
|
||||
{this.state.selectedSource && <>
|
||||
<Label>{intl.get("sources.selected")}</Label>
|
||||
<Stack horizontal>
|
||||
<Stack.Item grow>
|
||||
<TextField
|
||||
onGetErrorMessage={v => v.trim().length == 0 ? intl.get("emptyName") : ""}
|
||||
validateOnLoad={false}
|
||||
placeholder={intl.get("sources.name")}
|
||||
value={this.state.newSourceName}
|
||||
name="newSourceName"
|
||||
onChange={this.handleInputChange} />
|
||||
</Stack.Item>
|
||||
<Stack.Item>
|
||||
<DefaultButton
|
||||
disabled={this.state.newSourceName.trim().length == 0}
|
||||
onClick={this.updateSourceName}
|
||||
text={intl.get("sources.editName")} />
|
||||
<Dropdown
|
||||
options={this.sourceEditOptions()}
|
||||
selectedKey={this.state.sourceEditOption}
|
||||
onChange={this.onSourceEditOptionChange}
|
||||
style={{width: 120}} />
|
||||
</Stack.Item>
|
||||
{this.state.sourceEditOption === EditDropdownKeys.Name && <>
|
||||
<Stack.Item grow>
|
||||
<TextField
|
||||
onGetErrorMessage={v => v.trim().length == 0 ? intl.get("emptyName") : ""}
|
||||
validateOnLoad={false}
|
||||
placeholder={intl.get("sources.name")}
|
||||
value={this.state.newSourceName}
|
||||
name="newSourceName"
|
||||
onChange={this.handleInputChange} />
|
||||
</Stack.Item>
|
||||
<Stack.Item>
|
||||
<DefaultButton
|
||||
disabled={this.state.newSourceName.trim().length == 0}
|
||||
onClick={this.updateSourceName}
|
||||
text={intl.get("sources.editName")} />
|
||||
</Stack.Item>
|
||||
</>}
|
||||
{this.state.sourceEditOption === EditDropdownKeys.Icon && <>
|
||||
<Stack.Item grow>
|
||||
<TextField
|
||||
onGetErrorMessage={v => urlTest(v.trim()) ? "" : intl.get("sources.badUrl")}
|
||||
validateOnLoad={false}
|
||||
placeholder={intl.get("sources.inputUrl")}
|
||||
value={this.state.newSourceIcon}
|
||||
name="newSourceIcon"
|
||||
onChange={this.handleInputChange} />
|
||||
</Stack.Item>
|
||||
<Stack.Item>
|
||||
<DefaultButton
|
||||
disabled={!urlTest(this.state.newSourceIcon.trim())}
|
||||
onClick={this.updateSourceIcon}
|
||||
text={intl.get("edit")} />
|
||||
</Stack.Item>
|
||||
</>}
|
||||
{this.state.sourceEditOption === EditDropdownKeys.Url && <>
|
||||
<Stack.Item grow>
|
||||
<TextField disabled value={this.state.selectedSource.url} />
|
||||
</Stack.Item>
|
||||
<Stack.Item>
|
||||
<DefaultButton
|
||||
onClick={() => window.utils.writeClipboard(this.state.selectedSource.url)}
|
||||
text={intl.get("context.copy")} />
|
||||
</Stack.Item>
|
||||
</>}
|
||||
|
||||
</Stack>
|
||||
<Label>{intl.get("sources.fetchFrequency")}</Label>
|
||||
<Stack>
|
||||
|
@ -1,10 +1,12 @@
|
||||
import intl from "react-intl-universal"
|
||||
import { connect } from "react-redux"
|
||||
import { createSelector } from "reselect"
|
||||
import { RootState } from "../../scripts/reducer"
|
||||
import SourcesTab from "../../components/settings/sources"
|
||||
import { addSource, RSSSource, updateSource, deleteSource, SourceOpenTarget, deleteSources } from "../../scripts/models/source"
|
||||
import { importOPML, exportOPML } from "../../scripts/models/group"
|
||||
import { AppDispatch } from "../../scripts/utils"
|
||||
import { AppDispatch, validateFavicon } from "../../scripts/utils"
|
||||
import { saveSettings } from "../../scripts/models/app"
|
||||
|
||||
const getSources = (state: RootState) => state.sources
|
||||
|
||||
@ -21,6 +23,15 @@ const mapDispatchToProps = (dispatch: AppDispatch) => {
|
||||
updateSourceName: (source: RSSSource, name: string) => {
|
||||
dispatch(updateSource({ ...source, name: name } as RSSSource))
|
||||
},
|
||||
updateSourceIcon: async (source: RSSSource, iconUrl: string) => {
|
||||
dispatch(saveSettings())
|
||||
if (await validateFavicon(iconUrl)) {
|
||||
dispatch(updateSource({ ...source, iconurl: iconUrl }))
|
||||
} else {
|
||||
window.utils.showErrorBox(intl.get("sources.badIcon"), "")
|
||||
}
|
||||
dispatch(saveSettings())
|
||||
},
|
||||
updateSourceOpenTarget: (source: RSSSource, target: SourceOpenTarget) => {
|
||||
dispatch(updateSource({ ...source, openTarget: target } as RSSSource))
|
||||
},
|
||||
|
@ -60,6 +60,7 @@ export class WindowManager {
|
||||
webviewTag: true,
|
||||
enableRemoteModule: false,
|
||||
contextIsolation: true,
|
||||
spellcheck: false,
|
||||
preload: path.join(app.getAppPath(), (app.isPackaged ? "dist/" : "") + "preload.js")
|
||||
}
|
||||
})
|
||||
|
@ -30,7 +30,9 @@
|
||||
"log": {
|
||||
"empty": "No notifications",
|
||||
"fetchFailure": "Failed to load source \"{name}\".",
|
||||
"fetchSuccess": "Successfully fetched {count, plural, =1 {# article} other {# articles}}."
|
||||
"fetchSuccess": "Successfully fetched {count, plural, =1 {# article} other {# articles}}.",
|
||||
"networkError": "A network error has occurred.",
|
||||
"parseError": "An error has occurred when parsing the XML feed."
|
||||
},
|
||||
"nav": {
|
||||
"menu": "Menu",
|
||||
@ -108,6 +110,7 @@
|
||||
"rssText": "RSS full text",
|
||||
"loadWebpage": "Load webpage",
|
||||
"inputUrl": "Enter URL",
|
||||
"badIcon": "Invalid icon",
|
||||
"badUrl": "Invalid URL",
|
||||
"deleteWarning": "The source and all saved articles will be removed.",
|
||||
"selected": "Selected source",
|
||||
@ -174,4 +177,4 @@
|
||||
"setPac": "Set PAC",
|
||||
"pacHint": "For Socks proxies, it is recommended for PAC to return \"SOCKS5\" for proxy-side DNS. Turning off proxy requires restart."
|
||||
}
|
||||
}
|
||||
}
|
@ -30,7 +30,9 @@
|
||||
"log": {
|
||||
"empty": "无消息",
|
||||
"fetchFailure": "无法加载订阅源“{name}”",
|
||||
"fetchSuccess": "成功加载 {count} 篇文章"
|
||||
"fetchSuccess": "成功加载 {count} 篇文章",
|
||||
"networkError": "连接订阅源时出错",
|
||||
"parseError": "解析XML信息流时出错"
|
||||
},
|
||||
"nav": {
|
||||
"menu": "菜单",
|
||||
@ -108,6 +110,7 @@
|
||||
"rssText": "RSS正文",
|
||||
"loadWebpage": "加载网页",
|
||||
"inputUrl": "输入URL",
|
||||
"badIcon": "图标不存在或非图片",
|
||||
"badUrl": "请正确输入URL",
|
||||
"deleteWarning": "这将移除订阅源与所有已保存的文章",
|
||||
"selected": "选中订阅源",
|
||||
|
@ -1,6 +1,6 @@
|
||||
import * as db from "../db"
|
||||
import { SourceActionTypes, INIT_SOURCES, ADD_SOURCE, DELETE_SOURCE } from "./source"
|
||||
import { ItemActionTypes, FETCH_ITEMS, RSSItem, MARK_READ, MARK_UNREAD, TOGGLE_STARRED, TOGGLE_HIDDEN, applyItemReduction, ItemState } from "./item"
|
||||
import { ItemActionTypes, FETCH_ITEMS, RSSItem, MARK_READ, MARK_UNREAD, TOGGLE_STARRED, TOGGLE_HIDDEN, applyItemReduction, ItemState, MARK_ALL_READ } from "./item"
|
||||
import { ActionStatus, AppThunk, mergeSortedArrays } from "../utils"
|
||||
import { PageActionTypes, SELECT_PAGE, PageType, APPLY_FILTER } from "./page"
|
||||
|
||||
|
@ -2,7 +2,7 @@ import * as db from "../db"
|
||||
import intl from "react-intl-universal"
|
||||
import { domParser, htmlDecode, ActionStatus, AppThunk } from "../utils"
|
||||
import { RSSSource } from "./source"
|
||||
import { FeedActionTypes, INIT_FEED, LOAD_MORE } from "./feed"
|
||||
import { FeedActionTypes, INIT_FEED, LOAD_MORE, FilterType, initFeeds } from "./feed"
|
||||
import Parser from "@yang991178/rss-parser"
|
||||
|
||||
export class RSSItem {
|
||||
@ -38,19 +38,17 @@ export class RSSItem {
|
||||
item.content = parsed.content || ""
|
||||
item.snippet = htmlDecode(parsed.contentSnippet || "")
|
||||
}
|
||||
if (parsed.thumb) item.thumb = parsed.thumb
|
||||
else if (parsed.image) {
|
||||
if (parsed.image.$ && parsed.image.$.url) {
|
||||
item.thumb = parsed.image.$.url
|
||||
} else if (typeof parsed.image === "string") {
|
||||
item.thumb = parsed.image
|
||||
}
|
||||
}
|
||||
else if (parsed.mediaContent) {
|
||||
if (parsed.thumb) {
|
||||
item.thumb = parsed.thumb
|
||||
} else if (parsed.image && parsed.image.$ && parsed.image.$.url) {
|
||||
item.thumb = parsed.image.$.url
|
||||
} else if (parsed.image && typeof parsed.image === "string") {
|
||||
item.thumb = parsed.image
|
||||
} else if (parsed.mediaContent) {
|
||||
let images = parsed.mediaContent.filter(c => c.$ && c.$.medium === "image" && c.$.url)
|
||||
if (images.length > 0) item.thumb = images[0].$.url
|
||||
}
|
||||
if(!item.thumb) {
|
||||
if (!item.thumb) {
|
||||
let dom = domParser.parseFromString(item.content, "text/html")
|
||||
let baseEl = dom.createElement('base')
|
||||
baseEl.setAttribute('href', item.link.split("/").slice(0, 3).join("/"))
|
||||
@ -58,6 +56,9 @@ export class RSSItem {
|
||||
let img = dom.querySelector("img")
|
||||
if (img && img.src) item.thumb = img.src
|
||||
}
|
||||
if (item.thumb && !item.thumb.startsWith("https:") && !item.thumb.startsWith("http:")) {
|
||||
delete item.thumb
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -65,7 +66,7 @@ export type ItemState = {
|
||||
[_id: string]: RSSItem
|
||||
}
|
||||
|
||||
export const FETCH_ITEMS = 'FETCH_ITEMS'
|
||||
export const FETCH_ITEMS = "FETCH_ITEMS"
|
||||
export const MARK_READ = "MARK_READ"
|
||||
export const MARK_ALL_READ = "MARK_ALL_READ"
|
||||
export const MARK_UNREAD = "MARK_UNREAD"
|
||||
@ -223,8 +224,8 @@ export function markRead(item: RSSItem): AppThunk {
|
||||
|
||||
export function markAllRead(sids: number[] = null): AppThunk {
|
||||
return (dispatch, getState) => {
|
||||
let state = getState()
|
||||
if (sids === null) {
|
||||
let state = getState()
|
||||
let feed = state.feeds[state.page.feedId]
|
||||
sids = feed.sids
|
||||
}
|
||||
@ -235,6 +236,9 @@ export function markAllRead(sids: number[] = null): AppThunk {
|
||||
}
|
||||
})
|
||||
dispatch(markAllReadDone(sids))
|
||||
if (!(state.page.filter.type & FilterType.ShowRead)) {
|
||||
dispatch(initFeeds(true))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,3 +1,4 @@
|
||||
import intl from "react-intl-universal"
|
||||
import { ThunkAction, ThunkDispatch } from "redux-thunk"
|
||||
import { AnyAction } from "redux"
|
||||
import { RootState } from "./reducer"
|
||||
@ -27,15 +28,20 @@ const rssParser = new Parser({
|
||||
})
|
||||
|
||||
export async function parseRSS(url: string) {
|
||||
let result: Response
|
||||
try {
|
||||
let result = await fetch(url, { credentials: "omit" })
|
||||
if (result.ok) {
|
||||
return await rssParser.parseString(await result.text())
|
||||
} else {
|
||||
throw new Error(result.statusText)
|
||||
}
|
||||
result = await fetch(url, { credentials: "omit" })
|
||||
} catch {
|
||||
throw new Error("A network error has occurred.")
|
||||
throw new Error(intl.get("log.networkError"))
|
||||
}
|
||||
if (result && result.ok) {
|
||||
try {
|
||||
return await rssParser.parseString(await result.text())
|
||||
} catch {
|
||||
throw new Error(intl.get("log.parseError"))
|
||||
}
|
||||
} else {
|
||||
throw new Error(result.statusText)
|
||||
}
|
||||
}
|
||||
|
||||
@ -60,17 +66,29 @@ export async function fetchFavicon(url: string) {
|
||||
}
|
||||
}
|
||||
url = url + "/favicon.ico"
|
||||
result = await fetch(url, { credentials: "omit" })
|
||||
if (result.status == 200 && result.headers.has("Content-Type")
|
||||
&& result.headers.get("Content-Type").startsWith("image")) {
|
||||
if (await validateFavicon(url)) {
|
||||
return url
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
return null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function validateFavicon(url: string) {
|
||||
let flag = false
|
||||
try {
|
||||
const result = await fetch(url, { credentials: "omit" })
|
||||
if (result.status == 200 && result.headers.has("Content-Type")
|
||||
&& result.headers.get("Content-Type").startsWith("image")) {
|
||||
flag = true
|
||||
}
|
||||
} finally {
|
||||
return flag
|
||||
}
|
||||
}
|
||||
|
||||
export function htmlDecode(input: string) {
|
||||
var doc = domParser.parseFromString(input, "text/html")
|
||||
return doc.documentElement.textContent
|
||||
|
Loading…
x
Reference in New Issue
Block a user