Skip to content

Commit

Permalink
Add Manually Linked Files Utility (ShokoAnime#480)
Browse files Browse the repository at this point in the history
* Fix checkbox animation on mount

* Upgrade `react-virtual` to `@tanstack/react-virtual`

* Add Manually Linked Files Utility (for 1:1 links as of now)

* Fix yarn.lock?
  • Loading branch information
harshithmohan authored Dec 5, 2022
1 parent 4fd5534 commit f85adb9
Show file tree
Hide file tree
Showing 14 changed files with 598 additions and 39 deletions.
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"@babel/runtime": "^7.20.1",
"@fontsource/open-sans": "^4.5.13",
"@headlessui/react": "^1.7.4",
"@headlessui/tailwindcss": "^0.1.1",
"@lagunovsky/redux-react-router": "^4.3.0",
"@mdi/js": "^7.0.96",
"@mdi/react": "^1.6.1",
Expand All @@ -22,13 +23,15 @@
"@tailwindcss/line-clamp": "^0.4.2",
"@tanstack/match-sorter-utils": "^8.5.14",
"@tanstack/react-table": "^8.5.30",
"@tanstack/react-virtual": "^3.0.0-alpha.0",
"@types/react-virtualized": "^9.21.21",
"classnames": "^2.3.2",
"core-js": "^3.26.1",
"del": "^6.1.1",
"ejs": "^3.1.8",
"es6-promise": "^4.2.8",
"fast-json-patch": "^3.1.1",
"fuse.js": "^6.6.2",
"history": "^5.3.0",
"http-proxy-middleware": "^2.0.6",
"isomorphic-fetch": "^3.0.0",
Expand All @@ -46,7 +49,6 @@
"react-router-dom": "^6.4.3",
"react-scroll-sync": "^0.11.0",
"react-toastify": "^9.1.1",
"react-virtual": "^2.10.4",
"react-virtualized": "^9.22.3",
"redux": "^4.2.0",
"redux-actions": "^2.6.5",
Expand Down
6 changes: 3 additions & 3 deletions src/components/Input/Checkbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,17 +43,17 @@ function Checkbox({ id, label, isChecked, className, onChange, labelRight, justi
</span>
)}
{!intermediate && isChecked && (
<TransitionDiv className="flex text-highlight-1" enterFrom="opacity-50">
<TransitionDiv className="flex text-highlight-1" enterFrom="opacity-50" appear={false}>
<Icon path={mdiCheckboxMarkedCircleOutline} size={1} />
</TransitionDiv>
)}
{!intermediate && !isChecked && (
<TransitionDiv className="flex text-highlight-1" enterFrom="opacity-50">
<TransitionDiv className="flex text-highlight-1" enterFrom="opacity-50" appear={false}>
<Icon path={mdiCheckboxBlankCircleOutline} size={1} />
</TransitionDiv>
)}
{intermediate && (
<TransitionDiv className="flex text-highlight-1" enterFrom="opacity-50">
<TransitionDiv className="flex text-highlight-1" enterFrom="opacity-50" appear={false}>
<Icon path={mdiCircleHalfFull} size={1} />
</TransitionDiv>
)}
Expand Down
5 changes: 3 additions & 2 deletions src/components/TransitionDiv.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,18 @@ type Props = {
enterFrom?: string;
enterTo?: string;
show?: boolean;
appear?: boolean;
};

function TransitionDiv(props: Props) {
const {
enter, enterFrom, enterTo, className, children,
show,
show, appear,
} = props;

return (
<Transition
appear
appear={appear ?? true}
show={show ?? true}
enter={enter ?? 'transition-opacity'}
enterFrom={enterFrom ?? 'opacity-0'}
Expand Down
2 changes: 2 additions & 0 deletions src/core/router/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import FilterGroupList from '../../pages/collection/FilterGroupList';
// Utilities
import UnrecognizedUtility from '../../pages/utilities/UnrecognizedUtility';
import UnrecognizedTab from '../../pages/utilities/UnrecognizedUtilityTabs/UnrecognizedTab';
import ManuallyLinkedTab from '../../pages/utilities/UnrecognizedUtilityTabs/ManuallyLinkedTab';
import IgnoredFilesTab from '../../pages/utilities/UnrecognizedUtilityTabs/IgnoredFilesTab';
import MultipleFilesUtility from '../../pages/utilities/MultipleFilesUtility';
import SeriesWithoutFilesUtility from '../../pages/utilities/SeriesWithoutFilesUtility';
Expand Down Expand Up @@ -94,6 +95,7 @@ function Router(props: Props) {
<Route path="unrecognized" element={<UnrecognizedUtility />}>
<Route index element={<Navigate to="files" />} />
<Route path="files" element={<UnrecognizedTab />} />
<Route path="manually-linked-files" element={<ManuallyLinkedTab />} />
<Route path="ignored-files" element={<IgnoredFilesTab />} />
</Route>
<Route path="multiple-files" element={<MultipleFilesUtility />} />
Expand Down
2 changes: 1 addition & 1 deletion src/core/rtkQuery/splitV3Api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export const splitV3Api = createApi({
return headers;
},
}),
tagTypes: ['EpisodeUpdated', 'FileDeleted', 'FileHashed', 'FileIgnored', 'FileMatched', 'ImportFolder', 'SeriesEpisodes', 'SeriesUpdated', 'Settings', 'Users', 'SeriesSearch'],
tagTypes: ['EpisodeUpdated', 'FileDeleted', 'FileHashed', 'FileIgnored', 'FileMatched', 'ImportFolder', 'SeriesEpisodes', 'SeriesUpdated', 'Settings', 'Users', 'SeriesSearch', 'UtilitiesRefresh'],
refetchOnMountOrArgChange: true,
endpoints: () => ({}),
});
12 changes: 12 additions & 0 deletions src/core/rtkQuery/splitV3Api/fileApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,17 @@ const fileApi = splitV3Api.injectEndpoints({
body: params,
}),
}),

