added mangaupdates integration

cleanup db related code
cleanup ui
ui eye candy
This commit is contained in:
wea_ondara
2023-09-26 16:47:08 +02:00
parent 641a4425aa
commit 7f28012520
36 changed files with 1435 additions and 168 deletions

View File

@@ -20,6 +20,7 @@
"@popperjs/core": "^2.11.8",
"bootstrap": "^5.3.1",
"bootstrap-vue-next": "^0.13.3",
"idb": "^7.1.1",
"pinia": "^2.1.6",
"pinia-class-component": "^0.9.4",
"vue": "^3.3.4",

View File

@@ -1,30 +1,44 @@
<script lang="ts">
import {Options, Vue} from 'vue-class-component';
import {AniListStore} from '@/stores/AniListStore';
import {MangaStore} from '@/stores/MangaStore';
import {ServiceStore} from '@/stores/ServiceStore';
import {BSpinner} from 'bootstrap-vue-next';
@Options({
name: 'AniListUserSearch',
components: {},
components: {
BSpinner,
},
})
export default class AniListUserSearch extends Vue {
private aniListStore = new AniListStore();
searching = false;
private get userName(): string {
return this.aniListStore.userName ?? '';
get mangaStore(): MangaStore {
return new MangaStore();
}
private set userName(val: string) {
this.aniListStore.setUserName(val);
get serviceStore(): ServiceStore {
return new ServiceStore();
}
get userName(): string {
return this.mangaStore.userName ?? '';
}
set userName(val: string) {
this.mangaStore.updateUserName(val);
}
mounted(): void {
if (this.userName) {
this.onSearch();
this.mangaStore.reloadCache();
}
}
private onSearch(): void {
this.aniListStore.reload();
onSearch(): void {
this.searching = true;
this.serviceStore.aniListDataService.updateDb()
.finally(() => this.searching = false);
}
}
@@ -32,10 +46,11 @@ export default class AniListUserSearch extends Vue {
<template>
<div class="input-group">
<input class="form-control" type="search" :placeholder="$t('search')" aria-label="Search" v-model="userName"
@keydown.enter="onSearch">
<button class="btn btn-primary" @click="onSearch">
<i class="fa fa-search"></i>
<input v-model="userName" :disabled="searching" :placeholder="$t('search')" aria-label="Search" class="form-control"
type="search" @keydown.enter="onSearch">
<button :disabled="searching" class="btn btn-primary" @click="onSearch">
<BSpinner v-if="searching" small/>
<i v-else class="fa fa-search"></i>
</button>
</div>
</template>

View File

@@ -1,8 +1,37 @@
<script lang="ts">
import {Options, Vue} from 'vue-class-component';
import type {MangaListModel} from '@/models/MangaListCollection';
import {Prop} from 'vue-property-decorator';
import {BTable, type TableField} from 'bootstrap-vue-next';
import {Prop, Watch} from 'vue-property-decorator';
import {BTable, type TableItem} from 'bootstrap-vue-next';
import type {TableFieldObject} from 'bootstrap-vue-next/dist/src/types';
import type {AniListMangaListEntry} from '@/data/models/anilist/AniListMangaListEntry';
import type {AniListMedia} from '@/data/models/anilist/AniListMedia';
import type {AniListMangaList} from '@/data/models/anilist/AniListMangaList';
import type {MangaUpdatesRelation} from '@/data/models/mangaupdates/MangaUpdatesRelation';
import type {MangaUpdatesSeries} from '@/data/models/mangaupdates/MangaUpdatesSeries';
import type {MangaUpdatesChapter} from '@/data/models/mangaupdates/MangaUpdatesChapter';
export type ViewList = {
list: AniListMangaList,
entries: ViewEntry[]
}
export type ViewEntry = {
entry: AniListMangaListEntry,
media: AniListMedia | null,
relation: MangaUpdatesRelation | null,
series: MangaUpdatesSeries | null,
chapters: MangaUpdatesChapter[]
};
type CellData<I, V> = {
value: V,
index: number,
item: TableItem<I>,
field: TableFieldObject<I>,
items: TableItem<I>[],
toggleDetails: () => void,
detailsShowing: boolean | undefined
}
@Options({
name: 'MangaList',
@@ -10,15 +39,17 @@ import {BTable, type TableField} from 'bootstrap-vue-next';
})
export default class MangaList extends Vue {
@Prop({required: true})
readonly list!: MangaListModel;
readonly viewList!: ViewList;
private get listName(): string {
const key = 'manga.status.' + this.list.name.toLocaleLowerCase();
private bTableRefreshHack = true;
get localeListName(): string {
const key = 'manga.status.' + this.viewList.list.name.toLocaleLowerCase();
const res = this.$t(key);
return res == 'key' ? this.list.name : res;
return res == 'key' ? this.viewList.list.name : res;
}
private get fields(): TableField[] {
get fields(): TableFieldObject<ViewEntry>[] {
return [{
key: 'media.coverImage.large',
label: '',
@@ -28,50 +59,129 @@ export default class MangaList extends Vue {
label: this.$t('manga.title'),
sortable: true,
}, {
key: 'score',
key: 'entry.score',
label: this.$t('manga.score'),
sortable: true,
tdClass: 'text-end',
thClass: 'text-end',
}, {
key: 'progress',
key: 'entry.progress',
label: this.$t('manga.progress'),
sortable: true,
tdClass: 'text-end',
thClass: 'text-end',
}, {
key: 'newChapters',
formatter: (value, key, item: TableEntry) => this.newChapterCount(item),
label: this.$t('manga.chapters.newCount'),
sortable: true,
tdClass: 'text-end',
thClass: 'text-end text-nowrap',
}, {
key: 'latestChapters',
label: this.$t('manga.chapters.latest'),
sortable: true,
tdClass: 'text-nowrap',
}];
}
get tableEntries(): ViewEntry[] {
return this.viewList.entries;
}
newChapterCount(entry: ViewEntry): number {
const max = entry.media?.chapters || entry.chapters.reduce((l, r) => Math.max(l, r.chapter), 0);
return max === 0 ? 0 : max - entry.entry.progress;
}
latestChaptersSorted(entry: ViewEntry): MangaUpdatesChapter[] {
return entry.chapters.sort((l, r) => l.chapter - r.chapter);
}
cd<V = any>(data: V): CellData<ViewEntry, V> {
return (data as CellData<ViewEntry, V>);
}
@Watch('viewList', {deep: true})
private onViewListChanged(): void {
this.bTableRefreshHack = false;
this.$nextTick(() => {
this.bTableRefreshHack = true;
// (this.$refs.table as any).refresh();
// (this.$refs.table as any).$forceUpdate();
});
}
}
</script>
<template>
<div class="card">
<h3 class="card-title p-3">{{ listName }}</h3>
<BTable :fields="fields" :items="list.entries" :primary-key="'id'" class="manga-table" hover striped>
<h3 class="card-title p-3">{{ localeListName }}</h3>
<BTable ref="table" v-if="bTableRefreshHack" :fields="fields" :items="tableEntries" :primary-key="'id'"
class="manga-table" hover striped>
<template #cell(media.coverImage.large)="data">
<img :src="data.value as string" alt="cover-img" class="list-cover"/>
</template>
<template #cell(media.title.userPreferred)="data">
<div class="flex-grow-1">
<div>{{ cd(data).item.media?.title.userPreferred }}</div>
<div>{{ cd(data).item.media?.title.english }}</div>
</div>
<div v-if="cd(data).item.relation" style="font-size: 0.5em">
MangaUpdates:
<a v-if="cd(data).item.series" :href="cd(data).item.series.url" target="_blank">
{{ cd(data).item.series.title }}
</a>
</div>
</template>
<template #cell(entry.score)="data">
<i class="fa fa-star" style="color: #ff9933"/>
{{ cd(data).value }}
</template>
<template #cell(progress)="data">
{{ data.value + ' / ' + (data.item.media.chapters ?? '?') }}
{{ cd(data).value + ' / ' + (cd(data).item.media?.chapters ?? '?') }}
</template>
<template #cell(latestChapters)="data">
<div v-for="c in latestChaptersSorted(cd(data).item)">
{{ c.chapter + ' (' + c.group + ')' }}
</div>
</template>
</BTable>
<!-- <table class="table table-striped">-->
<!-- <table class="table table-striped manga-table mb-1">-->
<!-- <thead>-->
<!-- <tr>-->
<!-- <th></th>-->
<!-- <th>title</th>-->
<!-- <th>score</th>-->
<!-- <th>chapter</th>-->
<!-- <th>{{ $t('manga.title') }}</th>-->
<!-- <th class="text-end">{{ $t('manga.score') }}</th>-->
<!-- <th class="text-end">{{ $t('manga.progress') }}</th>-->
<!-- <th class="text-end">{{ $t('manga.chapters.newCount') }}</th>-->
<!-- <th>{{ $t('manga.chapters.latest') }}</th>-->
<!-- </tr>-->
<!-- </thead>-->
<!-- <tbody>-->
<!-- <tr v-for="manga in list.entries">-->
<!-- <td><img :src="manga.media.coverImage.large" class="list-cover" alt="cover-img"/></td>-->
<!-- <td>{{ manga.media.title.userPreferred }}</td>-->
<!-- <td>{{ manga.score }}</td>-->
<!-- <tr v-for="entry in tableEntries">-->
<!-- <td><img :src="entry.media?.coverImage.large" alt="cover-img" class="list-cover"/></td>-->
<!-- <td>-->
<!-- &lt;!&ndash; <div class="d-flex flex-column h-100">&ndash;&gt;-->
<!-- <div class="flex-grow-1">-->
<!-- <div>{{ entry.media?.title.userPreferred }}</div>-->
<!-- <div>{{ entry.media?.title.english }}</div>-->
<!-- </div>-->
<!-- <div v-if="entry.relation" style="font-size: 0.5em">-->
<!-- MangaUpdates:-->
<!-- <template v-if="entry.series">{{ entry.series.title }}</template>-->
<!-- </div>-->
<!-- &lt;!&ndash; </div>&ndash;&gt;-->
<!-- </td>-->
<!-- <td class="text-end">{{ entry.entry.score }}</td>-->
<!-- <td class="text-end" style="min-width: 5rem">-->
<!-- {{ manga.progress + ' / ' + (manga.media.chapters ?? '?') }}-->
<!-- {{ entry.entry.progress + ' / ' + (entry.media?.chapters ?? '?') }}-->
<!-- </td>-->
<!-- <td class="text-end" style="min-width: 5rem">-->
<!-- {{ newChapterCount(entry) }}-->
<!-- </td>-->
<!-- <td style="min-width: 5rem">-->
<!-- <div v-for="c in latestChaptersSorted(entry)">-->
<!-- {{ c.chapter + ' (' + c.group + ')' }}-->
<!-- </div>-->
<!-- </td>-->
<!-- </tr>-->
<!-- </tbody>-->
@@ -96,4 +206,8 @@ export default class MangaList extends Vue {
.manga-table tr th:last-child {
padding-inline-end: 1rem;
}
.manga-table > :not(caption) tr:last-child td {
border-bottom-width: 0;
}
</style>

View File

@@ -1,25 +1,68 @@
<script lang="ts">
import {Options, Vue} from 'vue-class-component';
import type {MangaListModel} from '@/models/MangaListCollection';
import {AniListStore} from '@/stores/AniListStore';
import MangaList from '@/components/MangaList.vue';
import {MangaStore} from '@/stores/MangaStore';
import MangaList, {ViewEntry, ViewList} from '@/components/MangaList.vue';
import {BTable} from 'bootstrap-vue-next';
import type {AniListMedia} from '@/data/models/anilist/AniListMedia';
import type {MangaUpdatesRelation} from '@/data/models/mangaupdates/MangaUpdatesRelation';
import type {MangaUpdatesSeries} from '@/data/models/mangaupdates/MangaUpdatesSeries';
import type {MangaUpdatesChapter} from '@/data/models/mangaupdates/MangaUpdatesChapter';
@Options({
name: 'MangaLists',
components: {MangaList},
components: {MangaList, BTable},
})
export default class MangaLists extends Vue {
private aniListStore = new AniListStore();
get mangaStore(): MangaStore {
return new MangaStore();
};
get lists(): MangaListModel[] {
return this.aniListStore.manga?.lists ?? [];
}
get mediaById(): Map<number, AniListMedia> {
const media = this.mangaStore.aniListMedia;
return new Map(media.map(e => [e.id, e]));
}
get relationsByAniListMediaId(): Map<number, MangaUpdatesRelation> {
const relations = this.mangaStore.mangaUpdatesRelations;
return new Map(relations.map(e => [e.aniListMediaId, e]));
}
private get seriesById(): Map<number, MangaUpdatesSeries> {
const series = this.mangaStore.mangaUpdatesSeries;
return new Map(series.map(e => [e.series_id, e]));
}
get chaptersBySeriesId(): Map<number, MangaUpdatesChapter[]> {
const chapters = this.mangaStore.mangaUpdatesChapters;
return Map.groupBy(chapters, e => e.series_id);
}
get viewLists(): ViewList[] {
const lists = this.mangaStore.aniListLists;
const manga = this.mangaStore.aniListManga;
return lists.map(l => ({
list: l,
entries: (manga.get(l.name) ?? []).map(e => {
const media = this.mediaById.get(e.mediaId) ?? null;
const relation = this.relationsByAniListMediaId.get(e.mediaId) ?? null;
const series = this.seriesById.get(relation?.mangaUpdatesSeriesId as any) ?? null;
const chapters = this.chaptersBySeriesId.get(relation?.mangaUpdatesSeriesId as any) ?? [];
return ({
entry: e,
media: media,
relation: relation,
series: series,
chapters: chapters,
} as ViewEntry);
}),
}));
}
}
</script>
<template>
<div class="overflow-y-auto p-3">
<MangaList v-for="list in lists" :key="list.name" :list="list"/>
<MangaList v-for="viewList in viewLists" :key="viewList.list.name" :viewList="viewList" class="mb-3"/>
</div>
</template>

View File

@@ -0,0 +1,69 @@
<script lang="ts">
import {Progress} from '@/data/service/MangaUpdatesDataService';
import {Options, Vue} from 'vue-class-component';
import {ServiceStore} from '@/stores/ServiceStore';
import {BSpinner} from 'bootstrap-vue-next';
@Options({
name: 'MangaUpdatesUpdater',
components: {
BSpinner,
},
})
export default class MangaUpdatesUpdater extends Vue {
//vars
progress: Progress = new Progress(this.onProgress.bind(this), this.onFinished.bind(this));
progressType: string | null = null;
progressValue: number | null = null;
progressMax: number | null = null;
get serviceStore(): ServiceStore {
return new ServiceStore();
}
//event handler
onUpdateMangaUpdatesDb(): void {
this.progressType = 'starting';
this.serviceStore.mangaUpdatesDataService.updateDb(this.progress);
};
onProgress(type: string, progress: number, max: number): void {
// console.log(type, progress, max);
this.progressType = type;
this.progressValue = progress;
this.progressMax = max;
}
onFinished(): void {
this.progressType = 'finished';
this.progressValue = null;
this.progressMax = null;
setTimeout(() => this.onReset(), 3000);
}
onReset(): void {
this.progressType = null;
this.progressValue = null;
this.progressMax = null;
}
}
</script>
<template>
<div class="d-flex flex-row">
<button :disabled="!!progressType" class="btn btn-secondary" @click="onUpdateMangaUpdatesDb">
<i v-if="!progressType" class="fa fa-refresh"/>
<template v-else>
<span class="text-nowrap">
<BSpinner small class="me-2"/>
<template v-if="progressValue === null || progressMax === null">
{{ $t('fetch.mangaUpdates.' + progressType) }}
</template>
<template v-else>
{{ $t('fetch.mangaUpdates.' + progressType) + ': ' + (progressValue ?? 0) + '/' + (progressMax ?? 1) }}
</template>
</span>
</template>
</button>
</div>
</template>

View File

@@ -2,6 +2,7 @@
import {Options, Vue} from 'vue-class-component';
import BootstrapThemeSwitch from '@/components/bootstrapThemeSwitch/BootstrapThemeSwitch.vue';
import AniListUserSearch from '@/components/AniListUserSearch.vue';
import MangaUpdatesUpdater from '@/components/MangaUpdatesUpdater.vue';
import LocaleSelector from '@/components/locale/LocaleSelector.vue';
@Options({
@@ -10,6 +11,7 @@ import LocaleSelector from '@/components/locale/LocaleSelector.vue';
AniListUserSearch,
BootstrapThemeSwitch,
LocaleSelector,
MangaUpdatesUpdater,
},
})
export default class NavBar extends Vue {
@@ -51,7 +53,10 @@ export default class NavBar extends Vue {
<!-- </li>-->
<!-- </ul>-->
<div class="mx-auto">
<AniListUserSearch/>
<div class="d-flex flex-row">
<AniListUserSearch class="mx-1"/>
<MangaUpdatesUpdater class="mx-1"/>
</div>
</div>
<div>
<div class="d-flex flex-row align-items-center">

View File

@@ -1,70 +1,33 @@
import {Pinia, Store} from 'pinia-class-component';
import type {User} from '@/models/User';
import type {MangaListCollection} from '@/models/MangaListCollection';
import type {AniListUser} from '@/data/models/anilist/AniListUser';
import {handleJsonResponse} from '@/data/api/ApiUtils';
import type {AniListMangaListCollection} from '@/data/models/anilist/AniListMangaListCollection';
@Store({
id: 'AniListStore',
name: 'AniListStore',
})
export class AniListStore extends Pinia {
//data
private x_manga: MangaListCollection | null = null;
private userNameUpdateTrigger: string | null = null;
private x_user: User | null = null;
//getter
get manga(): MangaListCollection | null {
return this.x_manga;
}
get userName(): string | null {
this.userNameUpdateTrigger;
return window.localStorage.getItem('userName');
}
get user(): User | null {
return this.x_user;
}
//actions
setUserName(userName: string | null) {
this.userNameUpdateTrigger = userName;
if (userName) {
window.localStorage.setItem('userName', userName ?? '');
} else {
window.localStorage.removeItem('userName');
}
}
async reload(): Promise<void> {
if (!this.userName) {
return;
}
let res = await fetch('/graphql', {
export default class AniListApi {
async fetchUser(userName: string): Promise<AniListUser> {
const res = fetch('/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
'query': 'query($id:Int,$name:String){User(id:$id,name:$name){id name previousNames{name updatedAt}avatar{large}bannerImage about isFollowing isFollower donatorTier donatorBadge createdAt moderatorRoles isBlocked bans options{profileColor restrictMessagesToFollowing}mediaListOptions{scoreFormat}statistics{anime{count meanScore standardDeviation minutesWatched episodesWatched genrePreview:genres(limit:10,sort:COUNT_DESC){genre count}}manga{count meanScore standardDeviation chaptersRead volumesRead genrePreview:genres(limit:10,sort:COUNT_DESC){genre count}}}stats{activityHistory{date amount level}}favourites{anime{edges{favouriteOrder node{id type status(version:2)format isAdult bannerImage title{userPreferred}coverImage{large}startDate{year}}}}manga{edges{favouriteOrder node{id type status(version:2)format isAdult bannerImage title{userPreferred}coverImage{large}startDate{year}}}}characters{edges{favouriteOrder node{id name{userPreferred}image{large}}}}staff{edges{favouriteOrder node{id name{userPreferred}image{large}}}}studios{edges{favouriteOrder node{id name}}}}}}',
'variables': {'name': this.userName},
'variables': {'name': userName},
}),
});
this.x_user = (await res.json()).data.User;
console.log(this.user);
return (await handleJsonResponse(res)).data.User;
}
res = await fetch('/graphql', {
async fetchManga(userId: number): Promise<AniListMangaListCollection> {
const res = fetch('/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
'query': 'query($userId:Int,$userName:String,$type:MediaType){MediaListCollection(userId:$userId,userName:$userName,type:$type){lists{name isCustomList isCompletedList:isSplitCompletedList entries{...mediaListEntry}}user{id name avatar{large}mediaListOptions{scoreFormat rowOrder animeList{sectionOrder customLists splitCompletedSectionByFormat theme}mangaList{sectionOrder customLists splitCompletedSectionByFormat theme}}}}}fragment mediaListEntry on MediaList{id mediaId status score progress progressVolumes repeat priority private hiddenFromStatusLists customLists advancedScores notes updatedAt startedAt{year month day}completedAt{year month day}media{id title{userPreferred romaji english native}coverImage{extraLarge large}type format status(version:2)episodes volumes chapters averageScore popularity isAdult countryOfOrigin genres bannerImage startDate{year month day}}}',
'variables': {'userId': this.user!.id, 'type': 'MANGA'},
'variables': {'userId': userId, 'type': 'MANGA'},
}),
});
this.x_manga = (await res.json()).data.MediaListCollection;
console.log(this.manga);
return (await handleJsonResponse(res)).data.MediaListCollection;
}
}

8
src/data/api/ApiUtils.ts Normal file
View File

@@ -0,0 +1,8 @@
export function handleJsonResponse(res: Primise<T>): Promise<T> {
return res.then(async e => {
if (e.status === 200) {
return await e.json();
}
throw new Error('Api error ' + e.status + ': ' + await e.text());
});
}

View File

@@ -0,0 +1,30 @@
import {handleJsonResponse} from '@/data/api/ApiUtils';
import type {MangaUpdatesSearchResult} from '@/data/models/mangaupdates/MangaUpdatesSearchResult';
import type {MangaUpdatesSeries} from '@/data/models/mangaupdates/MangaUpdatesSeries';
import type {MangaUpdatesSeriesGroups} from '@/data/models/mangaupdates/MangaUpdatesSeriesGroups';
export default class MangaUpdatesApi {
search(name: string): Promise<MangaUpdatesSearchResult> {
const res = fetch('/mangaupdates/v1/series/search', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
search: name,
stype: 'title',
type: 'Manga',
}),
});
return handleJsonResponse(res);
}
get(id: number): Promise<MangaUpdatesSeries> {
const res = fetch(`/mangaupdates/v1/series/${id}`);
return handleJsonResponse(res);
}
groups(id: number): Promise<MangaUpdatesSeriesGroups> {
const res = fetch(`/mangaupdates/v1/series/${id}/groups`);
return handleJsonResponse(res);
}
}

101
src/data/db/AniListDb.ts Normal file
View File

@@ -0,0 +1,101 @@
import {type IDBPDatabase, openDB} from 'idb';
import type {AniListUser} from '@/data/models/anilist/AniListUser';
import type {AniListMangaListEntry} from '@/data/models/anilist/AniListMangaListEntry';
import type {AniListMedia} from '@/data/models/anilist/AniListMedia';
import type {AniListMangaList} from '@/data/models/anilist/AniListMangaList';
export type AlDb = IDBPDatabase<AniListDBSchema>;
export default class AniListDb {
private intDb: IDBPDatabase<AniListDBSchema> | null = null;
get db(): IDBPDatabase<AniListDBSchema> | null {
return this.intDb;
}
static async withDb<T>(fn: (db: AlDb) => Promise<T> | T): Promise<T> {
const db = new AniListDb();
const idb = await db.open();
try {
return await fn(idb);
} finally {
db.close();
}
}
async open(): Promise<IDBPDatabase<AniListDBSchema>> {
this.intDb = await openDB('anilist', 1, {
upgrade(db, oldVersion, newVersion, transaction, event) {
switch (oldVersion) {
case 0:
const user = db.createObjectStore('user', {keyPath: 'id'});
user.createIndex('id', 'id', {unique: true});
user.createIndex('name', 'name', {unique: true});
const list = db.createObjectStore('list', {keyPath: '_userId_listName'});
list.createIndex('_userId', '_userId', {multiEntry: true});
list.createIndex('_userId_listName', '_userId_listName', {unique: true}); // TODO multiprop indexes
const manga = db.createObjectStore('manga', {keyPath: 'id'});
manga.createIndex('id', 'id', {unique: true});
manga.createIndex('_userId', '_userId', {multiEntry: true});
manga.createIndex('_listName', '_listName', {multiEntry: true});
manga.createIndex('_userId_listName', '_userId_listName', {multiEntry: true});
const media = db.createObjectStore('media', {keyPath: 'id'});
media.createIndex('id', 'id', {unique: true});
//fall through
default:
break;
}
},
});
return this.intDb;
}
close(): void {
this.intDb?.close();
}
}
export type AniListDBSchema = {
user: {
key: 'id',
value: AniListUser,
indexes: {
id: number,
name: string
}
},
list: {
value: AniListMangaList & {
_userId: number,
_userId_listName: string
},
indexes: {
_userId: number,
_userId_listName: string
}
},
manga: {
key: 'id';
value: AniListMangaListEntry & {
_userId: number,
_listName: string,
_userId_listName: string
},
indexes: {
id: number,
_userId: number,
_listName: string,
_userId_listName: string
}
},
media: {
key: 'id';
value: AniListMedia;
indexes: {
id: number
}
},
}

4
src/data/db/DbUtil.ts Normal file
View File

@@ -0,0 +1,4 @@
export function strip_<T extends {} | object>(obj: T): T {
Object.keys(obj).filter(e => e.startsWith('_')).forEach(k => delete (obj as any)[k]);
return obj;
}

View File

@@ -0,0 +1,73 @@
import {type IDBPDatabase, openDB} from 'idb';
import type {MangaUpdatesSeries} from '@/data/models/mangaupdates/MangaUpdatesSeries';
import type {MangaUpdatesChapter} from '@/data/models/mangaupdates/MangaUpdatesChapter';
export type MuDb = IDBPDatabase<MangaUpdatesDBSchema>;
export default class MangaUpdatesDb {
private intDb: IDBPDatabase<MangaUpdatesDBSchema> | null = null;
get db(): IDBPDatabase<MangaUpdatesDBSchema> | null {
return this.intDb;
}
static async withDb<T>(fn: (db: MuDb) => Promise<T> | T): Promise<T> {
const db = new MangaUpdatesDb();
const idb = await db.open();
try {
return await fn(idb);
} finally {
db.close();
}
}
async open(): Promise<IDBPDatabase<MangaUpdatesDBSchema>> {
this.intDb = await openDB('mangaupdates', 1, {
upgrade(db, oldVersion, newVersion, transaction, event) {
switch (oldVersion) {
case 0:
const relation = db.createObjectStore('relation', {autoIncrement: true});
relation.createIndex('unique', ['aniListMediaId', 'mangaUpdatesSeriesId'], {unique: true});
const media = db.createObjectStore('series', {keyPath: 'series_id'});
media.createIndex('series_id', 'series_id', {unique: true});
const chapter = db.createObjectStore('chapter', {autoIncrement: true});
chapter.createIndex('series_id', 'series_id', {multiEntry: true});
//fall through
default:
break;
}
},
});
return this.intDb;
}
close(): void {
this.intDb?.close();
}
}
export type MangaUpdatesDBSchema = {
relation: {
key: 'id',
value: MangaUpdatesRelation,
indexes: {
unique: [number, number]
}
},
series: {
key: 'series_id';
value: MangaUpdatesSeries;
indexes: {
series_id: number
}
},
chapter: {
key: 'id';
value: MangaUpdatesChapter;
indexes: {
series_id: number
}
},
}

View File

@@ -0,0 +1,8 @@
import {AniListMangaListEntry} from '@/data/models/anilist/AniListMangaListEntry';
export type AniListMangaList = {
name: 'Paused' | 'Completed' | 'Planning' | 'Dropped' | 'Reading',
isCustomList: boolean,
isCompletedList: boolean,
entries: AniListMangaListEntry[]
}

View File

@@ -0,0 +1,6 @@
import type {AniListMangaList} from '@/data/models/anilist/AniListMangaList';
export type AniListMangaListCollection = {
lists: AniListMangaList[]
}

View File

@@ -0,0 +1,21 @@
import type {AniListMedia} from '@/data/models/anilist/AniListMedia';
export type AniListMangaListEntry = {
id: number,
mediaId: number,
status: 'PAUSED' | 'COMPLETED' | 'PLANNING' | 'DROPPED' | 'READING',
score: number,
progress: number,
progressVolumes: number,
repeat: number,
priority: number,
private: boolean,
hiddenFromStatusLists: boolean,
customLists: any,
advancedScores: any,
notes: any,
updatedAt: number
startedAt: any,
completedAt: any,
media: AniListMedia,
}

View File

@@ -0,0 +1,26 @@
export type AniListMedia = {
id: number,
title: {
userPreferred: string,
romaji: string,
english: string,
native: string,
}
coverImage: {
extraLarge: string, //url
large: string, //url
}
type: 'MANGA' | string,
format: 'MANGA' | string,
status: 'RELEASING' | string,
episodes: number | null,
volumes: number | null,
chapters: number | null,
averageScore: number,
popularity: number,
isAdult: boolean,
countryOfOrigin: 'JP' | 'KR' | string,
genres: string[],
bannerImage: string, //url
startDate: any,
}

View File

@@ -1,4 +1,4 @@
export type User = {
export type AniListUser = {
id: number,
name: string,
//...

View File

@@ -0,0 +1,91 @@
export type SearchResultRecord = {
'series_id': number,
'title': string,
'url': string,
'description': string,
'image': {
'url': {
'original': string,
'thumb': string
},
'height': number,
'width': number
},
'type': 'Artbook' | 'Doujinshi' | 'Drama CD' | 'Filipino' | 'Indonesian' | 'Manga' | 'Manhwa' | 'Manhua' | 'Novel'
| 'OEL' | 'Thai' | 'Vietnamese' | 'Malaysian' | 'Nordic' | 'French' | 'Spanish',
'year': string,
'bayesian_rating': number,
'rating_votes': number,
'genres': [{
'genre': string
}],
'latest_chapter': number,
'rank': {
'position': {
'week': number,
'month': number,
'three_months': number,
'six_months': number,
'year': number
},
'old_position': {
'week': number,
'month': number,
'three_months': number,
'six_months': number,
'year': number
},
'lists': {
'reading': number,
'wish': number,
'complete': number,
'unfinished': number,
'custom': number
}
},
'last_updated': {
'timestamp': number,
'as_rfc3339': string,
'as_string': string
},
'admin': {
'added_by': {
'user_id': number,
'username': string,
'url': string,
'avatar': {
'id': number,
'url': string,
'height': number,
'width': number
},
'time_joined': {
'timestamp': number,
'as_rfc3339': string,
'as_string': string
},
'signature': string,
'forum_title': string,
'folding_at_home': true,
'profile': {
'upgrade': {
'requested': true,
'reason': string
}
},
'stats': {
'forum_posts': number,
'added_authors': number,
'added_groups': number,
'added_publishers': number,
'added_releases': number,
'added_series': number
},
'user_group': string,
'user_group_name': string
},
'approved': true
}
};

View File

@@ -0,0 +1,5 @@
export type MangaUpdatesChapter = {
series_id: number,
group: string,
chapter: number
}

View File

@@ -0,0 +1,5 @@
export type MangaUpdatesRelation = {
aniListMediaId: number,
mangaUpdatesSeriesId: number
}

View File

@@ -0,0 +1,13 @@
import type {SearchResultRecord} from '@/data/models/mangaupdates/MangaUpdateSearchResult';
import type {MangaUpdatesSearchResultMetaData} from '@/data/models/mangaupdates/MangaUpdatesSearchResultMetaData';
export type MangaUpdatesSearchResult = {
'total_hits': number,
'page': number,
'per_page': number,
'results': [{
'record': SearchResultRecord,
'hit_title': string,
'metadata': MangaUpdatesSearchResultMetaData
}]
}

View File

@@ -0,0 +1,25 @@
export type MangaUpdatesSearchResultMetaData = {
'user_list': {
'series': {
'id': number,
'title': string
},
'list_id': number,
'list_type': string,
'list_icon': string,
'status': {
'volume': number,
'chapter': number
},
'priority': number,
'time_added': {
'timestamp': number,
'as_rfc3339': string,
'as_string': string
}
},
'user_genre_highlights': [{
'genre': string,
'color': string
}]
}

View File

@@ -0,0 +1,67 @@
export type MangaUpdatesSeries = {
'series_id': number,
'title': string,
'url': string,
'associated': { 'title': string }[],
'description': string,
'image': {
'url': {
'original': string,
'thumb': string
},
'height': number,
'width': number
},
'type': 'Artbook' | 'Doujinshi' | 'Drama CD' | 'Filipino' | 'Indonesian' | 'Manga' | 'Manhwa' | 'Manhua' | 'Novel'
| 'OEL' | 'Thai' | 'Vietnamese' | 'Malaysian' | 'Nordic' | 'French' | 'Spanish',
'year': string,
'bayesian_rating': number,
'rating_votes': number,
'genres': { 'genre': string }[],
'categories': { 'series_id': number, 'category': string, 'votes': number, 'votes_plus': number, 'votes_minus': number, 'added_by': number }[],
'latest_chapter': number,
'forum_id': number,
'status': string,
'licensed': true,
'completed': false,
'anime': {
'start': string,
'end': string
},
'related_series': { 'relation_id': number, 'relation_type': string, 'related_series_id': number, 'related_series_name': string, 'triggered_by_relation_id': number }[],
'publishers': { 'publisher_name': string, 'publisher_id': number, 'type': string, 'notes': string }[],
'publications': { 'publication_name': string, 'publisher_name': string, 'publisher_id': number }[],
'recommendations': { 'series_name': string, 'series_id': number, 'weight': number }[],
'category_recommendations': { 'series_name': string, 'series_id': number, 'weight': number }[],
'rank': {
'position':
{
'week': number,
'month': number,
'three_months': number,
'six_months': number,
'year': number
},
'old_position':
{
'week': number,
'month': number,
'three_months': number,
'six_months': number,
'year': number
},
'lists':
{
'reading': number,
'wish': number,
'complete': number,
'unfinished': number,
'custom': number
}
},
'last_updated': {
'timestamp': number,
'as_rfc3339': string,
'as_string': string
}
}

View File

@@ -0,0 +1,25 @@
export type MangaUpdatesSeriesGroups = {
'group_list': {
'group_id': number,
'name': string,
'url': string,
'social': {
'site': string,
'facebook': string,
'twitter': string,
'irc': { 'channel': string, 'server': string },
'forum': string,
'discord': string
},
'active': true
}[],
'release_list': {
'id': number,
'title': string,
'volume': null,
'chapter': string,
'groups': { 'name': string, 'group_id': number }[],
'release_date': string,
'time_added': { 'timestamp': number, 'as_rfc3339': string, 'as_string': string }
}[]
}

View File

@@ -0,0 +1,114 @@
import AniListDb, {type AlDb} from '@/data/db/AniListDb';
import {strip_} from '@/data/db/DbUtil';
import type {AniListMangaListEntry} from '@/data/models/anilist/AniListMangaListEntry';
import type {AniListMedia} from '@/data/models/anilist/AniListMedia';
import type {AniListMangaList} from '@/data/models/anilist/AniListMangaList';
export default class AniListMangaRepository {
async getMangaLists(userId: number): Promise<AniListMangaList[]> {
return await AniListDb.withDb(async db => {
return (await db.getAllFromIndex('list', '_userId', userId)).map(strip_);
});
}
async getManga(userId: number): Promise<Map<string, AniListMangaListEntry[]>> {
return await AniListDb.withDb(async db => {
const lists = (await db.getAllFromIndex('list', '_userId', userId)) as AniListMangaList[];
const arr = await Promise.all(lists.map(async list => {
let entries = (await db
.getAllFromIndex('manga', '_userId_listName', IDBKeyRange.only(userId + '_' + list.name))
) as AniListMangaListEntry[];
return [list.name, entries.map(strip_)] as [string, AniListMangaListEntry[]];
}));
return new Map<string, AniListMangaListEntry[]>(arr);
});
}
async getMedia(): Promise<AniListMedia[]> {
return await AniListDb.withDb(async db => {
return (await db.getAll('media')).map(strip_);
});
}
async patchLists(userId: number, lists: AniListMangaList[]): Promise<void> {
await AniListDb.withDb(async db => {
await this.patchMangaDropUnnecessaryDbs(db, userId, lists);
await Promise.allSettled(lists.map(async list => {
await this.patchMangaList(db, userId, list);
}));
});
}
async patchManga(userId: number, manga: Map<string, AniListMangaListEntry[]>): Promise<void> {
await AniListDb.withDb(async db => {
await Promise.allSettled(Array.from(manga.entries()).map(async ([listName, entries]) => {
await this.patchMangaListEntries(db, userId, listName, entries);
}));
});
}
async updateMedia(media: AniListMedia[]): Promise<void> {
await AniListDb.withDb(async db => {
await this.patchMangaMedia(db, media);
});
}
private async patchMangaDropUnnecessaryDbs(db: AlDb, userId: number, lists: AniListMangaList[]): Promise<void> {
//drop lists if necessary
const newListNames = new Set(lists.map(e => e.name as string));
const listNamesPresent = (await db.getAllKeysFromIndex('list', '_userId', userId)) as string[];
const listNamesToDrop = listNamesPresent.filter(listName => !newListNames.has(listName));
let txList = db.transaction('list', 'readwrite');
await Promise.allSettled([...listNamesToDrop.map(async listName => {
await txList.store.delete(listName);
}), txList.done]);
//drop manga if necessary
const mangaIdGroupsToDrop = await Promise.all(listNamesToDrop.map(async listName => {
const ret = await db.getAllKeysFromIndex('manga', '_userId_listName', IDBKeyRange.only(userId + '_' + listName));
return ret as string[];
}));
const mangaIdsToDrop = mangaIdGroupsToDrop.flat();
const txManga = db.transaction('manga', 'readwrite');
await Promise.allSettled([...mangaIdsToDrop.map(async mangaId => {
await txManga.store.delete(mangaId);
}), txManga.done]);
}
private async patchMangaList(db: AlDb, userId: number, list: AniListMangaList): Promise<void> {
const o = {...list, _userId: userId, _userId_listName: userId + '_' + list.name} as any;
delete o.entries;
await db.put('list', o).catch(err => console.error(err));
}
private async patchMangaListEntries(db: AlDb, userId: number, listName: string, entries: AniListMangaListEntry[]): Promise<void> {
// console.log("patchMangaListEntries", list);
//drop entries if necessary
const newEntryIds = new Set(entries.map(e => e.id));
const entryIdsPresent = (await db
.getAllKeysFromIndex('manga', '_userId_listName', IDBKeyRange.only(userId + '_' + listName))) as number[];
const entryIdsToDrop = entryIdsPresent.filter(e => !newEntryIds.has(e));
let tx = db.transaction('manga', 'readwrite');
await Promise.allSettled([...entryIdsToDrop.map(async entryId => {
await tx.store.delete(entryId).catch(err => console.error(err));
}), tx.done]);
//create/update entries
tx = db.transaction('manga', 'readwrite');
await Promise.allSettled([...entries.map(async entry => {
const o = {...entry, _userId: userId, _listName: listName, _userId_listName: userId + '_' + listName} as any;
delete o.media;
await tx.store.put(o).catch(err => console.error(err));
}), tx.done]);
}
private async patchMangaMedia(db: AlDb, media: AniListMedia[]): Promise<void> {
//create/update entries
let tx = db.transaction('media', 'readwrite');
await Promise.allSettled([...media.map(async entry => {
await tx!.store.put(entry).catch(err => console.error(err));
}), tx.done]);
}
}

View File

@@ -0,0 +1,16 @@
import type {AniListUser} from '@/data/models/anilist/AniListUser';
import AniListDb from '@/data/db/AniListDb';
export default class AniListUserRepository {
async getUser(userName: string): Promise<AniListUser | null> {
return await AniListDb.withDb(async db => {
return await db.getFromIndex('user', 'name', IDBKeyRange.only(userName)) ?? null;
});
}
async updateUser(user: AniListUser): Promise<void> {
return await AniListDb.withDb(async db => {
await db.put('user', user);
});
}
}

View File

@@ -0,0 +1,64 @@
import MangaUpdatesDb from '@/data/db/MangaUpdatesDb';
import type {MangaUpdatesRelation} from '@/data/models/mangaupdates/MangaUpdatesRelation';
import type {MangaUpdatesMedia} from '@/data/models/mangaupdates/MangaUpdatesMedia';
import type {MangaUpdatesChapter} from '@/data/models/mangaupdates/MangaUpdatesChapter';
import type {MangaUpdatesSeries} from '@/data/models/mangaupdates/MangaUpdatesSeries';
export default class MangaUpdatesRepository {
async getChapters(): Promise<MangaUpdatesChapter[]> {
return await MangaUpdatesDb.withDb(async db => {
return await db.getAll('chapter');
});
}
async getRelations(): Promise<MangaUpdatesRelation[]> {
return await MangaUpdatesDb.withDb(async db => {
return await db.getAll('relation');
});
}
async getSeries(): Promise<MangaUpdatesSeries[]> {
return await MangaUpdatesDb.withDb(async db => {
return await db.getAll('series');
});
}
async getSeriesById(id: number) {
return await MangaUpdatesDb.withDb(async db => {
return await db.get('series', id);
});
}
async addRelations(newRelations: MangaUpdatesRelation[]) {
return await MangaUpdatesDb.withDb(async db => {
let txList = db.transaction('relation', 'readwrite');
await Promise.allSettled([
...newRelations.map(relation => txList.store.add(relation)),
txList.done]);
});
}
async updateChapters(newChapters: MangaUpdatesChapter[]) {
const seriesIds = Array.from(new Set(newChapters.map(e => e.series_id)).values());
return await MangaUpdatesDb.withDb(async db => {
const keysToDelete = await Promise.all(seriesIds.map(series_id => {
return db.getAllKeysFromIndex('chapter', 'series_id', IDBKeyRange.only(series_id)) as any as Promise<number[]>;
}));
let txList = db.transaction('chapter', 'readwrite');
await Promise.allSettled([
...keysToDelete.flat().map(key => txList.store.delete(IDBKeyRange.only(key))),
...newChapters.map(chapter => txList.store.add(chapter)),
txList.done]);
});
}
async updateSeries(newSeries: MangaUpdatesMedia[]) {
return await MangaUpdatesDb.withDb(async db => {
let txList = db.transaction('series', 'readwrite');
await Promise.allSettled([
...newSeries.map(series => txList.store.put(series)),
txList.done]);
});
}
}

View File

@@ -0,0 +1,46 @@
import AniListApi from '@/data/api/AniListApi';
import {MangaStore} from '@/stores/MangaStore';
import type {AniListMangaListEntry} from '@/data/models/anilist/AniListMangaListEntry';
import type {AniListMedia} from '@/data/models/anilist/AniListMedia';
import type {AniListMangaList} from '@/data/models/anilist/AniListMangaList';
export default class AniListDataService {
async updateDb(): Promise<void> {
const store = new MangaStore();
const userName = store.userName;
if (!userName) {
return Promise.reject('No username set');
}
const api = new AniListApi();
const user = await api.fetchUser(userName);
await store.updateAniListUser(user);
const manga = await api.fetchManga(user.id);
const lists = manga.lists
.map(e => {
const ret = {...e} as AniListMangaList; //shallow copy
delete (ret as any).entries;
return ret;
});
const mangaByList = new Map<string, AniListMangaListEntry[]>(manga.lists
.map(list => {
const entries = [...list.entries.map(manga => {
const ret = {...manga} as AniListMangaListEntry; //shallow copy
delete (ret as any).media;
return ret;
})];
return [list.name, entries];
}));
const media = manga.lists
.map(e => e.entries.map(e => e.media)).flat()
.filter(e => e)//not null
.map(e => ({...e} as AniListMedia)); //shallow copy
await store.updateAniListLists(user.id, lists);
await store.updateAniListManga(user.id, mangaByList);
await store.updateAniListMedia(media);
}
}

View File

@@ -0,0 +1,122 @@
import MangaUpdatesApi from '@/data/api/MangaUpdatesApi';
import {DbStore} from '@/stores/DbStore';
import {MangaStore} from '@/stores/MangaStore';
import type {MangaUpdatesChapter} from '@/data/models/mangaupdates/MangaUpdatesChapter';
export default class MangaUpdatesDataService {
private readonly mangaUpdatesApi = new MangaUpdatesApi();
updateDb(progress: Progress): Progress {
const mangaStore = new MangaStore();
const dbStore = new DbStore();
this.findMissingRelations(mangaStore, dbStore, progress)
.then(_ => this.fetchSeriesUpdates(mangaStore, dbStore, progress))
.then(_ => this.fetchSeriesChapterUpdates(mangaStore, dbStore, progress))
.then(_ => progress.onFinished());
return progress;
}
private async findMissingRelations(mangaStore: MangaStore, dbStore: DbStore, progress: Progress): Promise<void> {
const allowTypes = new Set(['Manga', 'Manhwa', 'Manhua'].map(e => e.toLowerCase()));
const media = await dbStore.aniListMangaRepository.getMedia();
const relations = await dbStore.mangaUpdatesRepository.getRelations();
const presentRelationIds = new Set(relations.map(e => e.aniListMediaId));
let i = 0;
for (const m of media) {
try {
if (presentRelationIds.has(m.id)) {
continue;
}
const results = await this.mangaUpdatesApi.search(m.title.romaji);
const matching = results.results
.filter(e => allowTypes.has(e.record.type.toLowerCase())) //check if a manga or similar but not novel
.filter(e => m.startDate.year - 1 <= e.record.year && e.record.year <= m.startDate.year + 1); //check year +-1
if (matching.length === 0) {
continue;
}
await mangaStore.addMangaUpdatesRelations([{aniListMediaId: m.id, mangaUpdatesSeriesId: matching[0].record.series_id}]);
await new Promise((r) => setTimeout(r, 1000));
} finally {
await progress.onProgress('relations', ++i, media.length);
}
}
}
private async fetchSeriesUpdates(mangaStore: MangaStore, dbStore: DbStore, progress: Progress): Promise<void> {
const relations = await dbStore.mangaUpdatesRepository.getRelations();
let i = 0;
for (const relation of relations) {
try {
const dbSeries = await dbStore.mangaUpdatesRepository.getSeriesById(relation.mangaUpdatesSeriesId);
if (dbSeries) { // TODO check for now - lastUpdated < 1d or sth
continue;
}
const series = await this.mangaUpdatesApi.get(relation.mangaUpdatesSeriesId);
await mangaStore.updateMangaUpdatesSeries([series]);
} finally {
await progress.onProgress('series', ++i, relations.length);
}
}
}
private async fetchSeriesChapterUpdates(mangaStore: MangaStore, dbStore: DbStore, progress: Progress): Promise<void> {
const series = await dbStore.mangaUpdatesRepository.getSeries();
let i = 0;
for (const s of series) {
try {
// const dbSeries = await dbStore.mangaUpdatesRepository.getSeriesById(s.series_id);
// if (dbSeries) { // TODO check for now - lastUpdated < 1d or sth
// continue;
// }
const groups = await this.mangaUpdatesApi.groups(s.series_id);
const updates = groups.release_list
.map(r => {
const match = r.chapter.match(/([0-9]+?)[^0-9]*$/);
if (!match) {
return [];
}
const chapter = parseInt(match[1]);
return r.groups.map(g => ({series_id: s.series_id, group: g.name, chapter: chapter} as MangaUpdatesChapter));
})
.flat();
//only keep chapter with the highest chapter number per group
const filtered = Array.from(Map.groupBy(updates, c => c.group).values())
.map(chaptersOfGroup => chaptersOfGroup.reduce((l, r) => l.chapter > r.chapter ? l : r, chaptersOfGroup[0]));
await mangaStore.updateMangaUpdatesChapters(filtered);
} finally {
await progress.onProgress('chapters', ++i, series.length);
}
}
}
}
export type ProgressCallBackFn = (type: string, progress: number, max: number) => (Promise<void> | void);
export type FinishCallBackFn = () => (Promise<void> | void);
export class Progress {
readonly progress: ProgressCallBackFn;
readonly finish: FinishCallBackFn;
constructor(onProgress: ProgressCallBackFn, onFinish: FinishCallBackFn) {
this.progress = onProgress;
this.finish = onFinish;
}
async onProgress(type: string, progress: number, max: number): Promise<void> {
return (this.progress ? this.progress(type, progress, max) : Promise.resolve());
}
async onFinished(): Promise<void> {
return (this.finish ? this.finish() : Promise.resolve());
}
}

View File

@@ -1,7 +1,21 @@
import {messagesEn} from '@/locale/en';
export const messagesDe = {
fetch: {
mangaUpdates: {
chapters: 'Kapitel',
finished: 'Fertig',
relations: 'Relationen',
series: 'Kapitel',
starting: 'Starte',
},
},
locale: messagesEn.locale,
manga: {
chapters: {
newCount: '#Neue Kapitel',
latest: 'Letzte Kapitel',
},
progress: 'Fortschritt',
score: 'Bewertung',
status: {

View File

@@ -1,9 +1,22 @@
export const messagesEn = {
fetch: {
mangaUpdates: {
chapters: 'Chapters',
finished: 'Finished',
relations: 'Relations',
series: 'Series',
starting: 'Starting',
},
},
locale: {
'de': 'Deutsch',
'en': 'English',
},
manga: {
chapters: {
newCount: '#new chapters',
latest: 'Last chapters',
},
progress: 'Progress',
score: 'Score',
status: {

View File

@@ -1,57 +0,0 @@
export type MangaListCollection = {
lists: MangaListModel[]
}
export type MangaListModel = {
name: 'Paused' | 'Completed' | 'Planning' | 'Dropped' | 'Reading',
isCustomList: boolean,
isCompletedList: boolean,
entries: MangaListEntry[]
}
export type MangaListEntry = {
id: number,
mediaId: number,
status: 'PAUSED' | 'COMPLETED' | 'PLANNING' | 'DROPPED' | 'READING',
score: number,
progress: number,
progressVolumes: number,
repeat: number,
priority: number,
private: boolean,
hiddenFromStatusLists: boolean,
customLists: any,
advancedScores: any,
notes: any,
updatedAt: number
startedAt: any,
completedAt: any,
media: Media,
}
export type Media = {
id: 86399
title: {
userPreferred: string,
romaji: string,
english: string,
native: string,
}
coverImage: {
extraLarge: string, //url
large: string, //url
}
type: 'MANGA',
format: 'MANGA' | any,
status: 'RELEASING' | any,
episodes: number | null,
volumes: number | null,
chapters: number | null,
averageScore: number,
popularity: number,
isAdult: boolean,
countryOfOrigin: 'JP' | 'KR' | any,
genres: string[],
bannerImage: string, //url
startDate: any,
}

28
src/stores/DbStore.ts Normal file
View File

@@ -0,0 +1,28 @@
import {Pinia, Store} from 'pinia-class-component';
import AniListMangaRepository from '@/data/repository/aniList/AniListMangaRepository';
import AniListUserRepository from '@/data/repository/aniList/AniListUserRepository';
import MangaUpdatesRepository from '@/data/repository/mangaUpdates/MangaUpdatesRepository';
@Store({
id: 'DbStore',
name: 'DbStore',
})
export class DbStore extends Pinia {
//data
private readonly intAniListMangaRepository = new AniListMangaRepository();
private readonly intAniListUserRepository = new AniListUserRepository();
private readonly intMangaUpdatesRepository = new MangaUpdatesRepository();
//getter
get aniListMangaRepository(): AniListMangaRepository {
return this.intAniListMangaRepository;
}
get aniListUserRepository(): AniListUserRepository {
return this.intAniListUserRepository;
}
get mangaUpdatesRepository(): MangaUpdatesRepository {
return this.intMangaUpdatesRepository;
}
}

162
src/stores/MangaStore.ts Normal file
View File

@@ -0,0 +1,162 @@
import {Pinia, Store} from 'pinia-class-component';
import type {AniListUser} from '@/data/models/anilist/AniListUser';
import {DbStore} from '@/stores/DbStore';
import type {AniListMangaListEntry} from '@/data/models/anilist/AniListMangaListEntry';
import type {AniListMedia} from '@/data/models/anilist/AniListMedia';
import type {AniListMangaList} from '@/data/models/anilist/AniListMangaList';
import type {MangaUpdatesRelation} from '@/data/models/mangaupdates/MangaUpdatesRelation';
import type {MangaUpdatesChapter} from '@/data/models/mangaupdates/MangaUpdatesChapter';
import type {MangaUpdatesSeries} from '@/data/models/mangaupdates/MangaUpdatesSeries';
@Store({
id: 'MangaStore',
name: 'MangaStore',
})
export class MangaStore extends Pinia {
//update trigger
private cachedAniListLists: AniListMangaList[] = [];
private cachedAniListManga = new Map<string, AniListMangaListEntry[]>();
private cachedAniListMedia: AniListMedia[] = [];
private cachedAniListUser: AniListUser | null = null;
private cachedMangaUpdatesChapters: MangaUpdatesChapter[] = [];
private cachedMangaUpdatesSeries: MangaUpdatesSeries[] = [];
private cachedMangaUpdatesRelations: MangaUpdatesRelation[] = [];
private cachedUserName: string | null = null;
constructor() {
super();
this.updateUserName(window.localStorage.getItem('userName'));
}
//getter
get aniListLists(): AniListMangaList[] {
return this.cachedAniListLists;
}
get aniListManga(): Map<string, AniListMangaListEntry[]> {
return this.cachedAniListManga;
}
get aniListMedia(): AniListMedia[] {
return this.cachedAniListMedia;
}
get aniListUser(): AniListUser | null {
return this.cachedAniListUser;
}
get mangaUpdatesChapters(): MangaUpdatesChapter[] {
return this.cachedMangaUpdatesChapters;
}
get mangaUpdatesRelations(): MangaUpdatesRelation[] {
return this.cachedMangaUpdatesRelations;
}
get mangaUpdatesSeries(): MangaUpdatesSeries[] {
return this.cachedMangaUpdatesSeries;
}
get userName(): string | null {
return this.cachedUserName;
}
//stores
private get dbStore(): DbStore {
return new DbStore();
}
//actions
async updateAniListLists(userId: number, lists: AniListMangaList[]): Promise<void> {
await this.dbStore.aniListMangaRepository.patchLists(userId, lists);
if (this.aniListUser?.id === userId) {
this.cachedAniListLists.splice(0);
this.cachedAniListLists.push(...lists);
}
}
async updateAniListManga(userId: number, manga: Map<string, AniListMangaListEntry[]>): Promise<void> {
await this.dbStore.aniListMangaRepository.patchManga(userId, manga);
if (this.aniListUser?.id === userId) {
this.cachedAniListManga.clear();
manga.forEach((v, k) => this.cachedAniListManga.set(k, v));
}
}
async updateAniListMedia(media: AniListMedia[]): Promise<void> {
await this.dbStore.aniListMangaRepository.updateMedia(media);
this.cachedAniListMedia.splice(0);
this.cachedAniListMedia.push(...media);
}
async updateAniListUser(user: AniListUser): Promise<void> {
await this.dbStore.aniListUserRepository.updateUser(user);
if (user.name === this.userName) {
this.cachedAniListUser = user;
}
}
async updateMangaUpdatesChapters(chapters: MangaUpdatesChapter[]): Promise<void> {
await this.dbStore.mangaUpdatesRepository.updateChapters(chapters);
// update cache
const cachedById = Map.groupBy(this.cachedMangaUpdatesChapters, c => c.series_id);
const chaptersById = Map.groupBy(chapters, c => c.series_id);
chaptersById.forEach((v, k) => cachedById.set(k, v));
this.cachedMangaUpdatesChapters.splice(0);
this.cachedMangaUpdatesChapters.push(...Array.from(cachedById.values()).flat());
}
async updateMangaUpdatesSeries(media: MangaUpdatesSeries[]): Promise<void> {
await this.dbStore.mangaUpdatesRepository.updateSeries(media);
// update cache
const cachedById = new Map<number, MangaUpdatesSeries>(this.cachedMangaUpdatesSeries.map(e => [e.series_id, e]));
media.forEach(m => cachedById.set(m.series_id, m));
this.cachedMangaUpdatesSeries.splice(0);
this.cachedMangaUpdatesSeries.push(...Array.from(cachedById.values()));
}
async addMangaUpdatesRelations(relations: MangaUpdatesRelation[]): Promise<void> {
await this.dbStore.mangaUpdatesRepository.addRelations(relations);
this.cachedMangaUpdatesRelations.push(...relations);
}
updateUserName(userName: string | null): void {
if (userName) {
window.localStorage.setItem('userName', userName ?? '');
} else {
window.localStorage.removeItem('userName');
}
this.clearCache();
this.cachedUserName = userName;
}
clearCache(): void {
this.cachedAniListManga.clear();
this.cachedAniListLists.splice(0);
this.cachedAniListMedia.splice(0);
this.cachedAniListUser = null;
this.cachedMangaUpdatesChapters.splice(0);
this.cachedMangaUpdatesSeries.splice(0);
this.cachedMangaUpdatesRelations.splice(0);
}
async reloadCache(): Promise<void> {
this.clearCache();
this.cachedAniListMedia.push(...await this.dbStore.aniListMangaRepository.getMedia());
if (this.userName) {
this.cachedAniListUser = await this.dbStore.aniListUserRepository.getUser(this.userName);
if (this.aniListUser) {
this.cachedAniListLists.push(...await this.dbStore.aniListMangaRepository.getMangaLists(this.aniListUser.id));
const manga = await this.dbStore.aniListMangaRepository.getManga(this.aniListUser.id);
manga.forEach((v, k) => this.cachedAniListManga.set(k, v));
}
}
this.cachedMangaUpdatesChapters.push(...await this.dbStore.mangaUpdatesRepository.getChapters());
this.cachedMangaUpdatesSeries.push(...await this.dbStore.mangaUpdatesRepository.getSeries());
this.cachedMangaUpdatesRelations.push(...await this.dbStore.mangaUpdatesRepository.getRelations());
}
}

View File

@@ -0,0 +1,22 @@
import {Pinia, Store} from 'pinia-class-component';
import AniListDataService from '@/data/service/AniListDataService';
import MangaUpdatesDataService from '@/data/service/MangaUpdatesDataService';
@Store({
id: 'ServiceStore',
name: 'ServiceStore',
})
export class ServiceStore extends Pinia {
//data
private readonly intAniListDataService = new AniListDataService();
private readonly intMangaUpdatesDataService = new MangaUpdatesDataService();
//getter
get aniListDataService(): AniListDataService {
return this.intAniListDataService;
}
get mangaUpdatesDataService(): MangaUpdatesDataService {
return this.intMangaUpdatesDataService;
}
}

View File

@@ -28,6 +28,11 @@ export default defineConfig({
target: 'https://graphql.anilist.co',
changeOrigin: true,
},
'^/mangaupdates/.*$': {
target: 'https://api.mangaupdates.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/mangaupdates/, ''),
},
},
},
});