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