// Unlink all the episodes if no body is given, or only the spesified episodes from the file.
deleteFileLink: build.mutation<void, number>({
query: fileId => ({
url: `File/${fileId}/Link`,
method: 'DELETE',
headers: {
'content-type': 'application/json',
},
}),
}),
}),
});

Expand All @@ -81,4 +92,5 @@ export const {
usePostFileRescanMutation,
usePostFileRehashMutation,
usePostFileLinkMutation,
useDeleteFileLinkMutation,
} = fileApi;
22 changes: 21 additions & 1 deletion src/core/rtkQuery/splitV3Api/seriesApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { splitV3Api } from '../splitV3Api';
import type { SeriesAniDBSearchResult, SeriesType, SeriesRecommendedType } from '../../types/api/series';
import type { ListResultType, PaginationType } from '../../types/api';
import { EpisodeType } from '../../types/api/episode';
import { FileType } from '../../types/api/file';

const seriesApi = splitV3Api.injectEndpoints({
endpoints: build => ({
Expand All @@ -28,7 +29,7 @@ const seriesApi = splitV3Api.injectEndpoints({
// Get the Shoko.Server.API.v3.Models.Shoko.Episodes for the Shoko.Server.API.v3.Models.Shoko.Series with seriesID.
getSeriesEpisodes: build.query<Array<EpisodeType>, { seriesId: number; }>({
query: ({ seriesId }) => ({ url: `Series/${seriesId}/Episode?includeMissing=true&includeDataFrom=AniDB` }),
providesTags: ['SeriesEpisodes'],
providesTags: ['SeriesEpisodes', 'UtilitiesRefresh'],
}),

// Queue a refresh of the AniDB Info for series with AniDB ID
Expand All @@ -48,15 +49,34 @@ const seriesApi = splitV3Api.injectEndpoints({
query: params => ({ url: 'Series/AniDB/RecommendedForYou', params: { ...params, showAll: true } }),
transformResponse: (response: any) => response.List,
}),

getSeriesWithManuallyLinkedFiles: build.query<ListResultType<Array<SeriesType>>, PaginationType>({
query: params => ({
url: 'Series/WithManuallyLinkedFiles',
params,
}),
providesTags: ['FileMatched', 'UtilitiesRefresh'],
}),

getSeriesFiles: build.query<Array<FileType>, { seriesId: number, isManuallyLinked: boolean, includeXRefs: boolean }>({
query: ({ seriesId, ...params }) => ({
url: `Series/${seriesId}/File`,
params,
}),
providesTags: ['FileMatched', 'UtilitiesRefresh'],
}),
}),
});

export const {
useDeleteSeriesMutation,
useGetSeriesWithoutFilesQuery,
useLazyGetSeriesAniDBSearchQuery,
useGetSeriesEpisodesQuery,
useLazyGetSeriesEpisodesQuery,
useRefreshAnidbSeriesMutation,
useLazyGetSeriesAniDBQuery,
useGetAniDBRecommendedAnimeQuery,
useGetSeriesWithManuallyLinkedFilesQuery,
useGetSeriesFilesQuery,
} = seriesApi;
10 changes: 10 additions & 0 deletions src/core/types/api/file.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
import { SeriesIDsType } from './series';
import { EpisodeIDsType } from './episode';

type XRefsType = Array<{
SeriesID: SeriesIDsType;
EpisodeIDs: EpisodeIDsType;
}>;

export type FileType = {
ID: number;
Size: number;
Expand All @@ -17,6 +25,8 @@ export type FileType = {
Watched: string | null;
Resolution: string;
Created: string;
Updated: string;
SeriesIDs?: XRefsType;
};

export type FileAniDBType = {
Expand Down
6 changes: 3 additions & 3 deletions src/pages/utilities/UnrecognizedUtility.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,16 +99,16 @@ function UnrecognizedUtility() {
<Icon path={mdiChevronRight} size={1} className="ml-2" />
{renderTabButton('files', 'Unrecognized')}
<div>|</div>
{/*{renderTabButton('manuallyLinked', 'Manually Linked')}*/}
{/*<div>|</div>*/}
{renderTabButton('manually-linked-files', 'Manually Linked')}
<div>|</div>
{renderTabButton('ignored-files', 'Ignored Files')}
</div>
);
};

const renderOptions = () => (
<div className="font-semibold">
<span className="text-highlight-2">{filesCount}</span> Files
<span className="text-highlight-2">{filesCount}</span> {pathname === '/webui/utilities/unrecognized/manually-linked-files' ? 'Series' : 'Files'}
</div>
);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import React, { useEffect, useMemo } from 'react';
import { find } from 'lodash';
import {
createColumnHelper,
flexRender,
getCoreRowModel,
getSortedRowModel,
useReactTable,
} from '@tanstack/react-table';
import { Icon } from '@mdi/react';
import { mdiLoading } from '@mdi/js';

import Checkbox from '../../../../components/Input/Checkbox';
import { fuzzyFilter, fuzzySort } from '../../../../core/util';

import type { FileType } from '../../../../core/types/api/file';
import { useGetSeriesEpisodesQuery, useGetSeriesFilesQuery } from '../../../../core/rtkQuery/splitV3Api/seriesApi';

type Props = {
seriesId: number;
modifySelectedFiles: (fileIds: Array<number>, remove: boolean) => void;
selectedFiles: Set<number>;
};

const columnHelper = createColumnHelper<FileType>();

function ManuallyLinkedFilesRow(props: Props) {
const { seriesId, modifySelectedFiles, selectedFiles } = props;
const filesQuery = useGetSeriesFilesQuery({ seriesId, isManuallyLinked: true, includeXRefs: true }, { refetchOnMountOrArgChange: false });
const files = filesQuery.data ?? [];

// We can either get the data for *all* the episodes in the series or call the api 1000 times. Choose your poison, I choose the former. Blame @revam
const episodesQuery = useGetSeriesEpisodesQuery({ seriesId }, { refetchOnMountOrArgChange: false });
const episodes = episodesQuery.data ?? [];

const columns = useMemo(() => [
columnHelper.display({
id: 'checkbox',
header: ({ table }) => (
<div className="flex items-center justify-center">
<Checkbox
id={`checkbox-${seriesId}-all`}
isChecked={table.getIsAllRowsSelected()}
onChange={() => {
const selectedIds: Array<number> = [];
table.getRowModel().flatRows.reduce((result, row) => {
result.push(row.original.ID);
return result;
}, selectedIds);
modifySelectedFiles(selectedIds, table.getIsAllRowsSelected());
table.toggleAllRowsSelected();
}}
intermediate={table.getIsSomeRowsSelected()}
/>
</div>
),
cell: ({ row }) => (
<div className="flex items-center justify-center">
<Checkbox
id={`checkbox-${seriesId}-${row.id}`}
isChecked={row.getIsSelected()}
onChange={() => {
modifySelectedFiles([row.original.ID], row.getIsSelected());
row.toggleSelected();
}}
/>
</div>
),
meta: {
className: 'w-20',
},
}),
columnHelper.accessor(row => row.Locations?.[0].RelativePath.split(/[\/\\]/g).pop(), {
header: 'File',
id: 'file',
cell: info => info.getValue(),
meta: {
className: 'w-auto',
},
filterFn: 'fuzzy',
sortingFn: fuzzySort,
}),
columnHelper.display({
id: 'type',
header: 'Type',
cell: ({ row }) => {
const episode = find(episodes, item => item.IDs.ID === row.original.SeriesIDs![0].EpisodeIDs[0].ID);
return episode ? episode.AniDB?.Type ?? 'Normal' : <Icon path={mdiLoading} size={1} className="text-highlight-1" spin />;
},
meta: {
className: 'w-36',
},
}),
columnHelper.display({
id: 'episode',
header: 'Episode',
cell: ({ row }) => {
const episode = find(episodes, item => item.IDs.ID === row.original.SeriesIDs![0].EpisodeIDs[0].ID);
return episode ? `${episode.AniDB?.EpisodeNumber ?? 'x'} - ${episode.Name}` : <Icon path={mdiLoading} size={1} className="text-highlight-1" spin />;
},
meta: {
className: 'w-96',
},
}),
columnHelper.display({
id: 'blank',
cell: '',
meta: {
className: 'w-8',
},
}),
], [episodes]);

const table = useReactTable({
data: files,
columns: columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
filterFns: {
fuzzy: fuzzyFilter,
},
});

useEffect(() => {
table.getRowModel().flatRows.forEach(row => row.toggleSelected(selectedFiles.has(row.original.ID)));
}, [selectedFiles, table.getRowModel()]);

return filesQuery.isLoading ? (
<div className="flex justify-center py-4">
<Icon path={mdiLoading} size={1} className="text-highlight-1" spin />
</div>
) : (
<table className="table-fixed text-left border-separate border-spacing-0 w-full">
<thead>
{table.getHeaderGroups().map(headerGroup => (
<tr key={headerGroup.id} className="bg-background-alt">
{headerGroup.headers.map(header => (
<th key={header.id} className={`${header.column.columnDef.meta?.className} py-3.5`}>
{flexRender(header.column.columnDef.header, header.getContext())}
</th>
))}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map(row => (
<tr key={row.id} className="bg-background-alt">
{row.getVisibleCells().map(cell => (
<td key={cell.id} className="py-3.5">
<span className="line-clamp-1">{flexRender(cell.column.columnDef.cell, cell.getContext())}</span>
</td>
))}
</tr>
))}
</tbody>
</table>
// </div>
);
}

export default ManuallyLinkedFilesRow;
Loading

0 comments on commit f85adb9

Please sign in to comment.