mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 09:19:56 -05:00
Feat: Settings Reset, Data Clear, Date Fixes (#56)
This commit is contained in:
@@ -16,46 +16,45 @@ fn unix_now() -> u64 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn export_app_data(app: tauri::AppHandle, json: String) -> Result<String, String> {
|
pub async fn export_app_data(app: tauri::AppHandle, bytes: Vec<u8>) -> Result<(), String> {
|
||||||
use tauri_plugin_dialog::DialogExt;
|
use tauri_plugin_dialog::DialogExt;
|
||||||
|
|
||||||
let filename = format!("moku-backup-{}.json", unix_now());
|
let filename = format!("moku-backup-{}.zip", unix_now());
|
||||||
|
|
||||||
let path = app
|
let path = app
|
||||||
.dialog()
|
.dialog()
|
||||||
.file()
|
.file()
|
||||||
.set_title("Save Moku app data backup")
|
.set_title("Save Moku app data backup")
|
||||||
.set_file_name(&filename)
|
.set_file_name(&filename)
|
||||||
|
.add_filter("Moku Backup", &["zip"])
|
||||||
.blocking_save_file()
|
.blocking_save_file()
|
||||||
.ok_or("Cancelled")?;
|
.ok_or("Cancelled")?;
|
||||||
|
|
||||||
let dest = PathBuf::from(path.to_string());
|
std::fs::write(PathBuf::from(path.to_string()), &bytes).map_err(|e| e.to_string())
|
||||||
std::fs::write(&dest, json.as_bytes()).map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
Ok(dest.to_string_lossy().into_owned())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn import_app_data(app: tauri::AppHandle) -> Result<String, String> {
|
pub async fn import_app_data(app: tauri::AppHandle) -> Result<Vec<u8>, String> {
|
||||||
use tauri_plugin_dialog::DialogExt;
|
use tauri_plugin_dialog::DialogExt;
|
||||||
|
|
||||||
let path = app
|
let path = app
|
||||||
.dialog()
|
.dialog()
|
||||||
.file()
|
.file()
|
||||||
.set_title("Open Moku app data backup")
|
.set_title("Open Moku app data backup")
|
||||||
|
.add_filter("Moku Backup", &["zip"])
|
||||||
.blocking_pick_file()
|
.blocking_pick_file()
|
||||||
.ok_or("Cancelled")?;
|
.ok_or("Cancelled")?;
|
||||||
|
|
||||||
std::fs::read_to_string(PathBuf::from(path.to_string())).map_err(|e| e.to_string())
|
std::fs::read(PathBuf::from(path.to_string())).map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn auto_backup_app_data(app: tauri::AppHandle, json: String) -> Result<(), String> {
|
pub fn auto_backup_app_data(app: tauri::AppHandle, bytes: Vec<u8>) -> Result<(), String> {
|
||||||
let dir = backup_dir(&app);
|
let dir = backup_dir(&app);
|
||||||
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
let dest = dir.join(format!("auto-moku-backup-{}.json", unix_now()));
|
let dest = dir.join(format!("auto-moku-backup-{}.zip", unix_now()));
|
||||||
std::fs::write(&dest, json.as_bytes()).map_err(|e| e.to_string())?;
|
std::fs::write(&dest, &bytes).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
let mut entries: Vec<_> = std::fs::read_dir(&dir)
|
let mut entries: Vec<_> = std::fs::read_dir(&dir)
|
||||||
.map_err(|e| e.to_string())?
|
.map_err(|e| e.to_string())?
|
||||||
@@ -80,3 +79,20 @@ pub fn auto_backup_app_data(app: tauri::AppHandle, json: String) -> Result<(), S
|
|||||||
pub fn get_auto_backup_dir(app: tauri::AppHandle) -> String {
|
pub fn get_auto_backup_dir(app: tauri::AppHandle) -> String {
|
||||||
backup_dir(&app).to_string_lossy().into_owned()
|
backup_dir(&app).to_string_lossy().into_owned()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn read_store_files(app: tauri::AppHandle, names: Vec<String>) -> Vec<(String, String)> {
|
||||||
|
let base = app
|
||||||
|
.path()
|
||||||
|
.app_local_data_dir()
|
||||||
|
.unwrap_or_else(|_| PathBuf::from("."));
|
||||||
|
|
||||||
|
names
|
||||||
|
.into_iter()
|
||||||
|
.map(|name| {
|
||||||
|
let content = std::fs::read_to_string(base.join(&name))
|
||||||
|
.unwrap_or_else(|_| "{}".to_string());
|
||||||
|
(name, content)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
@@ -50,3 +50,51 @@ pub async fn pick_downloads_folder(app: tauri::AppHandle) -> Option<String> {
|
|||||||
.blocking_pick_folder()
|
.blocking_pick_folder()
|
||||||
.map(|p| p.to_string())
|
.map(|p| p.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn exit_app(app: tauri::AppHandle) {
|
||||||
|
app.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn clear_moku_cache(app: tauri::AppHandle) -> Result<(), String> {
|
||||||
|
use tauri::Manager;
|
||||||
|
let cache_dir = app.path().app_cache_dir().map_err(|e| e.to_string())?;
|
||||||
|
if cache_dir.exists() {
|
||||||
|
std::fs::remove_dir_all(&cache_dir).map_err(|e| e.to_string())?;
|
||||||
|
std::fs::create_dir_all(&cache_dir).map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn clear_suwayomi_cache() -> Result<(), String> {
|
||||||
|
use crate::server::resolve::suwayomi_data_dir;
|
||||||
|
let data_dir = suwayomi_data_dir();
|
||||||
|
for dir in &["cache", "bin/kcef", "cache/kcef"] {
|
||||||
|
let p = data_dir.join(dir);
|
||||||
|
if p.exists() {
|
||||||
|
std::fs::remove_dir_all(&p).map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn reset_suwayomi_data(app: tauri::AppHandle) -> Result<(), String> {
|
||||||
|
use crate::server::resolve::suwayomi_data_dir;
|
||||||
|
|
||||||
|
crate::server::kill_tachidesk(&app);
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(500));
|
||||||
|
|
||||||
|
let data_dir = suwayomi_data_dir();
|
||||||
|
for entry_name in &["database.mv.db", "extensions", "settings", "logs", "local"] {
|
||||||
|
let p = data_dir.join(entry_name);
|
||||||
|
if p.is_dir() {
|
||||||
|
std::fs::remove_dir_all(&p).map_err(|e| format!("{entry_name}: {e}"))?;
|
||||||
|
} else if p.exists() {
|
||||||
|
std::fs::remove_file(&p).map_err(|e| format!("{entry_name}: {e}"))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -29,6 +29,10 @@ pub fn run() {
|
|||||||
commands::server::kill_server,
|
commands::server::kill_server,
|
||||||
commands::system::get_platform_ui_scale,
|
commands::system::get_platform_ui_scale,
|
||||||
commands::system::restart_app,
|
commands::system::restart_app,
|
||||||
|
commands::system::exit_app,
|
||||||
|
commands::system::clear_moku_cache,
|
||||||
|
commands::system::clear_suwayomi_cache,
|
||||||
|
commands::system::reset_suwayomi_data,
|
||||||
commands::system::open_path,
|
commands::system::open_path,
|
||||||
commands::system::pick_downloads_folder,
|
commands::system::pick_downloads_folder,
|
||||||
commands::backup::export_app_data,
|
commands::backup::export_app_data,
|
||||||
|
|||||||
+241
-23
@@ -1,38 +1,256 @@
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import {
|
||||||
|
persistSettings,
|
||||||
|
persistLibrary,
|
||||||
|
persistUpdates,
|
||||||
|
} from "@core/persistence/persist";
|
||||||
|
|
||||||
function collectAppData(): Record<string, string> {
|
const STORE_FILES = ["settings.json", "library.json", "updates.json"] as const;
|
||||||
const data: Record<string, string> = {};
|
|
||||||
for (let i = 0; i < localStorage.length; i++) {
|
|
||||||
const key = localStorage.key(i);
|
|
||||||
if (key !== null) data[key] = localStorage.getItem(key) ?? "";
|
|
||||||
}
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyAppData(data: Record<string, string>): void {
|
|
||||||
localStorage.clear();
|
|
||||||
for (const [key, value] of Object.entries(data)) {
|
|
||||||
localStorage.setItem(key, value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function exportAppData(): Promise<void> {
|
export async function exportAppData(): Promise<void> {
|
||||||
const json = JSON.stringify(collectAppData(), null, 2);
|
const entries: [string, string][] = await invoke("read_store_files", {
|
||||||
await invoke("export_app_data", { json });
|
names: [...STORE_FILES],
|
||||||
|
});
|
||||||
|
|
||||||
|
const zip = buildZip(
|
||||||
|
entries.map(([name, content]) => ({
|
||||||
|
name,
|
||||||
|
bytes: new TextEncoder().encode(content),
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
await invoke("export_app_data", { bytes: Array.from(zip) });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function importAppData(): Promise<void> {
|
export async function importAppData(): Promise<void> {
|
||||||
const json = await invoke<string>("import_app_data");
|
const raw: number[] = await invoke("import_app_data");
|
||||||
const data: Record<string, string> = JSON.parse(json);
|
const files = parseZip(new Uint8Array(raw));
|
||||||
applyAppData(data);
|
|
||||||
location.reload();
|
const decode = (name: string) => {
|
||||||
|
const bytes = files.get(name);
|
||||||
|
if (!bytes) throw new Error(`Backup is missing ${name}`);
|
||||||
|
return JSON.parse(new TextDecoder().decode(bytes));
|
||||||
|
};
|
||||||
|
|
||||||
|
const s = decode("settings.json");
|
||||||
|
const l = decode("library.json");
|
||||||
|
const u = decode("updates.json");
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
persistSettings({
|
||||||
|
settings: s.settings ?? null,
|
||||||
|
storeVersion: s.storeVersion ?? 1,
|
||||||
|
}),
|
||||||
|
persistLibrary({
|
||||||
|
history: l.history ?? [],
|
||||||
|
bookmarks: l.bookmarks ?? [],
|
||||||
|
markers: l.markers ?? [],
|
||||||
|
readLog: l.readLog ?? [],
|
||||||
|
readingStats: l.readingStats ?? null,
|
||||||
|
dailyReadCounts: l.dailyReadCounts ?? {},
|
||||||
|
}),
|
||||||
|
persistUpdates({
|
||||||
|
libraryUpdates: u.libraryUpdates ?? [],
|
||||||
|
lastLibraryRefresh: u.lastLibraryRefresh ?? 0,
|
||||||
|
acknowledgedUpdateIds: u.acknowledgedUpdateIds ?? [],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await showExitModal();
|
||||||
|
invoke("exit_app");
|
||||||
|
}
|
||||||
|
|
||||||
|
function showExitModal(): Promise<void> {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
const backdrop = document.createElement("div");
|
||||||
|
backdrop.className = "s-backdrop";
|
||||||
|
backdrop.style.cssText = "z-index:99999";
|
||||||
|
|
||||||
|
const modal = document.createElement("div");
|
||||||
|
modal.style.cssText = [
|
||||||
|
"background:var(--bg-surface)",
|
||||||
|
"border:1px solid var(--border-base)",
|
||||||
|
"border-radius:var(--radius-2xl)",
|
||||||
|
"box-shadow:0 0 0 1px rgba(255,255,255,0.04) inset,0 24px 80px rgba(0,0,0,0.7),0 8px 24px rgba(0,0,0,0.4)",
|
||||||
|
"width:min(400px,calc(100vw - 40px))",
|
||||||
|
"display:flex",
|
||||||
|
"flex-direction:column",
|
||||||
|
"overflow:hidden",
|
||||||
|
"animation:s-scale-in 0.2s cubic-bezier(0.16,1,0.3,1) both",
|
||||||
|
].join(";");
|
||||||
|
|
||||||
|
const header = document.createElement("div");
|
||||||
|
header.style.cssText = "padding:var(--sp-4) var(--sp-5) var(--sp-3);border-bottom:1px solid var(--border-dim)";
|
||||||
|
|
||||||
|
const title = document.createElement("p");
|
||||||
|
title.style.cssText = "margin:0;font-size:var(--text-sm);font-weight:var(--weight-medium);color:var(--text-primary);letter-spacing:0.01em";
|
||||||
|
title.textContent = "Import complete";
|
||||||
|
header.appendChild(title);
|
||||||
|
|
||||||
|
const body = document.createElement("div");
|
||||||
|
body.style.cssText = "padding:var(--sp-4) var(--sp-5);display:flex;flex-direction:column;gap:var(--sp-2)";
|
||||||
|
|
||||||
|
const sub = document.createElement("p");
|
||||||
|
sub.style.cssText = "margin:0;font-family:var(--font-ui);font-size:var(--text-xs);color:var(--text-faint);letter-spacing:var(--tracking-wide);line-height:var(--leading-snug)";
|
||||||
|
sub.textContent = "Your settings have been restored. Moku will close so you can relaunch with the imported data.";
|
||||||
|
|
||||||
|
const counter = document.createElement("p");
|
||||||
|
counter.style.cssText = "margin:0;font-family:var(--font-ui);font-size:var(--text-xs);color:var(--text-faint);letter-spacing:var(--tracking-wide)";
|
||||||
|
counter.textContent = "Closing in 3…";
|
||||||
|
|
||||||
|
body.append(sub, counter);
|
||||||
|
|
||||||
|
const footer = document.createElement("div");
|
||||||
|
footer.style.cssText = "padding:var(--sp-3) var(--sp-5);border-top:1px solid var(--border-dim);display:flex;justify-content:flex-end";
|
||||||
|
|
||||||
|
const btn = document.createElement("button");
|
||||||
|
btn.className = "s-btn s-btn-danger";
|
||||||
|
btn.textContent = "Close now";
|
||||||
|
|
||||||
|
footer.appendChild(btn);
|
||||||
|
modal.append(header, body, footer);
|
||||||
|
backdrop.appendChild(modal);
|
||||||
|
document.body.appendChild(backdrop);
|
||||||
|
|
||||||
|
let secs = 3;
|
||||||
|
const tick = setInterval(() => {
|
||||||
|
secs--;
|
||||||
|
counter.textContent = secs > 0 ? `Closing in ${secs}…` : "Closing…";
|
||||||
|
if (secs <= 0) { clearInterval(tick); backdrop.remove(); resolve(); }
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
btn.addEventListener("click", () => { clearInterval(tick); backdrop.remove(); resolve(); });
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function autoBackupAppData(): Promise<void> {
|
export async function autoBackupAppData(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const json = JSON.stringify(collectAppData());
|
const entries: [string, string][] = await invoke("read_store_files", {
|
||||||
await invoke("auto_backup_app_data", { json });
|
names: [...STORE_FILES],
|
||||||
|
});
|
||||||
|
const zip = buildZip(
|
||||||
|
entries.map(([name, content]) => ({
|
||||||
|
name,
|
||||||
|
bytes: new TextEncoder().encode(content),
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
await invoke("auto_backup_app_data", { bytes: Array.from(zip) });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("[moku] auto-backup failed:", e);
|
console.warn("[moku] auto-backup failed:", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function crc32(data: Uint8Array): number {
|
||||||
|
let crc = 0xffffffff;
|
||||||
|
for (const byte of data) {
|
||||||
|
crc ^= byte;
|
||||||
|
for (let j = 0; j < 8; j++) {
|
||||||
|
crc = crc & 1 ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (crc ^ 0xffffffff) >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function localHeader(name: Uint8Array, data: Uint8Array): Uint8Array {
|
||||||
|
const buf = new ArrayBuffer(30 + name.byteLength);
|
||||||
|
const v = new DataView(buf);
|
||||||
|
v.setUint32(0, 0x04034b50, true);
|
||||||
|
v.setUint16(4, 20, true);
|
||||||
|
v.setUint16(6, 0, true);
|
||||||
|
v.setUint16(8, 0, true);
|
||||||
|
v.setUint16(10, 0, true);
|
||||||
|
v.setUint16(12, 0, true);
|
||||||
|
v.setUint32(14, crc32(data), true);
|
||||||
|
v.setUint32(18, data.byteLength, true);
|
||||||
|
v.setUint32(22, data.byteLength, true);
|
||||||
|
v.setUint16(26, name.byteLength, true);
|
||||||
|
v.setUint16(28, 0, true);
|
||||||
|
new Uint8Array(buf).set(name, 30);
|
||||||
|
return new Uint8Array(buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
function centralHeader(name: Uint8Array, data: Uint8Array, offset: number): Uint8Array {
|
||||||
|
const buf = new ArrayBuffer(46 + name.byteLength);
|
||||||
|
const v = new DataView(buf);
|
||||||
|
v.setUint32(0, 0x02014b50, true);
|
||||||
|
v.setUint16(4, 20, true);
|
||||||
|
v.setUint16(6, 20, true);
|
||||||
|
v.setUint16(8, 0, true);
|
||||||
|
v.setUint16(10, 0, true);
|
||||||
|
v.setUint16(12, 0, true);
|
||||||
|
v.setUint16(14, 0, true);
|
||||||
|
v.setUint32(16, crc32(data), true);
|
||||||
|
v.setUint32(20, data.byteLength, true);
|
||||||
|
v.setUint32(24, data.byteLength, true);
|
||||||
|
v.setUint16(28, name.byteLength, true);
|
||||||
|
v.setUint16(30, 0, true);
|
||||||
|
v.setUint16(32, 0, true);
|
||||||
|
v.setUint16(34, 0, true);
|
||||||
|
v.setUint16(36, 0, true);
|
||||||
|
v.setUint32(38, 0, true);
|
||||||
|
v.setUint32(42, offset, true);
|
||||||
|
new Uint8Array(buf).set(name, 46);
|
||||||
|
return new Uint8Array(buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
function eocd(count: number, cdSize: number, cdOffset: number): Uint8Array {
|
||||||
|
const buf = new ArrayBuffer(22);
|
||||||
|
const v = new DataView(buf);
|
||||||
|
v.setUint32(0, 0x06054b50, true);
|
||||||
|
v.setUint16(4, 0, true);
|
||||||
|
v.setUint16(6, 0, true);
|
||||||
|
v.setUint16(8, count, true);
|
||||||
|
v.setUint16(10, count, true);
|
||||||
|
v.setUint32(12, cdSize, true);
|
||||||
|
v.setUint32(16, cdOffset, true);
|
||||||
|
v.setUint16(20, 0, true);
|
||||||
|
return new Uint8Array(buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildZip(files: { name: string; bytes: Uint8Array }[]): Uint8Array {
|
||||||
|
const enc = new TextEncoder();
|
||||||
|
const parts: Uint8Array[] = [];
|
||||||
|
const offsets: number[] = [];
|
||||||
|
let pos = 0;
|
||||||
|
|
||||||
|
for (const { name, bytes } of files) {
|
||||||
|
const nameBytes = enc.encode(name);
|
||||||
|
const lh = localHeader(nameBytes, bytes);
|
||||||
|
offsets.push(pos);
|
||||||
|
parts.push(lh, bytes);
|
||||||
|
pos += lh.byteLength + bytes.byteLength;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cdParts = files.map(({ name, bytes }, i) =>
|
||||||
|
centralHeader(enc.encode(name), bytes, offsets[i])
|
||||||
|
);
|
||||||
|
const cd = concat(cdParts);
|
||||||
|
|
||||||
|
return concat([...parts, cd, eocd(files.length, cd.byteLength, pos)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseZip(data: Uint8Array): Map<string, Uint8Array> {
|
||||||
|
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
||||||
|
const files = new Map<string, Uint8Array>();
|
||||||
|
let pos = 0;
|
||||||
|
|
||||||
|
while (pos + 30 <= data.byteLength && view.getUint32(pos, true) === 0x04034b50) {
|
||||||
|
const fnLen = view.getUint16(pos + 26, true);
|
||||||
|
const exLen = view.getUint16(pos + 28, true);
|
||||||
|
const cSize = view.getUint32(pos + 18, true);
|
||||||
|
const name = new TextDecoder().decode(data.subarray(pos + 30, pos + 30 + fnLen));
|
||||||
|
const start = pos + 30 + fnLen + exLen;
|
||||||
|
files.set(name, data.subarray(start, start + cSize));
|
||||||
|
pos = start + cSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
function concat(arrays: Uint8Array[]): Uint8Array {
|
||||||
|
const total = arrays.reduce((n, a) => n + a.byteLength, 0);
|
||||||
|
const out = new Uint8Array(total);
|
||||||
|
let pos = 0;
|
||||||
|
for (const a of arrays) { out.set(a, pos); pos += a.byteLength; }
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { LazyStore } from "@tauri-apps/plugin-store";
|
|||||||
const settingsStore = new LazyStore("settings.json", { autoSave: false });
|
const settingsStore = new LazyStore("settings.json", { autoSave: false });
|
||||||
const libraryStore = new LazyStore("library.json", { autoSave: false });
|
const libraryStore = new LazyStore("library.json", { autoSave: false });
|
||||||
const updatesStore = new LazyStore("updates.json", { autoSave: false });
|
const updatesStore = new LazyStore("updates.json", { autoSave: false });
|
||||||
|
const backupsStore = new LazyStore("backups.json", { autoSave: false });
|
||||||
|
|
||||||
export interface PersistedData {
|
export interface PersistedData {
|
||||||
settings: any;
|
settings: any;
|
||||||
@@ -133,3 +134,23 @@ export async function persistUpdates(data: {
|
|||||||
]);
|
]);
|
||||||
await updatesStore.save();
|
await updatesStore.save();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BackupEntry { url: string; name: string; }
|
||||||
|
|
||||||
|
export async function loadBackups(): Promise<BackupEntry[]> {
|
||||||
|
const fromStore = await backupsStore.get<BackupEntry[]>("backupList");
|
||||||
|
if (fromStore) return fromStore;
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem("moku_backups");
|
||||||
|
if (!raw) return [];
|
||||||
|
const migrated: BackupEntry[] = JSON.parse(raw);
|
||||||
|
await persistBackups(migrated);
|
||||||
|
localStorage.removeItem("moku_backups");
|
||||||
|
return migrated;
|
||||||
|
} catch { return []; }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function persistBackups(list: BackupEntry[]): Promise<void> {
|
||||||
|
await backupsStore.set("backupList", list);
|
||||||
|
await backupsStore.save();
|
||||||
|
}
|
||||||
+34
-22
@@ -28,9 +28,6 @@ export function longPress(node: HTMLElement, opts: LongPressOptions) {
|
|||||||
node.addEventListener("pointerleave", cancel);
|
node.addEventListener("pointerleave", cancel);
|
||||||
node.addEventListener("pointercancel",cancel);
|
node.addEventListener("pointercancel",cancel);
|
||||||
|
|
||||||
function suppressClick(e: MouseEvent) { if (fired) { fired = false; e.preventDefault(); e.stopPropagation(); } }
|
|
||||||
node.addEventListener("click", suppressClick, true);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
get fired() { return fired; },
|
get fired() { return fired; },
|
||||||
destroy() {
|
destroy() {
|
||||||
@@ -40,7 +37,6 @@ export function longPress(node: HTMLElement, opts: LongPressOptions) {
|
|||||||
node.removeEventListener("pointerup", cancel);
|
node.removeEventListener("pointerup", cancel);
|
||||||
node.removeEventListener("pointerleave", cancel);
|
node.removeEventListener("pointerleave", cancel);
|
||||||
node.removeEventListener("pointercancel",cancel);
|
node.removeEventListener("pointercancel",cancel);
|
||||||
node.removeEventListener("click", suppressClick, true);
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -134,53 +130,69 @@ export interface PinchOptions {
|
|||||||
onPinchEnd?: (scale: number) => void;
|
onPinchEnd?: (scale: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function pinch(node: HTMLElement, opts: PinchOptions) {
|
export interface PinchGestureOptions {
|
||||||
|
onPinch: (scale: number, origin: { x: number; y: number }) => void;
|
||||||
|
onPinchEnd?: (scale: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PinchGesture {
|
||||||
|
onPointerDown: (e: PointerEvent) => void;
|
||||||
|
onPointerMove: (e: PointerEvent) => void;
|
||||||
|
onPointerUp: (e: PointerEvent) => void;
|
||||||
|
isPinching: () => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPinchGesture(opts: PinchGestureOptions): PinchGesture {
|
||||||
const { onPinch, onPinchEnd } = opts;
|
const { onPinch, onPinchEnd } = opts;
|
||||||
const pointers = new Map<number, PointerEvent>();
|
const pointers = new Map<number, PointerEvent>();
|
||||||
let initDist = 0, initMid = { x: 0, y: 0 };
|
let initDist = 0;
|
||||||
|
|
||||||
function dist(a: PointerEvent, b: PointerEvent) {
|
function pdist(a: PointerEvent, b: PointerEvent) {
|
||||||
const dx = a.clientX - b.clientX, dy = a.clientY - b.clientY;
|
const dx = a.clientX - b.clientX, dy = a.clientY - b.clientY;
|
||||||
return Math.sqrt(dx * dx + dy * dy);
|
return Math.sqrt(dx * dx + dy * dy);
|
||||||
}
|
}
|
||||||
function mid(a: PointerEvent, b: PointerEvent) {
|
function pmid(a: PointerEvent, b: PointerEvent) {
|
||||||
return { x: (a.clientX + b.clientX) / 2, y: (a.clientY + b.clientY) / 2 };
|
return { x: (a.clientX + b.clientX) / 2, y: (a.clientY + b.clientY) / 2 };
|
||||||
}
|
}
|
||||||
|
|
||||||
function down(e: PointerEvent) {
|
function onPointerDown(e: PointerEvent) {
|
||||||
pointers.set(e.pointerId, e);
|
pointers.set(e.pointerId, e);
|
||||||
node.setPointerCapture(e.pointerId);
|
|
||||||
if (pointers.size === 2) {
|
if (pointers.size === 2) {
|
||||||
const [a, b] = [...pointers.values()];
|
const [a, b] = [...pointers.values()];
|
||||||
initDist = dist(a, b);
|
initDist = pdist(a, b);
|
||||||
initMid = mid(a, b);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function move(e: PointerEvent) {
|
function onPointerMove(e: PointerEvent) {
|
||||||
if (!pointers.has(e.pointerId)) return;
|
if (!pointers.has(e.pointerId)) return;
|
||||||
pointers.set(e.pointerId, e);
|
pointers.set(e.pointerId, e);
|
||||||
if (pointers.size !== 2 || initDist === 0) return;
|
if (pointers.size !== 2 || initDist === 0) return;
|
||||||
const [a, b] = [...pointers.values()];
|
const [a, b] = [...pointers.values()];
|
||||||
onPinch(dist(a, b) / initDist, mid(a, b));
|
onPinch(pdist(a, b) / initDist, pmid(a, b));
|
||||||
}
|
}
|
||||||
function up(e: PointerEvent) {
|
function onPointerUp(e: PointerEvent) {
|
||||||
if (pointers.size === 2 && onPinchEnd) {
|
if (pointers.size === 2 && onPinchEnd) {
|
||||||
const [a, b] = [...pointers.values()];
|
const [a, b] = [...pointers.values()];
|
||||||
onPinchEnd(dist(a, b) / initDist);
|
onPinchEnd(pdist(a, b) / initDist);
|
||||||
}
|
}
|
||||||
pointers.delete(e.pointerId);
|
pointers.delete(e.pointerId);
|
||||||
initDist = 0;
|
initDist = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return { onPointerDown, onPointerMove, onPointerUp, isPinching: () => pointers.size >= 2 };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pinch(node: HTMLElement, opts: PinchOptions) {
|
||||||
|
const gesture = createPinchGesture(opts);
|
||||||
|
function down(e: PointerEvent) { node.setPointerCapture(e.pointerId); gesture.onPointerDown(e); }
|
||||||
node.addEventListener("pointerdown", down);
|
node.addEventListener("pointerdown", down);
|
||||||
node.addEventListener("pointermove", move);
|
node.addEventListener("pointermove", gesture.onPointerMove);
|
||||||
node.addEventListener("pointerup", up);
|
node.addEventListener("pointerup", gesture.onPointerUp);
|
||||||
node.addEventListener("pointercancel", up);
|
node.addEventListener("pointercancel", gesture.onPointerUp);
|
||||||
return { destroy() {
|
return { destroy() {
|
||||||
node.removeEventListener("pointerdown", down);
|
node.removeEventListener("pointerdown", down);
|
||||||
node.removeEventListener("pointermove", move);
|
node.removeEventListener("pointermove", gesture.onPointerMove);
|
||||||
node.removeEventListener("pointerup", up);
|
node.removeEventListener("pointerup", gesture.onPointerUp);
|
||||||
node.removeEventListener("pointercancel", up);
|
node.removeEventListener("pointercancel", gesture.onPointerUp);
|
||||||
}};
|
}};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,10 @@
|
|||||||
return new Date(d + "T00:00:00").toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
return new Date(d + "T00:00:00").toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function localDateStr(d: Date): string {
|
||||||
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
let wrapEl: HTMLElement;
|
let wrapEl: HTMLElement;
|
||||||
let cellSize = $state(12);
|
let cellSize = $state(12);
|
||||||
let numWeeks = $state(26);
|
let numWeeks = $state(26);
|
||||||
@@ -55,7 +59,7 @@
|
|||||||
const visibleWeeks = $derived((() => {
|
const visibleWeeks = $derived((() => {
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
today.setHours(0, 0, 0, 0);
|
today.setHours(0, 0, 0, 0);
|
||||||
const todayStr = today.toISOString().slice(0, 10);
|
const todayStr = localDateStr(today);
|
||||||
const endDow = today.getDay(); // 0=Sun ... 6=Sat
|
const endDow = today.getDay(); // 0=Sun ... 6=Sat
|
||||||
const weekEnd = new Date(today);
|
const weekEnd = new Date(today);
|
||||||
weekEnd.setDate(weekEnd.getDate() + (6 - endDow)); // advance to Saturday
|
weekEnd.setDate(weekEnd.getDate() + (6 - endDow)); // advance to Saturday
|
||||||
@@ -66,7 +70,7 @@
|
|||||||
for (let di = 0; di < 7; di++) {
|
for (let di = 0; di < 7; di++) {
|
||||||
const d = new Date(weekEnd);
|
const d = new Date(weekEnd);
|
||||||
d.setDate(d.getDate() - wi * 7 - (6 - di));
|
d.setDate(d.getDate() - wi * 7 - (6 - di));
|
||||||
const dateStr = d.toISOString().slice(0, 10);
|
const dateStr = localDateStr(d);
|
||||||
week.push({ dateStr, count: dailyReadCounts[dateStr] ?? 0, isToday: dateStr === todayStr, isFuture: d > today });
|
week.push({ dateStr, count: dailyReadCounts[dateStr] ?? 0, isToday: dateStr === todayStr, isFuture: d > today });
|
||||||
}
|
}
|
||||||
weeks.push(week);
|
weeks.push(week);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { createPinchGesture } from "@core/ui/touchscreen";
|
||||||
import { clampZoom } from "./zoomHelpers";
|
import { clampZoom } from "./zoomHelpers";
|
||||||
import { ZOOM_MIN, ZOOM_MAX } from "../store/readerState.svelte";
|
import { ZOOM_MIN, ZOOM_MAX } from "../store/readerState.svelte";
|
||||||
|
|
||||||
@@ -10,69 +11,33 @@ export interface PinchTrackerOptions {
|
|||||||
isLongstrip: () => boolean;
|
isLongstrip: () => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PinchTracker {
|
export type { PinchGesture as PinchTracker } from "@core/ui/touchscreen";
|
||||||
onPointerDown: (e: PointerEvent) => void;
|
|
||||||
onPointerMove: (e: PointerEvent) => void;
|
|
||||||
onPointerUp: (e: PointerEvent) => void;
|
|
||||||
isPinching: () => boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const INSPECT_ZOOM_MAX = 8;
|
const INSPECT_ZOOM_MAX = 8;
|
||||||
|
|
||||||
export function createPinchTracker(opts: PinchTrackerOptions): PinchTracker {
|
export function createPinchTracker(opts: PinchTrackerOptions) {
|
||||||
const pointers = new Map<number, { x: number; y: number }>();
|
|
||||||
let startDist = 0;
|
|
||||||
let startZoom = 0;
|
let startZoom = 0;
|
||||||
let startInspect = 0;
|
let startInspect = 0;
|
||||||
let pinching = false;
|
|
||||||
|
|
||||||
function dist(a: { x: number; y: number }, b: { x: number; y: number }): number {
|
return createPinchGesture({
|
||||||
return Math.hypot(b.x - a.x, b.y - a.y);
|
onPinch(scale) {
|
||||||
}
|
if (startZoom === 0) {
|
||||||
|
|
||||||
function onPointerDown(e: PointerEvent) {
|
|
||||||
pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
|
||||||
if (pointers.size === 2) {
|
|
||||||
const [a, b] = [...pointers.values()];
|
|
||||||
startDist = dist(a, b);
|
|
||||||
startZoom = opts.getZoom();
|
startZoom = opts.getZoom();
|
||||||
startInspect = opts.getInspectScale();
|
startInspect = opts.getInspectScale();
|
||||||
pinching = true;
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
function onPointerMove(e: PointerEvent) {
|
|
||||||
if (!pinching || !pointers.has(e.pointerId)) return;
|
|
||||||
pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
|
||||||
if (pointers.size < 2) return;
|
|
||||||
|
|
||||||
const [a, b] = [...pointers.values()];
|
|
||||||
const current = dist(a, b);
|
|
||||||
if (startDist === 0) return;
|
|
||||||
const ratio = current / startDist;
|
|
||||||
|
|
||||||
if (opts.isLongstrip()) {
|
if (opts.isLongstrip()) {
|
||||||
opts.setZoom(clampZoom(startZoom * ratio));
|
opts.setZoom(clampZoom(startZoom * scale));
|
||||||
} else {
|
} else {
|
||||||
const next = Math.max(1, Math.min(INSPECT_ZOOM_MAX, startInspect * ratio));
|
const next = Math.max(1, Math.min(INSPECT_ZOOM_MAX, startInspect * scale));
|
||||||
if (next !== opts.getInspectScale()) {
|
if (next !== opts.getInspectScale()) {
|
||||||
if (next === 1) opts.resetInspectPan();
|
if (next === 1) opts.resetInspectPan();
|
||||||
opts.setInspectScale(next);
|
opts.setInspectScale(next);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
onPinchEnd() {
|
||||||
function onPointerUp(e: PointerEvent) {
|
|
||||||
pointers.delete(e.pointerId);
|
|
||||||
if (pointers.size < 2) {
|
|
||||||
pinching = false;
|
|
||||||
startDist = 0;
|
|
||||||
startZoom = 0;
|
startZoom = 0;
|
||||||
startInspect = 0;
|
startInspect = 0;
|
||||||
}
|
},
|
||||||
}
|
});
|
||||||
|
|
||||||
function isPinching() { return pinching; }
|
|
||||||
|
|
||||||
return { onPointerDown, onPointerMove, onPointerUp, isPinching };
|
|
||||||
}
|
}
|
||||||
@@ -1,14 +1,111 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Trash, ClockCounterClockwise } from "phosphor-svelte";
|
import { Trash, ClockCounterClockwise } from "phosphor-svelte";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { listen } from "@tauri-apps/api/event";
|
|
||||||
import { gql } from "@api/client";
|
import { gql } from "@api/client";
|
||||||
import { GET_DOWNLOADS_PATH, GET_RESTORE_STATUS, VALIDATE_BACKUP } from "@api/queries/manga";
|
import { GET_DOWNLOADS_PATH, GET_RESTORE_STATUS } from "@api/queries/manga";
|
||||||
import { CREATE_BACKUP, RESTORE_BACKUP } from "@api/mutations/manga";
|
import { CREATE_BACKUP } from "@api/mutations/manga";
|
||||||
import { SET_DOWNLOADS_PATH, SET_LOCAL_SOURCE_PATH } from "@api/mutations/downloads";
|
import { SET_DOWNLOADS_PATH, SET_LOCAL_SOURCE_PATH } from "@api/mutations/downloads";
|
||||||
import { untrack } from "svelte";
|
import { untrack } from "svelte";
|
||||||
import { store, updateSettings, addToast } from "@store/state.svelte";
|
import { store, updateSettings, addToast } from "@store/state.svelte";
|
||||||
import { exportAppData, importAppData } from "@core/backup";
|
import { exportAppData, importAppData } from "@core/backup";
|
||||||
|
import { loadBackups, persistBackups, persistSettings, persistLibrary } from "@core/persistence/persist";
|
||||||
|
import type { BackupEntry } from "@core/persistence/persist";
|
||||||
|
import { DEFAULT_SETTINGS } from "@types/settings";
|
||||||
|
import { DEFAULT_READING_STATS } from "@types/history";
|
||||||
|
|
||||||
|
type ResetState = "idle" | "busy" | "done" | "error";
|
||||||
|
interface ResetItem { key: string; label: string; desc: string; state: ResetState; error: string | null; confirm: boolean; }
|
||||||
|
|
||||||
|
let resetItems = $state<ResetItem[]>([
|
||||||
|
{ key: "moku-cache", label: "Clear Moku cache", desc: "Removes image cache and temporary files stored by Moku.", state: "idle", error: null, confirm: false },
|
||||||
|
{ key: "suwayomi-cache", label: "Clear Suwayomi cache", desc: "Deletes the Suwayomi cache and KCEF directories inside the data folder.", state: "idle", error: null, confirm: false },
|
||||||
|
{ key: "reading-history", label: "Clear reading history", desc: "Erases chapter history, read log, reading stats, and daily read counts.", state: "idle", error: null, confirm: true },
|
||||||
|
{ key: "moku-settings", label: "Reset Moku settings", desc: "Restores all app settings to their defaults. Does not affect library data.", state: "idle", error: null, confirm: true },
|
||||||
|
{ key: "suwayomi-data", label: "Reset Suwayomi data", desc: "Deletes the database, extensions, settings, and logs. Downloads and backups are preserved.", state: "idle", error: null, confirm: true },
|
||||||
|
]);
|
||||||
|
|
||||||
|
let confirming = $state<string | null>(null);
|
||||||
|
|
||||||
|
function patchReset(key: string, update: Partial<ResetItem>) {
|
||||||
|
resetItems = resetItems.map(i => i.key === key ? { ...i, ...update } : i);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showExitCountdown(): Promise<void> {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
const backdrop = document.createElement("div");
|
||||||
|
backdrop.className = "s-backdrop";
|
||||||
|
backdrop.style.cssText = "z-index:99999";
|
||||||
|
const modal = document.createElement("div");
|
||||||
|
modal.style.cssText = "background:var(--bg-surface);border:1px solid var(--border-base);border-radius:var(--radius-2xl);box-shadow:0 0 0 1px rgba(255,255,255,0.04) inset,0 24px 80px rgba(0,0,0,0.7);width:min(400px,calc(100vw - 40px));display:flex;flex-direction:column;overflow:hidden;animation:s-scale-in 0.2s cubic-bezier(0.16,1,0.3,1) both";
|
||||||
|
const header = document.createElement("div");
|
||||||
|
header.style.cssText = "padding:var(--sp-4) var(--sp-5) var(--sp-3);border-bottom:1px solid var(--border-dim)";
|
||||||
|
const title = document.createElement("p");
|
||||||
|
title.style.cssText = "margin:0;font-size:var(--text-sm);font-weight:var(--weight-medium);color:var(--text-primary);letter-spacing:0.01em";
|
||||||
|
title.textContent = "Reset complete";
|
||||||
|
header.appendChild(title);
|
||||||
|
const body = document.createElement("div");
|
||||||
|
body.style.cssText = "padding:var(--sp-4) var(--sp-5);display:flex;flex-direction:column;gap:var(--sp-2)";
|
||||||
|
const sub = document.createElement("p");
|
||||||
|
sub.style.cssText = "margin:0;font-family:var(--font-ui);font-size:var(--text-xs);color:var(--text-faint);letter-spacing:var(--tracking-wide);line-height:var(--leading-snug)";
|
||||||
|
sub.textContent = "Moku will close so you can relaunch with the reset applied.";
|
||||||
|
const counter = document.createElement("p");
|
||||||
|
counter.style.cssText = "margin:0;font-family:var(--font-ui);font-size:var(--text-xs);color:var(--text-faint);letter-spacing:var(--tracking-wide)";
|
||||||
|
counter.textContent = "Closing in 3…";
|
||||||
|
body.append(sub, counter);
|
||||||
|
const footer = document.createElement("div");
|
||||||
|
footer.style.cssText = "padding:var(--sp-3) var(--sp-5);border-top:1px solid var(--border-dim);display:flex;justify-content:flex-end";
|
||||||
|
const btn = document.createElement("button");
|
||||||
|
btn.className = "s-btn s-btn-danger";
|
||||||
|
btn.textContent = "Close now";
|
||||||
|
footer.appendChild(btn);
|
||||||
|
modal.append(header, body, footer);
|
||||||
|
backdrop.appendChild(modal);
|
||||||
|
document.body.appendChild(backdrop);
|
||||||
|
let secs = 3;
|
||||||
|
const tick = setInterval(() => {
|
||||||
|
secs--;
|
||||||
|
counter.textContent = secs > 0 ? `Closing in ${secs}…` : "Closing…";
|
||||||
|
if (secs <= 0) { clearInterval(tick); backdrop.remove(); resolve(); }
|
||||||
|
}, 1000);
|
||||||
|
btn.addEventListener("click", () => { clearInterval(tick); backdrop.remove(); resolve(); });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runReset(key: string) {
|
||||||
|
confirming = null;
|
||||||
|
patchReset(key, { state: "busy", error: null });
|
||||||
|
try {
|
||||||
|
switch (key) {
|
||||||
|
case "moku-cache":
|
||||||
|
await invoke("clear_moku_cache");
|
||||||
|
break;
|
||||||
|
case "suwayomi-cache":
|
||||||
|
await invoke("clear_suwayomi_cache");
|
||||||
|
break;
|
||||||
|
case "reading-history":
|
||||||
|
store.clearHistory();
|
||||||
|
await persistLibrary({ history: [], bookmarks: store.bookmarks, markers: store.markers, readLog: [], readingStats: DEFAULT_READING_STATS, dailyReadCounts: {} });
|
||||||
|
break;
|
||||||
|
case "moku-settings":
|
||||||
|
store.hydrate({ settings: DEFAULT_SETTINGS } as any);
|
||||||
|
await persistSettings({ settings: DEFAULT_SETTINGS, storeVersion: 1 });
|
||||||
|
patchReset(key, { state: "done" });
|
||||||
|
await showExitCountdown();
|
||||||
|
invoke("exit_app");
|
||||||
|
return;
|
||||||
|
case "suwayomi-data":
|
||||||
|
await invoke("reset_suwayomi_data");
|
||||||
|
patchReset(key, { state: "done" });
|
||||||
|
await showExitCountdown();
|
||||||
|
invoke("exit_app");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
patchReset(key, { state: "done" });
|
||||||
|
setTimeout(() => patchReset(key, { state: "idle" }), 3000);
|
||||||
|
} catch (e: any) {
|
||||||
|
patchReset(key, { state: "error", error: e?.message ?? String(e) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface StorageInfo { manga_bytes: number; total_bytes: number; free_bytes: number; path: string; }
|
interface StorageInfo { manga_bytes: number; total_bytes: number; free_bytes: number; path: string; }
|
||||||
|
|
||||||
@@ -56,6 +153,7 @@
|
|||||||
let advStorageOpen = $state(false);
|
let advStorageOpen = $state(false);
|
||||||
let backupSectionOpen = $state(false);
|
let backupSectionOpen = $state(false);
|
||||||
let appDataSectionOpen = $state(false);
|
let appDataSectionOpen = $state(false);
|
||||||
|
let resetSectionOpen = $state(false);
|
||||||
|
|
||||||
async function fetchStorage() {
|
async function fetchStorage() {
|
||||||
storageLoading = true; storageError = null;
|
storageLoading = true; storageError = null;
|
||||||
@@ -182,13 +280,14 @@
|
|||||||
|
|
||||||
let backupLoading = $state(false);
|
let backupLoading = $state(false);
|
||||||
let backupError = $state<string | null>(null);
|
let backupError = $state<string | null>(null);
|
||||||
let backupList = $state<{ url: string; name: string; deleting?: boolean }[]>([]);
|
let backupList = $state<(BackupEntry & { deleting?: boolean })[]>([]);
|
||||||
|
|
||||||
function loadBackupList() {
|
async function loadBackupList() {
|
||||||
try { backupList = JSON.parse(localStorage.getItem("moku_backups") ?? "[]"); } catch { backupList = []; }
|
backupList = (await loadBackups()).map(b => ({ ...b }));
|
||||||
}
|
}
|
||||||
function saveBackupList() {
|
|
||||||
try { localStorage.setItem("moku_backups", JSON.stringify(backupList)); } catch {}
|
async function saveBackupList() {
|
||||||
|
await persistBackups(backupList.map(({ url, name }) => ({ url, name })));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createBackup() {
|
async function createBackup() {
|
||||||
@@ -197,7 +296,8 @@
|
|||||||
const res = await gql<{ createBackup: { url: string } }>(CREATE_BACKUP);
|
const res = await gql<{ createBackup: { url: string } }>(CREATE_BACKUP);
|
||||||
const url = res.createBackup.url;
|
const url = res.createBackup.url;
|
||||||
const name = url.split("/").pop() ?? url;
|
const name = url.split("/").pop() ?? url;
|
||||||
backupList = [{ url, name }, ...backupList]; saveBackupList();
|
backupList = [{ url, name }, ...backupList];
|
||||||
|
await saveBackupList();
|
||||||
} catch (e: any) { backupError = e?.message ?? "Failed to create backup"; }
|
} catch (e: any) { backupError = e?.message ?? "Failed to create backup"; }
|
||||||
finally { backupLoading = false; }
|
finally { backupLoading = false; }
|
||||||
}
|
}
|
||||||
@@ -206,26 +306,19 @@
|
|||||||
backupList = backupList.map(b => b.url === url ? { ...b, deleting: true } : b);
|
backupList = backupList.map(b => b.url === url ? { ...b, deleting: true } : b);
|
||||||
try {
|
try {
|
||||||
const serverUrl = (store.settings.serverUrl ?? "http://localhost:4567").replace(/\/$/, "");
|
const serverUrl = (store.settings.serverUrl ?? "http://localhost:4567").replace(/\/$/, "");
|
||||||
const headers: Record<string, string> = {};
|
await fetch(`${serverUrl}${url}`, { method: "DELETE", headers: buildAuthHeaders() });
|
||||||
const pass = store.settings.serverAuthPass ?? "", user = store.settings.serverAuthUser ?? "";
|
backupList = backupList.filter(b => b.url !== url);
|
||||||
if (store.settings.serverAuthMode === "BASIC_AUTH" && user && pass)
|
await saveBackupList();
|
||||||
headers["Authorization"] = "Basic " + btoa(`${user}:${pass}`);
|
|
||||||
await fetch(`${serverUrl}${url}`, { method: "DELETE", headers });
|
|
||||||
backupList = backupList.filter(b => b.url !== url); saveBackupList();
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
backupList = backupList.map(b => b.url === url ? { ...b, deleting: false } : b);
|
backupList = backupList.map(b => b.url === url ? { ...b, deleting: false } : b);
|
||||||
backupError = (e as any)?.message ?? "Failed to delete backup";
|
backupError = e?.message ?? "Failed to delete backup";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function downloadBackup(backup: { url: string; name: string }) {
|
async function downloadBackup(backup: BackupEntry) {
|
||||||
try {
|
try {
|
||||||
const serverUrl = (store.settings.serverUrl ?? "http://localhost:4567").replace(/\/$/, "");
|
const serverUrl = (store.settings.serverUrl ?? "http://localhost:4567").replace(/\/$/, "");
|
||||||
const headers: Record<string, string> = {};
|
const resp = await fetch(`${serverUrl}${backup.url}`, { headers: buildAuthHeaders() });
|
||||||
const pass = store.settings.serverAuthPass ?? "", user = store.settings.serverAuthUser ?? "";
|
|
||||||
if (store.settings.serverAuthMode === "BASIC_AUTH" && user && pass)
|
|
||||||
headers["Authorization"] = "Basic " + btoa(`${user}:${pass}`);
|
|
||||||
const resp = await fetch(`${serverUrl}${backup.url}`, { headers });
|
|
||||||
if (!resp.ok) throw new Error(`Server returned ${resp.status}`);
|
if (!resp.ok) throw new Error(`Server returned ${resp.status}`);
|
||||||
const blob = await resp.blob();
|
const blob = await resp.blob();
|
||||||
if ("showSaveFilePicker" in window) {
|
if ("showSaveFilePicker" in window) {
|
||||||
@@ -683,7 +776,7 @@
|
|||||||
<div class="s-row">
|
<div class="s-row">
|
||||||
<div class="s-row-info">
|
<div class="s-row-info">
|
||||||
<span class="s-label">Export settings</span>
|
<span class="s-label">Export settings</span>
|
||||||
<span class="s-desc">Save all Moku app settings to a JSON file via a native save dialog.</span>
|
<span class="s-desc">Save all Moku app settings to a .zip via a native save dialog.</span>
|
||||||
</div>
|
</div>
|
||||||
<button class="s-btn s-btn-accent" onclick={handleExportAppData} disabled={appDataExporting}>
|
<button class="s-btn s-btn-accent" onclick={handleExportAppData} disabled={appDataExporting}>
|
||||||
{appDataExporting ? "Saving…" : "Export"}
|
{appDataExporting ? "Saving…" : "Export"}
|
||||||
@@ -693,7 +786,7 @@
|
|||||||
<div class="s-row">
|
<div class="s-row">
|
||||||
<div class="s-row-info">
|
<div class="s-row-info">
|
||||||
<span class="s-label">Import settings</span>
|
<span class="s-label">Import settings</span>
|
||||||
<span class="s-desc">Restore from a previously exported JSON file. Reloads the app immediately.</span>
|
<span class="s-desc">Restore from a previously exported .zip file. Reloads the app immediately.</span>
|
||||||
</div>
|
</div>
|
||||||
<button class="s-btn" onclick={handleImportAppData} disabled={appDataImporting}>
|
<button class="s-btn" onclick={handleImportAppData} disabled={appDataImporting}>
|
||||||
{appDataImporting ? "Importing…" : "Import"}
|
{appDataImporting ? "Importing…" : "Import"}
|
||||||
@@ -724,4 +817,41 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="s-section">
|
||||||
|
<button class="s-collapsible-trigger" onclick={() => resetSectionOpen = !resetSectionOpen}>
|
||||||
|
<span class="s-label">Reset</span>
|
||||||
|
<svg class="s-collapsible-caret" class:open={resetSectionOpen} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
|
||||||
|
</button>
|
||||||
|
{#if resetSectionOpen}
|
||||||
|
<div class="s-collapsible-body">
|
||||||
|
{#each resetItems as item}
|
||||||
|
<div class="s-row">
|
||||||
|
<div class="s-row-info">
|
||||||
|
<span class="s-label">{item.label}</span>
|
||||||
|
<span class="s-desc">{item.desc}</span>
|
||||||
|
{#if item.error}<span class="s-desc" style="color:var(--color-error)">{item.error}</span>{/if}
|
||||||
|
</div>
|
||||||
|
<div class="s-btn-row">
|
||||||
|
{#if item.state === "done"}
|
||||||
|
<span class="s-pill on">Done</span>
|
||||||
|
{:else if item.state === "busy"}
|
||||||
|
<button class="s-btn" disabled>Working…</button>
|
||||||
|
{:else if confirming === item.key}
|
||||||
|
<span class="s-desc" style="color:var(--text-muted)">Sure?</span>
|
||||||
|
<button class="s-btn s-btn-danger" onclick={() => runReset(item.key)}>Confirm</button>
|
||||||
|
<button class="s-btn" onclick={() => confirming = null}>Cancel</button>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
class="s-btn"
|
||||||
|
class:s-btn-danger={item.confirm}
|
||||||
|
onclick={() => item.confirm ? (confirming = item.key) : runReset(item.key)}
|
||||||
|
>Reset</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -11,6 +11,10 @@ import { app } from "./ap
|
|||||||
import { persistSettings, persistLibrary, persistUpdates } from "../core/persistence/persist";
|
import { persistSettings, persistLibrary, persistUpdates } from "../core/persistence/persist";
|
||||||
import type { PersistedData } from "../core/persistence/persist";
|
import type { PersistedData } from "../core/persistence/persist";
|
||||||
|
|
||||||
|
function localDateStr(d: Date): string {
|
||||||
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
export type { NavPage } from "./app.svelte";
|
export type { NavPage } from "./app.svelte";
|
||||||
export type { Toast, ActiveDownload } from "./notifications.svelte";
|
export type { Toast, ActiveDownload } from "./notifications.svelte";
|
||||||
export type { Settings, ReaderSettings, ReaderPreset, CustomTheme,
|
export type { Settings, ReaderSettings, ReaderPreset, CustomTheme,
|
||||||
@@ -158,9 +162,9 @@ class Store {
|
|||||||
const uniqueChapters = new Set(this.readLog.map(e => e.chapterId));
|
const uniqueChapters = new Set(this.readLog.map(e => e.chapterId));
|
||||||
const uniqueManga = new Set(this.readLog.map(e => e.mangaId));
|
const uniqueManga = new Set(this.readLog.map(e => e.mangaId));
|
||||||
const totalMinutes = this.readLog.reduce((sum, e) => sum + e.minutes, 0);
|
const totalMinutes = this.readLog.reduce((sum, e) => sum + e.minutes, 0);
|
||||||
const todayStr = new Date().toISOString().slice(0, 10);
|
const todayStr = localDateStr(new Date());
|
||||||
const yesterday = new Date(); yesterday.setDate(yesterday.getDate() - 1);
|
const yesterday = new Date(); yesterday.setDate(yesterday.getDate() - 1);
|
||||||
const yesterdayStr = yesterday.toISOString().slice(0, 10);
|
const yesterdayStr = localDateStr(yesterday);
|
||||||
const lastDate = this.readingStats.lastStreakDate;
|
const lastDate = this.readingStats.lastStreakDate;
|
||||||
const streak = lastDate === todayStr ? this.readingStats.currentStreakDays
|
const streak = lastDate === todayStr ? this.readingStats.currentStreakDays
|
||||||
: lastDate === yesterdayStr ? this.readingStats.currentStreakDays + 1 : 1;
|
: lastDate === yesterdayStr ? this.readingStats.currentStreakDays + 1 : 1;
|
||||||
@@ -170,7 +174,7 @@ class Store {
|
|||||||
lastReadAt: entry.readAt, currentStreakDays: streak,
|
lastReadAt: entry.readAt, currentStreakDays: streak,
|
||||||
longestStreakDays: Math.max(this.readingStats.longestStreakDays, streak), lastStreakDate: todayStr,
|
longestStreakDays: Math.max(this.readingStats.longestStreakDays, streak), lastStreakDate: todayStr,
|
||||||
};
|
};
|
||||||
const dayKey = new Date().toISOString().slice(0, 10);
|
const dayKey = localDateStr(new Date());
|
||||||
this.dailyReadCounts = { ...this.dailyReadCounts, [dayKey]: (this.dailyReadCounts[dayKey] ?? 0) + 1 };
|
this.dailyReadCounts = { ...this.dailyReadCounts, [dayKey]: (this.dailyReadCounts[dayKey] ?? 0) + 1 };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user