diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..946ae51 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +dist/article/article.js text eol=lf \ No newline at end of file diff --git a/.github/workflows/release-linux.yml b/.github/workflows/release-linux.yml new file mode 100644 index 0000000..02b04fa --- /dev/null +++ b/.github/workflows/release-linux.yml @@ -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 diff --git a/.github/workflows/release-main.yml b/.github/workflows/release-main.yml new file mode 100644 index 0000000..da6d7c2 --- /dev/null +++ b/.github/workflows/release-main.yml @@ -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 diff --git a/package.json b/package.json index ad056ff..6da5c0f 100644 --- a/package.json +++ b/package.json @@ -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, diff --git a/src/components/settings/sources.tsx b/src/components/settings/sources.tsx index 0d62a71..0931785 100644 --- a/src/components/settings/sources.tsx +++ b/src/components/settings/sources.tsx @@ -10,6 +10,7 @@ type SourcesTabProps = { sources: SourceState addSource: (url: string) => void updateSourceName: (source: RSSSource, name: string) => void + updateSourceIcon: (source: RSSSource, iconUrl: string) => Promise 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 { selection: Selection @@ -44,7 +51,9 @@ class SourcesTab extends React.Component { 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 { } ] + 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 { 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 { {this.state.selectedSource && <> - - v.trim().length == 0 ? intl.get("emptyName") : ""} - validateOnLoad={false} - placeholder={intl.get("sources.name")} - value={this.state.newSourceName} - name="newSourceName" - onChange={this.handleInputChange} /> - - + + {this.state.sourceEditOption === EditDropdownKeys.Name && <> + + v.trim().length == 0 ? intl.get("emptyName") : ""} + validateOnLoad={false} + placeholder={intl.get("sources.name")} + value={this.state.newSourceName} + name="newSourceName" + onChange={this.handleInputChange} /> + + + + + } + {this.state.sourceEditOption === EditDropdownKeys.Icon && <> + + urlTest(v.trim()) ? "" : intl.get("sources.badUrl")} + validateOnLoad={false} + placeholder={intl.get("sources.inputUrl")} + value={this.state.newSourceIcon} + name="newSourceIcon" + onChange={this.handleInputChange} /> + + + + + } + {this.state.sourceEditOption === EditDropdownKeys.Url && <> + + + + + window.utils.writeClipboard(this.state.selectedSource.url)} + text={intl.get("context.copy")} /> + + } + diff --git a/src/containers/settings/sources-container.tsx b/src/containers/settings/sources-container.tsx index 6a9b871..72e7b50 100644 --- a/src/containers/settings/sources-container.tsx +++ b/src/containers/settings/sources-container.tsx @@ -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)) }, diff --git a/src/main/window.ts b/src/main/window.ts index 54c606f..2e4fc96 100644 --- a/src/main/window.ts +++ b/src/main/window.ts @@ -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") } }) diff --git a/src/scripts/i18n/en-US.json b/src/scripts/i18n/en-US.json index 2b90bb3..defdaab 100644 --- a/src/scripts/i18n/en-US.json +++ b/src/scripts/i18n/en-US.json @@ -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." } -} +} \ No newline at end of file diff --git a/src/scripts/i18n/zh-CN.json b/src/scripts/i18n/zh-CN.json index bf7da01..e36436a 100644 --- a/src/scripts/i18n/zh-CN.json +++ b/src/scripts/i18n/zh-CN.json @@ -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": "选中订阅源", diff --git a/src/scripts/models/feed.ts b/src/scripts/models/feed.ts index 5c7fccd..cd2de78 100644 --- a/src/scripts/models/feed.ts +++ b/src/scripts/models/feed.ts @@ -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" diff --git a/src/scripts/models/item.ts b/src/scripts/models/item.ts index d02b48f..dd63019 100644 --- a/src/scripts/models/item.ts +++ b/src/scripts/models/item.ts @@ -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)) + } } } diff --git a/src/scripts/utils.ts b/src/scripts/utils.ts index c8032f8..18eda7c 100644 --- a/src/scripts/utils.ts +++ b/src/scripts/utils.ts @@ -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