mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 01:09:56 -05:00
Chore: GQL Cleanup P.3
This commit is contained in:
@@ -76,7 +76,7 @@
|
|||||||
let entries = 0, oldest: number | null = null, newest: number | null = null
|
let entries = 0, oldest: number | null = null, newest: number | null = null
|
||||||
const foundKeys: string[] = []
|
const foundKeys: string[] = []
|
||||||
const checkKey = (k: string) => {
|
const checkKey = (k: string) => {
|
||||||
const age = cache.ageOf(k)
|
const age = cache?.ageOf?.(k)
|
||||||
if (age !== undefined) {
|
if (age !== undefined) {
|
||||||
entries++
|
entries++
|
||||||
foundKeys.push(k)
|
foundKeys.push(k)
|
||||||
@@ -85,7 +85,7 @@
|
|||||||
if (newest === null || ts > newest) newest = ts
|
if (newest === null || ts > newest) newest = ts
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
['library', 'sources', 'popular'].forEach(checkKey)
|
['library', 'sources', 'popular'].forEach(checkKey);
|
||||||
['Action','Romance','Fantasy','Comedy','Drama','Horror','Sci-Fi','Adventure','Thriller',
|
['Action','Romance','Fantasy','Comedy','Drama','Horror','Sci-Fi','Adventure','Thriller',
|
||||||
'Isekai','Supernatural','Historical','Psychological','Sports','Mystery','Mecha',
|
'Isekai','Supernatural','Historical','Psychological','Sports','Mystery','Mecha',
|
||||||
'Slice of Life','School Life','Martial Arts','Magic','Military'].forEach(g => checkKey(`genre:${g}`))
|
'Slice of Life','School Life','Martial Arts','Magic','Military'].forEach(g => checkKey(`genre:${g}`))
|
||||||
@@ -104,7 +104,7 @@
|
|||||||
function triggerSplash() {
|
function triggerSplash() {
|
||||||
splashTriggered = true
|
splashTriggered = true
|
||||||
setTimeout(() => splashTriggered = false, 200)
|
setTimeout(() => splashTriggered = false, 200)
|
||||||
;(window as any).__mokuShowSplash?.()
|
appState.idleSplash = true
|
||||||
}
|
}
|
||||||
|
|
||||||
async function testWindowsHello() {
|
async function testWindowsHello() {
|
||||||
@@ -192,7 +192,7 @@
|
|||||||
<div class="s-dev-grid">
|
<div class="s-dev-grid">
|
||||||
<span class="s-dev-key">Filter</span> <span class="s-dev-val">{appState.libraryFilter}</span>
|
<span class="s-dev-key">Filter</span> <span class="s-dev-val">{appState.libraryFilter}</span>
|
||||||
<span class="s-dev-key">Folders</span> <span class="s-dev-val">{appState.categories.filter(c => c.id !== 0).map(c => c.name).join(', ') || 'none'}</span>
|
<span class="s-dev-key">Folders</span> <span class="s-dev-val">{appState.categories.filter(c => c.id !== 0).map(c => c.name).join(', ') || 'none'}</span>
|
||||||
<span class="s-dev-key">History</span> <span class="s-dev-val">{appState.history.length} entries</span>
|
<span class="s-dev-key">History</span> <span class="s-dev-val">{appState.history?.length ?? 0} entries</span>
|
||||||
<span class="s-dev-key">Cache</span> <span class="s-dev-val">{perfSnapshot?.cacheEntries ?? '—'} entries</span>
|
<span class="s-dev-key">Cache</span> <span class="s-dev-val">{perfSnapshot?.cacheEntries ?? '—'} entries</span>
|
||||||
<span class="s-dev-key">Toasts</span> <span class="s-dev-val">{appState.toasts.length} queued</span>
|
<span class="s-dev-key">Toasts</span> <span class="s-dev-val">{appState.toasts.length} queued</span>
|
||||||
<span class="s-dev-key">Version</span> <span class="s-dev-val">{appVersion} · {import.meta.env.MODE}</span>
|
<span class="s-dev-key">Version</span> <span class="s-dev-val">{appVersion} · {import.meta.env.MODE}</span>
|
||||||
|
|||||||
@@ -83,8 +83,8 @@
|
|||||||
<div class="s-section">
|
<div class="s-section">
|
||||||
<div class="s-section-body">
|
<div class="s-section-body">
|
||||||
<div class="s-row">
|
<div class="s-row">
|
||||||
<div class="s-row-info"><span class="s-label">Reading history</span><span class="s-desc">{homeState.history.length} entries</span></div>
|
<div class="s-row-info"><span class="s-label">Reading history</span><span class="s-desc">{homeState.history?.length ?? 0} entries</span></div>
|
||||||
<button class="s-btn s-btn-danger" onclick={clearHistory} disabled={homeState.history.length === 0}>Clear</button>
|
<button class="s-btn s-btn-danger" onclick={clearHistory} disabled={!homeState.history?.length}>Clear</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,6 +12,9 @@
|
|||||||
import { clearBlobCache } from '$lib/core/cache/imageCache'
|
import { clearBlobCache } from '$lib/core/cache/imageCache'
|
||||||
import { clearPageCache } from '$lib/request-manager'
|
import { clearPageCache } from '$lib/request-manager'
|
||||||
import { cache as queryCache } from '$lib/core/cache/queryCache'
|
import { cache as queryCache } from '$lib/core/cache/queryCache'
|
||||||
|
import { getAdapter } from '$lib/request-manager'
|
||||||
|
import { requestManager } from '$lib/request-manager'
|
||||||
|
import type { ValidateBackupResult, RestoreStatus } from '$lib/server-adapters/types'
|
||||||
|
|
||||||
const supportsFilesystem = platformService.isSupported('filesystem')
|
const supportsFilesystem = platformService.isSupported('filesystem')
|
||||||
|
|
||||||
@@ -79,7 +82,7 @@
|
|||||||
await Promise.all([
|
await Promise.all([
|
||||||
platformService.clearMokuCache(),
|
platformService.clearMokuCache(),
|
||||||
platformService.clearSuwayomiCache(),
|
platformService.clearSuwayomiCache(),
|
||||||
gql(`mutation { clearCachedImages(input: { cachedPages: true, cachedThumbnails: true, downloadedThumbnails: false }) { cachedPages cachedThumbnails } }`),
|
getAdapter().clearCachedImages({ cachedPages: true, cachedThumbnails: true, downloadedThumbnails: false }),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,11 +171,7 @@
|
|||||||
if (!supportsFilesystem) return
|
if (!supportsFilesystem) return
|
||||||
storageLoading = true; storageError = null
|
storageLoading = true; storageError = null
|
||||||
try {
|
try {
|
||||||
const pathData = await gql<{ settings: { downloadsPath: string | null; localSourcePath: string | null } }>(
|
const { downloadsPath: dl, localSourcePath: loc } = await getAdapter().getDownloadsPath()
|
||||||
`{ settings { downloadsPath localSourcePath } }`
|
|
||||||
)
|
|
||||||
const dl = pathData.settings.downloadsPath ?? ''
|
|
||||||
const loc = pathData.settings.localSourcePath ?? ''
|
|
||||||
downloadsPathInput = dl; localSourcePathInput = loc
|
downloadsPathInput = dl; localSourcePathInput = loc
|
||||||
confirmedDownloadsPath = dl; confirmedLocalSourcePath = loc
|
confirmedDownloadsPath = dl; confirmedLocalSourcePath = loc
|
||||||
updateSettings({ serverDownloadsPath: dl, serverLocalSourcePath: loc })
|
updateSettings({ serverDownloadsPath: dl, serverLocalSourcePath: loc })
|
||||||
@@ -218,8 +217,8 @@
|
|||||||
if (dlErr || locErr) { pathsFieldError = { ...(dlErr ? { dl: dlErr } : {}), ...(locErr ? { loc: locErr } : {}) }; return }
|
if (dlErr || locErr) { pathsFieldError = { ...(dlErr ? { dl: dlErr } : {}), ...(locErr ? { loc: locErr } : {}) }; return }
|
||||||
pathsSaving = true
|
pathsSaving = true
|
||||||
try {
|
try {
|
||||||
await gql(`mutation($path: String!) { setSettings(input: { settings: { downloadsPath: $path } }) { settings { downloadsPath } } }`, { path: dl })
|
await getAdapter().setDownloadsPath(dl)
|
||||||
if (loc) await gql(`mutation($path: String!) { setSettings(input: { settings: { localSourcePath: $path } }) { settings { localSourcePath } } }`, { path: loc })
|
if (loc) await getAdapter().setLocalSourcePath(loc)
|
||||||
updateSettings({ serverDownloadsPath: dl, serverLocalSourcePath: loc })
|
updateSettings({ serverDownloadsPath: dl, serverLocalSourcePath: loc })
|
||||||
if (supportsFilesystem && !isExternalServer) {
|
if (supportsFilesystem && !isExternalServer) {
|
||||||
const oldDl = confirmedDownloadsPath || defaultDownloadsPath
|
const oldDl = confirmedDownloadsPath || defaultDownloadsPath
|
||||||
@@ -301,8 +300,7 @@
|
|||||||
async function createBackup() {
|
async function createBackup() {
|
||||||
backupLoading = true; backupError = null
|
backupLoading = true; backupError = null
|
||||||
try {
|
try {
|
||||||
const data = await gql<{ createBackup: { url: string } }>(`mutation { createBackup { url } }`)
|
const { url } = await getAdapter().createBackup()
|
||||||
const { url } = data.createBackup
|
|
||||||
const name = url.split('/').pop() ?? url
|
const name = url.split('/').pop() ?? url
|
||||||
backupList = [{ url, name }, ...backupList]
|
backupList = [{ url, name }, ...backupList]
|
||||||
await saveBackupList()
|
await saveBackupList()
|
||||||
@@ -313,7 +311,7 @@
|
|||||||
async function deleteBackup(url: string) {
|
async function deleteBackup(url: string) {
|
||||||
backupList = backupList.map(b => b.url === url ? { ...b, deleting: true } : b)
|
backupList = backupList.map(b => b.url === url ? { ...b, deleting: true } : b)
|
||||||
try {
|
try {
|
||||||
await fetch(`${serverUrl()}${url}`, { method: 'DELETE', headers: buildAuthHeaders() })
|
await getAdapter().deleteBackup(url)
|
||||||
backupList = backupList.filter(b => b.url !== url)
|
backupList = backupList.filter(b => b.url !== url)
|
||||||
await saveBackupList()
|
await saveBackupList()
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -324,9 +322,7 @@
|
|||||||
|
|
||||||
async function downloadBackup(backup: BackupEntry) {
|
async function downloadBackup(backup: BackupEntry) {
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(`${serverUrl()}${backup.url}`, { headers: buildAuthHeaders() })
|
const blob = await getAdapter().downloadBackup(backup.url)
|
||||||
if (!resp.ok) throw new Error(`Server returned ${resp.status}`)
|
|
||||||
const blob = await resp.blob()
|
|
||||||
if ('showSaveFilePicker' in window) {
|
if ('showSaveFilePicker' in window) {
|
||||||
try {
|
try {
|
||||||
const handle = await (window as any).showSaveFilePicker({
|
const handle = await (window as any).showSaveFilePicker({
|
||||||
@@ -349,12 +345,11 @@
|
|||||||
|
|
||||||
let restoreLoading = $state(false)
|
let restoreLoading = $state(false)
|
||||||
let restoreError = $state<string | null>(null)
|
let restoreError = $state<string | null>(null)
|
||||||
let restoreJobId = $state<string | null>(null)
|
let restoreStatus = $state<RestoreStatus | null>(null)
|
||||||
let restoreStatus = $state<{ mangaProgress: number; state: string; totalManga: number } | null>(null)
|
|
||||||
let restorePollInterval = $state<ReturnType<typeof setInterval> | null>(null)
|
let restorePollInterval = $state<ReturnType<typeof setInterval> | null>(null)
|
||||||
let validateLoading = $state(false)
|
let validateLoading = $state(false)
|
||||||
let validateError = $state<string | null>(null)
|
let validateError = $state<string | null>(null)
|
||||||
let validateResult = $state<{ missingSources: { id: string; name: string }[]; missingTrackers: { name: string }[] } | null>(null)
|
let validateResult = $state<ValidateBackupResult | null>(null)
|
||||||
let restoreFile = $state<File | null>(null)
|
let restoreFile = $state<File | null>(null)
|
||||||
|
|
||||||
function stopRestorePoll() {
|
function stopRestorePoll() {
|
||||||
@@ -363,62 +358,19 @@
|
|||||||
|
|
||||||
async function pollRestoreStatus(id: string) {
|
async function pollRestoreStatus(id: string) {
|
||||||
try {
|
try {
|
||||||
const data = await gql<{ restoreStatus: { mangaProgress: number; state: string; totalManga: number } }>(
|
const status = await getAdapter().pollRestoreStatus(id)
|
||||||
`query($id: String!) { restoreStatus(id: $id) { mangaProgress state totalManga } }`,
|
|
||||||
{ id }
|
|
||||||
)
|
|
||||||
const status = data.restoreStatus
|
|
||||||
restoreStatus = status
|
restoreStatus = status
|
||||||
if (status?.state === 'SUCCESS' || status?.state === 'FAILURE') stopRestorePoll()
|
if (status?.state === 'SUCCESS' || status?.state === 'FAILURE') stopRestorePoll()
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildBackupFormData(file: File, query: string, variables: Record<string, unknown>) {
|
|
||||||
const form = new FormData()
|
|
||||||
form.append('operations', JSON.stringify({ query, variables }))
|
|
||||||
form.append('map', JSON.stringify({ '0': ['variables.backup'] }))
|
|
||||||
form.append('0', file, file.name)
|
|
||||||
return form
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildAuthHeaders(): Record<string, string> {
|
|
||||||
const headers: Record<string, string> = { Accept: 'application/json' }
|
|
||||||
const pass = settingsState.settings.serverAuthPass ?? '', user = settingsState.settings.serverAuthUser ?? ''
|
|
||||||
if (settingsState.settings.serverAuthMode === 'BASIC_AUTH' && user && pass)
|
|
||||||
headers['Authorization'] = 'Basic ' + btoa(`${user}:${pass}`)
|
|
||||||
return headers
|
|
||||||
}
|
|
||||||
|
|
||||||
function serverUrl(): string {
|
|
||||||
return (settingsState.settings.serverUrl ?? 'http://localhost:4567').replace(/\/$/, '')
|
|
||||||
}
|
|
||||||
|
|
||||||
async function gql<T = unknown>(query: string, variables?: Record<string, unknown>): Promise<T> {
|
|
||||||
const res = await fetch(`${serverUrl()}/api/graphql`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json', ...buildAuthHeaders() },
|
|
||||||
body: JSON.stringify({ query, variables }),
|
|
||||||
})
|
|
||||||
const json = await res.json()
|
|
||||||
if (json.errors?.length) throw new Error(json.errors[0].message)
|
|
||||||
return json.data as T
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submitRestore() {
|
async function submitRestore() {
|
||||||
if (!restoreFile) return
|
if (!restoreFile) return
|
||||||
restoreLoading = true; restoreError = null; restoreStatus = null; restoreJobId = null
|
restoreLoading = true; restoreError = null; restoreStatus = null
|
||||||
stopRestorePoll()
|
stopRestorePoll()
|
||||||
try {
|
try {
|
||||||
const form = buildBackupFormData(
|
const result = await requestManager.meta.restoreBackup(restoreFile)
|
||||||
restoreFile,
|
restoreStatus = result.status
|
||||||
`mutation RestoreBackup($backup: Upload!) { restoreBackup(input: { backup: $backup }) { id status { mangaProgress state totalManga } } }`,
|
|
||||||
{ backup: null }
|
|
||||||
)
|
|
||||||
const resp = await fetch(`${serverUrl()}/api/graphql`, { method: 'POST', headers: buildAuthHeaders(), body: form })
|
|
||||||
const json = await resp.json()
|
|
||||||
if (json.errors?.length) throw new Error(json.errors[0].message)
|
|
||||||
const result = json.data.restoreBackup
|
|
||||||
restoreJobId = result.id; restoreStatus = result.status
|
|
||||||
if (result.status?.state !== 'SUCCESS' && result.status?.state !== 'FAILURE')
|
if (result.status?.state !== 'SUCCESS' && result.status?.state !== 'FAILURE')
|
||||||
restorePollInterval = setInterval(() => pollRestoreStatus(result.id), 1500)
|
restorePollInterval = setInterval(() => pollRestoreStatus(result.id), 1500)
|
||||||
} catch (e: any) { restoreError = e?.message ?? 'Failed to start restore' }
|
} catch (e: any) { restoreError = e?.message ?? 'Failed to start restore' }
|
||||||
@@ -429,15 +381,7 @@
|
|||||||
if (!restoreFile) return
|
if (!restoreFile) return
|
||||||
validateLoading = true; validateError = null; validateResult = null
|
validateLoading = true; validateError = null; validateResult = null
|
||||||
try {
|
try {
|
||||||
const form = buildBackupFormData(
|
validateResult = await requestManager.meta.validateBackup(restoreFile)
|
||||||
restoreFile,
|
|
||||||
`query ValidateBackup($backup: Upload!) { validateBackup(input: { backup: $backup }) { missingSources { id name } missingTrackers { name } } }`,
|
|
||||||
{ backup: null }
|
|
||||||
)
|
|
||||||
const resp = await fetch(`${serverUrl()}/api/graphql`, { method: 'POST', headers: buildAuthHeaders(), body: form })
|
|
||||||
const json = await resp.json()
|
|
||||||
if (json.errors?.length) throw new Error(json.errors[0].message)
|
|
||||||
validateResult = json.data.validateBackup
|
|
||||||
} catch (e: any) { validateError = e?.message ?? 'Failed to validate backup' }
|
} catch (e: any) { validateError = e?.message ?? 'Failed to validate backup' }
|
||||||
finally { validateLoading = false }
|
finally { validateLoading = false }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { settingsState, updateSettings } from "$lib/state/settings.svelte";
|
import { settingsState, updateSettings } from "$lib/state/settings.svelte";
|
||||||
import { toast } from "$lib/state/notifications.svelte";
|
import { toast } from "$lib/state/notifications.svelte";
|
||||||
import { getAdapter } from "$lib/request-manager";
|
import { getAdapter } from "$lib/request-manager";
|
||||||
|
import { platformService } from "$lib/platform-service";
|
||||||
import { syncBackFromTracker } from "$lib/components/tracking/lib/trackingSync";
|
import { syncBackFromTracker } from "$lib/components/tracking/lib/trackingSync";
|
||||||
import { trackingState } from "$lib/state/tracking.svelte";
|
import { trackingState } from "$lib/state/tracking.svelte";
|
||||||
import type { Tracker, TrackRecord } from "$lib/types/index";
|
import type { Tracker, TrackRecord } from "$lib/types/index";
|
||||||
@@ -41,7 +42,7 @@
|
|||||||
async function startOAuth(tracker: Tracker) {
|
async function startOAuth(tracker: Tracker) {
|
||||||
if (!tracker.authUrl) return;
|
if (!tracker.authUrl) return;
|
||||||
oauthTrackerId = tracker.id; oauthCallbackInput = "";
|
oauthTrackerId = tracker.id; oauthCallbackInput = "";
|
||||||
window.open(tracker.authUrl, "_blank");
|
await platformService.openExternal(tracker.authUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitOAuth() {
|
async function submitOAuth() {
|
||||||
@@ -274,6 +275,7 @@
|
|||||||
|
|
||||||
<style>
|
<style>
|
||||||
.s-tracker-status-row { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; }
|
.s-tracker-status-row { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; }
|
||||||
|
.s-tracker-status-row .s-pill { border-radius: 4px; }
|
||||||
.s-pill-warn { background: color-mix(in srgb, var(--color-warn, #c97c2b) 15%, transparent); color: var(--color-warn, #c97c2b); border-color: color-mix(in srgb, var(--color-warn, #c97c2b) 35%, transparent); }
|
.s-pill-warn { background: color-mix(in srgb, var(--color-warn, #c97c2b) 15%, transparent); color: var(--color-warn, #c97c2b); border-color: color-mix(in srgb, var(--color-warn, #c97c2b) 35%, transparent); }
|
||||||
.s-banner-dismissible { cursor: pointer; max-height: 8rem; overflow-y: auto; }
|
.s-banner-dismissible { cursor: pointer; max-height: 8rem; overflow-y: auto; }
|
||||||
.s-banner-dismissible:hover { opacity: 0.85; }
|
.s-banner-dismissible:hover { opacity: 0.85; }
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { getAdapter } from "$lib/request-manager";
|
import { getAdapter } from "$lib/request-manager";
|
||||||
|
import type { ValidateBackupResult, RestoreStatus } from "$lib/server-adapters/types";
|
||||||
|
|
||||||
export async function getAboutServer() {
|
export async function getAboutServer() {
|
||||||
return getAdapter().getAboutServer();
|
return getAdapter().getAboutServer();
|
||||||
@@ -6,4 +7,12 @@ export async function getAboutServer() {
|
|||||||
|
|
||||||
export async function getAboutWebUI() {
|
export async function getAboutWebUI() {
|
||||||
return getAdapter().getAboutWebUI();
|
return getAdapter().getAboutWebUI();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function restoreBackup(file: File): Promise<{ id: string; status: RestoreStatus }> {
|
||||||
|
return getAdapter().restoreBackup(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function validateBackup(file: File): Promise<ValidateBackupResult> {
|
||||||
|
return getAdapter().validateBackup(file);
|
||||||
}
|
}
|
||||||
@@ -16,6 +16,8 @@ import type {
|
|||||||
TrackRecordPatch,
|
TrackRecordPatch,
|
||||||
AboutServer,
|
AboutServer,
|
||||||
AboutWebUI,
|
AboutWebUI,
|
||||||
|
RestoreStatus,
|
||||||
|
ValidateBackupResult,
|
||||||
} from '$lib/server-adapters/types'
|
} from '$lib/server-adapters/types'
|
||||||
import type { DownloadStatus } from '$lib/types/api'
|
import type { DownloadStatus } from '$lib/types/api'
|
||||||
import type { Manga, Chapter, Extension, Source, Tracker, TrackRecord, Category } from '$lib/types'
|
import type { Manga, Chapter, Extension, Source, Tracker, TrackRecord, Category } from '$lib/types'
|
||||||
@@ -39,11 +41,10 @@ import {
|
|||||||
UPDATE_STOP,
|
UPDATE_STOP,
|
||||||
SET_MANGA_META,
|
SET_MANGA_META,
|
||||||
DELETE_MANGA_META,
|
DELETE_MANGA_META,
|
||||||
CREATE_BACKUP,
|
|
||||||
RESTORE_BACKUP,
|
|
||||||
FETCH_SOURCE_MANGA,
|
FETCH_SOURCE_MANGA,
|
||||||
LIBRARY_UPDATE_STATUS,
|
LIBRARY_UPDATE_STATUS,
|
||||||
MANGAS_BY_GENRE,
|
MANGAS_BY_GENRE,
|
||||||
|
POLL_RESTORE_STATUS,
|
||||||
} from './manga'
|
} from './manga'
|
||||||
import {
|
import {
|
||||||
GET_CHAPTERS,
|
GET_CHAPTERS,
|
||||||
@@ -101,6 +102,7 @@ import {
|
|||||||
UPDATE_TRACK,
|
UPDATE_TRACK,
|
||||||
LOGIN_TRACKER_CREDENTIALS,
|
LOGIN_TRACKER_CREDENTIALS,
|
||||||
LOGOUT_TRACKER,
|
LOGOUT_TRACKER,
|
||||||
|
LOGIN_TRACKER_OAUTH,
|
||||||
} from './tracking'
|
} from './tracking'
|
||||||
import {
|
import {
|
||||||
GET_ABOUT_SERVER,
|
GET_ABOUT_SERVER,
|
||||||
@@ -110,6 +112,9 @@ import {
|
|||||||
GET_METAS,
|
GET_METAS,
|
||||||
SET_SOCKS_PROXY,
|
SET_SOCKS_PROXY,
|
||||||
SET_FLARE_SOLVERR,
|
SET_FLARE_SOLVERR,
|
||||||
|
RESTORE_BACKUP,
|
||||||
|
VALIDATE_BACKUP,
|
||||||
|
CREATE_BACKUP,
|
||||||
} from './meta'
|
} from './meta'
|
||||||
import {
|
import {
|
||||||
type GQLResponse,
|
type GQLResponse,
|
||||||
@@ -136,7 +141,7 @@ function mapDownloadStatus(raw: { state: string; queue: RawQueueItem[] }): Downl
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class SuwayomiAdapter implements ServerAdapter {
|
export class SuwayomiAdapter implements ServerAdapter {
|
||||||
private baseUrl = 'http://127.0.0.1:4567'
|
private baseUrl = 'http://127.0.0.1:4567'
|
||||||
private authHeader: string | null = null
|
private authHeader: string | null = null
|
||||||
|
|
||||||
async connect(config: ServerConfig): Promise<void> {
|
async connect(config: ServerConfig): Promise<void> {
|
||||||
@@ -220,6 +225,26 @@ export class SuwayomiAdapter implements ServerAdapter {
|
|||||||
return { items, hasNextPage: false }
|
return { items, hasNextPage: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getMangasByGenre(
|
||||||
|
filter: Record<string, unknown>,
|
||||||
|
first: number,
|
||||||
|
offset: number,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<{ items: Manga[]; hasNextPage: boolean; totalCount: number }> {
|
||||||
|
const data = await this.gql<{
|
||||||
|
mangas: {
|
||||||
|
nodes: Record<string, unknown>[]
|
||||||
|
pageInfo: { hasNextPage: boolean }
|
||||||
|
totalCount: number
|
||||||
|
}
|
||||||
|
}>(MANGAS_BY_GENRE, { filter, first, offset }, signal)
|
||||||
|
return {
|
||||||
|
items: data.mangas.nodes.map(mapManga),
|
||||||
|
hasNextPage: data.mangas.pageInfo.hasNextPage,
|
||||||
|
totalCount: data.mangas.totalCount,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async searchManga(query: string, sourceId?: string): Promise<Manga[]> {
|
async searchManga(query: string, sourceId?: string): Promise<Manga[]> {
|
||||||
if (!sourceId) return []
|
if (!sourceId) return []
|
||||||
const data = await this.gql<{ fetchSourceManga: { mangas: Record<string, unknown>[] } }>(
|
const data = await this.gql<{ fetchSourceManga: { mangas: Record<string, unknown>[] } }>(
|
||||||
@@ -280,9 +305,7 @@ export class SuwayomiAdapter implements ServerAdapter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getRecentlyUpdated(): Promise<Chapter[]> {
|
async getRecentlyUpdated(): Promise<Chapter[]> {
|
||||||
const data = await this.gql<{ chapters: { nodes: Record<string, unknown>[] } }>(
|
const data = await this.gql<{ chapters: { nodes: Record<string, unknown>[] } }>(GET_RECENTLY_UPDATED)
|
||||||
GET_RECENTLY_UPDATED
|
|
||||||
)
|
|
||||||
return data.chapters.nodes.map(mapChapter)
|
return data.chapters.nodes.map(mapChapter)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -357,9 +380,7 @@ export class SuwayomiAdapter implements ServerAdapter {
|
|||||||
reorderChapterDownload: { downloadStatus: { state: string; queue: RawQueueItem[] } }
|
reorderChapterDownload: { downloadStatus: { state: string; queue: RawQueueItem[] } }
|
||||||
}>(REORDER_DOWNLOAD, { chapterId: Number(chapterId), to })
|
}>(REORDER_DOWNLOAD, { chapterId: Number(chapterId), to })
|
||||||
return mapDownloadStatus(data.reorderChapterDownload.downloadStatus)
|
return mapDownloadStatus(data.reorderChapterDownload.downloadStatus)
|
||||||
} catch {
|
} catch { return null }
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async clearDownloads(): Promise<void> {
|
async clearDownloads(): Promise<void> {
|
||||||
@@ -545,6 +566,18 @@ export class SuwayomiAdapter implements ServerAdapter {
|
|||||||
await this.gql(TRACK_PROGRESS, { mangaId: Number(mangaId) })
|
await this.gql(TRACK_PROGRESS, { mangaId: Number(mangaId) })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async loginTrackerOAuth(trackerId: string, callbackUrl: string): Promise<void> {
|
||||||
|
await this.gql(LOGIN_TRACKER_OAUTH, { trackerId: Number(trackerId), callbackUrl })
|
||||||
|
}
|
||||||
|
|
||||||
|
async loginTrackerCredentials(trackerId: string, username: string, password: string): Promise<void> {
|
||||||
|
await this.gql(LOGIN_TRACKER_CREDENTIALS, { trackerId: Number(trackerId), username, password })
|
||||||
|
}
|
||||||
|
|
||||||
|
async logoutTracker(trackerId: string): Promise<void> {
|
||||||
|
await this.gql(LOGOUT_TRACKER, { trackerId: Number(trackerId) })
|
||||||
|
}
|
||||||
|
|
||||||
async getServerSecurity(): Promise<ServerSecurity> {
|
async getServerSecurity(): Promise<ServerSecurity> {
|
||||||
const data = await this.gql<{ settings: ServerSecurity }>(GET_SERVER_SECURITY)
|
const data = await this.gql<{ settings: ServerSecurity }>(GET_SERVER_SECURITY)
|
||||||
return data.settings
|
return data.settings
|
||||||
@@ -581,26 +614,60 @@ export class SuwayomiAdapter implements ServerAdapter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getMangasByGenre(
|
async getDownloadsPath(): Promise<{ downloadsPath: string; localSourcePath: string }> {
|
||||||
filter: Record<string, unknown>,
|
const data = await this.gql<{ settings: { downloadsPath: string | null; localSourcePath: string | null } }>(
|
||||||
first: number,
|
GET_DOWNLOADS_PATH
|
||||||
offset: number,
|
)
|
||||||
signal?: AbortSignal,
|
|
||||||
): Promise<{ items: Manga[]; hasNextPage: boolean; totalCount: number }> {
|
|
||||||
const data = await this.gql<{
|
|
||||||
mangas: {
|
|
||||||
nodes: Record<string, unknown>[]
|
|
||||||
pageInfo: { hasNextPage: boolean }
|
|
||||||
totalCount: number
|
|
||||||
}
|
|
||||||
}>(MANGAS_BY_GENRE, { filter, first, offset }, signal)
|
|
||||||
return {
|
return {
|
||||||
items: data.mangas.nodes.map(mapManga),
|
downloadsPath: data.settings.downloadsPath ?? '',
|
||||||
hasNextPage: data.mangas.pageInfo.hasNextPage,
|
localSourcePath: data.settings.localSourcePath ?? '',
|
||||||
totalCount: data.mangas.totalCount,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async setDownloadsPath(path: string): Promise<void> {
|
||||||
|
await this.gql(SET_DOWNLOADS_PATH, { path })
|
||||||
|
}
|
||||||
|
|
||||||
|
async setLocalSourcePath(path: string): Promise<void> {
|
||||||
|
await this.gql(SET_LOCAL_SOURCE_PATH, { path })
|
||||||
|
}
|
||||||
|
|
||||||
|
async createBackup(): Promise<{ url: string }> {
|
||||||
|
const data = await this.gql<{ createBackup: { url: string } }>(CREATE_BACKUP)
|
||||||
|
return data.createBackup
|
||||||
|
}
|
||||||
|
|
||||||
|
private multipartGql<T>(query: string, file: File): Promise<T> {
|
||||||
|
const form = new FormData()
|
||||||
|
form.append('operations', JSON.stringify({ query, variables: { backup: null } }))
|
||||||
|
form.append('map', JSON.stringify({ '0': ['variables.backup'] }))
|
||||||
|
form.append('0', file, file.name)
|
||||||
|
const headers: Record<string, string> = { Accept: 'application/json' }
|
||||||
|
if (this.authHeader) headers['Authorization'] = this.authHeader
|
||||||
|
return fetch(`${this.baseUrl}/api/graphql`, { method: 'POST', headers, body: form })
|
||||||
|
.then(r => { if (!r.ok) throw new Error(`Suwayomi HTTP ${r.status}`); return r.json() })
|
||||||
|
.then((json: GQLResponse<T>) => { if (json.errors?.length) throw new Error(json.errors[0].message); return json.data })
|
||||||
|
}
|
||||||
|
|
||||||
|
async restoreBackup(file: File): Promise<{ id: string; status: RestoreStatus }> {
|
||||||
|
const data = await this.multipartGql<{ restoreBackup: { id: string; status: RestoreStatus } }>(RESTORE_BACKUP, file)
|
||||||
|
return data.restoreBackup
|
||||||
|
}
|
||||||
|
|
||||||
|
async validateBackup(file: File): Promise<ValidateBackupResult> {
|
||||||
|
const data = await this.multipartGql<{ validateBackup: ValidateBackupResult }>(VALIDATE_BACKUP, file)
|
||||||
|
return data.validateBackup
|
||||||
|
}
|
||||||
|
|
||||||
|
async pollRestoreStatus(id: string): Promise<RestoreStatus> {
|
||||||
|
const data = await this.gql<{ restoreStatus: RestoreStatus }>(POLL_RESTORE_STATUS, { id })
|
||||||
|
return data.restoreStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
async clearCachedImages(opts: { cachedPages: boolean; cachedThumbnails: boolean; downloadedThumbnails: boolean }): Promise<void> {
|
||||||
|
await this.gql(CLEAR_CACHED_IMAGES, opts)
|
||||||
|
}
|
||||||
|
|
||||||
async checkForUpdates(mangaIds?: string[]): Promise<UpdateResult[]> {
|
async checkForUpdates(mangaIds?: string[]): Promise<UpdateResult[]> {
|
||||||
if (mangaIds?.length) {
|
if (mangaIds?.length) {
|
||||||
const results: UpdateResult[] = []
|
const results: UpdateResult[] = []
|
||||||
|
|||||||
@@ -76,6 +76,12 @@ export const GET_DOWNLOADS_PATH = `
|
|||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
||||||
|
export const POLL_RESTORE_STATUS = `
|
||||||
|
query PollRestoreStatus($id: String!) {
|
||||||
|
restoreStatus(id: $id) { mangaProgress state totalManga }
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
export const FETCH_MANGA = `
|
export const FETCH_MANGA = `
|
||||||
mutation FetchManga($id: Int!) {
|
mutation FetchManga($id: Int!) {
|
||||||
fetchManga(input: { id: $id }) {
|
fetchManga(input: { id: $id }) {
|
||||||
@@ -214,6 +220,15 @@ export const RESTORE_BACKUP = `
|
|||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
||||||
|
export const VALIDATE_BACKUP = `
|
||||||
|
query ValidateBackup($backup: Upload!) {
|
||||||
|
validateBackup(input: { backup: $backup }) {
|
||||||
|
missingSources { id name }
|
||||||
|
missingTrackers { name }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
export const FETCH_SOURCE_MANGA = `
|
export const FETCH_SOURCE_MANGA = `
|
||||||
mutation FetchSourceManga($source: LongString!, $type: FetchSourceMangaType!, $page: Int!, $query: String, $filters: [FilterChangeInput!]) {
|
mutation FetchSourceManga($source: LongString!, $type: FetchSourceMangaType!, $page: Int!, $query: String, $filters: [FilterChangeInput!]) {
|
||||||
fetchSourceManga(input: { source: $source, type: $type, page: $page, query: $query, filters: $filters }) {
|
fetchSourceManga(input: { source: $source, type: $type, page: $page, query: $query, filters: $filters }) {
|
||||||
|
|||||||
@@ -60,6 +60,28 @@ export const SET_SOCKS_PROXY = `
|
|||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
||||||
|
export const RESTORE_BACKUP = `
|
||||||
|
mutation RestoreBackup($backup: Upload!) {
|
||||||
|
restoreBackup(input: { backup: $backup }) {
|
||||||
|
id status { mangaProgress state totalManga }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
export const VALIDATE_BACKUP = `
|
||||||
|
query ValidateBackup($backup: Upload!) {
|
||||||
|
validateBackup(input: { backup: $backup }) {
|
||||||
|
missingSources { id name } missingTrackers { name }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
export const CREATE_BACKUP = `
|
||||||
|
mutation CreateBackup {
|
||||||
|
createBackup(input: {}) { url }
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
export const SET_FLARE_SOLVERR = `
|
export const SET_FLARE_SOLVERR = `
|
||||||
mutation SetFlareSolverr(
|
mutation SetFlareSolverr(
|
||||||
$flareSolverrEnabled: Boolean!
|
$flareSolverrEnabled: Boolean!
|
||||||
|
|||||||
@@ -114,6 +114,14 @@ export const LOGIN_TRACKER_CREDENTIALS = `
|
|||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
||||||
|
export const LOGIN_TRACKER_OAUTH = `
|
||||||
|
mutation LoginTrackerOAuth($trackerId: Int!, $callbackUrl: String!) {
|
||||||
|
loginTrackerOAuth(input: { trackerId: $trackerId, callbackUrl: $callbackUrl }) {
|
||||||
|
isLoggedIn
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
export const LOGOUT_TRACKER = `
|
export const LOGOUT_TRACKER = `
|
||||||
mutation LogoutTracker($trackerId: Int!) {
|
mutation LogoutTracker($trackerId: Int!) {
|
||||||
logoutTracker(input: { trackerId: $trackerId }) {
|
logoutTracker(input: { trackerId: $trackerId }) {
|
||||||
|
|||||||
@@ -128,6 +128,17 @@ export interface TrackRecordPatch {
|
|||||||
private?: boolean
|
private?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RestoreStatus {
|
||||||
|
mangaProgress: number
|
||||||
|
state: string
|
||||||
|
totalManga: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ValidateBackupResult {
|
||||||
|
missingSources: { id: string; name: string }[]
|
||||||
|
missingTrackers: { name: string }[]
|
||||||
|
}
|
||||||
|
|
||||||
export interface ServerAdapter {
|
export interface ServerAdapter {
|
||||||
connect(config: ServerConfig): Promise<void>
|
connect(config: ServerConfig): Promise<void>
|
||||||
getStatus(): Promise<ServerStatus>
|
getStatus(): Promise<ServerStatus>
|
||||||
@@ -135,6 +146,7 @@ export interface ServerAdapter {
|
|||||||
|
|
||||||
getManga(id: string, signal?: AbortSignal): Promise<Manga>
|
getManga(id: string, signal?: AbortSignal): Promise<Manga>
|
||||||
getMangaList(filters: MangaFilters): Promise<PaginatedResult<Manga>>
|
getMangaList(filters: MangaFilters): Promise<PaginatedResult<Manga>>
|
||||||
|
getMangasByGenre(filter: Record<string, unknown>, first: number, offset: number, signal?: AbortSignal): Promise<{ items: Manga[]; hasNextPage: boolean; totalCount: number }>
|
||||||
searchManga(query: string, sourceId?: string): Promise<Manga[]>
|
searchManga(query: string, sourceId?: string): Promise<Manga[]>
|
||||||
fetchManga(id: string): Promise<Manga>
|
fetchManga(id: string): Promise<Manga>
|
||||||
addToLibrary(mangaId: string): Promise<void>
|
addToLibrary(mangaId: string): Promise<void>
|
||||||
@@ -200,12 +212,24 @@ export interface ServerAdapter {
|
|||||||
updateTrackRecord(recordId: string, patch: TrackRecordPatch): Promise<TrackRecord>
|
updateTrackRecord(recordId: string, patch: TrackRecordPatch): Promise<TrackRecord>
|
||||||
fetchTrackRecord(recordId: string): Promise<TrackRecord>
|
fetchTrackRecord(recordId: string): Promise<TrackRecord>
|
||||||
syncTracking(mangaId: string): Promise<void>
|
syncTracking(mangaId: string): Promise<void>
|
||||||
|
loginTrackerOAuth(trackerId: string, callbackUrl: string): Promise<void>
|
||||||
|
loginTrackerCredentials(trackerId: string, username: string, password: string): Promise<void>
|
||||||
|
logoutTracker(trackerId: string): Promise<void>
|
||||||
|
|
||||||
getServerSecurity(): Promise<ServerSecurity>
|
getServerSecurity(): Promise<ServerSecurity>
|
||||||
setServerAuth(input: SetServerAuthInput): Promise<void>
|
setServerAuth(input: SetServerAuthInput): Promise<void>
|
||||||
setSocksProxy(input: SetSocksProxyInput): Promise<void>
|
setSocksProxy(input: SetSocksProxyInput): Promise<void>
|
||||||
setFlareSolverr(input: SetFlareSolverrInput): Promise<void>
|
setFlareSolverr(input: SetFlareSolverrInput): Promise<void>
|
||||||
|
|
||||||
|
getDownloadsPath(): Promise<{ downloadsPath: string; localSourcePath: string }>
|
||||||
|
setDownloadsPath(path: string): Promise<void>
|
||||||
|
setLocalSourcePath(path: string): Promise<void>
|
||||||
|
createBackup(): Promise<{ url: string }>
|
||||||
|
restoreBackup(file: File): Promise<{ id: string; status: RestoreStatus }>
|
||||||
|
validateBackup(file: File): Promise<ValidateBackupResult>
|
||||||
|
pollRestoreStatus(id: string): Promise<RestoreStatus>
|
||||||
|
clearCachedImages(opts: { cachedPages: boolean; cachedThumbnails: boolean; downloadedThumbnails: boolean }): Promise<void>
|
||||||
|
|
||||||
checkForUpdates(mangaIds?: string[]): Promise<UpdateResult[]>
|
checkForUpdates(mangaIds?: string[]): Promise<UpdateResult[]>
|
||||||
stopLibraryUpdate(): Promise<void>
|
stopLibraryUpdate(): Promise<void>
|
||||||
getLibraryUpdateStatus(): Promise<LibraryUpdateProgress>
|
getLibraryUpdateStatus(): Promise<LibraryUpdateProgress>
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export const appState = $state({
|
|||||||
history: [] as unknown[],
|
history: [] as unknown[],
|
||||||
toasts: [] as unknown[],
|
toasts: [] as unknown[],
|
||||||
appDir: '',
|
appDir: '',
|
||||||
|
idleSplash: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
export function setSettingsOpen(next: boolean) { app.setSettingsOpen(next) }
|
export function setSettingsOpen(next: boolean) { app.setSettingsOpen(next) }
|
||||||
|
|||||||
@@ -130,6 +130,7 @@
|
|||||||
function onSplashReady() { splashVisible = false }
|
function onSplashReady() { splashVisible = false }
|
||||||
function onSplashUnlock() { appState.status = 'ready'; splashVisible = false }
|
function onSplashUnlock() { appState.status = 'ready'; splashVisible = false }
|
||||||
function onSplashBypass() { bypassed = true; splashVisible = false }
|
function onSplashBypass() { bypassed = true; splashVisible = false }
|
||||||
|
function onIdleDismiss() { appState.idleSplash = false }
|
||||||
|
|
||||||
function onSplashRetry() {
|
function onSplashRetry() {
|
||||||
import('$lib/state/boot.svelte').then(({ retryBoot }) => {
|
import('$lib/state/boot.svelte').then(({ retryBoot }) => {
|
||||||
@@ -158,6 +159,10 @@
|
|||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if appState.idleSplash}
|
||||||
|
<SplashScreen mode="idle" onDismiss={onIdleDismiss} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if showApp}
|
{#if showApp}
|
||||||
{#if strippedLayout}
|
{#if strippedLayout}
|
||||||
{@render children()}
|
{@render children()}
|
||||||
|
|||||||
Reference in New Issue
Block a user