feat: improve offline support to replace the "local" provider

This commit is contained in:
Ashhhleyyy 2023-08-02 17:56:57 +01:00
parent e52fc41118
commit f9859d7eca
Signed by: ash
GPG key ID: 83B789081A0878FB
24 changed files with 1006 additions and 598 deletions

View file

@ -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>

View file

@ -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

View 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;

View file

@ -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 {

View file

@ -1,5 +1,5 @@
import { FC, ReactNode } from "react";
import { Text, View } from "react-native";
import { Text } from "react-native";
interface Props {
colour: string;

View file

@ -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}>

View file

@ -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} />

View file

@ -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)}>

View file

@ -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>
}

View file

@ -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} />
}

View file

@ -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) {

View file

@ -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
View 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,
},
});

View file

@ -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>

View file

@ -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
View 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[] = [];

View file

@ -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
View 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;
}

View file

@ -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,

View file

@ -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
View 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
View 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;
}

View file

@ -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))

1044
yarn.lock

File diff suppressed because it is too large Load diff