@@ -23,6 +23,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|||||||
- (**Browse**) Merge languages filter of the sources and extensions into one
|
- (**Browse**) Merge languages filter of the sources and extensions into one
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
- (**General**) Fix saving large client data on the server (e.g., custom source filters)
|
||||||
- (**Library**) Fix total library size chip color in light mode
|
- (**Library**) Fix total library size chip color in light mode
|
||||||
- (**Browse**) Fix missing pinned sources in the source language filter
|
- (**Browse**) Fix missing pinned sources in the source language filter
|
||||||
- (**Browse**) Fix incorrectly showing "local source" source in the source language filter (the local source can't be disabled)
|
- (**Browse**) Fix incorrectly showing "local source" source in the source language filter (the local source can't be disabled)
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { GqlMetaHolder } from '@/features/metadata/Metadata.types.ts';
|
|||||||
|
|
||||||
export const FALLBACK_MANGA: MangaIdInfo & GqlMetaHolder = { id: -1 };
|
export const FALLBACK_MANGA: MangaIdInfo & GqlMetaHolder = { id: -1 };
|
||||||
|
|
||||||
export const GLOBAL_READER_SETTINGS_MANGA: MangaIdInfo = { id: -2 };
|
export const GLOBAL_READER_SETTINGS_MANGA: MangaIdInfo & GqlMetaHolder = { id: -2 };
|
||||||
|
|
||||||
export const MANGA_COVER_ASPECT_RATIO = '1 / 1.5';
|
export const MANGA_COVER_ASPECT_RATIO = '1 / 1.5';
|
||||||
|
|
||||||
|
|||||||
@@ -37,9 +37,12 @@ export const getMetadataKey = (key: string, prefixes: string[] = [], appPrefix:
|
|||||||
return `${finalPrefix.join('_')}_${key}`;
|
return `${finalPrefix.join('_')}_${key}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const doesMetadataKeyExistIn = (
|
export const doesAppMetadataKeyExistIn = (
|
||||||
meta: Metadata | undefined,
|
meta: Metadata | undefined,
|
||||||
key: string,
|
key: string,
|
||||||
prefixes?: string[],
|
prefixes?: string[],
|
||||||
appPrefix?: string,
|
appPrefix?: string,
|
||||||
): boolean => Object.prototype.hasOwnProperty.call(meta ?? {}, getMetadataKey(key, prefixes, appPrefix));
|
): boolean => Object.prototype.hasOwnProperty.call(meta ?? {}, getMetadataKey(key, prefixes, appPrefix));
|
||||||
|
|
||||||
|
export const doesMetadataKeyExistIn = (meta: Metadata | undefined, key: string): boolean =>
|
||||||
|
Object.prototype.hasOwnProperty.call(meta ?? {}, key);
|
||||||
|
|||||||
213
src/features/metadata/services/MetadataChunker.ts
Normal file
213
src/features/metadata/services/MetadataChunker.ts
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) Contributors to the Suwayomi project
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { GqlMetaHolder, Metadata, MetadataHolderType } from '@/features/metadata/Metadata.types.ts';
|
||||||
|
import { convertFromGqlMeta } from '@/features/metadata/services/MetadataConverter.ts';
|
||||||
|
import { requestManager } from '@/lib/requests/RequestManager.ts';
|
||||||
|
import { GET_GLOBAL_METADATAS } from '@/lib/graphql/metadata/GlobalMetadataQuery.ts';
|
||||||
|
import {
|
||||||
|
GetGlobalMetadatasQuery,
|
||||||
|
GetGlobalMetadatasQueryVariables,
|
||||||
|
MetaInput,
|
||||||
|
} from '@/lib/graphql/generated/graphql.ts';
|
||||||
|
import { doesMetadataKeyExistIn } from '@/features/metadata/Metadata.utils.ts';
|
||||||
|
|
||||||
|
export class MetadataChunker {
|
||||||
|
static readonly MAX_METADATA_VALUE_LENGTH = 4000;
|
||||||
|
|
||||||
|
static getChunkLengthKey(fullKey: string): string {
|
||||||
|
return `${fullKey}_length`;
|
||||||
|
}
|
||||||
|
|
||||||
|
static getChunkIndexKey(fullKey: string, index: number): string {
|
||||||
|
return `${fullKey}_${index}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
static isChunkedInMetadata(metadata: Metadata | undefined, fullKey: string): boolean {
|
||||||
|
if (!metadata) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return doesMetadataKeyExistIn(metadata, this.getChunkLengthKey(fullKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
static getExistingChunkCount(metadata: Metadata | undefined, fullKey: string): number {
|
||||||
|
if (!metadata) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lengthKey = this.getChunkLengthKey(fullKey);
|
||||||
|
if (!doesMetadataKeyExistIn(metadata, lengthKey)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const count = Number(metadata[lengthKey]);
|
||||||
|
|
||||||
|
return Number.isNaN(count) ? 0 : count;
|
||||||
|
}
|
||||||
|
|
||||||
|
static getStaleChunkKeyCount(metadata: Metadata | undefined, fullKey: string): number {
|
||||||
|
if (!metadata) return 0;
|
||||||
|
|
||||||
|
const scanFrom = (index: number): number =>
|
||||||
|
doesMetadataKeyExistIn(metadata, this.getChunkIndexKey(fullKey, index)) ? scanFrom(index + 1) : index;
|
||||||
|
|
||||||
|
return scanFrom(this.getExistingChunkCount(metadata, fullKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
static reassembleChunkedValue(metadata: Metadata, fullKey: string): string | undefined {
|
||||||
|
const chunkCount = this.getExistingChunkCount(metadata, fullKey);
|
||||||
|
|
||||||
|
if (chunkCount <= 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array(chunkCount)
|
||||||
|
.fill(1)
|
||||||
|
.reduce((acc, _, index) => {
|
||||||
|
if (acc === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunkKey = this.getChunkIndexKey(fullKey, index);
|
||||||
|
|
||||||
|
if (!doesMetadataKeyExistIn(metadata, chunkKey)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${acc}${metadata[chunkKey]}`;
|
||||||
|
}, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
static reassembleAllChunkedValues(metadata: Metadata | undefined): Metadata | undefined {
|
||||||
|
if (metadata === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunkedKeys = new Set<string>();
|
||||||
|
|
||||||
|
const reassembledEntries = Object.entries(metadata)
|
||||||
|
.map(([key]) => {
|
||||||
|
if (!key.endsWith('_length')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseKey = key.slice(0, -'_length'.length);
|
||||||
|
const count = Number(metadata[key]);
|
||||||
|
|
||||||
|
if (Number.isNaN(count) || count <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reassembledValue = this.reassembleChunkedValue(metadata, baseKey);
|
||||||
|
|
||||||
|
if (reassembledValue === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
chunkedKeys.add(key);
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
chunkedKeys.add(this.getChunkIndexKey(baseKey, i));
|
||||||
|
}
|
||||||
|
|
||||||
|
return [baseKey, reassembledValue];
|
||||||
|
})
|
||||||
|
.filter((entry): entry is [string, string] => entry !== null);
|
||||||
|
|
||||||
|
const nonChunkedEntries = Object.entries(metadata)
|
||||||
|
.map(([key, value]) => {
|
||||||
|
if (chunkedKeys.has(key)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [key, value];
|
||||||
|
})
|
||||||
|
.filter((entry): entry is [string, string] => entry !== null);
|
||||||
|
|
||||||
|
const reassembledMetadata = Object.fromEntries([...nonChunkedEntries, ...reassembledEntries]);
|
||||||
|
|
||||||
|
return reassembledMetadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
static chunkValue(fullKey: string, value: string): MetaInput[] {
|
||||||
|
if (value.length <= this.MAX_METADATA_VALUE_LENGTH) {
|
||||||
|
return [{ key: fullKey, value }];
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries: MetaInput[] = [];
|
||||||
|
let offset = 0;
|
||||||
|
let index = 0;
|
||||||
|
|
||||||
|
while (offset < value.length) {
|
||||||
|
const chunk = value.substring(offset, offset + this.MAX_METADATA_VALUE_LENGTH);
|
||||||
|
|
||||||
|
entries.push({ key: this.getChunkIndexKey(fullKey, index), value: chunk });
|
||||||
|
|
||||||
|
offset += this.MAX_METADATA_VALUE_LENGTH;
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
entries.push({ key: this.getChunkLengthKey(fullKey), value: `${index}` });
|
||||||
|
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
static computeChunkDeletions(
|
||||||
|
existingMetadata: Metadata | undefined,
|
||||||
|
fullKey: string,
|
||||||
|
newChunkCount: number,
|
||||||
|
): string[] {
|
||||||
|
if (!existingMetadata) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const isNowChunked = newChunkCount > 0;
|
||||||
|
const hasDirectKey = doesMetadataKeyExistIn(existingMetadata, fullKey);
|
||||||
|
const hasLengthKey = doesMetadataKeyExistIn(existingMetadata, this.getChunkLengthKey(fullKey));
|
||||||
|
const staleChunkKeyCount = this.getStaleChunkKeyCount(existingMetadata, fullKey);
|
||||||
|
|
||||||
|
const keysToDelete: string[] = [];
|
||||||
|
|
||||||
|
const changedToChunkedFromUnchunked = hasDirectKey && isNowChunked;
|
||||||
|
if (changedToChunkedFromUnchunked) {
|
||||||
|
keysToDelete.push(fullKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
const changedToUnchunkedFromChunked = hasLengthKey && !isNowChunked;
|
||||||
|
if (changedToUnchunkedFromChunked) {
|
||||||
|
keysToDelete.push(this.getChunkLengthKey(fullKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete stale chunk index keys
|
||||||
|
const startIndex = isNowChunked ? newChunkCount : 0;
|
||||||
|
for (let i = startIndex; i < staleChunkKeyCount; i++) {
|
||||||
|
keysToDelete.push(this.getChunkIndexKey(fullKey, i));
|
||||||
|
}
|
||||||
|
|
||||||
|
return keysToDelete;
|
||||||
|
}
|
||||||
|
|
||||||
|
static getExistingMetadata(metadataHolder: GqlMetaHolder, holderType: MetadataHolderType): Metadata | undefined {
|
||||||
|
if (holderType === 'global' && metadataHolder.meta === undefined) {
|
||||||
|
const cached = requestManager.graphQLClient.client.readQuery<
|
||||||
|
GetGlobalMetadatasQuery,
|
||||||
|
GetGlobalMetadatasQueryVariables
|
||||||
|
>({
|
||||||
|
query: GET_GLOBAL_METADATAS,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!cached) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return convertFromGqlMeta(cached.metas.nodes);
|
||||||
|
}
|
||||||
|
|
||||||
|
return convertFromGqlMeta(metadataHolder.meta);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ import { MangaIdInfo } from '@/features/manga/Manga.types.ts';
|
|||||||
import { CategoryIdInfo } from '@/features/category/Category.types.ts';
|
import { CategoryIdInfo } from '@/features/category/Category.types.ts';
|
||||||
import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts';
|
import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts';
|
||||||
import { getMetadataUpdateFunction } from '@/features/metadata/services/MetadataUpdater.ts';
|
import { getMetadataUpdateFunction } from '@/features/metadata/services/MetadataUpdater.ts';
|
||||||
|
import { MetadataChunker } from '@/features/metadata/services/MetadataChunker.ts';
|
||||||
import { SourceIdInfo } from '@/features/source/Source.types.ts';
|
import { SourceIdInfo } from '@/features/source/Source.types.ts';
|
||||||
import { ChapterIdInfo } from '@/features/chapter/Chapter.types.ts';
|
import { ChapterIdInfo } from '@/features/chapter/Chapter.types.ts';
|
||||||
|
|
||||||
@@ -278,7 +279,8 @@ const commitMigratedMetadata = (
|
|||||||
migratedMetadata: Metadata,
|
migratedMetadata: Metadata,
|
||||||
useEffectFn: typeof useEffect = (fn: () => void) => fn(),
|
useEffectFn: typeof useEffect = (fn: () => void) => fn(),
|
||||||
): void => {
|
): void => {
|
||||||
const metadata = metadataHolder?.meta;
|
const rawMetadata = metadataHolder?.meta;
|
||||||
|
const metadata = MetadataChunker.reassembleAllChunkedValues(rawMetadata);
|
||||||
|
|
||||||
const migrationId = Number(metadata?.[getMetadataKey('migration')] ?? 1);
|
const migrationId = Number(metadata?.[getMetadataKey('migration')] ?? 1);
|
||||||
|
|
||||||
@@ -294,6 +296,16 @@ const commitMigratedMetadata = (
|
|||||||
migratedMetadata[key],
|
migratedMetadata[key],
|
||||||
]) as MetadataKeyValuePair[];
|
]) as MetadataKeyValuePair[];
|
||||||
|
|
||||||
|
// Expand app keys to chunk keys for deletion (e.g., some_key -> some_key_0, some_key_1, some_key_2, ..., some_key_n, some_key_length
|
||||||
|
const keysToDelete = metadataKeysToDelete.flatMap((fullKey) => {
|
||||||
|
if (rawMetadata && MetadataChunker.isChunkedInMetadata(rawMetadata, fullKey)) {
|
||||||
|
const count = MetadataChunker.getExistingChunkCount(rawMetadata, fullKey);
|
||||||
|
const chunkKeys = Array.from({ length: count }, (_, i) => MetadataChunker.getChunkIndexKey(fullKey, i));
|
||||||
|
return [MetadataChunker.getChunkLengthKey(fullKey), ...chunkKeys];
|
||||||
|
}
|
||||||
|
return [fullKey];
|
||||||
|
}) as AppMetadataKeys[];
|
||||||
|
|
||||||
const updateMetadata = getMetadataUpdateFunction(type, metadataHolder ?? { id: -1, meta: {} });
|
const updateMetadata = getMetadataUpdateFunction(type, metadataHolder ?? { id: -1, meta: {} });
|
||||||
|
|
||||||
useEffectFn(() => {
|
useEffectFn(() => {
|
||||||
@@ -318,7 +330,7 @@ const commitMigratedMetadata = (
|
|||||||
try {
|
try {
|
||||||
await updateMetadata({
|
await updateMetadata({
|
||||||
update: metadataToUpdate,
|
update: metadataToUpdate,
|
||||||
delete: metadataKeysToDelete,
|
delete: keysToDelete,
|
||||||
migrate: [['migration', METADATA_MIGRATIONS.length]],
|
migrate: [['migration', METADATA_MIGRATIONS.length]],
|
||||||
isMetadataKey: true,
|
isMetadataKey: true,
|
||||||
});
|
});
|
||||||
@@ -343,7 +355,8 @@ export const applyMetadataMigrations = (
|
|||||||
| (SourceIdInfo & MetadataHolder),
|
| (SourceIdInfo & MetadataHolder),
|
||||||
useEffectFn: typeof useEffect = (fn: () => void) => fn(),
|
useEffectFn: typeof useEffect = (fn: () => void) => fn(),
|
||||||
): Metadata | undefined => {
|
): Metadata | undefined => {
|
||||||
const meta = { ...(metadataHolder?.meta ?? {}) };
|
const rawMeta = metadataHolder?.meta ?? {};
|
||||||
|
const meta = MetadataChunker.reassembleAllChunkedValues(rawMeta) ?? {};
|
||||||
|
|
||||||
const migrationIdKey = getMetadataKey('migration');
|
const migrationIdKey = getMetadataKey('migration');
|
||||||
const appliedMigrationId = Number.isNaN(Number(meta[migrationIdKey]))
|
const appliedMigrationId = Number.isNaN(Number(meta[migrationIdKey]))
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { MangaIdInfo } from '@/features/manga/Manga.types.ts';
|
|||||||
|
|
||||||
import { CategoryIdInfo } from '@/features/category/Category.types.ts';
|
import { CategoryIdInfo } from '@/features/category/Category.types.ts';
|
||||||
import { doesMetadataKeyExistIn, getMetadataKey } from '@/features/metadata/Metadata.utils.ts';
|
import { doesMetadataKeyExistIn, getMetadataKey } from '@/features/metadata/Metadata.utils.ts';
|
||||||
|
import { MetadataChunker } from '@/features/metadata/services/MetadataChunker.ts';
|
||||||
import { applyMetadataMigrations } from '@/features/metadata/services/MetadataMigrations.ts';
|
import { applyMetadataMigrations } from '@/features/metadata/services/MetadataMigrations.ts';
|
||||||
import { SourceIdInfo } from '@/features/source/Source.types.ts';
|
import { SourceIdInfo } from '@/features/source/Source.types.ts';
|
||||||
import { ChapterIdInfo } from '@/features/chapter/Chapter.types.ts';
|
import { ChapterIdInfo } from '@/features/chapter/Chapter.types.ts';
|
||||||
@@ -55,15 +56,21 @@ const getRawMetadataValueFrom = (
|
|||||||
key: string,
|
key: string,
|
||||||
prefixes?: string[],
|
prefixes?: string[],
|
||||||
): string | undefined => {
|
): string | undefined => {
|
||||||
if (
|
if (metadata === undefined) {
|
||||||
metadata === undefined ||
|
|
||||||
!doesMetadataKeyExistIn(metadata, key, prefixes) ||
|
|
||||||
metadata[getMetadataKey(key, prefixes)] === undefined
|
|
||||||
) {
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
return metadata[getMetadataKey(key, prefixes)];
|
const fullKey = getMetadataKey(key, prefixes);
|
||||||
|
|
||||||
|
if (doesMetadataKeyExistIn(metadata, fullKey) && metadata[fullKey] !== undefined) {
|
||||||
|
return metadata[fullKey];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (MetadataChunker.isChunkedInMetadata(metadata, fullKey)) {
|
||||||
|
return MetadataChunker.reassembleChunkedValue(metadata, fullKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getMetadataValueFrom = <Key extends AppMetadataKeys, Value extends AllowedMetadataValueTypes>(
|
const getMetadataValueFrom = <Key extends AppMetadataKeys, Value extends AllowedMetadataValueTypes>(
|
||||||
|
|||||||
@@ -18,8 +18,10 @@ import {
|
|||||||
import { MangaIdInfo } from '@/features/manga/Manga.types.ts';
|
import { MangaIdInfo } from '@/features/manga/Manga.types.ts';
|
||||||
import { getMetadataKey } from '@/features/metadata/Metadata.utils.ts';
|
import { getMetadataKey } from '@/features/metadata/Metadata.utils.ts';
|
||||||
import { convertToGqlMeta } from '@/features/metadata/services/MetadataConverter.ts';
|
import { convertToGqlMeta } from '@/features/metadata/services/MetadataConverter.ts';
|
||||||
|
import { MetadataChunker } from '@/features/metadata/services/MetadataChunker.ts';
|
||||||
import { SourceIdInfo } from '@/features/source/Source.types.ts';
|
import { SourceIdInfo } from '@/features/source/Source.types.ts';
|
||||||
import { ChapterIdInfo } from '@/features/chapter/Chapter.types.ts';
|
import { ChapterIdInfo } from '@/features/chapter/Chapter.types.ts';
|
||||||
|
import { MetaInput } from '@/lib/graphql/generated/graphql.ts';
|
||||||
|
|
||||||
type MetadataUpdateOptions = {
|
type MetadataUpdateOptions = {
|
||||||
update?: MetadataKeyValuePair[];
|
update?: MetadataKeyValuePair[];
|
||||||
@@ -52,19 +54,47 @@ const requestMetadataUpdate = async (
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateMetas = keysToValues.map(([key, value]) => ({
|
const existingMetadata = MetadataChunker.getExistingMetadata(metadataHolder, holderType);
|
||||||
key: isMetadataKey ? key : getMetadataKey(key, keyPrefixes),
|
|
||||||
value: `${value}`,
|
const allUpdateMetas: MetaInput[] = [];
|
||||||
}));
|
const allDeleteKeys: string[] = [];
|
||||||
const deleteKeys = keysToDelete.map((key) => (isMetadataKey ? key : getMetadataKey(key, keyPrefixes)));
|
|
||||||
|
keysToValues.forEach(([key, value]) => {
|
||||||
|
const fullKey = isMetadataKey ? key : getMetadataKey(key, keyPrefixes);
|
||||||
|
const stringValue = `${value}`;
|
||||||
|
|
||||||
|
const chunkEntries = MetadataChunker.chunkValue(fullKey, stringValue);
|
||||||
|
allUpdateMetas.push(...chunkEntries);
|
||||||
|
|
||||||
|
const doFullCleanup = !!existingMetadata;
|
||||||
|
if (doFullCleanup) {
|
||||||
|
const newChunkCount = chunkEntries.length - 1;
|
||||||
|
allDeleteKeys.push(...MetadataChunker.computeChunkDeletions(existingMetadata, fullKey, newChunkCount));
|
||||||
|
} else {
|
||||||
|
const isNowChunked = chunkEntries.length > 1;
|
||||||
|
if (!isNowChunked) {
|
||||||
|
allDeleteKeys.push(MetadataChunker.getChunkLengthKey(fullKey));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
keysToDelete.forEach((key) => {
|
||||||
|
const fullKey = isMetadataKey ? key : getMetadataKey(key, keyPrefixes);
|
||||||
|
allDeleteKeys.push(fullKey);
|
||||||
|
|
||||||
|
allDeleteKeys.push(...MetadataChunker.computeChunkDeletions(existingMetadata, fullKey, 0));
|
||||||
|
});
|
||||||
|
|
||||||
|
const uniqueDeleteKeys = [...new Set(allDeleteKeys)];
|
||||||
|
|
||||||
const migrateMetas = keysToMigrate.map(([key, value]) => ({
|
const migrateMetas = keysToMigrate.map(([key, value]) => ({
|
||||||
key: getMetadataKey(key, keyPrefixes),
|
key: getMetadataKey(key, keyPrefixes),
|
||||||
value: `${value}`,
|
value: `${value}`,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const updateMetaInput = {
|
const updateMetaInput = {
|
||||||
updateInput: { metas: updateMetas },
|
updateInput: { metas: allUpdateMetas },
|
||||||
deleteInput: { keys: deleteKeys },
|
deleteInput: { keys: uniqueDeleteKeys },
|
||||||
migrateInput: { metas: migrateMetas },
|
migrateInput: { metas: migrateMetas },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,14 @@ export class MetadataValueCache {
|
|||||||
return `${type}::${holderId ?? ''}::${key}`;
|
return `${type}::${holderId ?? ''}::${key}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static getCachedValue<T>(
|
||||||
|
type: MetadataHolderType,
|
||||||
|
holderId: string | number | undefined,
|
||||||
|
key: string,
|
||||||
|
): T | undefined {
|
||||||
|
return this.convertedValueByKey.get(this.getCacheKey(type, holderId, key)) as T | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
static getStableValue<T>(
|
static getStableValue<T>(
|
||||||
type: MetadataHolderType,
|
type: MetadataHolderType,
|
||||||
holderId: string | number | undefined,
|
holderId: string | number | undefined,
|
||||||
@@ -26,7 +34,7 @@ export class MetadataValueCache {
|
|||||||
): T {
|
): T {
|
||||||
const cacheKey = this.getCacheKey(type, holderId, key);
|
const cacheKey = this.getCacheKey(type, holderId, key);
|
||||||
const cachedRawValue = this.rawValueByKey.get(cacheKey);
|
const cachedRawValue = this.rawValueByKey.get(cacheKey);
|
||||||
const cachedConvertedValue = this.convertedValueByKey.get(cacheKey);
|
const cachedConvertedValue = this.getCachedValue<T>(type, holderId, key);
|
||||||
|
|
||||||
if (cachedRawValue !== undefined && rawValue === cachedRawValue) {
|
if (cachedRawValue !== undefined && rawValue === cachedRawValue) {
|
||||||
return cachedConvertedValue as T;
|
return cachedConvertedValue as T;
|
||||||
|
|||||||
@@ -20,13 +20,10 @@ import {
|
|||||||
ReadingDirection,
|
ReadingDirection,
|
||||||
ReadingMode,
|
ReadingMode,
|
||||||
} from '@/features/reader/Reader.types.ts';
|
} from '@/features/reader/Reader.types.ts';
|
||||||
import { updateReaderSettings } from '@/features/reader/settings/ReaderSettingsMetadata.ts';
|
|
||||||
import { requestManager } from '@/lib/requests/RequestManager.ts';
|
import { requestManager } from '@/lib/requests/RequestManager.ts';
|
||||||
import { MANGA_META_FIELDS } from '@/lib/graphql/manga/MangaFragments.ts';
|
import { MANGA_META_FIELDS } from '@/lib/graphql/manga/MangaFragments.ts';
|
||||||
import { makeToast } from '@/base/utils/Toast.ts';
|
import { makeToast } from '@/base/utils/Toast.ts';
|
||||||
import { MediaQuery } from '@/base/utils/MediaQuery.tsx';
|
import { MediaQuery } from '@/base/utils/MediaQuery.tsx';
|
||||||
import { GLOBAL_METADATA } from '@/lib/graphql/common/Fragments.ts';
|
|
||||||
import { updateMetadataList } from '@/features/metadata/services/MetadataApolloCacheHandler.ts';
|
|
||||||
import { useBackButton } from '@/base/hooks/useBackButton.ts';
|
import { useBackButton } from '@/base/hooks/useBackButton.ts';
|
||||||
import { GLOBAL_READER_SETTING_KEYS } from '@/features/reader/settings/ReaderSettings.constants.tsx';
|
import { GLOBAL_READER_SETTING_KEYS } from '@/features/reader/settings/ReaderSettings.constants.tsx';
|
||||||
import { UpdateChapterPatchInput } from '@/lib/graphql/generated/graphql.ts';
|
import { UpdateChapterPatchInput } from '@/lib/graphql/generated/graphql.ts';
|
||||||
@@ -54,6 +51,13 @@ import {
|
|||||||
} from '@/features/reader/stores/ReaderStore.ts';
|
} from '@/features/reader/stores/ReaderStore.ts';
|
||||||
import { ReactRouter } from '@/lib/react-router/ReactRouter.ts';
|
import { ReactRouter } from '@/lib/react-router/ReactRouter.ts';
|
||||||
import { getPage } from '@/features/reader/overlay/progress-bar/ReaderProgressBar.utils.tsx';
|
import { getPage } from '@/features/reader/overlay/progress-bar/ReaderProgressBar.utils.tsx';
|
||||||
|
import { getMetadataUpdateFunction } from '@/features/metadata/services/MetadataUpdater.ts';
|
||||||
|
import { MetadataChunker } from '@/features/metadata/services/MetadataChunker.ts';
|
||||||
|
import { MetadataValueCache } from '@/features/metadata/services/MetadataValueCache.ts';
|
||||||
|
import { convertFromGqlMeta, convertToGqlMeta } from '@/features/metadata/services/MetadataConverter.ts';
|
||||||
|
import { GLOBAL_METADATA } from '@/lib/graphql/common/Fragments.ts';
|
||||||
|
import { AppMetadataKeys, GqlMetaHolder, Metadata, MetadataHolderType } from '@/features/metadata/Metadata.types.ts';
|
||||||
|
import { MangaIdInfo } from '@/features/manga/Manga.types.ts';
|
||||||
|
|
||||||
const DIRECTION_TO_INVERTED: Record<Direction, Direction> = {
|
const DIRECTION_TO_INVERTED: Record<Direction, Direction> = {
|
||||||
ltr: 'rtl',
|
ltr: 'rtl',
|
||||||
@@ -272,6 +276,37 @@ export class ReaderService {
|
|||||||
ReaderService.updateSetting('shouldOffsetDoubleSpreads', shouldOffset);
|
ReaderService.updateSetting('shouldOffsetDoubleSpreads', shouldOffset);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static getMetadataWithUnmodifiedKey(
|
||||||
|
metaHolder: (MangaIdInfo & GqlMetaHolder) | GqlMetaHolder,
|
||||||
|
metaHolderType: MetadataHolderType,
|
||||||
|
key: string,
|
||||||
|
isGlobalSetting: boolean,
|
||||||
|
): Metadata | undefined {
|
||||||
|
// Since we update the value in the apollo cache before sending the mutation, we need the actual unmodified value
|
||||||
|
// to be able to handle updating chunked metadata
|
||||||
|
const unmodifiedOriginalValue =
|
||||||
|
MetadataValueCache.getCachedValue(
|
||||||
|
metaHolderType,
|
||||||
|
isGlobalSetting ? undefined : (metaHolder as MangaIdInfo).id,
|
||||||
|
key,
|
||||||
|
) ?? '';
|
||||||
|
|
||||||
|
const currentMetadata = isGlobalSetting
|
||||||
|
? MetadataChunker.getExistingMetadata(metaHolder, metaHolderType)
|
||||||
|
: convertFromGqlMeta(metaHolder.meta);
|
||||||
|
|
||||||
|
const settingMetaKeysToDelete = MetadataChunker.computeChunkDeletions(currentMetadata, key, 0);
|
||||||
|
const currentMetadataWithoutKey = convertToGqlMeta(currentMetadata)?.filter(
|
||||||
|
(meta) => !settingMetaKeysToDelete.includes(meta.key),
|
||||||
|
);
|
||||||
|
const existingMeta = {
|
||||||
|
...convertFromGqlMeta(currentMetadataWithoutKey),
|
||||||
|
...convertFromGqlMeta(MetadataChunker.chunkValue(key, unmodifiedOriginalValue.toString())),
|
||||||
|
};
|
||||||
|
|
||||||
|
return existingMeta;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Writes the change immediately to the cache and sends a mutation in case "commit" is true.
|
* Writes the change immediately to the cache and sends a mutation in case "commit" is true.
|
||||||
*/
|
*/
|
||||||
@@ -283,60 +318,66 @@ export class ReaderService {
|
|||||||
profile?: ReadingMode,
|
profile?: ReadingMode,
|
||||||
): void {
|
): void {
|
||||||
const { manga: currentManga } = getReaderStore();
|
const { manga: currentManga } = getReaderStore();
|
||||||
const manga = currentManga ?? (isGlobal ? GLOBAL_READER_SETTINGS_MANGA : currentManga);
|
const manga = isGlobal ? GLOBAL_READER_SETTINGS_MANGA : (currentManga ?? FALLBACK_MANGA);
|
||||||
|
|
||||||
if (!manga || manga.id === FALLBACK_MANGA.id) {
|
if (!manga || manga.id === FALLBACK_MANGA.id) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isGlobalSetting = isGlobal || GLOBAL_READER_SETTING_KEYS.includes(setting);
|
||||||
|
const metaHolderType = isGlobalSetting ? 'global' : 'manga';
|
||||||
|
|
||||||
const key = getMetadataKey(setting, profile !== undefined ? [profile?.toString()] : undefined);
|
const key = getMetadataKey(setting, profile !== undefined ? [profile?.toString()] : undefined);
|
||||||
const metaValue = JSON.stringify(value);
|
const metaValue = JSON.stringify(value);
|
||||||
|
const chunkedMeta = MetadataChunker.chunkValue(key, metaValue);
|
||||||
|
|
||||||
|
const existingMeta = this.getMetadataWithUnmodifiedKey(manga, metaHolderType, key, isGlobalSetting);
|
||||||
|
|
||||||
|
const keysToDelete = MetadataChunker.computeChunkDeletions(existingMeta, key, chunkedMeta.length - 1);
|
||||||
|
|
||||||
|
const updatedMetadata = convertToGqlMeta({
|
||||||
|
...existingMeta,
|
||||||
|
...convertFromGqlMeta(chunkedMeta),
|
||||||
|
})?.filter((meta) => !keysToDelete.includes(meta.key));
|
||||||
|
|
||||||
const { cache } = requestManager.graphQLClient.client;
|
const { cache } = requestManager.graphQLClient.client;
|
||||||
|
|
||||||
const isGlobalSetting = isGlobal || GLOBAL_READER_SETTING_KEYS.includes(setting);
|
|
||||||
if (isGlobalSetting) {
|
if (isGlobalSetting) {
|
||||||
const reference = cache.writeFragment({
|
|
||||||
fragment: GLOBAL_METADATA,
|
|
||||||
data: {
|
|
||||||
__typename: 'GlobalMetaType',
|
|
||||||
key,
|
|
||||||
value: metaValue,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
cache.modify({
|
cache.modify({
|
||||||
fields: {
|
fields: {
|
||||||
metas(existingMetas, { readField }) {
|
metas(existingMetas) {
|
||||||
return {
|
return {
|
||||||
...existingMetas,
|
...existingMetas,
|
||||||
nodes: updateMetadataList(
|
nodes: updatedMetadata?.map((meta) =>
|
||||||
[{ key, value: metaValue }],
|
cache.writeFragment({
|
||||||
existingMetas?.nodes,
|
fragment: GLOBAL_METADATA,
|
||||||
readField,
|
data: {
|
||||||
() => reference,
|
__typename: 'GlobalMetaType',
|
||||||
|
mangaId: manga.id,
|
||||||
|
key: meta.key,
|
||||||
|
value: meta.value,
|
||||||
|
},
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const reference = cache.writeFragment({
|
|
||||||
fragment: MANGA_META_FIELDS,
|
|
||||||
data: {
|
|
||||||
__typename: 'MangaMetaType',
|
|
||||||
mangaId: manga.id,
|
|
||||||
key,
|
|
||||||
value: metaValue,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
cache.modify({
|
cache.modify({
|
||||||
id: cache.identify({ __typename: 'MangaType', id: manga.id }),
|
id: cache.identify({ __typename: 'MangaType', id: manga.id }),
|
||||||
fields: {
|
fields: {
|
||||||
meta(existingMetas, { readField }) {
|
meta() {
|
||||||
return updateMetadataList(
|
return updatedMetadata?.map((meta) =>
|
||||||
[{ key, value: metaValue }],
|
cache.writeFragment({
|
||||||
existingMetas,
|
fragment: MANGA_META_FIELDS,
|
||||||
readField,
|
data: {
|
||||||
() => reference,
|
__typename: 'MangaMetaType',
|
||||||
|
mangaId: manga.id,
|
||||||
|
key: meta.key,
|
||||||
|
value: meta.value,
|
||||||
|
},
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -344,41 +385,42 @@ export class ReaderService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (commit) {
|
if (commit) {
|
||||||
updateReaderSettings(manga, setting, value, isGlobal, profile).catch((e) =>
|
getMetadataUpdateFunction(metaHolderType, { id: manga.id, meta: convertFromGqlMeta(updatedMetadata) })({
|
||||||
makeToast(t`Could not save the reader settings to the server`, 'error', getErrorMessage(e)),
|
update: [[setting, metaValue]],
|
||||||
);
|
delete: keysToDelete as AppMetadataKeys[],
|
||||||
|
keyPrefixes: profile !== undefined ? [profile.toString()] : undefined,
|
||||||
|
}).catch((e) => {
|
||||||
|
makeToast(t`Could not save the reader settings to the server`, 'error', getErrorMessage(e));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static deleteSetting<Setting extends keyof IReaderSettings>(
|
static deleteSetting<Setting extends keyof IReaderSettings>(
|
||||||
setting: Setting,
|
setting: Setting,
|
||||||
isGlobal: boolean = false,
|
isGlobal: boolean = false,
|
||||||
profile?: string,
|
profile?: ReadingMode,
|
||||||
): void {
|
): void {
|
||||||
const { manga: currentManga } = getReaderStore();
|
const { manga: currentManga } = getReaderStore();
|
||||||
const manga = currentManga ?? (isGlobal ? GLOBAL_READER_SETTINGS_MANGA : currentManga);
|
const manga = isGlobal ? GLOBAL_READER_SETTINGS_MANGA : (currentManga ?? FALLBACK_MANGA);
|
||||||
|
|
||||||
if (!manga || manga.id === FALLBACK_MANGA.id) {
|
if (!manga || manga.id === FALLBACK_MANGA.id) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const key = getMetadataKey(setting, profile !== undefined ? [profile] : undefined);
|
const key = getMetadataKey(setting, profile !== undefined ? [profile?.toString()] : undefined);
|
||||||
|
|
||||||
const { cache } = requestManager.graphQLClient.client;
|
|
||||||
|
|
||||||
const isGlobalSetting = isGlobal || GLOBAL_READER_SETTING_KEYS.includes(setting);
|
const isGlobalSetting = isGlobal || GLOBAL_READER_SETTING_KEYS.includes(setting);
|
||||||
if (isGlobalSetting) {
|
const metaHolderType = isGlobalSetting ? 'global' : 'manga';
|
||||||
cache.evict({ id: cache.identify({ __typename: 'GlobalMetaType', key }) });
|
|
||||||
} else {
|
|
||||||
cache.evict({ id: cache.identify({ __typename: 'MangaMetaType', mangaId: manga.id, key }) });
|
|
||||||
}
|
|
||||||
|
|
||||||
const deleteSetting = isGlobalSetting
|
const existingMeta = this.getMetadataWithUnmodifiedKey(manga, metaHolderType, key, isGlobalSetting);
|
||||||
? () => requestManager.deleteGlobalMeta({ keys: [key] }).response
|
|
||||||
: () => requestManager.deleteMangaMeta({ items: [{ mangaIds: [manga.id], keys: [key] }] }).response;
|
const keysToDelete = MetadataChunker.computeChunkDeletions(existingMeta, key, 0);
|
||||||
deleteSetting().catch((e) =>
|
|
||||||
makeToast(t`Could not save the reader settings to the server`, 'error', getErrorMessage(e)),
|
const updatedMetadata = convertToGqlMeta(existingMeta)?.filter((meta) => !keysToDelete.includes(meta.key));
|
||||||
);
|
|
||||||
|
getMetadataUpdateFunction(metaHolderType, { id: manga.id, meta: convertFromGqlMeta(updatedMetadata) })({
|
||||||
|
delete: [setting],
|
||||||
|
keyPrefixes: profile !== undefined ? [`${profile}`] : undefined,
|
||||||
|
}).catch((e) => makeToast(t`Could not save the reader settings to the server`, 'error', getErrorMessage(e)));
|
||||||
}
|
}
|
||||||
|
|
||||||
static useOverlayMode(): { mode: ReaderOverlayMode; isDesktop: boolean; isMobile: boolean } {
|
static useOverlayMode(): { mode: ReaderOverlayMode; isDesktop: boolean; isMobile: boolean } {
|
||||||
|
|||||||
Reference in New Issue
Block a user