diff --git a/.oxlintrc.jsonc b/.oxlintrc.jsonc
index 510473f1..64e3f19a 100644
--- a/.oxlintrc.jsonc
+++ b/.oxlintrc.jsonc
@@ -159,12 +159,20 @@
"!@mui/material/",
"!@mui/icons-material/",
"!@mui/x-date-pickers/"
- ]
+ ],
},
{
"group": [
"@mui/*/*/*"
]
+ },
+ {
+ "group": [
+ "lodash",
+ "lodash/*",
+ "!lodash/fp/*"
+ ],
+ "message": "Use lodash-fp instead."
}
]
}
@@ -411,6 +419,7 @@
"src/features/authentication/AuthManager.ts",
"src/features/chapter/services/Chapters.ts",
"src/features/metadata/services/MetadataMigrations.ts",
+ "src/features/migration/MigrationManager.ts",
"src/features/reader/services/ReaderControls.ts",
"src/features/reader/services/ReaderService.ts",
"src/features/source/services/Sources.ts",
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c61b43b4..ecfd1ef0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- (**WebUI Update**) Add an option to (partially) disable showing information when the webUI got updated
- (**Server Update**) Add an option to disable showing information when the server got updated
- (**Manga**) Add an option to include client data during migration
+- (**Migrate**) Add bulk migration
### Changed
diff --git a/package.json b/package.json
index 463e70f0..5985b4e9 100644
--- a/package.json
+++ b/package.json
@@ -66,6 +66,7 @@
"immer": "11.1.4",
"jsonrepair": "3.13.2",
"koration": "1.0.0",
+ "lodash": "4.18.1",
"material-ui-popup-state": "5.3.6",
"mui-nested-menu": "4.0.3",
"node-vibrant": "4.0.4",
@@ -96,6 +97,7 @@
"@lingui/swc-plugin": "5.11.0",
"@lingui/vite-plugin": "5.9.2",
"@tony.ganchev/eslint-plugin-header": "3.3.4",
+ "@types/lodash": "4.17.24",
"@types/node": "24.10.1",
"@types/react": "19.2.14",
"@types/react-beautiful-dnd": "13.1.8",
diff --git a/src/App.tsx b/src/App.tsx
index 98dda23c..eb2241f5 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -32,12 +32,17 @@ import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts'
import { ReactRouter } from '@/lib/react-router/ReactRouter.ts';
import { AuthManager } from '@/features/authentication/AuthManager.ts';
import { ImageProcessingType } from '@/features/settings/Settings.types.ts';
+import { MigrationFABIndicator } from '@/features/migration/components/MigrationFABIndicator.tsx';
const { Browse } = loadable(() => import('@/features/browse/screens/Browse.tsx'), lazyLoadFallback);
const { DownloadQueue } = loadable(() => import('@/features/downloads/screens/DownloadQueue.tsx'), lazyLoadFallback);
const { Library } = loadable(() => import('@/features/library/screens/Library.tsx'), lazyLoadFallback);
const { Manga } = loadable(() => import('@/features/manga/screens/Manga.tsx'), lazyLoadFallback);
const { SearchAll } = loadable(() => import('@/features/global-search/screens/SearchAll.tsx'), lazyLoadFallback);
+const { MigrationManualSearch } = loadable(
+ () => import('@/features/migration/screens/MigrationManualSearch.tsx'),
+ lazyLoadFallback,
+);
const { Settings } = loadable(() => import('@/features/settings/screens/Settings.tsx'), lazyLoadFallback);
const { About } = loadable(() => import('@/features/settings/screens/About.tsx'), lazyLoadFallback);
const { Backup } = loadable(() => import('@/features/backup/screens/Backup.tsx'), lazyLoadFallback);
@@ -69,7 +74,7 @@ const { ImageProcessingSetting } = loadable(
const { ServerSettings } = loadable(() => import('@/features/settings/screens/ServerSettings.tsx'), lazyLoadFallback);
const { BrowseSettings } = loadable(() => import('@/features/browse/screens/BrowseSettings.tsx'), lazyLoadFallback);
const { WebUISettings } = loadable(() => import('@/features/settings/screens/WebUISettings.tsx'), lazyLoadFallback);
-const { Migrate } = loadable(() => import('@/features/migration/screens/Migrate.tsx'), lazyLoadFallback);
+const { Migration } = loadable(() => import('@/features/migration/screens/Migration.tsx'), lazyLoadFallback);
const { DeviceSetting } = loadable(() => import('@/features/device/screens/DeviceSetting.tsx'), lazyLoadFallback);
const { TrackingSettings } = loadable(
() => import('@/features/tracker/screens/TrackingSettings.tsx'),
@@ -293,9 +298,17 @@ const MainApp = () => {
} />
{!hideHistory && } />}
} />
+ } />
- } />
- } />
+ } />
+ }
+ />
+ }
+ />
} />
@@ -339,6 +352,7 @@ export const App: React.FC = () => (
} />
+
);
diff --git a/src/UtilTypes.d.ts b/src/UtilTypes.d.ts
index 993afeb4..9e293c99 100644
--- a/src/UtilTypes.d.ts
+++ b/src/UtilTypes.d.ts
@@ -24,8 +24,12 @@ type NullAndUndefined = T | null | undefined;
type NonNullableProperties = { [P in keyof T]-?: NonNullable };
+type NonNullableProperty = Omit & NonNullableProperties>;
+
type OptionalProperty = Omit & Partial>;
+type RequiredProperty = Omit & Required>;
+
type PropertiesNever = { [key in keyof T]?: never };
type OmitFirst = T extends [any, ...infer R] ? R : never;
diff --git a/src/base/AppRoute.constants.ts b/src/base/AppRoute.constants.ts
index b650cf8a..71289605 100644
--- a/src/base/AppRoute.constants.ts
+++ b/src/base/AppRoute.constants.ts
@@ -199,15 +199,19 @@ export const AppRoutes = {
}),
},
migrate: {
- match: 'migrate/source/:sourceId',
- path: (sourceId: SourceType['id']) => `/migrate/source/${sourceId}`,
-
+ match: 'migrate/*',
+ path: '/migrate',
childRoutes: {
- search: {
- match: 'manga/:mangaId/search',
+ singleMangaSearch: {
+ match: 'source/:sourceId/manga/:mangaId/search',
path: (sourceId: SourceType['id'], mangaId: MangaIdInfo['id'], query?: string | null | undefined) =>
UrlUtil.addQueryParam(`/migrate/source/${sourceId}/manga/${mangaId}/search`, query),
},
+ manualSearch: {
+ match: 'manual-search/:mangaId',
+ path: (mangaId: MangaIdInfo['id'], query?: string | null | undefined) =>
+ UrlUtil.addQueryParam(`/migrate/manual-search/${mangaId}`, query),
+ },
},
},
tracker: {
diff --git a/src/base/collection/components/SelectableCollectionSelectMode.tsx b/src/base/collection/components/SelectableCollectionSelectMode.tsx
index 06a46a2b..29a56770 100644
--- a/src/base/collection/components/SelectableCollectionSelectMode.tsx
+++ b/src/base/collection/components/SelectableCollectionSelectMode.tsx
@@ -18,12 +18,14 @@ export const SelectableCollectionSelectMode = ({
areNoItemsSelected,
onSelectAll,
onModeChange,
+ isCancelable = true,
}: {
isActive: boolean;
areAllItemsSelected: boolean;
areNoItemsSelected: boolean;
onSelectAll: (selectAll: boolean) => void;
onModeChange: (checked: boolean) => void;
+ isCancelable?: boolean;
}) => {
const { t } = useLingui();
@@ -36,20 +38,22 @@ export const SelectableCollectionSelectMode = ({
onChange={onSelectAll}
/>
)}
-
- }
- sx={{
- padding: '8px',
- color: 'inherit',
- '&.Mui-checked': {
+ {isCancelable && (
+
+ }
+ sx={{
+ padding: '8px',
color: 'inherit',
- },
- }}
- checked={isActive}
- onChange={(_, checked) => onModeChange(checked)}
- />
-
+ '&.Mui-checked': {
+ color: 'inherit',
+ },
+ }}
+ checked={isActive}
+ onChange={(_, checked) => onModeChange(checked)}
+ />
+
+ )}
>
);
};
diff --git a/src/base/components/feedback/LoadingPlaceholder.tsx b/src/base/components/feedback/LoadingPlaceholder.tsx
index 6fb40232..725d0b7b 100644
--- a/src/base/components/feedback/LoadingPlaceholder.tsx
+++ b/src/base/components/feedback/LoadingPlaceholder.tsx
@@ -10,17 +10,21 @@ import React, { type JSX } from 'react';
import CircularProgress from '@mui/material/CircularProgress';
import Box from '@mui/material/Box';
-interface IProps {
+export function LoadingPlaceholder({
+ children,
+ shouldRender,
+ component,
+ componentProps,
+ usePadding,
+ size,
+}: {
shouldRender?: boolean | (() => boolean);
children?: React.ReactNode;
component?: string | React.FunctionComponent | React.ComponentClass;
componentProps?: any;
usePadding?: boolean;
-}
-
-export function LoadingPlaceholder(props: IProps) {
- const { children, shouldRender, component, componentProps, usePadding } = props;
-
+ size?: number;
+}) {
let condition = true;
if (shouldRender !== undefined) {
condition = shouldRender instanceof Function ? shouldRender() : shouldRender;
@@ -47,7 +51,7 @@ export function LoadingPlaceholder(props: IProps) {
justifyContent: 'center',
}}
>
-
+
);
}
diff --git a/src/base/components/feedback/SnackbarWithDescription.tsx b/src/base/components/feedback/SnackbarWithDescription.tsx
index 9a0f3cef..eb22680c 100644
--- a/src/base/components/feedback/SnackbarWithDescription.tsx
+++ b/src/base/components/feedback/SnackbarWithDescription.tsx
@@ -83,7 +83,7 @@ export const SnackbarWithDescription = memo(
>
{message}
{actualDescription}
- {isDescriptionTooLong || (isGraphqlException && graphqlStackTrace) ? (
+ {(isDescriptionTooLong || (isGraphqlException && graphqlStackTrace)) && (
- ) : (
- ''
)}
diff --git a/src/features/browse/screens/Browse.tsx b/src/features/browse/screens/Browse.tsx
index 3f5a85c1..c9be09d5 100644
--- a/src/features/browse/screens/Browse.tsx
+++ b/src/features/browse/screens/Browse.tsx
@@ -15,12 +15,12 @@ import { Extensions } from '@/features/browse/extensions/Extensions.tsx';
import { TabPanel } from '@/base/components/tabs/TabPanel.tsx';
import { TabsWrapper } from '@/base/components/tabs/TabsWrapper.tsx';
import { TabsMenu } from '@/base/components/tabs/TabsMenu.tsx';
-import { Migration } from '@/features/migration/screens/Migration.tsx';
import { useResizeObserver } from '@/base/hooks/useResizeObserver.tsx';
import { useAppTitle } from '@/features/navigation-bar/hooks/useAppTitle.ts';
import { BrowseTab } from '@/features/browse/Browse.types.ts';
import { GROUPED_VIRTUOSO_Z_INDEX } from '@/lib/virtuoso/Virtuoso.constants.ts';
import { SearchParam } from '@/base/Base.types.ts';
+import { Migration } from '@/features/migration/screens/Migration.tsx';
export function Browse() {
const { t } = useLingui();
diff --git a/src/features/global-search/screens/SearchAll.tsx b/src/features/global-search/screens/SearchAll.tsx
index 96b274bf..eafdd40c 100644
--- a/src/features/global-search/screens/SearchAll.tsx
+++ b/src/features/global-search/screens/SearchAll.tsx
@@ -10,7 +10,7 @@ import Card from '@mui/material/Card';
import CardActionArea from '@mui/material/CardActionArea';
import Typography from '@mui/material/Typography';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
-import { Link, useLocation, useNavigate } from 'react-router-dom';
+import { Link, useLocation, useNavigate, useParams } from 'react-router-dom';
import { StringParam, useQueryParam } from 'use-query-params';
import Box from '@mui/material/Box';
import Stack from '@mui/material/Stack';
@@ -28,7 +28,7 @@ import { AppbarSearch } from '@/base/components/AppbarSearch.tsx';
import { useDebounce } from '@/base/hooks/useDebounce.ts';
import type { MangaCardProps } from '@/features/manga/Manga.types.ts';
import { EmptyView } from '@/base/components/feedback/EmptyView.tsx';
-import { STABLE_EMPTY_ARRAY } from '@/base/Base.constants.ts';
+import { STABLE_EMPTY_ARRAY, STABLE_EMPTY_OBJECT } from '@/base/Base.constants.ts';
import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts';
import { LoadingPlaceholder } from '@/base/components/feedback/LoadingPlaceholder.tsx';
import { BaseMangaGrid } from '@/features/manga/components/BaseMangaGrid.tsx';
@@ -56,6 +56,9 @@ import { MUIUtil } from '@/lib/mui/MUI.util.ts';
import type { MetadataBrowseSettings } from '@/features/browse/Browse.types.ts';
import { SourceLanguageSelect } from '@/features/source/components/SourceLanguageSelect.tsx';
import { SearchParam } from '@/base/Base.types.ts';
+import { MigrationManager } from '@/features/migration/MigrationManager.ts';
+import { assertIsDefined } from '@/base/Asserts.ts';
+import { useBackButton } from '@/base/hooks/useBackButton.ts';
type SourceLoadingState = { isLoading: boolean; hasResults: boolean; emptySearch: boolean; error: any };
type SourceToLoadingStateMap = Map;
@@ -122,12 +125,13 @@ const SourceSearchPreview = React.memo(
emptyQuery,
mode,
shouldShowOnlySourcesWithResults,
+ onMigrateSelect,
}: {
source: SourceIdInfo & SourceDisplayNameInfo & SourceNameInfo & SourceLanguageInfo;
onSearchRequestFinished: (source: SourceIdInfo, state: SourceLoadingState) => void;
searchString: string | null | undefined;
emptyQuery: boolean;
- } & Pick &
+ } & Pick &
Pick) => {
const { t } = useLingui();
@@ -230,6 +234,7 @@ const SourceSearchPreview = React.memo(
message={errorMessage}
inLibraryIndicator
mode={mode}
+ onMigrateSelect={onMigrateSelect}
/>
)}
@@ -237,15 +242,21 @@ const SourceSearchPreview = React.memo(
},
);
-export const SearchAll: React.FC = () => {
+export const SearchAll = ({
+ migrationDestinationSourceIds,
+}: {
+ migrationDestinationSourceIds?: SourceIdInfo['id'][];
+}) => {
const { t } = useLingui();
const navigate = useNavigate();
+ const handleBack = useBackButton();
const { pathname, state } = useLocation<{ mangaTitle?: string; shouldShowOnlyPinnedSources?: boolean }>();
const { ref: filterHeaderRef, height: filterHeaderHeight } = useElementSize();
const shouldShowOnlyPinnedSources = state?.shouldShowOnlyPinnedSources ?? true;
- const isMigrateMode = pathname.startsWith('/migrate/source');
+ const isMigrateMode = pathname.startsWith('/migrate/source') || pathname.startsWith('/migrate/manual-search');
+ const { mangaId } = useParams<{ mangaId?: string }>() ?? STABLE_EMPTY_OBJECT;
const [query] = useQueryParam(SearchParam.QUERY, StringParam);
const searchString = useDebounce(query, TRIGGER_SEARCH_THRESHOLD);
@@ -255,7 +266,14 @@ export const SearchAll: React.FC = () => {
} = useMetadataServerSettings();
const { data, loading, error, refetch } = requestManager.useGetSourceList({ notifyOnNetworkStatusChange: true });
- const sources = data?.sources.nodes ?? STABLE_EMPTY_ARRAY;
+ const tmpSources = data?.sources.nodes ?? STABLE_EMPTY_ARRAY;
+ const sources = useMemo(
+ () =>
+ tmpSources.filter(
+ (source) => !migrationDestinationSourceIds || migrationDestinationSourceIds.includes(source.id),
+ ),
+ [tmpSources, migrationDestinationSourceIds],
+ );
const [sourceToLoadingStateMap, setSourceToLoadingStateMap] = useState(new Map());
const debouncedSourceToLoadingStateMap = useDebounce(sourceToLoadingStateMap, 500);
@@ -398,6 +416,19 @@ export const SearchAll: React.FC = () => {
emptyQuery={!query}
mode={isMigrateMode ? 'migrate.select' : 'source'}
shouldShowOnlySourcesWithResults={shouldShowOnlySourcesWithResults}
+ onMigrateSelect={
+ migrationDestinationSourceIds
+ ? (match) => {
+ assertIsDefined(mangaId);
+ MigrationManager.selectManualMatch(Number(mangaId), {
+ ...match,
+ sourceTitle: Sources.getFromCache(match.sourceId)?.displayName,
+ latestChapterNumber: undefined,
+ });
+ handleBack();
+ }
+ : undefined
+ }
/>
))}
diff --git a/src/features/manga/Manga.types.ts b/src/features/manga/Manga.types.ts
index 0d2e2e2a..abb7d10e 100644
--- a/src/features/manga/Manga.types.ts
+++ b/src/features/manga/Manga.types.ts
@@ -22,6 +22,7 @@ import type {
import type { SingleModeProps } from '@/features/manga/components/MangaActionMenuItems.tsx';
import type { GridLayout } from '@/base/Base.types.ts';
import type { ChapterListOptions } from '@/features/chapter/Chapter.types.ts';
+import type { MigrationMatch } from '@/features/migration/Migration.types.ts';
export type MangaCardMode = 'default' | 'source' | 'migrate.search' | 'migrate.select' | 'duplicate';
@@ -33,6 +34,7 @@ type MangaCardBaseProps = Pick &
export type MangaIdInfo = Pick;
export type MangaChapterCountInfo = { chapters: Pick };
+export type MangaHighestChapterNumberInfo = { highestNumberedChapter?: Pick };
export type MangaInLibraryInfo = Pick;
export type MangaDownloadInfo = Pick & MangaChapterCountInfo;
export type MangaUnreadInfo = Pick & MangaChapterCountInfo;
@@ -42,7 +44,7 @@ export type MangaTrackRecordInfo = MangaIdInfo & {
};
export type MangaGenreInfo = Pick;
export type MangaSourceIdInfo = Pick;
-export type MangaSourceNameInfo = { source?: Maybe> };
+export type MangaSourceNameInfo = { source?: Maybe> };
export type MangaSourceLngInfo = { source?: Maybe> };
export type MangaArtistInfo = Pick;
export type MangaAuthorInfo = Pick;
@@ -58,6 +60,7 @@ export interface MangaCardProps {
inLibraryIndicator?: boolean;
selected?: boolean | null;
handleSelection?: SelectableCollectionReturnType['handleSelection'];
+ onMigrateSelect?: (manga: Omit) => void;
mode?: MangaCardMode;
}
diff --git a/src/features/manga/components/MangaActionMenuItems.tsx b/src/features/manga/components/MangaActionMenuItems.tsx
index a1aafb15..8cc7ea47 100644
--- a/src/features/manga/components/MangaActionMenuItems.tsx
+++ b/src/features/manga/components/MangaActionMenuItems.tsx
@@ -150,7 +150,11 @@ export const MangaActionMenuItems = ({
)}
{isSingleMode && (
diff --git a/src/features/manga/components/MangaGrid.tsx b/src/features/manga/components/MangaGrid.tsx
index 20771838..7f8d64a6 100644
--- a/src/features/manga/components/MangaGrid.tsx
+++ b/src/features/manga/components/MangaGrid.tsx
@@ -25,7 +25,6 @@ import { useLingui } from '@lingui/react/macro';
import { EmptyViewAbsoluteCentered } from '@/base/components/feedback/EmptyViewAbsoluteCentered.tsx';
import { LoadingPlaceholder } from '@/base/components/feedback/LoadingPlaceholder.tsx';
import { MangaCard } from '@/features/manga/components/cards/MangaCard.tsx';
-import type { SelectableCollectionReturnType } from '@/base/collection/hooks/useSelectableCollection.ts';
import { DEFAULT_FULL_FAB_HEIGHT } from '@/base/components/buttons/StyledFab.tsx';
import type { MangaCardProps } from '@/features/manga/Manga.types.ts';
import type { MangaType } from '@/lib/graphql/generated/graphql.ts';
@@ -68,6 +67,7 @@ const createMangaCard = (
selectedMangaIds?: MangaType['id'][],
handleSelection?: DefaultGridProps['handleSelection'],
mode?: MangaCardProps['mode'],
+ onMigrateSelect?: DefaultGridProps['onMigrateSelect'],
) => (
);
-type DefaultGridProps = Pick & {
+type DefaultGridProps = Omit & {
isLoading: boolean;
mangas: TManga[];
inLibraryIndicator?: boolean;
@@ -87,7 +88,6 @@ type DefaultGridProps = Pick & {
gridLayout?: GridLayout;
isSelectModeActive?: boolean;
selectedMangaIds?: Required[];
- handleSelection?: SelectableCollectionReturnType['handleSelection'];
ref?: ForwardedRef;
};
@@ -102,6 +102,7 @@ const HorizontalGrid = ({
handleSelection,
mode,
ref,
+ onMigrateSelect,
}: DefaultGridProps) => (
))
@@ -149,6 +151,7 @@ const VerticalGrid = ({
handleSelection,
mode,
ref,
+ onMigrateSelect,
}: DefaultGridProps & {
hasNextPage: boolean;
loadMore: () => void;
@@ -175,6 +178,7 @@ const VerticalGrid = ({
selectedMangaIds,
handleSelection,
mode,
+ onMigrateSelect,
)
}
/>
@@ -220,6 +224,7 @@ export const MangaGrid: React.FC = ({
mode,
retry,
gridWrapperProps,
+ onMigrateSelect,
}) => {
const { t } = useLingui();
@@ -346,6 +351,7 @@ export const MangaGrid: React.FC = ({
selectedMangaIds={selectedMangaIds}
handleSelection={handleSelection}
mode={mode}
+ onMigrateSelect={onMigrateSelect}
/>
) : (
= ({
selectedMangaIds={selectedMangaIds}
handleSelection={handleSelection}
mode={mode}
+ onMigrateSelect={onMigrateSelect}
/>
)}
diff --git a/src/features/manga/components/MangaToolbarMenu.tsx b/src/features/manga/components/MangaToolbarMenu.tsx
index 08945f38..c1c85044 100644
--- a/src/features/manga/components/MangaToolbarMenu.tsx
+++ b/src/features/manga/components/MangaToolbarMenu.tsx
@@ -84,7 +84,7 @@ export const MangaToolbarMenu = ({ manga, onRefresh, refreshing }: IProps) => {
<>
{