From 7f28012520d39f33d202f2d176e9c34312eaabbf Mon Sep 17 00:00:00 2001 From: wea_ondara Date: Tue, 26 Sep 2023 16:47:08 +0200 Subject: [PATCH] added mangaupdates integration cleanup db related code cleanup ui ui eye candy --- package.json | 1 + src/components/AniListUserSearch.vue | 43 ++-- src/components/MangaList.vue | 186 ++++++++++++++---- src/components/MangaLists.vue | 61 +++++- src/components/MangaUpdatesUpdater.vue | 69 +++++++ src/components/NavBar.vue | 7 +- .../api/AniListApi.ts} | 63 ++---- src/data/api/ApiUtils.ts | 8 + src/data/api/MangaUpdatesApi.ts | 30 +++ src/data/db/AniListDb.ts | 101 ++++++++++ src/data/db/DbUtil.ts | 4 + src/data/db/MangaUpdatesDb.ts | 73 +++++++ src/data/models/anilist/AniListMangaList.ts | 8 + .../anilist/AniListMangaListCollection.ts | 6 + .../models/anilist/AniListMangaListEntry.ts | 21 ++ src/data/models/anilist/AniListMedia.ts | 26 +++ .../models/anilist/AniListUser.ts} | 2 +- .../mangaupdates/MangaUpdateSearchResult.ts | 91 +++++++++ .../mangaupdates/MangaUpdatesChapter.ts | 5 + .../mangaupdates/MangaUpdatesRelation.ts | 5 + .../mangaupdates/MangaUpdatesSearchResult.ts | 13 ++ .../MangaUpdatesSearchResultMetaData.ts | 25 +++ .../models/mangaupdates/MangaUpdatesSeries.ts | 67 +++++++ .../mangaupdates/MangaUpdatesSeriesGroups.ts | 25 +++ .../aniList/AniListMangaRepository.ts | 114 +++++++++++ .../aniList/AniListUserRepository.ts | 16 ++ .../mangaUpdates/MangaUpdatesRepository.ts | 64 ++++++ src/data/service/AniListDataService.ts | 46 +++++ src/data/service/MangaUpdatesDataService.ts | 122 ++++++++++++ src/locale/de.ts | 14 ++ src/locale/en.ts | 13 ++ src/models/MangaListCollection.ts | 57 ------ src/stores/DbStore.ts | 28 +++ src/stores/MangaStore.ts | 162 +++++++++++++++ src/stores/ServiceStore.ts | 22 +++ vite.config.ts | 5 + 36 files changed, 1435 insertions(+), 168 deletions(-) create mode 100644 src/components/MangaUpdatesUpdater.vue rename src/{stores/AniListStore.ts => data/api/AniListApi.ts} (62%) create mode 100644 src/data/api/ApiUtils.ts create mode 100644 src/data/api/MangaUpdatesApi.ts create mode 100644 src/data/db/AniListDb.ts create mode 100644 src/data/db/DbUtil.ts create mode 100644 src/data/db/MangaUpdatesDb.ts create mode 100644 src/data/models/anilist/AniListMangaList.ts create mode 100644 src/data/models/anilist/AniListMangaListCollection.ts create mode 100644 src/data/models/anilist/AniListMangaListEntry.ts create mode 100644 src/data/models/anilist/AniListMedia.ts rename src/{models/User.ts => data/models/anilist/AniListUser.ts} (58%) create mode 100644 src/data/models/mangaupdates/MangaUpdateSearchResult.ts create mode 100644 src/data/models/mangaupdates/MangaUpdatesChapter.ts create mode 100644 src/data/models/mangaupdates/MangaUpdatesRelation.ts create mode 100644 src/data/models/mangaupdates/MangaUpdatesSearchResult.ts create mode 100644 src/data/models/mangaupdates/MangaUpdatesSearchResultMetaData.ts create mode 100644 src/data/models/mangaupdates/MangaUpdatesSeries.ts create mode 100644 src/data/models/mangaupdates/MangaUpdatesSeriesGroups.ts create mode 100644 src/data/repository/aniList/AniListMangaRepository.ts create mode 100644 src/data/repository/aniList/AniListUserRepository.ts create mode 100644 src/data/repository/mangaUpdates/MangaUpdatesRepository.ts create mode 100644 src/data/service/AniListDataService.ts create mode 100644 src/data/service/MangaUpdatesDataService.ts delete mode 100644 src/models/MangaListCollection.ts create mode 100644 src/stores/DbStore.ts create mode 100644 src/stores/MangaStore.ts create mode 100644 src/stores/ServiceStore.ts diff --git a/package.json b/package.json index fc660e5..acfdf48 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/AniListUserSearch.vue b/src/components/AniListUserSearch.vue index 1e68a04..50aefe0 100644 --- a/src/components/AniListUserSearch.vue +++ b/src/components/AniListUserSearch.vue @@ -1,30 +1,44 @@ @@ -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; +} diff --git a/src/components/MangaLists.vue b/src/components/MangaLists.vue index 2063149..0c81efe 100644 --- a/src/components/MangaLists.vue +++ b/src/components/MangaLists.vue @@ -1,25 +1,68 @@ diff --git a/src/components/MangaUpdatesUpdater.vue b/src/components/MangaUpdatesUpdater.vue new file mode 100644 index 0000000..dc78e2b --- /dev/null +++ b/src/components/MangaUpdatesUpdater.vue @@ -0,0 +1,69 @@ + + + diff --git a/src/components/NavBar.vue b/src/components/NavBar.vue index b0198dd..60f8a17 100644 --- a/src/components/NavBar.vue +++ b/src/components/NavBar.vue @@ -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 {
- +
+ + +
diff --git a/src/stores/AniListStore.ts b/src/data/api/AniListApi.ts similarity index 62% rename from src/stores/AniListStore.ts rename to src/data/api/AniListApi.ts index c53a5cc..ca956fb 100644 --- a/src/stores/AniListStore.ts +++ b/src/data/api/AniListApi.ts @@ -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 { - if (!this.userName) { - return; - } - - let res = await fetch('/graphql', { +export default class AniListApi { + async fetchUser(userName: string): Promise { + 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 { + 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; } } \ No newline at end of file diff --git a/src/data/api/ApiUtils.ts b/src/data/api/ApiUtils.ts new file mode 100644 index 0000000..3edc2c0 --- /dev/null +++ b/src/data/api/ApiUtils.ts @@ -0,0 +1,8 @@ +export function handleJsonResponse(res: Primise): Promise { + return res.then(async e => { + if (e.status === 200) { + return await e.json(); + } + throw new Error('Api error ' + e.status + ': ' + await e.text()); + }); +} \ No newline at end of file diff --git a/src/data/api/MangaUpdatesApi.ts b/src/data/api/MangaUpdatesApi.ts new file mode 100644 index 0000000..5c2d2b1 --- /dev/null +++ b/src/data/api/MangaUpdatesApi.ts @@ -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 { + 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 { + const res = fetch(`/mangaupdates/v1/series/${id}`); + return handleJsonResponse(res); + } + + groups(id: number): Promise { + const res = fetch(`/mangaupdates/v1/series/${id}/groups`); + return handleJsonResponse(res); + } +} diff --git a/src/data/db/AniListDb.ts b/src/data/db/AniListDb.ts new file mode 100644 index 0000000..2889da9 --- /dev/null +++ b/src/data/db/AniListDb.ts @@ -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; + +export default class AniListDb { + private intDb: IDBPDatabase | null = null; + + get db(): IDBPDatabase | null { + return this.intDb; + } + + static async withDb(fn: (db: AlDb) => Promise | T): Promise { + const db = new AniListDb(); + const idb = await db.open(); + try { + return await fn(idb); + } finally { + db.close(); + } + } + + async open(): Promise> { + 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 + } + }, +} \ No newline at end of file diff --git a/src/data/db/DbUtil.ts b/src/data/db/DbUtil.ts new file mode 100644 index 0000000..531c55a --- /dev/null +++ b/src/data/db/DbUtil.ts @@ -0,0 +1,4 @@ +export function strip_(obj: T): T { + Object.keys(obj).filter(e => e.startsWith('_')).forEach(k => delete (obj as any)[k]); + return obj; +} \ No newline at end of file diff --git a/src/data/db/MangaUpdatesDb.ts b/src/data/db/MangaUpdatesDb.ts new file mode 100644 index 0000000..d697af9 --- /dev/null +++ b/src/data/db/MangaUpdatesDb.ts @@ -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; + +export default class MangaUpdatesDb { + private intDb: IDBPDatabase | null = null; + + get db(): IDBPDatabase | null { + return this.intDb; + } + + static async withDb(fn: (db: MuDb) => Promise | T): Promise { + const db = new MangaUpdatesDb(); + const idb = await db.open(); + try { + return await fn(idb); + } finally { + db.close(); + } + } + + async open(): Promise> { + 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 + } + }, +} \ No newline at end of file diff --git a/src/data/models/anilist/AniListMangaList.ts b/src/data/models/anilist/AniListMangaList.ts new file mode 100644 index 0000000..f7efa13 --- /dev/null +++ b/src/data/models/anilist/AniListMangaList.ts @@ -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[] +} \ No newline at end of file diff --git a/src/data/models/anilist/AniListMangaListCollection.ts b/src/data/models/anilist/AniListMangaListCollection.ts new file mode 100644 index 0000000..862cfd2 --- /dev/null +++ b/src/data/models/anilist/AniListMangaListCollection.ts @@ -0,0 +1,6 @@ +import type {AniListMangaList} from '@/data/models/anilist/AniListMangaList'; + +export type AniListMangaListCollection = { + lists: AniListMangaList[] +} + diff --git a/src/data/models/anilist/AniListMangaListEntry.ts b/src/data/models/anilist/AniListMangaListEntry.ts new file mode 100644 index 0000000..4de6d6b --- /dev/null +++ b/src/data/models/anilist/AniListMangaListEntry.ts @@ -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, +} \ No newline at end of file diff --git a/src/data/models/anilist/AniListMedia.ts b/src/data/models/anilist/AniListMedia.ts new file mode 100644 index 0000000..e7d94b5 --- /dev/null +++ b/src/data/models/anilist/AniListMedia.ts @@ -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, +} \ No newline at end of file diff --git a/src/models/User.ts b/src/data/models/anilist/AniListUser.ts similarity index 58% rename from src/models/User.ts rename to src/data/models/anilist/AniListUser.ts index e737c1f..3af8333 100644 --- a/src/models/User.ts +++ b/src/data/models/anilist/AniListUser.ts @@ -1,4 +1,4 @@ -export type User = { +export type AniListUser = { id: number, name: string, //... diff --git a/src/data/models/mangaupdates/MangaUpdateSearchResult.ts b/src/data/models/mangaupdates/MangaUpdateSearchResult.ts new file mode 100644 index 0000000..7af4784 --- /dev/null +++ b/src/data/models/mangaupdates/MangaUpdateSearchResult.ts @@ -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 + } +}; + + diff --git a/src/data/models/mangaupdates/MangaUpdatesChapter.ts b/src/data/models/mangaupdates/MangaUpdatesChapter.ts new file mode 100644 index 0000000..8b94193 --- /dev/null +++ b/src/data/models/mangaupdates/MangaUpdatesChapter.ts @@ -0,0 +1,5 @@ +export type MangaUpdatesChapter = { + series_id: number, + group: string, + chapter: number +} \ No newline at end of file diff --git a/src/data/models/mangaupdates/MangaUpdatesRelation.ts b/src/data/models/mangaupdates/MangaUpdatesRelation.ts new file mode 100644 index 0000000..a863b5d --- /dev/null +++ b/src/data/models/mangaupdates/MangaUpdatesRelation.ts @@ -0,0 +1,5 @@ +export type MangaUpdatesRelation = { + aniListMediaId: number, + mangaUpdatesSeriesId: number +} + diff --git a/src/data/models/mangaupdates/MangaUpdatesSearchResult.ts b/src/data/models/mangaupdates/MangaUpdatesSearchResult.ts new file mode 100644 index 0000000..d20d75b --- /dev/null +++ b/src/data/models/mangaupdates/MangaUpdatesSearchResult.ts @@ -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 + }] +} \ No newline at end of file diff --git a/src/data/models/mangaupdates/MangaUpdatesSearchResultMetaData.ts b/src/data/models/mangaupdates/MangaUpdatesSearchResultMetaData.ts new file mode 100644 index 0000000..86ec6fd --- /dev/null +++ b/src/data/models/mangaupdates/MangaUpdatesSearchResultMetaData.ts @@ -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 + }] +} \ No newline at end of file diff --git a/src/data/models/mangaupdates/MangaUpdatesSeries.ts b/src/data/models/mangaupdates/MangaUpdatesSeries.ts new file mode 100644 index 0000000..9314f98 --- /dev/null +++ b/src/data/models/mangaupdates/MangaUpdatesSeries.ts @@ -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 + } +} \ No newline at end of file diff --git a/src/data/models/mangaupdates/MangaUpdatesSeriesGroups.ts b/src/data/models/mangaupdates/MangaUpdatesSeriesGroups.ts new file mode 100644 index 0000000..947427c --- /dev/null +++ b/src/data/models/mangaupdates/MangaUpdatesSeriesGroups.ts @@ -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 } + }[] +} \ No newline at end of file diff --git a/src/data/repository/aniList/AniListMangaRepository.ts b/src/data/repository/aniList/AniListMangaRepository.ts new file mode 100644 index 0000000..1afa16a --- /dev/null +++ b/src/data/repository/aniList/AniListMangaRepository.ts @@ -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 { + return await AniListDb.withDb(async db => { + return (await db.getAllFromIndex('list', '_userId', userId)).map(strip_); + }); + } + + async getManga(userId: number): Promise> { + 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(arr); + }); + } + + async getMedia(): Promise { + return await AniListDb.withDb(async db => { + return (await db.getAll('media')).map(strip_); + }); + } + + async patchLists(userId: number, lists: AniListMangaList[]): Promise { + 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): Promise { + 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 { + await AniListDb.withDb(async db => { + await this.patchMangaMedia(db, media); + }); + } + + private async patchMangaDropUnnecessaryDbs(db: AlDb, userId: number, lists: AniListMangaList[]): Promise { + //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 { + 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 { + // 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 { + //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]); + } +} diff --git a/src/data/repository/aniList/AniListUserRepository.ts b/src/data/repository/aniList/AniListUserRepository.ts new file mode 100644 index 0000000..52ab590 --- /dev/null +++ b/src/data/repository/aniList/AniListUserRepository.ts @@ -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 { + return await AniListDb.withDb(async db => { + return await db.getFromIndex('user', 'name', IDBKeyRange.only(userName)) ?? null; + }); + } + + async updateUser(user: AniListUser): Promise { + return await AniListDb.withDb(async db => { + await db.put('user', user); + }); + } +} \ No newline at end of file diff --git a/src/data/repository/mangaUpdates/MangaUpdatesRepository.ts b/src/data/repository/mangaUpdates/MangaUpdatesRepository.ts new file mode 100644 index 0000000..0579021 --- /dev/null +++ b/src/data/repository/mangaUpdates/MangaUpdatesRepository.ts @@ -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 { + return await MangaUpdatesDb.withDb(async db => { + return await db.getAll('chapter'); + }); + } + + async getRelations(): Promise { + return await MangaUpdatesDb.withDb(async db => { + return await db.getAll('relation'); + }); + } + + async getSeries(): Promise { + 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; + })); + + 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]); + }); + } +} \ No newline at end of file diff --git a/src/data/service/AniListDataService.ts b/src/data/service/AniListDataService.ts new file mode 100644 index 0000000..585eb57 --- /dev/null +++ b/src/data/service/AniListDataService.ts @@ -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 { + 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(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); + } +} \ No newline at end of file diff --git a/src/data/service/MangaUpdatesDataService.ts b/src/data/service/MangaUpdatesDataService.ts new file mode 100644 index 0000000..8e8a9c0 --- /dev/null +++ b/src/data/service/MangaUpdatesDataService.ts @@ -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 { + 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 { + 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 { + 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); +export type FinishCallBackFn = () => (Promise | 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 { + return (this.progress ? this.progress(type, progress, max) : Promise.resolve()); + } + + async onFinished(): Promise { + return (this.finish ? this.finish() : Promise.resolve()); + } +} \ No newline at end of file diff --git a/src/locale/de.ts b/src/locale/de.ts index b162992..1bd3585 100644 --- a/src/locale/de.ts +++ b/src/locale/de.ts @@ -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: { diff --git a/src/locale/en.ts b/src/locale/en.ts index 99d1601..88faffe 100644 --- a/src/locale/en.ts +++ b/src/locale/en.ts @@ -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: { diff --git a/src/models/MangaListCollection.ts b/src/models/MangaListCollection.ts deleted file mode 100644 index 1bbe8c9..0000000 --- a/src/models/MangaListCollection.ts +++ /dev/null @@ -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, -} \ No newline at end of file diff --git a/src/stores/DbStore.ts b/src/stores/DbStore.ts new file mode 100644 index 0000000..46aaee4 --- /dev/null +++ b/src/stores/DbStore.ts @@ -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; + } +} \ No newline at end of file diff --git a/src/stores/MangaStore.ts b/src/stores/MangaStore.ts new file mode 100644 index 0000000..d9f0a21 --- /dev/null +++ b/src/stores/MangaStore.ts @@ -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(); + 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 { + 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 { + 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): Promise { + 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 { + await this.dbStore.aniListMangaRepository.updateMedia(media); + this.cachedAniListMedia.splice(0); + this.cachedAniListMedia.push(...media); + } + + async updateAniListUser(user: AniListUser): Promise { + await this.dbStore.aniListUserRepository.updateUser(user); + if (user.name === this.userName) { + this.cachedAniListUser = user; + } + } + + async updateMangaUpdatesChapters(chapters: MangaUpdatesChapter[]): Promise { + 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 { + await this.dbStore.mangaUpdatesRepository.updateSeries(media); + + // update cache + const cachedById = new Map(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 { + 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 { + 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()); + } +} \ No newline at end of file diff --git a/src/stores/ServiceStore.ts b/src/stores/ServiceStore.ts new file mode 100644 index 0000000..7b371e0 --- /dev/null +++ b/src/stores/ServiceStore.ts @@ -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; + } +} \ No newline at end of file diff --git a/vite.config.ts b/vite.config.ts index b4e1e92..7ffc82f 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -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/, ''), + }, }, }, });