diff --git a/apps/server/package.json b/apps/server/package.json index e708754e..d424fe49 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -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": { diff --git a/apps/server/src/upload/upload-router.ts b/apps/server/src/upload/upload-router.ts index 57a483f8..9d4ba63a 100644 --- a/apps/server/src/upload/upload-router.ts +++ b/apps/server/src/upload/upload-router.ts @@ -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) => { @@ -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; @@ -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' }); + } } }); diff --git a/apps/web/src/components/navbar/navbar.tsx b/apps/web/src/components/navbar/navbar.tsx index 67045f92..8da4dced 100644 --- a/apps/web/src/components/navbar/navbar.tsx +++ b/apps/web/src/components/navbar/navbar.tsx @@ -14,6 +14,7 @@ import { IconMoon, IconReload, IconSun, + IconCloud, } from '@tabler/icons-react'; import { JSX, useState } from 'react'; import { NavLink, useLocation } from 'react-router'; @@ -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(); @@ -84,6 +86,7 @@ export function Navbar({ onNavigate }: { onNavigate?: () => void }): JSX.Element
+ 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 ( + +
+ + setUrl(e.currentTarget.value)} + required + /> + + Remote Folder + + + + + + + } + placeholder="koreader_statistics" + value={folder} + onChange={(e) => setFolder(e.currentTarget.value)} + /> + setUsername(e.currentTarget.value)} + /> + setPassword(e.currentTarget.value)} + /> + + + + + +
+
+ ); +} \ No newline at end of file diff --git a/apps/web/src/components/upload/web-dav-sync-button.tsx b/apps/web/src/components/upload/web-dav-sync-button.tsx new file mode 100644 index 00000000..00f7fbbe --- /dev/null +++ b/apps/web/src/components/upload/web-dav-sync-button.tsx @@ -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(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: , + 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 ( + <> + + + + + setModalOpened(false)} + loading={loading} + initialConfig={config} + onSubmit={handleModalSubmit} + /> + + ); +} \ No newline at end of file