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.
This commit is contained in:
@@ -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<SearchResult> {
|
||||
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 = /<a\s[^>]*href="([^"]+\.gme)"[^>]*>([\s\S]*?)<\/a>/gi
|
||||
const srcRe = /src="([^"]+)"/i
|
||||
const altRe = /alt="([^"]*)"/i
|
||||
|
||||
const seen = new Set<string>()
|
||||
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<Book[]> {
|
||||
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<PenInfo | null> {
|
||||
const { uri } = await Tiptoi.getPenUri()
|
||||
if (!uri) return null
|
||||
return { uri, device: '', label: null, candidates: [] }
|
||||
}
|
||||
|
||||
export async function requestPenAccess(): Promise<PenInfo | null> {
|
||||
const { uri } = await Tiptoi.requestPenAccess()
|
||||
return { uri, device: '', label: null, candidates: [] }
|
||||
}
|
||||
|
||||
// ── Stift-Dateien ────────────────────────────────────────────────────────────
|
||||
|
||||
export async function listPenFiles(uri: string): Promise<string[]> {
|
||||
const { files } = await Tiptoi.listPenFiles({ uri })
|
||||
return files
|
||||
}
|
||||
|
||||
export async function syncFile(
|
||||
uri: string,
|
||||
filename: string,
|
||||
onProgress?: (fraction: number) => void,
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
await Tiptoi.deleteDownload({ filename })
|
||||
}
|
||||
|
||||
export async function deletePenFile(uri: string, filename: string): Promise<void> {
|
||||
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' }
|
||||
}
|
||||
Reference in New Issue
Block a user