diff --git a/dist/styles.css b/dist/styles.css index 2b382da..960c9eb 100644 --- a/dist/styles.css +++ b/dist/styles.css @@ -75,6 +75,10 @@ body { background: #a4262c; border-color: #a4262c; } +.ms-Button--primary.danger.is-disabled { + background: var(--neutralLighter); + border-color: var(--neutralLighter); +} .ms-Button--commandBar.active { background-color: var(--neutralLight); color: var(--neutralDark); @@ -326,6 +330,11 @@ img.favicon { font-size: 12px; color: var(--neutralSecondary); } +.settings-hint.up { + position: relative; + top: -12px; + line-height: unset; +} .settings-about { margin: 72px 0; color: var(--black); @@ -543,6 +552,15 @@ img.favicon { .flex-fix { min-width: 280px; } +.cards-feed-container > .empty, .list-feed > .empty { + width: 100%; + height: calc(100vh - 64px); + display: flex; + justify-content: space-around; + align-items: center; + color: var(--neutralSecondary); + font-size: 14px; +} .info { position: relative; diff --git a/items b/items new file mode 100644 index 0000000..43f72db --- /dev/null +++ b/items @@ -0,0 +1 @@ +{"$$indexCreated":{"fieldName":"source","unique":false,"sparse":false}} diff --git a/package.json b/package.json index 636f9f4..a20c7d3 100644 --- a/package.json +++ b/package.json @@ -22,8 +22,7 @@ "output": "./bin/${platform}/${arch}/" }, "win": { - "target": [ "nsis", "appx" ], - "certificateFile": "./bin/key.pfx" + "target": [ "nsis", "appx" ] }, "appx": { "applicationId": "FluentReader", diff --git a/sources b/sources new file mode 100644 index 0000000..308ddbf --- /dev/null +++ b/sources @@ -0,0 +1,2 @@ +{"$$indexCreated":{"fieldName":"sid","unique":true,"sparse":false}} +{"$$indexCreated":{"fieldName":"url","unique":true,"sparse":false}} diff --git a/src/components/feeds/cards-feed.tsx b/src/components/feeds/cards-feed.tsx index 292ce82..bff03ea 100644 --- a/src/components/feeds/cards-feed.tsx +++ b/src/components/feeds/cards-feed.tsx @@ -53,6 +53,9 @@ class CardsFeed extends React.Component { onClick={() => this.props.loadMore(this.props.feed)} /> : null } + { this.props.items.length === 0 && ( +
{intl.get("article.empty")}
+ )} ) } diff --git a/src/components/feeds/list-feed.tsx b/src/components/feeds/list-feed.tsx index 23baca7..64e4054 100644 --- a/src/components/feeds/list-feed.tsx +++ b/src/components/feeds/list-feed.tsx @@ -28,6 +28,9 @@ class ListFeed extends React.Component { onClick={() => this.props.loadMore(this.props.feed)} /> : null } + { this.props.items.length === 0 && ( +
{intl.get("article.empty")}
+ )} ) } diff --git a/src/components/settings/app.tsx b/src/components/settings/app.tsx index 24ccfe0..9f08d22 100644 --- a/src/components/settings/app.tsx +++ b/src/components/settings/app.tsx @@ -1,19 +1,56 @@ import * as React from "react" import intl = require("react-intl-universal") -import { urlTest } from "../../scripts/utils" +import { urlTest, byteToMB, calculateItemSize } from "../../scripts/utils" import { getProxy, getProxyStatus, toggleProxyStatus, setProxy, getThemeSettings, setThemeSettings, ThemeSettings, getLocaleSettings, exportAll } from "../../scripts/settings" import { Stack, Label, Toggle, TextField, DefaultButton, ChoiceGroup, IChoiceGroupOption, loadTheme, Dropdown, IDropdownOption, PrimaryButton } from "@fluentui/react" import { remote } from "electron" +import DangerButton from "../utils/danger-button" type AppTabProps = { setLanguage: (option: string) => void + deleteArticles: (days: number) => Promise + importAll: () => void } -class AppTab extends React.Component { - state = { - pacStatus: getProxyStatus(), - pacUrl: getProxy(), - themeSettings: getThemeSettings() +type AppTabState = { + pacStatus: boolean + pacUrl: string + themeSettings: ThemeSettings + itemSize: string + cacheSize: string + deleteIndex: string +} + +class AppTab extends React.Component { + constructor(props) { + super(props) + this.state = { + pacStatus: getProxyStatus(), + pacUrl: getProxy(), + themeSettings: getThemeSettings(), + itemSize: null, + cacheSize: null, + deleteIndex: null + } + this.getItemSize() + this.getCacheSize() + } + + getCacheSize = () => { + remote.session.defaultSession.getCacheSize().then(size => { + this.setState({ cacheSize: byteToMB(size) }) + }) + } + getItemSize = () => { + calculateItemSize().then((size) => { + this.setState({ itemSize: byteToMB(size) }) + }) + } + + clearCache = () => { + remote.session.defaultSession.clearCache().then(() => { + this.getCacheSize() + }) } themeChoices = (): IChoiceGroupOption[] => [ @@ -22,6 +59,24 @@ class AppTab extends React.Component { { key: ThemeSettings.Dark, text: intl.get("app.darkTheme") } ] + deleteOptions = (): IDropdownOption[] => [ + { key: "7", text: intl.get("app.daysAgo", { days: 7 }) }, + { key: "14", text: intl.get("app.daysAgo", { days: 14 }) }, + { key: "21", text: intl.get("app.daysAgo", { days: 21 }) }, + { key: "28", text: intl.get("app.daysAgo", { days: 28 }) }, + { key: "0", text: intl.get("app.deleteAll") }, + ] + + deleteChange = (_, item: IDropdownOption) => { + this.setState({ deleteIndex: item ? String(item.key) : null }) + } + + confirmDelete = () => { + this.setState({ itemSize: null }) + this.props.deleteArticles(parseInt(this.state.deleteIndex)) + .then(() => this.getItemSize()) + } + languageOptions = (): IDropdownOption[] => [ { key: "default", text: intl.get("followSystem") }, { key: "en-US", text: "English" }, @@ -38,6 +93,7 @@ class AppTab extends React.Component { handleInputChange = (event) => { const name: string = event.target.name + // @ts-ignore this.setState({[name]: event.target.value.trim()}) } @@ -48,7 +104,7 @@ class AppTab extends React.Component { onThemeChange = (_, option: IChoiceGroupOption) => { setThemeSettings(option.key as ThemeSettings) - this.setState({ themeSettings: option.key }) + this.setState({ themeSettings: option.key as ThemeSettings }) } exportAll = () => { @@ -110,13 +166,44 @@ class AppTab extends React.Component { } + + + + + + + + + + + {this.state.itemSize ? intl.get("app.itemSize", {size: this.state.itemSize}) : intl.get("app.calculatingSize")} + + + + + + + + {this.state.cacheSize ? intl.get("app.cacheSize", {size: this.state.cacheSize}) : intl.get("app.calculatingSize")} + + - + diff --git a/src/components/utils/danger-button.tsx b/src/components/utils/danger-button.tsx index 1846aea..4bcd1d1 100644 --- a/src/components/utils/danger-button.tsx +++ b/src/components/utils/danger-button.tsx @@ -15,15 +15,17 @@ class DangerButton extends PrimaryButton { } onClick = (event: React.MouseEvent) => { - if (this.state.confirming) { - if (this.props.onClick) this.props.onClick(event) - clearTimeout(this.timerID) - this.clear() - } else { - this.setState({ confirming: true }) - this.timerID = setTimeout(() => { + if (!this.props.disabled) { + if (this.state.confirming) { + if (this.props.onClick) this.props.onClick(event) + clearTimeout(this.timerID) this.clear() - }, 5000) + } else { + this.setState({ confirming: true }) + this.timerID = setTimeout(() => { + this.clear() + }, 5000) + } } } diff --git a/src/containers/settings/app-container.tsx b/src/containers/settings/app-container.tsx index fb0064d..702bd2c 100644 --- a/src/containers/settings/app-container.tsx +++ b/src/containers/settings/app-container.tsx @@ -1,12 +1,51 @@ +import intl = require("react-intl-universal") import { connect } from "react-redux" -import { setLocaleSettings } from "../../scripts/settings" -import { initIntl } from "../../scripts/models/app" +import { setLocaleSettings, importAll } from "../../scripts/settings" +import { initIntl, saveSettings } from "../../scripts/models/app" +import * as db from "../../scripts/db" import AppTab from "../../components/settings/app" +import { initFeeds } from "../../scripts/models/feed" +import { remote } from "electron" const mapDispatchToProps = dispatch => ({ setLanguage: (option: string) => { setLocaleSettings(option) dispatch(initIntl()) + }, + deleteArticles: (days: number) => new Promise((resolve) => { + dispatch(saveSettings()) + let date = new Date() + date.setTime(date.getTime() - days * 86400000) + db.idb.remove({ date: { $lt: date } }, { multi: true }, () => { + dispatch(initFeeds(true)).then(() => dispatch(saveSettings())) + db.idb.prependOnceListener("compaction.done", () => { + resolve() + }) + db.idb.persistence.compactDatafile() + }) + }), + importAll: () => { + let window = remote.getCurrentWindow() + remote.dialog.showOpenDialog(window, { + filters: [{ name: intl.get("app.frData"), extensions: ["frdata"] }], + properties: ["openFile"] + }).then(result => { + if (!result.canceled) { + remote.dialog.showMessageBox(remote.getCurrentWindow(), { + type: "warning", + title: intl.get("app.restore"), + message: intl.get("app.confirmImport"), + buttons: process.platform === "win32" ? ["Yes", "No"] : [intl.get("confirm"), intl.get("cancel")], + defaultId: 1, + cancelId: 1 + }).then(response => { + if (response.response === 0) { + dispatch(saveSettings()) + importAll(result.filePaths[0]) + } + }) + } + }) } }) diff --git a/src/electron.ts b/src/electron.ts index 9a4a6b9..563bcf7 100644 --- a/src/electron.ts +++ b/src/electron.ts @@ -3,7 +3,8 @@ import windowStateKeeper = require("electron-window-state") import Store = require("electron-store") let mainWindow: BrowserWindow -const store = new Store() +let store = new Store() +let restarting = false nativeTheme.themeSource = store.get("theme", "system") function createWindow() { @@ -31,28 +32,38 @@ function createWindow() { } }) mainWindowState.manage(mainWindow) - mainWindow.on('ready-to-show', () => { + mainWindow.on("ready-to-show", () => { mainWindow.show() mainWindow.focus() mainWindow.webContents.openDevTools() }); // and load the index.html of the app. - mainWindow.loadFile((app.isPackaged ? "dist/" : "") + 'index.html') + mainWindow.loadFile((app.isPackaged ? "dist/" : "") + "index.html") } Menu.setApplicationMenu(null) -app.on('ready', createWindow) +app.on("ready", createWindow) -app.on('window-all-closed', function () { +app.on("window-all-closed", function () { mainWindow = null - if (process.platform !== 'darwin') { + if (restarting) { + restarting = false + store = new Store() + nativeTheme.themeSource = store.get("theme", "system") + createWindow() + } else if (process.platform !== "darwin") { app.quit() } }) -app.on('activate', function () { +app.on("activate", function () { if (mainWindow === null) { createWindow() } }) + +ipcMain.on("restart", () => { + restarting = true + mainWindow.close() +}) diff --git a/src/scripts/config-schema.ts b/src/scripts/config-schema.ts index 05d3ff4..29ced55 100644 --- a/src/scripts/config-schema.ts +++ b/src/scripts/config-schema.ts @@ -12,6 +12,7 @@ import { SourceGroup } from "./models/group" } */ export type schemaTypes = { + version: string theme: ThemeSettings pac: string pacOn: boolean diff --git a/src/scripts/i18n/en-US.json b/src/scripts/i18n/en-US.json index 16b3484..1fc7658 100644 --- a/src/scripts/i18n/en-US.json +++ b/src/scripts/i18n/en-US.json @@ -35,6 +35,7 @@ "subscriptions": "Subscriptions" }, "article": { + "empty": "No articles", "untitled": "(Untitled)", "hide": "Hide article", "unhide": "Unhide article", @@ -112,6 +113,16 @@ "groupHint": "Double click on group to edit sources. Drag and drop to reorder." }, "app": { + "cleanup": "Clean up", + "cache": "Clear cache", + "cacheSize": "Cached {size} of data", + "deleteChoices": "Delete articles from ... days ago", + "confirmDelete": "Delete", + "daysAgo": "{days} days ago", + "deleteAll": "Delete all articles", + "calculatingSize": "Calculating size...", + "itemSize": "Around {size} of local storage is occupied by articles", + "confirmImport": "Do you really want to import data from the backup file? All current data will be wiped.", "data": "Application Data", "backup": "Backup", "restore": "Restore", diff --git a/src/scripts/i18n/zh-CN.json b/src/scripts/i18n/zh-CN.json index 5db08fe..8fd44b7 100644 --- a/src/scripts/i18n/zh-CN.json +++ b/src/scripts/i18n/zh-CN.json @@ -35,6 +35,7 @@ "subscriptions": "订阅源" }, "article": { + "empty": "无文章", "untitled": "(无标题)", "hide": "隐藏文章", "unhide": "取消隐藏", @@ -112,6 +113,16 @@ "groupHint": "双击分组以修改订阅源,可通过拖拽排序" }, "app": { + "cleanup": "清理", + "cache": "清空缓存", + "cacheSize": "已缓存{size}数据", + "deleteChoices": "删除 … 天前的文章", + "confirmDelete": "删除文章", + "daysAgo": "{days}天前", + "deleteAll": "删除全部文章", + "calculatingSize": "正在计算占用空间…", + "itemSize": "本地文章约占用{size}空间", + "confirmImport": "确认要从备份文件导入数据吗?这将清除所有应用数据。", "data": "应用数据", "backup": "备份", "restore": "还原", diff --git a/src/scripts/models/app.ts b/src/scripts/models/app.ts index d722179..9b2dfd2 100644 --- a/src/scripts/models/app.ts +++ b/src/scripts/models/app.ts @@ -7,6 +7,7 @@ import { SourceGroupActionTypes, UPDATE_SOURCE_GROUP, ADD_SOURCE_TO_GROUP, DELET import { PageActionTypes, SELECT_PAGE, PageType, selectAllArticles } from "./page" import { getCurrentLocale } from "../settings" import locales from "../i18n/_locales" +import * as db from "../db" export enum ContextMenuType { Hidden, Item, Text, View, Group @@ -198,7 +199,10 @@ export function initApp(): AppThunk { dispatch(initFeeds()) ).then(() => { dispatch(selectAllArticles()) - dispatch(fetchItems()) + return dispatch(fetchItems()) + }).then(() => { + db.sdb.persistence.compactDatafile() + db.idb.persistence.compactDatafile() }) } } diff --git a/src/scripts/models/item.ts b/src/scripts/models/item.ts index 9af9e2e..3c312e8 100644 --- a/src/scripts/models/item.ts +++ b/src/scripts/models/item.ts @@ -26,16 +26,6 @@ export class RSSItem { this.link = item.link || "" this.fetchedDate = new Date() this.date = item.isoDate ? new Date(item.isoDate) : this.fetchedDate - if (item.thumb) this.thumb = item.thumb - else if (item.image) this.thumb = item.image - else { - let dom = domParser.parseFromString(item.content, "text/html") - let baseEl = dom.createElement('base') - baseEl.setAttribute('href', this.link.split("/").slice(0, 3).join("/")) - dom.head.append(baseEl) - let img = dom.querySelector("img") - if (img && img.src) this.thumb = img.src - } if (item.fullContent) { this.content = item.fullContent this.snippet = htmlDecode(item.fullContent) @@ -43,6 +33,16 @@ export class RSSItem { this.content = item.content || "" this.snippet = htmlDecode(item.contentSnippet || "") } + if (item.thumb) this.thumb = item.thumb + else if (item.image) this.thumb = item.image + else { + let dom = domParser.parseFromString(this.content, "text/html") + let baseEl = dom.createElement('base') + baseEl.setAttribute('href', this.link.split("/").slice(0, 3).join("/")) + dom.head.append(baseEl) + let img = dom.querySelector("img") + if (img && img.src) this.thumb = img.src + } this.creator = item.creator this.hasRead = false } diff --git a/src/scripts/settings.ts b/src/scripts/settings.ts index faea277..2015714 100644 --- a/src/scripts/settings.ts +++ b/src/scripts/settings.ts @@ -1,6 +1,6 @@ import { remote, ipcRenderer } from "electron" import { ViewType } from "./models/page" -import { IPartialTheme, loadTheme } from "@fluentui/react" +import { IPartialTheme, loadTheme, values } from "@fluentui/react" import locales from "./i18n/_locales" import Store = require("electron-store") import { schemaTypes } from "./config-schema" @@ -109,7 +109,7 @@ export function getCurrentLocale() { return (locale in locales) ? locale : "en-US" } -export function exportAll(path) { +export function exportAll(path: string) { let output = {} for (let [key, value] of store) { output[key] = value @@ -132,4 +132,35 @@ export function exportAll(path) { } } } -} \ No newline at end of file +} + +export function importAll(path) { + fs.readFile(path, "utf-8", async (err, data) => { + if (err) { + console.log(err) + } else { + let configs = JSON.parse(data) + let openRequest = window.indexedDB.open("NeDB") + openRequest.onsuccess = () => { + let db = openRequest.result + let objectStore = db.transaction("nedbdata", "readwrite").objectStore("nedbdata") + let requests = Object.entries(configs.nedb).map(([key, value]) => { + return objectStore.put(value, key) + }) + let promises = requests.map(req => new Promise((resolve, reject) => { + req.onsuccess = () => resolve() + req.onerror = () => reject() + })) + Promise.all(promises).then(() => { + delete configs.nedb + store.clear() + for (let [key, value] of Object.entries(configs)) { + // @ts-ignore + store.set(key, value) + } + ipcRenderer.send("restart") + }) + } + } + }) +} diff --git a/src/scripts/update-scripts.ts b/src/scripts/update-scripts.ts new file mode 100644 index 0000000..bcd94c5 --- /dev/null +++ b/src/scripts/update-scripts.ts @@ -0,0 +1,11 @@ +import { app } from "electron" +import Store = require("electron-store") + +export default function performUpdate(store: Store) { + let version = store.get("version", null) + let currentVersion = app.getVersion() + + if (version != currentVersion) { + store.set("version", currentVersion) + } +} \ No newline at end of file diff --git a/src/scripts/utils.ts b/src/scripts/utils.ts index 2694a56..e0db184 100644 --- a/src/scripts/utils.ts +++ b/src/scripts/utils.ts @@ -84,4 +84,26 @@ export function mergeSortedArrays(a: T[], b: T[], cmp: ((x: T, y: T) => numbe while (i < a.length) merged.push(a[i++]) while (j < b.length) merged.push(b[j++]) return merged +} + +export function byteToMB(B: number) { + let MB = Math.round(B / 1048576) + return MB + "MB" +} + +export function calculateItemSize(): Promise { + return new Promise((resolve, reject) => { + let openRequest = window.indexedDB.open("NeDB") + openRequest.onsuccess = () => { + let db = openRequest.result + let objectStore = db.transaction("nedbdata").objectStore("nedbdata") + let getRequest = objectStore.get("items") + getRequest.onsuccess = () => { + let resultBuffer = Buffer.from(getRequest.result) + resolve(resultBuffer.length) + } + getRequest.onerror = () => reject() + } + openRequest.onerror = () => reject() + }) } \ No newline at end of file diff --git a/webpack.config.js b/webpack.config.js index 0ba253e..b993195 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -2,7 +2,7 @@ const HtmlWebpackPlugin = require('html-webpack-plugin'); module.exports = [ { - mode: 'development', + mode: 'production', entry: './src/electron.ts', target: 'electron-main', module: {