storage clean up & import settings

This commit is contained in:
刘浩远 2020-06-15 14:16:49 +08:00
parent 31c5cfc5a1
commit 8261fbf5b4
19 changed files with 298 additions and 42 deletions

18
dist/styles.css vendored
View File

@ -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;

1
items Normal file
View File

@ -0,0 +1 @@
{"$$indexCreated":{"fieldName":"source","unique":false,"sparse":false}}

View File

@ -22,8 +22,7 @@
"output": "./bin/${platform}/${arch}/"
},
"win": {
"target": [ "nsis", "appx" ],
"certificateFile": "./bin/key.pfx"
"target": [ "nsis", "appx" ]
},
"appx": {
"applicationId": "FluentReader",

2
sources Normal file
View File

@ -0,0 +1,2 @@
{"$$indexCreated":{"fieldName":"sid","unique":true,"sparse":false}}
{"$$indexCreated":{"fieldName":"url","unique":true,"sparse":false}}

View File

@ -53,6 +53,9 @@ class CardsFeed extends React.Component<FeedProps> {
onClick={() => this.props.loadMore(this.props.feed)} /></div>
: null
}
{ this.props.items.length === 0 && (
<div className="empty">{intl.get("article.empty")}</div>
)}
</div>
)
}

View File

@ -28,6 +28,9 @@ class ListFeed extends React.Component<FeedProps> {
onClick={() => this.props.loadMore(this.props.feed)} /></div>
: null
}
{ this.props.items.length === 0 && (
<div className="empty">{intl.get("article.empty")}</div>
)}
</div>
)
}

View File

@ -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<void>
importAll: () => void
}
class AppTab extends React.Component<AppTabProps> {
state = {
type AppTabState = {
pacStatus: boolean
pacUrl: string
themeSettings: ThemeSettings
itemSize: string
cacheSize: string
deleteIndex: string
}
class AppTab extends React.Component<AppTabProps, AppTabState> {
constructor(props) {
super(props)
this.state = {
pacStatus: getProxyStatus(),
pacUrl: getProxy(),
themeSettings: getThemeSettings()
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<AppTabProps> {
{ 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<AppTabProps> {
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<AppTabProps> {
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<AppTabProps> {
</Stack>
</form>}
<Label>{intl.get("app.cleanup")}</Label>
<Stack horizontal>
<Stack.Item grow>
<Dropdown
placeholder={intl.get("app.deleteChoices")}
options={this.deleteOptions()}
selectedKey={this.state.deleteIndex}
onChange={this.deleteChange} />
</Stack.Item>
<Stack.Item>
<DangerButton
disabled={this.state.itemSize === null || this.state.deleteIndex === null}
text={intl.get("app.confirmDelete")}
onClick={this.confirmDelete} />
</Stack.Item>
</Stack>
<span className="settings-hint up">
{this.state.itemSize ? intl.get("app.itemSize", {size: this.state.itemSize}) : intl.get("app.calculatingSize")}
</span>
<Stack horizontal>
<Stack.Item>
<DefaultButton
text={intl.get("app.cache")}
disabled={this.state.cacheSize === null || this.state.cacheSize === "0MB"}
onClick={this.clearCache} />
</Stack.Item>
</Stack>
<span className="settings-hint up">
{this.state.cacheSize ? intl.get("app.cacheSize", {size: this.state.cacheSize}) : intl.get("app.calculatingSize")}
</span>
<Label>{intl.get("app.data")}</Label>
<Stack horizontal>
<Stack.Item>
<PrimaryButton onClick={this.exportAll} text={intl.get("app.backup")} />
</Stack.Item>
<Stack.Item>
<DefaultButton text={intl.get("app.restore")} />
<DefaultButton onClick={this.props.importAll} text={intl.get("app.restore")} />
</Stack.Item>
</Stack>
</div>

View File

@ -15,6 +15,7 @@ class DangerButton extends PrimaryButton {
}
onClick = (event: React.MouseEvent<HTMLButtonElement>) => {
if (!this.props.disabled) {
if (this.state.confirming) {
if (this.props.onClick) this.props.onClick(event)
clearTimeout(this.timerID)
@ -26,6 +27,7 @@ class DangerButton extends PrimaryButton {
}, 5000)
}
}
}
componentWillUnmount() {
if (this.timerID) clearTimeout(this.timerID)

View File

@ -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])
}
})
}
})
}
})

View File

@ -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()
})

View File

@ -12,6 +12,7 @@ import { SourceGroup } from "./models/group"
} */
export type schemaTypes = {
version: string
theme: ThemeSettings
pac: string
pacOn: boolean

View File

@ -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",

View File

@ -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": "还原",

View File

@ -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()
})
}
}

View File

@ -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
}

View File

@ -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
@ -133,3 +133,34 @@ export function exportAll(path) {
}
}
}
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")
})
}
}
})
}

View File

@ -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)
}
}

View File

@ -85,3 +85,25 @@ export function mergeSortedArrays<T>(a: T[], b: T[], cmp: ((x: T, y: T) => numbe
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<number> {
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()
})
}

View File

@ -2,7 +2,7 @@ const HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports = [
{
mode: 'development',
mode: 'production',
entry: './src/electron.ts',
target: 'electron-main',
module: {