feat: improve offline support to replace the "local" provider
This commit is contained in:
parent
e52fc41118
commit
f9859d7eca
24 changed files with 1006 additions and 598 deletions
3
App.tsx
3
App.tsx
|
@ -10,6 +10,7 @@ import { ComicProviderKey } from './src/provider';
|
|||
import { NonLocalProviderKey } from './src/provider/local';
|
||||
import Save from './src/pages/save';
|
||||
import ComicList, { TabParamList } from './src/pages/comiclist';
|
||||
import DebugCache from './src/pages/debug/cache';
|
||||
|
||||
const Stack = createNativeStackNavigator<RootStackParamList>();
|
||||
const queryClient = new QueryClient();
|
||||
|
@ -21,6 +22,7 @@ export type RootStackParamList = {
|
|||
Chapter: { provider: ComicProviderKey; comicId: string; chapterId: string; };
|
||||
Page: { provider: ComicProviderKey; comicId: string; pageId: string; };
|
||||
Save: { provider: NonLocalProviderKey; comicId: string; };
|
||||
DebugCache: undefined;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
|
@ -36,6 +38,7 @@ export default function App() {
|
|||
<Stack.Screen name="Chapter" component={Chapter} />
|
||||
<Stack.Screen name="Page" component={Comic} />
|
||||
<Stack.Screen name="Save" component={Save} />
|
||||
<Stack.Screen name="DebugCache" component={DebugCache} />
|
||||
</Stack.Navigator>
|
||||
<StatusBar style='dark' />
|
||||
</NavigationContainer>
|
||||
|
|
|
@ -17,6 +17,7 @@
|
|||
"@react-navigation/native": "^6.0.12",
|
||||
"@react-navigation/native-stack": "^6.8.0",
|
||||
"cheerio": "^1.0.0-rc.12",
|
||||
"crypto-js": "^4.1.1",
|
||||
"expo": "^49.0.0",
|
||||
"expo-file-system": "~15.4.2",
|
||||
"expo-status-bar": "~1.6.0",
|
||||
|
@ -27,7 +28,7 @@
|
|||
"react-native-gesture-handler": "~2.12.0",
|
||||
"react-native-image-progress": "^1.2.0",
|
||||
"react-native-modalize": "^2.1.1",
|
||||
"react-native-progress": "^5.0.0",
|
||||
"react-native-progress": "https://github.com/EslamElMeniawy/react-native-progress",
|
||||
"react-native-safe-area-context": "4.6.3",
|
||||
"react-native-screens": "~3.22.0",
|
||||
"react-native-svg": "13.9.0",
|
||||
|
@ -36,8 +37,9 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.20.0",
|
||||
"@types/crypto-js": "^4.1.1",
|
||||
"@types/react": "~18.2.14",
|
||||
"@types/react-native": "~0.70.6",
|
||||
"@types/react-native": "^0.72.2",
|
||||
"typescript": "^5.1.3"
|
||||
},
|
||||
"private": true
|
||||
|
|
26
src/components/CachedImage.tsx
Normal file
26
src/components/CachedImage.tsx
Normal file
|
@ -0,0 +1,26 @@
|
|||
import { FC } from "react";
|
||||
import { useImageUrl } from "../provider";
|
||||
import { ActivityIndicator } from "react-native";
|
||||
import { Image as NativeImage, Text } from "react-native";
|
||||
import { createImageProgress } from "react-native-image-progress";
|
||||
import { Pie } from "react-native-progress";
|
||||
|
||||
const Image = createImageProgress(NativeImage);
|
||||
|
||||
interface Props {
|
||||
url: string;
|
||||
style?: any;
|
||||
imageStyle?: any;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
const CachedImage: FC<Props> = (props) => {
|
||||
const { data, error, isLoading } = useImageUrl(props.url);
|
||||
if (isLoading) return <ActivityIndicator />;
|
||||
if (error) return <Text>Failed to load image: {error.toString()}</Text>;
|
||||
if (!data) return <Text>Something has gone horribly wrong!</Text>
|
||||
return <Image style={props.style} imageStyle={props.imageStyle} source={data} resizeMethod='scale' resizeMode='cover' indicator={Pie} width={props.width} height={props.height} />;
|
||||
}
|
||||
|
||||
export default CachedImage;
|
|
@ -3,7 +3,8 @@ import { NativeStackNavigationProp } from "@react-navigation/native-stack";
|
|||
import { FC } from "react";
|
||||
import { ActivityIndicator, FlatList, StyleSheet, Text, View } from "react-native";
|
||||
import { RootStackParamList } from "../../App";
|
||||
import { SimpleChapter as ChapterData, ComicProviderKey, useComicChapters } from "../provider";
|
||||
import { ComicProviderKey, useComicChapters } from "../provider";
|
||||
import { SimpleChapter as ChapterData } from "../provider/provider";
|
||||
import Touchable from "./Touchable";
|
||||
|
||||
interface Props {
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { FC, ReactNode } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { Text } from "react-native";
|
||||
|
||||
interface Props {
|
||||
colour: string;
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
import { MaterialCommunityIcons } from "@expo/vector-icons";
|
||||
import { FC } from "react";
|
||||
import { Image, StyleSheet, Text, View } from "react-native";
|
||||
import { ComicMetadata } from "../provider";
|
||||
import { StyleSheet, Text, View } from "react-native";
|
||||
import { ComicMetadata } from "../provider/provider";
|
||||
import Chip from "./Chip";
|
||||
import CachedImage from "./CachedImage";
|
||||
|
||||
interface Props {
|
||||
comic: ComicMetadata & { isLocal?: boolean }
|
||||
|
@ -10,7 +11,7 @@ interface Props {
|
|||
|
||||
const ComicCard: FC<Props> = (props) => {
|
||||
return <View style={styles.aboutRow}>
|
||||
<Image source={{ uri: props.comic.avatarUrl }} style={styles.avatar} />
|
||||
<CachedImage url={props.comic.avatarUrl} style={styles.avatar} imageStyle={styles.avatar} />
|
||||
<View style={styles.titleContainer}>
|
||||
<View style={styles.titleRow}>
|
||||
<Text style={styles.title}>
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
import { FC } from "react"
|
||||
import { StyleSheet, View } from "react-native"
|
||||
import { ComicMetadata } from "../provider"
|
||||
import { ComicMetadata } from "../provider/provider"
|
||||
import ComicCard from "./ComicCard"
|
||||
import Touchable from "./Touchable"
|
||||
|
||||
const ComicSelector: FC<ComicMetadata & { onClick: () => void; isLocal?: boolean }> = (props) => {
|
||||
const ComicSelector: FC<ComicMetadata & { onClick: () => void }> = (props) => {
|
||||
return <Touchable onPress={() => props.onClick()}>
|
||||
<View style={styles.comic}>
|
||||
<ComicCard comic={props} />
|
||||
|
|
|
@ -4,7 +4,8 @@ import { ActivityIndicator, FlatList, Platform, StyleSheet, Text, TouchableNativ
|
|||
import { RootStackParamList } from "../../App";
|
||||
import Divider from "../components/Divider";
|
||||
import Touchable from "../components/Touchable";
|
||||
import { SimplePage, useComicChapter } from "../provider";
|
||||
import { useComicChapter } from "../provider";
|
||||
import { SimplePage } from "../provider/provider";
|
||||
|
||||
const Page: FC<SimplePage & { onClick: (id: string) => void }> = (props) => {
|
||||
return <Touchable onPress={() => props.onClick(props.id)}>
|
||||
|
|
|
@ -2,15 +2,14 @@ import { MaterialCommunityIcons } from "@expo/vector-icons";
|
|||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { NativeStackScreenProps } from "@react-navigation/native-stack";
|
||||
import { useEffect } from "react";
|
||||
import { ActivityIndicator, Image as NativeImage, ScrollView, StyleSheet, Text, View } from "react-native";
|
||||
import { ActivityIndicator, Dimensions, Image as NativeImage, ScrollView, StyleSheet, Text, View } from "react-native";
|
||||
import { createImageProgress } from "react-native-image-progress";
|
||||
import { Pie } from 'react-native-progress';
|
||||
import { useQueryClient } from "react-query";
|
||||
import { RootStackParamList } from "../../App";
|
||||
import Touchable from "../components/Touchable";
|
||||
import { useComicPage } from "../provider";
|
||||
|
||||
const Image = createImageProgress(NativeImage);
|
||||
import CachedImage from "../components/CachedImage";
|
||||
|
||||
export default function Comic(props: Props) {
|
||||
const { data, error, isLoading } = useComicPage(props.route.params.provider, props.route.params.comicId, props.route.params.pageId, props.navigation);
|
||||
|
@ -24,6 +23,8 @@ export default function Comic(props: Props) {
|
|||
}
|
||||
}, [data]);
|
||||
|
||||
const width = Dimensions.get('window').width;
|
||||
|
||||
if (isLoading) return <ActivityIndicator />;
|
||||
if (error) return <Text>Failed to load comic: {error.toString()}</Text>;
|
||||
if (!data) return <Text>Whoops! We couldn't find that page :/</Text>;
|
||||
|
@ -38,10 +39,11 @@ export default function Comic(props: Props) {
|
|||
}
|
||||
|
||||
return <ScrollView>
|
||||
{data.imageSegments.map((item) => <Image style={{
|
||||
width: '100%',
|
||||
{data.imageSegments.map((item) => <CachedImage
|
||||
key={item.url} style={{
|
||||
// width: '100vw',
|
||||
aspectRatio: item.width / item.height,
|
||||
}} source={{ uri: item.url }} resizeMode='center' key={item.url} indicator={Pie} />)}
|
||||
}} url={item.url} width={width} />)}
|
||||
|
||||
<View style={styles.navigationRow}>
|
||||
<Touchable onPress={() => newPage(data.previousPageId)} disabled={data.previousPageId === undefined}>
|
||||
|
@ -61,6 +63,9 @@ export default function Comic(props: Props) {
|
|||
</View>
|
||||
</Touchable>
|
||||
</View>
|
||||
<Text>
|
||||
{props.route.params.provider}:{props.route.params.comicId}:{props.route.params.pageId}
|
||||
</Text>
|
||||
</ScrollView>
|
||||
}
|
||||
|
||||
|
|
|
@ -55,7 +55,7 @@ export default function Library() {
|
|||
}
|
||||
|
||||
return <FlatList data={data} renderItem={({ item }) =>
|
||||
<ComicSelector onClick={() => openComic(item.provider, item.comicId)} isLocal={item.provider === 'local'} {...item} />
|
||||
<ComicSelector onClick={() => openComic(item.provider, item.comicId)} {...item} />
|
||||
} keyExtractor={item => item.provider + ':' + item.comicId} />
|
||||
}
|
||||
|
||||
|
|
|
@ -2,7 +2,7 @@ import { FC, useEffect } from "react"
|
|||
import { ActivityIndicator, FlatList, StyleSheet, Text, TouchableOpacity, View } from "react-native"
|
||||
import Touchable from "../../components/Touchable";
|
||||
import ComicCard from '../../components/ComicCard';
|
||||
import { ComicMetadata, useLocalComics } from "../../provider"
|
||||
import { useLocalComics } from "../../provider"
|
||||
import { CompositeNavigationProp, useNavigation } from "@react-navigation/native";
|
||||
import { BottomTabNavigationProp } from "@react-navigation/bottom-tabs";
|
||||
import { TabParamList } from "../comiclist";
|
||||
|
@ -54,10 +54,11 @@ export default function LocalComicList() {
|
|||
if (!data) return <Text>Whoops! We couldn't those comics :/</Text>;
|
||||
|
||||
function openComic(comicId: LocalComicId) {
|
||||
navigation.navigate('ComicProfile', {
|
||||
provider: 'local',
|
||||
comicId,
|
||||
});
|
||||
alert('oops');
|
||||
// navigation.navigate('ComicProfile', {
|
||||
// provider: 'local',
|
||||
// comicId,
|
||||
// });
|
||||
}
|
||||
|
||||
if (data.length === 0) {
|
||||
|
|
|
@ -12,15 +12,13 @@ export default function ComicProfile(props: Props) {
|
|||
const comicQuery = useComicMetadata(props.route.params.provider, props.route.params.comicId, (data) => {
|
||||
props.navigation.setOptions({
|
||||
title: data.title,
|
||||
headerRight: props.route.params.provider === 'local' ? undefined : () => (
|
||||
headerRight: () => (
|
||||
<TouchableOpacity onPress={() => {
|
||||
const { provider, comicId } = props.route.params;
|
||||
if (provider !== 'local') {
|
||||
props.navigation.push('Save', {
|
||||
provider,
|
||||
comicId,
|
||||
});
|
||||
}
|
||||
}}>
|
||||
<MaterialCommunityIcons name='plus-circle' size={24} style={{ color: 'black' }} />
|
||||
</TouchableOpacity>
|
||||
|
|
29
src/pages/debug/cache.tsx
Normal file
29
src/pages/debug/cache.tsx
Normal file
|
@ -0,0 +1,29 @@
|
|||
import { NativeStackScreenProps } from "@react-navigation/native-stack";
|
||||
import { RootStackParamList } from "../../../App";
|
||||
import { ScrollView, StyleSheet, Text, View } from "react-native";
|
||||
import { CACHES } from "../../provider";
|
||||
import { logBuffer } from "../../provider/cache";
|
||||
|
||||
export default function DebugCache(props: Props) {
|
||||
return <ScrollView>
|
||||
{CACHES.map((item) => {
|
||||
return <View style={styles.item} key={item.name}>
|
||||
<Text>
|
||||
{item.name}: {item.size} items
|
||||
</Text>
|
||||
</View>;
|
||||
})}
|
||||
<Text style={styles.item}>
|
||||
LOGS:{'\n'}
|
||||
{logBuffer.join('\n')}
|
||||
</Text>
|
||||
</ScrollView>
|
||||
}
|
||||
|
||||
type Props = NativeStackScreenProps<RootStackParamList, 'DebugCache'>;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
item: {
|
||||
padding: 8,
|
||||
},
|
||||
});
|
|
@ -4,7 +4,7 @@ import { useState } from "react";
|
|||
import { Button, StyleSheet, Text, TextInput, View } from "react-native";
|
||||
import { useQueryClient } from "react-query";
|
||||
import { RootStackParamList } from "../../App";
|
||||
import { ComicProviderKey } from "../provider";
|
||||
import { CACHES, ComicProviderKey } from "../provider";
|
||||
import * as FileSystem from 'expo-file-system';
|
||||
|
||||
export default function Home(props: Props) {
|
||||
|
@ -23,7 +23,7 @@ export default function Home(props: Props) {
|
|||
<Button title="Load comic" onPress={() => props.navigation.navigate('ComicProfile', { provider: selectedProvider, comicId })} />
|
||||
</View>
|
||||
<View style={styles.button}>
|
||||
<Button title="Yeet cache" onPress={() => queryClient.getQueryCache().clear()} />
|
||||
<Button title="Yeet RQ cache" onPress={() => queryClient.getQueryCache().clear()} />
|
||||
</View>
|
||||
<View style={styles.button}>
|
||||
<Button title="Yeet library" onPress={async () => {
|
||||
|
@ -32,10 +32,11 @@ export default function Home(props: Props) {
|
|||
}} />
|
||||
</View>
|
||||
<View style={styles.button}>
|
||||
<Button title="New home" onPress={async () => {
|
||||
props.navigation.navigate('Home', {
|
||||
screen: 'Local',
|
||||
});
|
||||
<Button title="Cache debug" onPress={async () => {
|
||||
for (const cache of CACHES) {
|
||||
await cache.readCache();
|
||||
}
|
||||
props.navigation.navigate('DebugCache');
|
||||
}} />
|
||||
</View>
|
||||
</View>
|
||||
|
|
|
@ -2,33 +2,27 @@ import { NativeStackScreenProps } from "@react-navigation/native-stack";
|
|||
import { useEffect, useState } from "react";
|
||||
import { ActivityIndicator, StyleSheet, Text, View } from "react-native";
|
||||
import { RootStackParamList } from "../../App";
|
||||
import localProvider from "../provider/local";
|
||||
import { updateOfflineComic } from "../provider/download";
|
||||
import { MaterialIcons } from "@expo/vector-icons";
|
||||
|
||||
export default function Save(props: Props) {
|
||||
const [newId, setNewId] = useState<string | null>(null);
|
||||
const [error, setError] = useState<any | null>(null);
|
||||
const [message, setMessage] = useState('');
|
||||
|
||||
useEffect(() => props.navigation.addListener('beforeRemove', (e) => {
|
||||
if (newId || error) return;
|
||||
if (error) return;
|
||||
e.preventDefault();
|
||||
}), [props.navigation, newId, error]);
|
||||
}), [props.navigation, error]);
|
||||
|
||||
useEffect(() => {
|
||||
if (newId) {
|
||||
props.navigation.pop(2);
|
||||
props.navigation.push('ComicProfile', {
|
||||
provider: 'local',
|
||||
comicId: newId,
|
||||
});
|
||||
}
|
||||
}, [newId]);
|
||||
|
||||
useEffect(() => {
|
||||
localProvider.downloadComicData(props.route.params.provider, props.route.params.comicId, setMessage)
|
||||
.then((newId) => {
|
||||
if (newId) {
|
||||
setNewId(newId);
|
||||
updateOfflineComic(props.route.params.provider, props.route.params.comicId, (m) => {
|
||||
console.log(m);
|
||||
setMessage(m);
|
||||
})
|
||||
.then((ok) => {
|
||||
if (ok) {
|
||||
console.log('done!');
|
||||
props.navigation.pop();
|
||||
}
|
||||
}).catch((err) => {
|
||||
setError(err);
|
||||
|
@ -37,7 +31,8 @@ export default function Save(props: Props) {
|
|||
}, []);
|
||||
|
||||
return <View style={styles.container}>
|
||||
<ActivityIndicator size='large' />
|
||||
{!error && <ActivityIndicator size='large' />}
|
||||
{error && <MaterialIcons size={36} name='error-outline' color='#555' />}
|
||||
<Text>Saving comic...</Text>
|
||||
<Text>{message}</Text>
|
||||
</View>
|
||||
|
|
133
src/provider/cache.ts
Normal file
133
src/provider/cache.ts
Normal file
|
@ -0,0 +1,133 @@
|
|||
import * as FileSystem from 'expo-file-system';
|
||||
import { dataPath, pathExists } from '../util';
|
||||
import { ImageSourcePropType } from 'react-native/types';
|
||||
import { USER_AGENT } from '../util/fetch';
|
||||
import SHA512 from 'crypto-js/sha512';
|
||||
|
||||
export class DataCache<T> {
|
||||
name: string;
|
||||
private cache: Record<string, T> = {};
|
||||
private cachePath: string;
|
||||
private loaded: boolean = false;
|
||||
|
||||
constructor(name: string) {
|
||||
this.name = name;
|
||||
this.cachePath = dataPath(`cache-${name}.json`);
|
||||
}
|
||||
|
||||
async getWithFallback(key: string, fetcher: () => Promise<T | null>): Promise<T | null> {
|
||||
if (!this.loaded) {
|
||||
this.debug('not loaded yet, checking disk');
|
||||
await this.readCache();
|
||||
}
|
||||
this.debug('fetching using provided fetcher...');
|
||||
try {
|
||||
const result = await fetcher();
|
||||
if (result) {
|
||||
this.debug('got result, adding to cache');
|
||||
this.cache[key] = result;
|
||||
await this.writeCache();
|
||||
return result;
|
||||
}
|
||||
} catch (e) {
|
||||
this.debug(`Failed to fetch: ${e}`);
|
||||
}
|
||||
this.debug('failed to get from fetcher, checking local cache');
|
||||
if (!(key in this.cache)) {
|
||||
this.debug('cache miss');
|
||||
return null;
|
||||
}
|
||||
const cacheEntry = this.cache[key];
|
||||
this.debug('cache hit');
|
||||
return cacheEntry;
|
||||
}
|
||||
|
||||
async readCache() {
|
||||
if (await pathExists(this.cachePath)) {
|
||||
this.debug('disk cache exists, loading...');
|
||||
const data = await FileSystem.readAsStringAsync(this.cachePath);
|
||||
this.cache = JSON.parse(data);
|
||||
} else {
|
||||
this.debug('no cache on disk');
|
||||
}
|
||||
this.loaded = true;
|
||||
}
|
||||
|
||||
private async writeCache() {
|
||||
this.debug('writing updated cache to disk');
|
||||
const data = JSON.stringify(this.cache);
|
||||
await FileSystem.writeAsStringAsync(this.cachePath, data);
|
||||
}
|
||||
|
||||
private debug(s: string) {
|
||||
console.log(`[cache:${this.name}]`, s);
|
||||
if (logBuffer.length > MAX_LOG_LENGTH) {
|
||||
logBuffer.slice(logBuffer.length - MAX_LOG_LENGTH - 1);
|
||||
}
|
||||
logBuffer.push(`[cache:${this.name}] ${s}`);
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return Object.keys(this.cache).length;
|
||||
}
|
||||
}
|
||||
|
||||
export class ImageCache {
|
||||
private name: string;
|
||||
private rootPath: string;
|
||||
|
||||
constructor(name: string) {
|
||||
this.name = name;
|
||||
this.rootPath = dataPath(`image-cache/${name}/`);
|
||||
}
|
||||
|
||||
private imagePath(url: string): string {
|
||||
const hash = SHA512(url).toString();
|
||||
const extension = url.split(/[#?]/)[0].split('.').pop()?.trim() ?? '';
|
||||
const path = this.rootPath + `${hash}.${extension}`;
|
||||
return path;
|
||||
}
|
||||
|
||||
async loadIntoCache(url: string): Promise<void> {
|
||||
const cachePath = this.imagePath(url);
|
||||
if (!(await pathExists(this.rootPath))) {
|
||||
await FileSystem.makeDirectoryAsync(this.rootPath, { intermediates: true });
|
||||
}
|
||||
await FileSystem.downloadAsync(url, cachePath, {
|
||||
headers: {
|
||||
'user-agent': USER_AGENT,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async removeFromCache(url: string) {
|
||||
const cachePath = this.imagePath(url);
|
||||
await FileSystem.deleteAsync(cachePath, { idempotent: true });
|
||||
}
|
||||
|
||||
async tryFromCache(url: string): Promise<ImageSourcePropType> {
|
||||
const cachePath = this.imagePath(url);
|
||||
if (await pathExists(cachePath)) {
|
||||
this.debug(`cache hit for ${url}`);
|
||||
return {
|
||||
uri: cachePath,
|
||||
};
|
||||
} else {
|
||||
this.debug(`cache miss for ${url}`);
|
||||
return {
|
||||
uri: url,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private debug(s: string) {
|
||||
console.log(`[imagecache:${this.name}]`, s);
|
||||
if (logBuffer.length > MAX_LOG_LENGTH) {
|
||||
logBuffer.slice(logBuffer.length - MAX_LOG_LENGTH - 1);
|
||||
}
|
||||
logBuffer.push(`[imagecache:${this.name}] ${s}`);
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_LOG_LENGTH = 1024;
|
||||
export let logBuffer: string[] = [];
|
|
@ -1,4 +1,4 @@
|
|||
import type { SimpleChapter, ComicProvider, SimplePage, ImageSegment } from ".";
|
||||
import type { SimpleChapter, ComicProvider, SimplePage, ImageSegment } from "./provider";
|
||||
import scrapePage from "../util/fetch";
|
||||
|
||||
const comicfuryProvider: ComicProvider = {
|
||||
|
|
49
src/provider/download.ts
Normal file
49
src/provider/download.ts
Normal file
|
@ -0,0 +1,49 @@
|
|||
import { ComicProviderKey, IMAGE_CACHE, getComicChapter, getComicChapters, getComicMetadata, getComicPage } from ".";
|
||||
|
||||
export async function updateOfflineComic(
|
||||
provider: ComicProviderKey,
|
||||
comicId: string,
|
||||
statusCallback: (message: string) => void
|
||||
): Promise<boolean> {
|
||||
const info = await getComicMetadata(provider, comicId);
|
||||
if (info === null) return false;
|
||||
|
||||
const chapters = await getComicChapters(provider, comicId);
|
||||
if (chapters === null) throw new Error('invalid state');
|
||||
|
||||
let chapterIndex = 0;
|
||||
|
||||
const images = [
|
||||
info.authorAvatarUrl,
|
||||
info.avatarUrl,
|
||||
];
|
||||
|
||||
console.log(images);
|
||||
|
||||
for (const incompleteChapter of chapters) {
|
||||
chapterIndex += 1;
|
||||
if (statusCallback) statusCallback(`Chapter ${chapterIndex}/${chapters.length}`);
|
||||
const chapter = await getComicChapter(provider, comicId, incompleteChapter.id);
|
||||
if (chapter === null) throw new Error('invalid state');
|
||||
|
||||
let completedPages = 0;
|
||||
for (const incompletePage of chapter.pages) {
|
||||
const page = await getComicPage(provider, comicId, incompletePage.id);
|
||||
if (page === null) throw new Error('invalid state');
|
||||
completedPages += 1;
|
||||
if (statusCallback) statusCallback(`Chapter ${chapterIndex}/${chapters.length} - ${completedPages}/${chapter.pages.length}`)
|
||||
for (const segment of page.imageSegments) {
|
||||
images.push(segment.url);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let i = 0;
|
||||
for (const imageUrl of images) {
|
||||
i++;
|
||||
if (statusCallback) statusCallback(`Downloading ${i}/${images.length}`);
|
||||
await IMAGE_CACHE.loadIntoCache(imageUrl);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
|
@ -4,69 +4,42 @@ import { useQuery } from "react-query";
|
|||
import { RootStackParamList } from "../../App";
|
||||
import comicfuryProvider from "./comicfury";
|
||||
import localProvider, { LocalComicMetadata } from "./local";
|
||||
|
||||
export interface ComicProvider {
|
||||
getComicInfo(comicId: string): Promise<ComicMetadata | null>;
|
||||
listChapters(comicId: string): Promise<SimpleChapter[] | null>;
|
||||
chapterInfo(comicId: string, chapterId: string): Promise<FullChapter | null>;
|
||||
getPage(comicId: string, pageId: string): Promise<FullPage | null>;
|
||||
}
|
||||
|
||||
export interface ComicMetadata {
|
||||
title: string;
|
||||
author: string;
|
||||
authorAvatarUrl: string;
|
||||
shortDescription: string;
|
||||
description: string;
|
||||
avatarUrl: string;
|
||||
}
|
||||
|
||||
export type FullComicMetadata = ComicMetadata & { comicId: string; provider: ComicProviderKey; };
|
||||
|
||||
export interface SimpleChapter {
|
||||
id: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface FullChapter {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
pages: SimplePage[];
|
||||
}
|
||||
|
||||
export interface SimplePage {
|
||||
id: string;
|
||||
title: string;
|
||||
published: string;
|
||||
}
|
||||
|
||||
export interface FullPage {
|
||||
id: string;
|
||||
title: string;
|
||||
published: string;
|
||||
imageSegments: ImageSegment[];
|
||||
previousPageId?: string;
|
||||
nextPageId?: string;
|
||||
}
|
||||
|
||||
export interface ImageSegment {
|
||||
url: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
import { ComicMetadata, FullChapter, FullPage, SimpleChapter } from "./provider";
|
||||
import { DataCache, ImageCache } from "./cache";
|
||||
|
||||
export const COMIC_PROVIDERS = {
|
||||
comicfury: comicfuryProvider,
|
||||
local: localProvider,
|
||||
// local: localProvider,
|
||||
};
|
||||
|
||||
export type FullComicMetadata = ComicMetadata & { comicId: string; provider: ComicProviderKey; };
|
||||
|
||||
export type ComicProviderKey = keyof typeof COMIC_PROVIDERS;
|
||||
|
||||
export const COMIC_METADATA_CACHE = new DataCache<ComicMetadata>('comic_metadata');
|
||||
export const COMIC_CHAPTER_LIST_CACHE = new DataCache<SimpleChapter[]>('comic_chapter_list');
|
||||
export const COMIC_CHAPTER_CACHE = new DataCache<FullChapter>('comic_chapter');
|
||||
export const COMIC_PAGE_CACHE = new DataCache<FullPage>('comic_page');
|
||||
|
||||
export const CACHES = [
|
||||
COMIC_METADATA_CACHE,
|
||||
COMIC_CHAPTER_LIST_CACHE,
|
||||
COMIC_CHAPTER_CACHE,
|
||||
COMIC_PAGE_CACHE,
|
||||
];
|
||||
|
||||
export const IMAGE_CACHE = new ImageCache('image');
|
||||
|
||||
export function getComicMetadata(provider: ComicProviderKey, comicId: string) {
|
||||
return COMIC_METADATA_CACHE.getWithFallback(
|
||||
`provider:${provider};comic:${comicId}`,
|
||||
() => COMIC_PROVIDERS[provider].getComicInfo(comicId));
|
||||
}
|
||||
|
||||
export function useComicMetadata(provider: ComicProviderKey, comicId: string, onLoaded?: (data: ComicMetadata) => void) {
|
||||
return useQuery<ComicMetadata | null, Error>(
|
||||
['comic', provider, comicId],
|
||||
() => COMIC_PROVIDERS[provider].getComicInfo(comicId),
|
||||
() => getComicMetadata(provider, comicId),
|
||||
{
|
||||
onSuccess(data) {
|
||||
if (onLoaded && data) {
|
||||
|
@ -77,17 +50,29 @@ export function useComicMetadata(provider: ComicProviderKey, comicId: string, on
|
|||
);
|
||||
}
|
||||
|
||||
export function getComicChapters(provider: ComicProviderKey, comicId: string) {
|
||||
return COMIC_CHAPTER_LIST_CACHE.getWithFallback(
|
||||
`provider:${provider};comic:${comicId}`,
|
||||
() => COMIC_PROVIDERS[provider].listChapters(comicId));
|
||||
}
|
||||
|
||||
export function useComicChapters(provider: ComicProviderKey, comicId: string) {
|
||||
return useQuery<SimpleChapter[] | null, Error>(
|
||||
['comicChapters', provider, comicId],
|
||||
() => COMIC_PROVIDERS[provider].listChapters(comicId)
|
||||
() => getComicChapters(provider, comicId),
|
||||
);
|
||||
}
|
||||
|
||||
export function getComicChapter(provider: ComicProviderKey, comicId: string, chapterId: string) {
|
||||
return COMIC_CHAPTER_CACHE.getWithFallback(
|
||||
`provider:${provider};comic:${comicId};chapter:${chapterId}`,
|
||||
() => COMIC_PROVIDERS[provider].chapterInfo(comicId, chapterId));
|
||||
}
|
||||
|
||||
export function useComicChapter(provider: ComicProviderKey, comicId: string, chapterId: string, navigation?: NativeStackNavigationProp<RootStackParamList>) {
|
||||
return useQuery<FullChapter | null, Error>(
|
||||
['comicChapter', provider, comicId, chapterId],
|
||||
() => COMIC_PROVIDERS[provider].chapterInfo(comicId, chapterId),
|
||||
() => getComicChapter(provider, comicId, chapterId),
|
||||
{
|
||||
onSuccess(data) {
|
||||
if (navigation && data) {
|
||||
|
@ -100,10 +85,16 @@ export function useComicChapter(provider: ComicProviderKey, comicId: string, cha
|
|||
);
|
||||
}
|
||||
|
||||
export function getComicPage(provider: ComicProviderKey, comicId: string, pageId: string) {
|
||||
return COMIC_PAGE_CACHE.getWithFallback(
|
||||
`provider:${provider};comic:${comicId};page:${pageId}`,
|
||||
() => COMIC_PROVIDERS[provider].getPage(comicId, pageId));
|
||||
}
|
||||
|
||||
export function useComicPage(provider: ComicProviderKey, comicId: string, pageId: string, navigation?: NativeStackNavigationProp<RootStackParamList>) {
|
||||
return useQuery<FullPage | null, Error>(
|
||||
['comicPage', provider, comicId, pageId],
|
||||
() => COMIC_PROVIDERS[provider].getPage(comicId, pageId),
|
||||
() => getComicPage(provider, comicId, pageId),
|
||||
{
|
||||
onSuccess(data) {
|
||||
if (navigation && data) {
|
||||
|
@ -116,10 +107,18 @@ export function useComicPage(provider: ComicProviderKey, comicId: string, pageId
|
|||
);
|
||||
}
|
||||
|
||||
export function useImageUrl(url: string) {
|
||||
return useQuery(
|
||||
['comicImageUrl', url],
|
||||
() => IMAGE_CACHE.tryFromCache(url),
|
||||
);
|
||||
}
|
||||
|
||||
export function useLocalComics() {
|
||||
return useQuery<LocalComicMetadata[], Error>(
|
||||
'localComics',
|
||||
() => COMIC_PROVIDERS['local'].listComics(),
|
||||
// () => COMIC_PROVIDERS['local'].listComics(),
|
||||
async () => [],
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -135,9 +134,14 @@ export function useLibraryComics() {
|
|||
for (const key of keys) {
|
||||
const matches = /^comic:([a-z]+):([a-zA-Z0-9-_]+):lastPageRead$/.exec(key);
|
||||
if (!matches) continue;
|
||||
// Fix for removal of old 'local' provider
|
||||
if (matches[1] === 'local') {
|
||||
await AsyncStorage.removeItem(key);
|
||||
continue;
|
||||
}
|
||||
const provider = matches[1] as ComicProviderKey;
|
||||
const id = matches[2];
|
||||
promises.push(COMIC_PROVIDERS[provider].getComicInfo(id).then(comic => {
|
||||
promises.push(getComicMetadata(provider, id).then(comic => {
|
||||
if (!comic) return comic;
|
||||
return {
|
||||
...comic,
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
import { ComicMetadata, ComicProvider, ComicProviderKey, COMIC_PROVIDERS, FullPage } from ".";
|
||||
import { ComicProviderKey, COMIC_PROVIDERS } from ".";
|
||||
import * as FileSystem from 'expo-file-system';
|
||||
import { dataPath, pathExists } from "../util";
|
||||
import { ComicMetadata, ComicProvider, FullPage } from "./provider";
|
||||
|
||||
export type NonLocalProviderKey = Exclude<ComicProviderKey, 'local'>;
|
||||
export type LocalComicId = `${NonLocalProviderKey}-${string}`;
|
||||
|
@ -11,10 +13,6 @@ type LocalProvider = ComicProvider & {
|
|||
downloadComicData(providerKey: NonLocalProviderKey, comicId: string, statusCallback?: (message: string) => void): Promise<LocalComicId | null>;
|
||||
};
|
||||
|
||||
function dataPath(path: string): string {
|
||||
return FileSystem.documentDirectory + '/' + path;
|
||||
}
|
||||
|
||||
async function ensureDataDirectory(path: string) {
|
||||
const dirInfo = await FileSystem.getInfoAsync(dataPath(path));
|
||||
if (!dirInfo.exists) {
|
||||
|
@ -30,11 +28,6 @@ export function parseComicId(comicId: string): [providerKey: ComicProviderKey, c
|
|||
return pts as [ComicProviderKey, string]; // typescript inference bad.
|
||||
}
|
||||
|
||||
async function pathExists(path: string): Promise<boolean> {
|
||||
const info = await FileSystem.getInfoAsync(path);
|
||||
return info.exists;
|
||||
}
|
||||
|
||||
async function localJson<T>(comicId: string, path: string): Promise<T | null> {
|
||||
const parsed = parseComicId(comicId);
|
||||
if (parsed === null) return null;
|
||||
|
|
48
src/provider/provider.ts
Normal file
48
src/provider/provider.ts
Normal file
|
@ -0,0 +1,48 @@
|
|||
export interface ComicProvider {
|
||||
getComicInfo(comicId: string): Promise<ComicMetadata | null>;
|
||||
listChapters(comicId: string): Promise<SimpleChapter[] | null>;
|
||||
chapterInfo(comicId: string, chapterId: string): Promise<FullChapter | null>;
|
||||
getPage(comicId: string, pageId: string): Promise<FullPage | null>;
|
||||
}
|
||||
|
||||
export interface ComicMetadata {
|
||||
title: string;
|
||||
author: string;
|
||||
authorAvatarUrl: string;
|
||||
shortDescription: string;
|
||||
description: string;
|
||||
avatarUrl: string;
|
||||
}
|
||||
|
||||
export interface SimpleChapter {
|
||||
id: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface FullChapter {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
pages: SimplePage[];
|
||||
}
|
||||
|
||||
export interface SimplePage {
|
||||
id: string;
|
||||
title: string;
|
||||
published: string;
|
||||
}
|
||||
|
||||
export interface FullPage {
|
||||
id: string;
|
||||
title: string;
|
||||
published: string;
|
||||
imageSegments: ImageSegment[];
|
||||
previousPageId?: string;
|
||||
nextPageId?: string;
|
||||
}
|
||||
|
||||
export interface ImageSegment {
|
||||
url: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
10
src/util.ts
Normal file
10
src/util.ts
Normal file
|
@ -0,0 +1,10 @@
|
|||
import * as FileSystem from 'expo-file-system';
|
||||
|
||||
export function dataPath(path: string): string {
|
||||
return FileSystem.documentDirectory + '/' + path;
|
||||
}
|
||||
|
||||
export async function pathExists(path: string): Promise<boolean> {
|
||||
const info = await FileSystem.getInfoAsync(path);
|
||||
return info.exists;
|
||||
}
|
|
@ -1,11 +1,17 @@
|
|||
import { CheerioAPI, load as cheerioLoad } from 'cheerio';
|
||||
|
||||
export default async function scrapePage(url: string): Promise<CheerioAPI | null> {
|
||||
const res = await fetch(url, {
|
||||
export const USER_AGENT = 'comet/1.0 (+https://git.ashhhleyyy.dev/ash/comet/)';
|
||||
|
||||
export function uaFetch(url: string): Promise<Response> {
|
||||
return fetch(url, {
|
||||
headers: {
|
||||
'user-agent': 'comet/1.0 (+https://git.ashhhleyyy.dev/ash/comet/)',
|
||||
'user-agent': USER_AGENT,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export default async function scrapePage(url: string): Promise<CheerioAPI | null> {
|
||||
const res = await uaFetch(url);
|
||||
if (res.ok) {
|
||||
return res.text()
|
||||
.then(html => cheerioLoad(html))
|
||||
|
|
Loading…
Reference in a new issue