Chore: Implement Server Adapters & Request Manager

This commit is contained in:
Youwes09
2026-05-22 20:44:55 -05:00
parent 8cef74bb98
commit c891cb349c
12 changed files with 662 additions and 330 deletions
+2 -1
View File
@@ -1,4 +1,5 @@
declare global { declare global {
namespace App {} namespace App {}
const __APP_VERSION__: string
} }
export {}; export {}
@@ -0,0 +1,93 @@
import type {
PlatformAdapter,
PlatformFeature,
ServerLaunchConfig,
DiscordPresence,
AppUpdateInfo,
} from '$lib/platform-adapters/types'
export class CapacitorAdapter implements PlatformAdapter {
async init() {}
isSupported(feature: PlatformFeature): boolean {
const supported: PlatformFeature[] = ['biometric-auth', 'filesystem']
return supported.includes(feature)
}
async launchServer(_config: ServerLaunchConfig) {}
async stopServer() {}
async getServerStatus(): Promise<'running' | 'stopped' | 'error'> {
return 'stopped'
}
async readFile(path: string): Promise<Uint8Array> {
const { Filesystem, Directory } = await import('@capacitor/filesystem')
const result = await Filesystem.readFile({ path, directory: Directory.Data })
const base64 = result.data as string
const binary = atob(base64)
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
return bytes
}
async writeFile(path: string, data: Uint8Array): Promise<void> {
const { Filesystem, Directory } = await import('@capacitor/filesystem')
const binary = String.fromCharCode(...data)
const base64 = btoa(binary)
await Filesystem.writeFile({ path, data: base64, directory: Directory.Data })
}
async pickFolder(): Promise<string | null> {
return null
}
async authenticateBiometric(): Promise<boolean> {
try {
const { NativeBiometric } = await import('capacitor-native-biometric')
await NativeBiometric.verifyIdentity({ reason: 'Authenticate to access Moku', title: 'Biometric Auth' })
return true
} catch {
return false
}
}
async storeCredential(key: string, value: string): Promise<void> {
const { NativeBiometric } = await import('capacitor-native-biometric')
await NativeBiometric.setCredentials({ username: key, password: value, server: 'moku' })
}
async getCredential(key: string): Promise<string | null> {
try {
const { NativeBiometric } = await import('capacitor-native-biometric')
const result = await NativeBiometric.getCredentials({ server: 'moku' })
return result.username === key ? result.password : null
} catch {
return null
}
}
async setTitle(_title: string) {}
async minimize() {}
async maximize() {}
async close() {}
async setDiscordPresence(_presence: DiscordPresence) {}
async clearDiscordPresence() {}
async getVersion(): Promise<string> {
const { App } = await import('@capacitor/app')
const info = await App.getInfo()
return info.version
}
async openExternal(url: string): Promise<void> {
const { Browser } = await import('@capacitor/browser')
await Browser.open({ url })
}
async checkForAppUpdate(): Promise<AppUpdateInfo | null> {
return null
}
async installAppUpdate(): Promise<void> {}
}
+119
View File
@@ -0,0 +1,119 @@
import { invoke } from '@tauri-apps/api/core'
import { open } from '@tauri-apps/plugin-dialog'
import { readFile, writeFile } from '@tauri-apps/plugin-fs'
import { open as openUrl } from '@tauri-apps/plugin-shell'
import { getVersion } from '@tauri-apps/api/app'
import { check } from '@tauri-apps/plugin-updater'
import { relaunch } from '@tauri-apps/plugin-process'
import type {
PlatformAdapter,
PlatformFeature,
ServerLaunchConfig,
DiscordPresence,
AppUpdateInfo,
} from '$lib/platform-adapters/types'
export class TauriAdapter implements PlatformAdapter {
async init() {
await invoke('init_app')
}
isSupported(feature: PlatformFeature): boolean {
const supported: PlatformFeature[] = [
'server-management',
'biometric-auth',
'native-window',
'filesystem',
'app-updates',
'discord-rpc',
]
return supported.includes(feature)
}
async launchServer(config: ServerLaunchConfig) {
await invoke('launch_server', { config })
}
async stopServer() {
await invoke('stop_server')
}
async getServerStatus(): Promise<'running' | 'stopped' | 'error'> {
return invoke('get_server_status')
}
async readFile(path: string): Promise<Uint8Array> {
return readFile(path)
}
async writeFile(path: string, data: Uint8Array) {
await writeFile(path, data)
}
async pickFolder(): Promise<string | null> {
const result = await open({ directory: true, multiple: false })
return typeof result === 'string' ? result : null
}
async authenticateBiometric(): Promise<boolean> {
return invoke('authenticate_biometric')
}
async storeCredential(key: string, value: string) {
await invoke('store_credential', { key, value })
}
async getCredential(key: string): Promise<string | null> {
return invoke('get_credential', { key })
}
async setTitle(title: string) {
await invoke('set_window_title', { title })
}
async minimize() {
await invoke('minimize_window')
}
async maximize() {
await invoke('maximize_window')
}
async close() {
await invoke('close_window')
}
async setDiscordPresence(presence: DiscordPresence) {
await invoke('set_discord_presence', { presence })
}
async clearDiscordPresence() {
await invoke('clear_discord_presence')
}
async getVersion(): Promise<string> {
return getVersion()
}
async openExternal(url: string) {
await openUrl(url)
}
async checkForAppUpdate(): Promise<AppUpdateInfo | null> {
const update = await check()
if (!update?.available) return null
return {
version: update.version,
url: update.body ?? '',
notes: update.body,
}
}
async installAppUpdate() {
const update = await check()
if (update?.available) {
await update.downloadAndInstall()
await relaunch()
}
}
}
+55
View File
@@ -0,0 +1,55 @@
export type PlatformFeature =
| 'server-management'
| 'biometric-auth'
| 'native-window'
| 'filesystem'
| 'app-updates'
| 'discord-rpc'
export interface ServerLaunchConfig {
jarPath: string
port: number
dataPath: string
}
export interface DiscordPresence {
title: string
chapter: string
startTimestamp?: number
}
export interface AppUpdateInfo {
version: string
url: string
notes?: string
}
export interface PlatformAdapter {
init(): Promise<void>
isSupported(feature: PlatformFeature): boolean
launchServer(config: ServerLaunchConfig): Promise<void>
stopServer(): Promise<void>
getServerStatus(): Promise<'running' | 'stopped' | 'error'>
readFile(path: string): Promise<Uint8Array>
writeFile(path: string, data: Uint8Array): Promise<void>
pickFolder(): Promise<string | null>
authenticateBiometric(): Promise<boolean>
storeCredential(key: string, value: string): Promise<void>
getCredential(key: string): Promise<string | null>
setTitle(title: string): Promise<void>
minimize(): Promise<void>
maximize(): Promise<void>
close(): Promise<void>
setDiscordPresence(presence: DiscordPresence): Promise<void>
clearDiscordPresence(): Promise<void>
getVersion(): Promise<string>
openExternal(url: string): Promise<void>
checkForAppUpdate(): Promise<AppUpdateInfo | null>
installAppUpdate(): Promise<void>
}
+66
View File
@@ -0,0 +1,66 @@
import type {
PlatformAdapter,
PlatformFeature,
ServerLaunchConfig,
DiscordPresence,
AppUpdateInfo,
} from '$lib/platform-adapters/types'
export class WebAdapter implements PlatformAdapter {
async init() {}
isSupported(_feature: PlatformFeature): boolean {
return false
}
async launchServer(_config: ServerLaunchConfig) {}
async stopServer() {}
async getServerStatus(): Promise<'running' | 'stopped' | 'error'> {
return 'stopped'
}
async readFile(_path: string): Promise<Uint8Array> {
return new Uint8Array()
}
async writeFile(_path: string, _data: Uint8Array) {}
async pickFolder(): Promise<string | null> {
return null
}
async authenticateBiometric(): Promise<boolean> {
return false
}
async storeCredential(_key: string, _value: string) {}
async getCredential(_key: string): Promise<string | null> {
return null
}
async setTitle(title: string) {
document.title = title
}
async minimize() {}
async maximize() {}
async close() {}
async setDiscordPresence(_presence: DiscordPresence) {}
async clearDiscordPresence() {}
async getVersion(): Promise<string> {
return __APP_VERSION__
}
async openExternal(url: string) {
window.open(url, '_blank', 'noopener,noreferrer')
}
async checkForAppUpdate(): Promise<AppUpdateInfo | null> {
return null
}
async installAppUpdate() {}
}
+98
View File
@@ -0,0 +1,98 @@
import type {
PlatformAdapter,
PlatformFeature,
ServerLaunchConfig,
DiscordPresence,
AppUpdateInfo,
} from '$lib/platform-adapters/types'
let adapter: PlatformAdapter
export function initPlatformService(a: PlatformAdapter) {
adapter = a
}
function getAdapter(): PlatformAdapter {
if (!adapter) throw new Error('PlatformService not initialized')
return adapter
}
export function isSupported(feature: PlatformFeature): boolean {
return getAdapter().isSupported(feature)
}
export function launchServer(config: ServerLaunchConfig) {
return getAdapter().launchServer(config)
}
export function stopServer() {
return getAdapter().stopServer()
}
export function getServerStatus() {
return getAdapter().getServerStatus()
}
export function readFile(path: string) {
return getAdapter().readFile(path)
}
export function writeFile(path: string, data: Uint8Array) {
return getAdapter().writeFile(path, data)
}
export function pickFolder() {
return getAdapter().pickFolder()
}
export function authenticateBiometric() {
return getAdapter().authenticateBiometric()
}
export function storeCredential(key: string, value: string) {
return getAdapter().storeCredential(key, value)
}
export function getCredential(key: string) {
return getAdapter().getCredential(key)
}
export function setTitle(title: string) {
return getAdapter().setTitle(title)
}
export function minimize() {
return getAdapter().minimize()
}
export function maximize() {
return getAdapter().maximize()
}
export function close() {
return getAdapter().close()
}
export function setDiscordPresence(presence: DiscordPresence) {
return getAdapter().setDiscordPresence(presence)
}
export function clearDiscordPresence() {
return getAdapter().clearDiscordPresence()
}
export function getVersion() {
return getAdapter().getVersion()
}
export function openExternal(url: string) {
return getAdapter().openExternal(url)
}
export function checkForAppUpdate(): Promise<AppUpdateInfo | null> {
return getAdapter().checkForAppUpdate()
}
export function installAppUpdate() {
return getAdapter().installAppUpdate()
}
+12
View File
@@ -0,0 +1,12 @@
import type { ServerAdapter } from '$lib/server-adapters/types'
let adapter: ServerAdapter
export function initRequestManager(a: ServerAdapter) {
adapter = a
}
export function getAdapter(): ServerAdapter {
if (!adapter) throw new Error('RequestManager not initialized')
return adapter
}
+3 -3
View File
@@ -47,12 +47,12 @@ export async function addToLibrary(mangaId: string) {
export async function removeFromLibrary(mangaId: string) { export async function removeFromLibrary(mangaId: string) {
await getAdapter().removeFromLibrary(mangaId) await getAdapter().removeFromLibrary(mangaId)
libraryState.items = libraryState.items.filter(m => m.id !== mangaId) libraryState.items = libraryState.items.filter(m => String(m.id) !== mangaId)
} }
export async function updateMangaMeta(id: string, meta: Partial<MangaMeta>) { export async function updateMangaMeta(id: string, meta: Partial<MangaMeta>) {
await getAdapter().updateMangaMeta(id, meta) await getAdapter().updateMangaMeta(id, meta)
if (seriesState.current?.id === id) { if (String(seriesState.current?.id) === id) {
await loadManga(id) await loadManga(id)
} }
} }
+53
View File
@@ -0,0 +1,53 @@
import type {
ServerAdapter,
ServerConfig,
ServerStatus,
MangaFilters,
MangaMeta,
PaginatedResult,
Page,
DownloadItem,
UpdateResult,
} from '$lib/server-adapters/types'
import type { Manga, Chapter, Extension, Source, Tracker } from '$lib/types'
function notImplemented(): never {
throw new Error('MokuAdapter: not implemented')
}
export class MokuAdapter implements ServerAdapter {
async connect(_config: ServerConfig): Promise<void> { notImplemented() }
async getStatus(): Promise<ServerStatus> { return notImplemented() }
async getManga(_id: string): Promise<Manga> { return notImplemented() }
async getMangaList(_filters: MangaFilters): Promise<PaginatedResult<Manga>> { return notImplemented() }
async searchManga(_query: string, _sourceId?: string): Promise<Manga[]> { return notImplemented() }
async addToLibrary(_mangaId: string): Promise<void> { notImplemented() }
async removeFromLibrary(_mangaId: string): Promise<void> { notImplemented() }
async updateMangaMeta(_id: string, _meta: Partial<MangaMeta>): Promise<void> { notImplemented() }
async getChapters(_mangaId: string): Promise<Chapter[]> { return notImplemented() }
async getChapter(_id: string): Promise<Chapter> { return notImplemented() }
async getChapterPages(_id: string): Promise<Page[]> { return notImplemented() }
async markChapterRead(_id: string, _read: boolean): Promise<void> { notImplemented() }
async markChaptersRead(_ids: string[], _read: boolean): Promise<void> { notImplemented() }
async getDownloads(): Promise<DownloadItem[]> { return notImplemented() }
async enqueueDownload(_chapterId: string): Promise<void> { notImplemented() }
async dequeueDownload(_chapterId: string): Promise<void> { notImplemented() }
async clearDownloads(): Promise<void> { notImplemented() }
async getExtensions(): Promise<Extension[]> { return notImplemented() }
async installExtension(_id: string): Promise<void> { notImplemented() }
async uninstallExtension(_id: string): Promise<void> { notImplemented() }
async updateExtension(_id: string): Promise<void> { notImplemented() }
async getSources(): Promise<Source[]> { return notImplemented() }
async browseSource(_sourceId: string, _page: number): Promise<PaginatedResult<Manga>> { return notImplemented() }
async getTrackers(): Promise<Tracker[]> { return notImplemented() }
async linkTracker(_mangaId: string, _trackerId: string, _remoteId: string): Promise<void> { notImplemented() }
async syncTracking(_mangaId: string): Promise<void> { notImplemented() }
async checkForUpdates(_mangaIds?: string[]): Promise<UpdateResult[]> { return notImplemented() }
}
+63 -323
View File
@@ -10,299 +10,57 @@ import type {
UpdateResult, UpdateResult,
} from '$lib/server-adapters/types' } from '$lib/server-adapters/types'
import type { Manga, Chapter, Extension, Source, Tracker } from '$lib/types' import type { Manga, Chapter, Extension, Source, Tracker } from '$lib/types'
import {
GET_LIBRARY,
GET_MANGA,
GET_CATEGORIES,
FETCH_MANGA,
UPDATE_MANGA,
SET_MANGA_META,
UPDATE_LIBRARY,
FETCH_SOURCE_MANGA,
} from './manga'
import {
GET_CHAPTERS,
FETCH_CHAPTERS,
FETCH_CHAPTER_PAGES,
MARK_CHAPTER_READ,
MARK_CHAPTERS_READ,
} from './chapters'
import {
GET_DOWNLOAD_STATUS,
ENQUEUE_DOWNLOAD,
DEQUEUE_DOWNLOAD,
CLEAR_DOWNLOADER,
} from './downloads'
import {
GET_EXTENSIONS,
GET_SOURCES,
FETCH_EXTENSIONS,
UPDATE_EXTENSION,
} from './extensions'
import {
GET_TRACKERS,
BIND_TRACK,
TRACK_PROGRESS,
} from './tracking'
import {
GQLResponse,
mapManga,
mapChapter,
mapExtension,
mapDownloadItem,
} from './types'
interface GQLResponse<T> { const GET_CHAPTER = `
data: T query GetChapter($id: Int!) {
errors?: { message: string }[] chapter(id: $id) {
} id name chapterNumber sourceOrder isRead isDownloaded isBookmarked
pageCount mangaId uploadDate realUrl lastPageRead lastReadAt scanlator
const GET_LIBRARY = `
query GetLibrary {
mangas(condition: { inLibrary: true }) {
nodes {
id title thumbnailUrl inLibrary downloadCount unreadCount bookmarkCount
description status author artist genre inLibraryAt lastFetchedAt
source { id name displayName }
chapters { totalCount }
lastReadChapter { id chapterNumber }
firstUnreadChapter { id chapterNumber }
}
} }
} }
` `
const GET_MANGA = `
query GetManga($id: Int!) {
manga(id: $id) {
id title description thumbnailUrl status author artist genre inLibrary realUrl
inLibraryAt lastFetchedAt updateStrategy
source { id name displayName }
lastReadChapter { id chapterNumber lastPageRead }
firstUnreadChapter { id chapterNumber }
highestNumberedChapter { id chapterNumber }
}
}
`
const GET_CHAPTERS = `
query GetChapters($mangaId: Int!) {
chapters(condition: { mangaId: $mangaId }) {
nodes {
id name chapterNumber sourceOrder isRead isDownloaded isBookmarked
pageCount mangaId uploadDate realUrl lastPageRead lastReadAt scanlator
}
}
}
`
const GET_DOWNLOAD_STATUS = `
query GetDownloadStatus {
downloadStatus {
state
queue {
progress state tries
chapter {
id name pageCount mangaId
manga { id title thumbnailUrl }
}
}
}
}
`
const GET_EXTENSIONS = `
query GetExtensions {
extensions {
nodes {
apkName pkgName name lang versionName
isInstalled isObsolete hasUpdate iconUrl
}
}
}
`
const GET_SOURCES = `
query GetSources {
sources {
nodes {
id name lang displayName iconUrl isNsfw
isConfigurable supportsLatest
}
}
}
`
const GET_TRACKERS = `
query GetTrackers {
trackers {
nodes {
id name icon isLoggedIn isTokenExpired authUrl
supportsPrivateTracking supportsReadingDates supportsTrackDeletion
scores
statuses { value name }
}
}
}
`
const FETCH_MANGA = `
mutation FetchManga($id: Int!) {
fetchManga(input: { id: $id }) {
manga {
id title description thumbnailUrl status author artist genre inLibrary realUrl
source { id name displayName }
}
}
}
`
const FETCH_SOURCE_MANGA = `
mutation FetchSourceManga($source: LongString!, $type: FetchSourceMangaType!, $page: Int!, $query: String) {
fetchSourceManga(input: { source: $source, type: $type, page: $page, query: $query }) {
mangas { id title thumbnailUrl inLibrary }
hasNextPage
}
}
`
const UPDATE_MANGA = `
mutation UpdateManga($id: Int!, $inLibrary: Boolean) {
updateManga(input: { id: $id, patch: { inLibrary: $inLibrary } }) {
manga { id inLibrary }
}
}
`
const SET_MANGA_META = `
mutation SetMangaMeta($mangaId: Int!, $key: String!, $value: String!) {
setMangaMeta(input: { meta: { mangaId: $mangaId, key: $key, value: $value } }) {
meta { key value }
}
}
`
const FETCH_CHAPTERS = `
mutation FetchChapters($mangaId: Int!) {
fetchChapters(input: { mangaId: $mangaId }) {
chapters {
id name chapterNumber sourceOrder isRead isDownloaded isBookmarked
pageCount mangaId uploadDate realUrl lastPageRead lastReadAt scanlator
}
}
}
`
const FETCH_CHAPTER_PAGES = `
mutation FetchChapterPages($chapterId: Int!) {
fetchChapterPages(input: { chapterId: $chapterId }) { pages }
}
`
const MARK_CHAPTER_READ = `
mutation MarkChapterRead($id: Int!, $isRead: Boolean!) {
updateChapter(input: { id: $id, patch: { isRead: $isRead } }) {
chapter { id isRead }
}
}
`
const MARK_CHAPTERS_READ = `
mutation MarkChaptersRead($ids: [Int!]!, $isRead: Boolean!) {
updateChapters(input: { ids: $ids, patch: { isRead: $isRead } }) {
chapters { id isRead }
}
}
`
const ENQUEUE_DOWNLOAD = `
mutation EnqueueDownload($chapterId: Int!) {
enqueueChapterDownload(input: { id: $chapterId }) {
downloadStatus { state }
}
}
`
const DEQUEUE_DOWNLOAD = `
mutation DequeueDownload($chapterId: Int!) {
dequeueChapterDownload(input: { id: $chapterId }) {
downloadStatus { state }
}
}
`
const CLEAR_DOWNLOADER = `
mutation ClearDownloader {
clearDownloader(input: {}) {
downloadStatus { state }
}
}
`
const FETCH_EXTENSIONS = `
mutation FetchExtensions {
fetchExtensions(input: {}) {
extensions {
apkName pkgName name lang versionName
isInstalled isObsolete hasUpdate iconUrl
}
}
}
`
const UPDATE_EXTENSION = `
mutation UpdateExtension($id: String!, $install: Boolean, $uninstall: Boolean, $update: Boolean) {
updateExtension(input: { id: $id, patch: { install: $install, uninstall: $uninstall, update: $update } }) {
extension { apkName pkgName name isInstalled hasUpdate }
}
}
`
const BIND_TRACK = `
mutation BindTrack($mangaId: Int!, $trackerId: Int!, $remoteId: LongString!) {
bindTrack(input: { mangaId: $mangaId, trackerId: $trackerId, remoteId: $remoteId }) {
trackRecord { id trackerId remoteId }
}
}
`
const TRACK_PROGRESS = `
mutation TrackProgress($mangaId: Int!) {
trackProgress(input: { mangaId: $mangaId }) {
trackRecords { id trackerId lastChapterRead status }
}
}
`
const UPDATE_LIBRARY = `
mutation UpdateLibrary {
updateLibrary(input: {}) {
updateStatus { jobsInfo { isRunning finishedJobs totalJobs } }
}
}
`
function mapChapter(raw: Record<string, unknown>): Chapter {
return {
id: raw.id as number,
name: raw.name as string,
chapterNumber: raw.chapterNumber as number,
sourceOrder: raw.sourceOrder as number,
read: (raw.isRead as boolean) ?? false,
downloaded: (raw.isDownloaded as boolean) ?? false,
bookmarked: (raw.isBookmarked as boolean) ?? false,
pageCount: (raw.pageCount as number) ?? 0,
mangaId: raw.mangaId as number,
fetchedAt: raw.fetchedAt as string | undefined,
uploadDate: raw.uploadDate as string | null | undefined,
realUrl: raw.realUrl as string | null | undefined,
lastPageRead: raw.lastPageRead as number | undefined,
lastReadAt: raw.lastReadAt as string | undefined,
scanlator: raw.scanlator as string | null | undefined,
manga: raw.manga as Chapter['manga'],
}
}
function mapManga(raw: Record<string, unknown>): Manga {
const inLibraryAt = raw.inLibraryAt as string | null | undefined
return {
...(raw as unknown as Manga),
tags: raw.genre as string[] | undefined,
addedAt: inLibraryAt ? new Date(inLibraryAt).getTime() : undefined,
lastReadAt: raw.lastReadChapter
? Date.now()
: undefined,
}
}
function mapExtension(raw: Record<string, unknown>): Extension {
return {
...(raw as unknown as Extension),
id: raw.pkgName as string,
}
}
function mapDownloadItem(raw: Record<string, unknown>): DownloadItem {
const chapter = raw.chapter as Record<string, unknown>
const manga = chapter?.manga as Record<string, unknown>
return {
chapterId: String(chapter?.id),
mangaId: String(chapter?.mangaId ?? manga?.id),
chapterName: chapter?.name as string,
mangaTitle: manga?.title as string,
progress: (raw.progress as number) ?? 0,
state: mapDownloadState(raw.state as string),
}
}
function mapDownloadState(state: string): DownloadItem['state'] {
switch (state) {
case 'DOWNLOADING': return 'downloading'
case 'FINISHED': return 'finished'
case 'ERROR': return 'error'
default: return 'queued'
}
}
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
@@ -347,31 +105,25 @@ export class SuwayomiAdapter implements ServerAdapter {
} }
async getManga(id: string): Promise<Manga> { async getManga(id: string): Promise<Manga> {
const data = await this.gql<{ manga: Record<string, unknown> }>( const data = await this.gql<{ manga: Record<string, unknown> }>(GET_MANGA, { id: Number(id) })
GET_MANGA, { id: Number(id) }
)
return mapManga(data.manga) return mapManga(data.manga)
} }
async getMangaList(filters: MangaFilters): Promise<PaginatedResult<Manga>> { async getMangaList(filters: MangaFilters): Promise<PaginatedResult<Manga>> {
if (filters.inLibrary) {
const data = await this.gql<{ mangas: { nodes: Record<string, unknown>[] } }>(GET_LIBRARY)
return { items: data.mangas.nodes.map(mapManga), hasNextPage: false }
}
const data = await this.gql<{ mangas: { nodes: Record<string, unknown>[] } }>(GET_LIBRARY) const data = await this.gql<{ mangas: { nodes: Record<string, unknown>[] } }>(GET_LIBRARY)
return { items: data.mangas.nodes.map(mapManga), hasNextPage: false } let items = data.mangas.nodes.map(mapManga)
if (filters.status) items = items.filter(m => m.status === filters.status)
if (filters.tags?.length) items = items.filter(m => filters.tags!.every(t => m.tags?.includes(t)))
if (filters.unread) items = items.filter(m => (m.unreadCount ?? 0) > 0)
if (filters.sourceId) items = items.filter(m => String(m.source?.id) === filters.sourceId)
return { items, hasNextPage: false }
} }
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<{ const data = await this.gql<{
fetchSourceManga: { mangas: Record<string, unknown>[] } fetchSourceManga: { mangas: Record<string, unknown>[] }
}>(FETCH_SOURCE_MANGA, { }>(FETCH_SOURCE_MANGA, { source: sourceId, type: 'SEARCH', page: 1, query })
source: sourceId,
type: 'SEARCH',
page: 1,
query,
})
return data.fetchSourceManga.mangas.map(mapManga) return data.fetchSourceManga.mangas.map(mapManga)
} }
@@ -386,11 +138,7 @@ export class SuwayomiAdapter implements ServerAdapter {
async updateMangaMeta(id: string, meta: Partial<MangaMeta>) { async updateMangaMeta(id: string, meta: Partial<MangaMeta>) {
for (const [key, value] of Object.entries(meta)) { for (const [key, value] of Object.entries(meta)) {
if (value === undefined) continue if (value === undefined) continue
await this.gql(SET_MANGA_META, { await this.gql(SET_MANGA_META, { mangaId: Number(id), key, value: String(value) })
mangaId: Number(id),
key,
value: String(value),
})
} }
} }
@@ -402,12 +150,10 @@ export class SuwayomiAdapter implements ServerAdapter {
} }
async getChapter(id: string): Promise<Chapter> { async getChapter(id: string): Promise<Chapter> {
const chapters = await this.gql<{ chapters: { nodes: Record<string, unknown>[] } }>( const data = await this.gql<{ chapter: Record<string, unknown> }>(
GET_CHAPTERS, { mangaId: 0 } GET_CHAPTER, { id: Number(id) }
) )
const found = chapters.chapters.nodes.find(c => String(c.id) === id) return mapChapter(data.chapter)
if (!found) throw new Error(`Chapter ${id} not found`)
return mapChapter(found)
} }
async getChapterPages(id: string): Promise<Page[]> { async getChapterPages(id: string): Promise<Page[]> {
@@ -426,9 +172,9 @@ export class SuwayomiAdapter implements ServerAdapter {
} }
async getDownloads(): Promise<DownloadItem[]> { async getDownloads(): Promise<DownloadItem[]> {
const data = await this.gql<{ const data = await this.gql<{ downloadStatus: { queue: Record<string, unknown>[] } }>(
downloadStatus: { queue: Record<string, unknown>[] } GET_DOWNLOAD_STATUS
}>(GET_DOWNLOAD_STATUS) )
return data.downloadStatus.queue.map(mapDownloadItem) return data.downloadStatus.queue.map(mapDownloadItem)
} }
@@ -446,9 +192,7 @@ export class SuwayomiAdapter implements ServerAdapter {
async getExtensions(): Promise<Extension[]> { async getExtensions(): Promise<Extension[]> {
await this.gql(FETCH_EXTENSIONS) await this.gql(FETCH_EXTENSIONS)
const data = await this.gql<{ extensions: { nodes: Record<string, unknown>[] } }>( const data = await this.gql<{ extensions: { nodes: Record<string, unknown>[] } }>(GET_EXTENSIONS)
GET_EXTENSIONS
)
return data.extensions.nodes.map(mapExtension) return data.extensions.nodes.map(mapExtension)
} }
@@ -472,11 +216,7 @@ export class SuwayomiAdapter implements ServerAdapter {
async browseSource(sourceId: string, page: number): Promise<PaginatedResult<Manga>> { async browseSource(sourceId: string, page: number): Promise<PaginatedResult<Manga>> {
const data = await this.gql<{ const data = await this.gql<{
fetchSourceManga: { mangas: Record<string, unknown>[]; hasNextPage: boolean } fetchSourceManga: { mangas: Record<string, unknown>[]; hasNextPage: boolean }
}>(FETCH_SOURCE_MANGA, { }>(FETCH_SOURCE_MANGA, { source: sourceId, type: 'LATEST', page })
source: sourceId,
type: 'LATEST',
page,
})
return { return {
items: data.fetchSourceManga.mangas.map(mapManga), items: data.fetchSourceManga.mangas.map(mapManga),
hasNextPage: data.fetchSourceManga.hasNextPage, hasNextPage: data.fetchSourceManga.hasNextPage,
@@ -0,0 +1,92 @@
export const GET_TRACKERS = `
query GetTrackers {
trackers {
nodes {
id name icon isLoggedIn isTokenExpired authUrl
supportsPrivateTracking supportsReadingDates supportsTrackDeletion
scores
statuses { value name }
}
}
}
`
export const GET_MANGA_TRACK_RECORDS = `
query GetMangaTrackRecords($mangaId: Int!) {
manga(id: $mangaId) {
trackRecords {
nodes {
id trackerId remoteId title status score displayScore
lastChapterRead totalChapters remoteUrl startDate finishDate private libraryId
}
}
}
}
`
export const SEARCH_TRACKER = `
query SearchTracker($trackerId: Int!, $query: String!) {
searchTracker(input: { trackerId: $trackerId, query: $query }) {
trackSearches {
id trackerId remoteId title coverUrl summary
publishingStatus publishingType startDate totalChapters trackingUrl
}
}
}
`
export const BIND_TRACK = `
mutation BindTrack($mangaId: Int!, $trackerId: Int!, $remoteId: LongString!) {
bindTrack(input: { mangaId: $mangaId, trackerId: $trackerId, remoteId: $remoteId }) {
trackRecord { id trackerId remoteId }
}
}
`
export const TRACK_PROGRESS = `
mutation TrackProgress($mangaId: Int!) {
trackProgress(input: { mangaId: $mangaId }) {
trackRecords { id trackerId lastChapterRead status }
}
}
`
export const UPDATE_TRACK = `
mutation UpdateTrack($recordId: Int!, $status: Int, $score: Float, $lastChapterRead: Float, $startDate: LongString, $finishDate: LongString, $private: Boolean) {
updateTrack(input: {
recordId: $recordId
status: $status
score: $score
lastChapterRead: $lastChapterRead
startDate: $startDate
finishDate: $finishDate
private: $private
}) {
trackRecord { id status score lastChapterRead }
}
}
`
export const UNLINK_TRACK = `
mutation UnlinkTrack($trackRecordId: Int!) {
unlinkTrack(input: { trackRecordId: $trackRecordId }) {
trackRecord { id }
}
}
`
export const LOGIN_TRACKER_CREDENTIALS = `
mutation LoginTrackerCredentials($trackerId: Int!, $username: String!, $password: String!) {
loginTrackerCredentials(input: { trackerId: $trackerId, username: $username, password: $password }) {
isLoggedIn
}
}
`
export const LOGOUT_TRACKER = `
mutation LogoutTracker($trackerId: Int!) {
logoutTracker(input: { trackerId: $trackerId }) {
isLoggedIn
}
}
`
+6 -3
View File
@@ -1,9 +1,12 @@
import { sveltekit } from '@sveltejs/kit/vite'; import { sveltekit } from '@sveltejs/kit/vite'
import { defineConfig } from 'vite'; import { defineConfig } from 'vite'
export default defineConfig({ export default defineConfig({
plugins: [sveltekit()], plugins: [sveltekit()],
clearScreen: false, clearScreen: false,
define: {
__APP_VERSION__: JSON.stringify(process.env.npm_package_version ?? '0.0.0'),
},
server: { server: {
port: 1420, port: 1420,
strictPort: true, strictPort: true,
@@ -17,4 +20,4 @@ export default defineConfig({
minify: !process.env.TAURI_DEBUG ? 'oxc' : false, minify: !process.env.TAURI_DEBUG ? 'oxc' : false,
sourcemap: !!process.env.TAURI_DEBUG, sourcemap: !!process.env.TAURI_DEBUG,
}, },
}); })