From 49786ac54ff5991019d7190f1a481ce4a19b1ced Mon Sep 17 00:00:00 2001 From: arnef Date: Wed, 12 Aug 2026 11:46:28 +0200 Subject: [PATCH] Add Ravensburger search/download API client Scrapes the Ravensburger site for .gme download links (barcode or free-text query), decodes HTML entities, and orchestrates download/library/sync operations against the native Tiptoi plugin. --- client/src/api.ts | 204 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 204 insertions(+) create mode 100644 client/src/api.ts diff --git a/client/src/api.ts b/client/src/api.ts new file mode 100644 index 0000000..e27d4a7 --- /dev/null +++ b/client/src/api.ts @@ -0,0 +1,204 @@ +import { Capacitor, CapacitorHttp } from '@capacitor/core' +import { Tiptoi } from './native/TiptoiPlugin' + +// ── HTML-Entity-Dekodierung ───────────────────────────────────────────────── + +function decodeHtmlEntities(str: string): string { + return str + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/ /g, ' ') + .replace(/ß/g, 'ß') + .replace(/ä/g, 'ä').replace(/Ä/g, 'Ä') + .replace(/ö/g, 'ö').replace(/Ö/g, 'Ö') + .replace(/ü/g, 'ü').replace(/Ü/g, 'Ü') + .replace(/&#(\d+);/g, (_, n) => String.fromCharCode(Number(n))) + .replace(/&#x([0-9a-fA-F]+);/g, (_, h) => String.fromCharCode(parseInt(h, 16))) +} + +// ── Typen ──────────────────────────────────────────────────────────────────── + +export interface GmeEntry { + url: string + label: string + imageUrl: string | null +} + +export interface SearchResult { + title?: string + gmeUrls?: GmeEntry[] + error?: string +} + +export interface Book { + filename: string + coverUrl: string | null + size: number | null +} + +export interface PenInfo { + uri: string + device: string + label: string | null + candidates: PenInfo[] +} + +// ── Suche ──────────────────────────────────────────────────────────────────── + +export async function searchBook(q: string): Promise { + let query = q + const isBarcode = /^\d{8,}$/.test(q) + if (isBarcode) query = q.slice(q.length - 6, q.length - 1) + + try { + const apiRes = await CapacitorHttp.get({ + url: `https://service.ravensburger.de/@api/deki/site/query?dream.out.format=json&limit=1&q=${encodeURIComponent(query)}&recommendations=true&sortBy=-rank&types=wiki¬rack=false&parser=bestguess`, + }) + const data = apiRes.data as { result?: { uri?: string; title?: string } } + const pageUri = data.result?.uri + if (!pageUri) return { error: 'Kein Buch gefunden' } + const title = decodeHtmlEntities(data.result?.title ?? '') + if (isBarcode && !title.includes(query)) return { error: 'Kein Buch gefunden' } + + const pageRes = await CapacitorHttp.get({ url: pageUri, responseType: 'text' }) + const html = pageRes.data as string + + const anchorRe = /]*href="([^"]+\.gme)"[^>]*>([\s\S]*?)<\/a>/gi + const srcRe = /src="([^"]+)"/i + const altRe = /alt="([^"]*)"/i + + const seen = new Set() + const gmeUrls: GmeEntry[] = [] + let am + while ((am = anchorRe.exec(html)) !== null) { + const url = decodeHtmlEntities(am[1]) + if (seen.has(url)) continue + seen.add(url) + const content = am[2] + const srcMatch = srcRe.exec(content) + const altMatch = altRe.exec(content) + const imageUrl = srcMatch ? decodeHtmlEntities(srcMatch[1]) : null + const altText = altMatch ? decodeHtmlEntities(altMatch[1]) : '' + const numMatch = altText.match(/\b(\d{4,6})\b/) + const label = numMatch ? numMatch[1] : url.split('/').pop()!.replace(/\.gme$/i, '') + gmeUrls.push({ url, label, imageUrl }) + } + if (gmeUrls.length === 0) { + for (const fm of html.matchAll(/href="([^"]+\.gme)"/gi)) { + const url = decodeHtmlEntities(fm[1]) + if (!seen.has(url)) { + seen.add(url) + gmeUrls.push({ url, label: url.split('/').pop()!.replace(/\.gme$/i, ''), imageUrl: null }) + } + } + } + if (gmeUrls.length === 0) return { error: 'Keine GME-Datei gefunden' } + + return { title, gmeUrls } + } catch (e) { + return { error: e instanceof Error ? e.message : String(e) } + } +} + +// ── Download ───────────────────────────────────────────────────────────────── + +export async function downloadBook( + gmeUrl: string, + imageUrl?: string, +): Promise<{ message: string; filename?: string }> { + const urlPath = new URL(gmeUrl).pathname + const base = decodeURIComponent(urlPath.split('/').pop()!).replace(/\.gme$/i, '') + const filename = `${base}.gme` + + await Tiptoi.downloadGme({ url: gmeUrl, filename }) + + if (imageUrl) { + try { + const ext = new URL(imageUrl).pathname.match(/\.[^.]+$/)?.[0] ?? '.png' + await Tiptoi.downloadCover({ url: imageUrl, filename: `${base}${ext}` }) + } catch { /* Cover ist optional */ } + } + + return { message: `${filename} heruntergeladen`, filename } +} + +// ── Bibliothek ─────────────────────────────────────────────────────────────── + +export async function listDownloads(): Promise { + const { books } = await Tiptoi.listDownloads() + return books.map(b => ({ + filename: b.filename, + coverUrl: b.coverPath ? Capacitor.convertFileSrc(b.coverPath) : null, + size: b.size, + })) +} + +// ── Stift-Erkennung ────────────────────────────────────────────────────────── + +export async function detectPen(): Promise { + const { uri } = await Tiptoi.getPenUri() + if (!uri) return null + return { uri, device: '', label: null, candidates: [] } +} + +export async function requestPenAccess(): Promise { + const { uri } = await Tiptoi.requestPenAccess() + return { uri, device: '', label: null, candidates: [] } +} + +// ── Stift-Dateien ──────────────────────────────────────────────────────────── + +export async function listPenFiles(uri: string): Promise { + const { files } = await Tiptoi.listPenFiles({ uri }) + return files +} + +export async function syncFile( + uri: string, + filename: string, + onProgress?: (fraction: number) => void, +): Promise { + let handle: import('@capacitor/core').PluginListenerHandle | undefined + if (onProgress) { + handle = await Tiptoi.addListener('copyProgress', (e) => { + if (e.filename === filename && e.total > 0) { + onProgress(e.bytes / e.total) + } + }) + } + try { + await Tiptoi.copyToPen({ uri, filename }) + onProgress?.(1) + } finally { + await handle?.remove() + } +} + +export async function syncAll( + uri: string, + onProgress?: (filename: string, fraction: number) => void, +): Promise { + const [downloads, penFiles] = await Promise.all([listDownloads(), listPenFiles(uri)]) + const onPen = new Set(penFiles) + for (const book of downloads) { + if (!onPen.has(book.filename)) { + await syncFile(uri, book.filename, onProgress ? (f) => onProgress(book.filename, f) : undefined) + } + } +} + +export async function deleteDownload(filename: string): Promise { + await Tiptoi.deleteDownload({ filename }) +} + +export async function deletePenFile(uri: string, filename: string): Promise { + await Tiptoi.deletePenFile({ uri, filename }) +} + +export async function ejectPen(_uri: string, _device: string): Promise<{ success: boolean; message: string }> { + // Android: OS übernimmt das physische Trennen + return { success: true, message: 'Stift kann sicher abgezogen werden' } +}