Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"openai": "6.5.0",
"ramda": "0.31.1",
"sqlite3": "^5.1.7",
"webdav": "^5.8.0",
"zod": "4.1.12"
},
"devDependencies": {
Expand Down
68 changes: 54 additions & 14 deletions apps/server/src/upload/upload-router.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import { Router } from 'express';
import { unlinkSync } from 'fs';
import multer from 'multer';
import path from 'path';
import { appConfig } from '../config';
import { UploadService } from './upload-service';
import { createClient } from 'webdav';
import { createWriteStream } from "fs";
import { pipeline } from "stream/promises";

const storage = multer.diskStorage({
destination: (_req, _res, cb) => {
Expand All @@ -27,6 +31,29 @@ const upload = multer({

const router = Router();

async function processDbFile(filePath: string, res: any) {
let db;
try {
db = UploadService.openStatisticsDbFile(filePath);
} catch (err) {
console.error(err);
res.status(400).json({ error: "Invalid SQLite file or no books found" });
return;
}

try {
const { newBooks, newPageStats } = UploadService.extractDataFromStatisticsDb(db);
await UploadService.uploadStatisticData(newBooks, newPageStats);
res.json({ message: "Database imported successfully" });
} catch (err) {
console.error(err);
res.status(500).json({ error: "Failed to import database" });
} finally {
db.close();
unlinkSync(filePath);
}
}

router.post('/', upload.single('file'), async (req, res, next) => {
const uploadedFilePath = req.file?.path;

Expand All @@ -35,27 +62,40 @@ router.post('/', upload.single('file'), async (req, res, next) => {
next();
return;
}
await processDbFile(uploadedFilePath, res);
});

let db;
try {
db = UploadService.openStatisticsDbFile(uploadedFilePath);
} catch (err) {
console.error(err);
res.status(400).json({ error: 'Invalid SQLite file or no books found' });
return;

router.post('/from-webdav', async (req, res) => {
const { url, folder, username, password } = req.body ?? {};

if (!url) {
return res.status(400).json({ error: 'Missing url' });
}

const webdavClient = createClient(url, {
username,
password,
});

const localPath = path.join(appConfig.dataPath, appConfig.upload.filename);

try {
const { newBooks, newPageStats } = UploadService.extractDataFromStatisticsDb(db);
await UploadService.uploadStatisticData(newBooks, newPageStats);
const sqlPath = folder?.trim() ? `/${folder.replace(/^\/+|\/+$/g, '')}/statistics.sqlite3` : '/statistics.sqlite3';
const readStream = await webdavClient.createReadStream(sqlPath);
const writeStream = createWriteStream(localPath);
await pipeline(readStream, writeStream);

res.json({ message: 'Database imported successfully' });
await processDbFile(localPath, res);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Failed to import database' });
} finally {
db.close();
unlinkSync(uploadedFilePath);
try {
unlinkSync(localPath);
} catch {}

if (!res.headersSent) {
res.status(500).json({ error: 'Failed to import database from WebDAV' });
}
}
});

Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/components/navbar/navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
IconMoon,
IconReload,
IconSun,
IconCloud,
} from '@tabler/icons-react';
import { JSX, useState } from 'react';
import { NavLink, useLocation } from 'react-router';
Expand All @@ -23,6 +24,7 @@ import { DownloadPluginModal } from './download-plugin';
import { UploadForm } from './upload-form';

import style from './navbar.module.css';
import { WebDavSyncButton } from '../upload/web-dav-sync-button';

export function Navbar({ onNavigate }: { onNavigate?: () => void }): JSX.Element {
const { pathname } = useLocation();
Expand Down Expand Up @@ -84,6 +86,7 @@ export function Navbar({ onNavigate }: { onNavigate?: () => void }): JSX.Element
<div className={style.Footer}>
<Flex gap="xs">
<UploadForm />
<WebDavSyncButton />
<ActionIcon
onClick={toggleColorScheme}
variant="default"
Expand Down
109 changes: 109 additions & 0 deletions apps/web/src/components/upload/web-dav-connect.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { FormEvent, useEffect, useState } from 'react';
import {
ActionIcon,
Button,
Group,
Modal,
PasswordInput,
Stack,
TextInput,
Tooltip,
} from '@mantine/core';
import { showNotification } from '@mantine/notifications';
import { IconCloud, IconInfoCircle} from '@tabler/icons-react';

type WebDavConnectModalProps = {
opened: boolean;
onClose: () => void;
loading: boolean;
initialConfig?: WebdavConfig | null;
onSubmit: (config: WebdavConfig) => void;
};

export type WebdavConfig = {
url: string;
folder: string;
username?: string;
password?: string;
};

export function WebDavConnectModal({ opened, onClose, loading, initialConfig, onSubmit}: WebDavConnectModalProps) {
const [url, setUrl] = useState('');
const [folder, setFolder] = useState('');
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');

useEffect(() => {
if (opened) {
setUrl(initialConfig?.url ?? '');
setFolder(initialConfig?.folder ?? '');
setUsername(initialConfig?.username ?? '');
setPassword(initialConfig?.password ?? '');
}
}, [opened, initialConfig]);


const handleSubmit = (e: FormEvent) => {
e.preventDefault();
onSubmit({
url: url.trim(),
folder: folder.trim(),
username: username.trim() || undefined,
password: password || undefined,
});
};

return (
<Modal
opened={opened}
onClose={onClose}
title="Sync from WebDAV"
centered
>
<form onSubmit={handleSubmit}>
<Stack>
<TextInput
label="WebDAV Address"
placeholder="https://example.com/dav"
value={url}
onChange={(e) => setUrl(e.currentTarget.value)}
required
/>
<TextInput
label={
<Group gap={4}>
Remote Folder
<Tooltip label="The remote folder that holds the database.">
<ActionIcon variant="subtle" size="sm" color="gray">
<IconInfoCircle size={14} />
</ActionIcon>
</Tooltip>
</Group>
}
placeholder="koreader_statistics"
value={folder}
onChange={(e) => setFolder(e.currentTarget.value)}
/>
<TextInput
label="Username"
value={username}
onChange={(e) => setUsername(e.currentTarget.value)}
/>
<PasswordInput
label="Password"
value={password}
onChange={(e) => setPassword(e.currentTarget.value)}
/>
<Group justify="flex-end" mt="md">
<Button variant="default" onClick={onClose}>
Cancel
</Button>
<Button type="submit" loading={loading}>
Connect
</Button>
</Group>
</Stack>
</form>
</Modal>
);
}
82 changes: 82 additions & 0 deletions apps/web/src/components/upload/web-dav-sync-button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { useState } from 'react';
import { ActionIcon } from '@mantine/core';
import { showNotification } from '@mantine/notifications';
import { IconCloud } from '@tabler/icons-react';
import { WebDavConnectModal, WebdavConfig } from './web-dav-connect';

export function WebDavSyncButton() {
const [config, setConfig] = useState<WebdavConfig | null>(null);
const [modalOpened, setModalOpened] = useState(false);
const [loading, setLoading] = useState(false);

const syncWithConfig = async (cfg: WebdavConfig) => {
setLoading(true);
try {
const res = await fetch('/api/upload/from-webdav', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(cfg),
});

if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(text || `Request failed with status ${res.status}`);
}

showNotification({
title: 'Successfully Synced',
message: 'Successfully imported database from WebDAV.',
icon: <IconCloud size={18} />,
color: 'green',
});
} catch (err: any) {
showNotification({
title: 'Sync Failed',
message:
err?.message ??
'Failed to connect to WebDAV server or import database.',
color: 'red',
});

setModalOpened(true);
} finally {
setLoading(false);
}
};

const handleClick = () => {
if (!config) {
setModalOpened(true);
} else {
void syncWithConfig(config);
}
};

const handleModalSubmit = (cfg: WebdavConfig) => {
setConfig(cfg);
setModalOpened(false);
void syncWithConfig(cfg);
};

return (
<>
<ActionIcon
variant="default"
size="lg"
aria-label="WebDAV"
loading={loading}
onClick={handleClick}
>
<IconCloud stroke={1.5} />
</ActionIcon>

<WebDavConnectModal
opened={modalOpened}
onClose={() => setModalOpened(false)}
loading={loading}
initialConfig={config}
onSubmit={handleModalSubmit}
/>
</>
);
}