known duplicates

This commit is contained in:
Gauthier Roebroeck 2025-10-28 12:09:34 +08:00
parent 35dbd70c2a
commit 6157fdde79
21 changed files with 1109 additions and 20 deletions

View file

@ -14,6 +14,7 @@
"@pinia/colada-plugin-delay": "^0.1.0",
"@vueuse/core": "^13.9.0",
"core-js": "^3.45.1",
"filesize": "^11.0.13",
"marked": "^16.3.0",
"openapi-fetch": "^0.14.1",
"pinia": "^3.0.3",
@ -370,6 +371,16 @@
}
}
},
"node_modules/@chromatic-com/storybook/node_modules/filesize": {
"version": "10.1.6",
"resolved": "https://registry.npmjs.org/filesize/-/filesize-10.1.6.tgz",
"integrity": "sha512-sJslQKU2uM33qH5nqewAwVB2QgR6w1aMNsYUp3aN5rMRyXEwJGmZvaWzeJFNTOXWlHQyBFCWrdj3fV/fsTOX8w==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">= 10.4.0"
}
},
"node_modules/@chromatic-com/storybook/node_modules/strip-ansi": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
@ -6271,13 +6282,12 @@
}
},
"node_modules/filesize": {
"version": "10.1.6",
"resolved": "https://registry.npmjs.org/filesize/-/filesize-10.1.6.tgz",
"integrity": "sha512-sJslQKU2uM33qH5nqewAwVB2QgR6w1aMNsYUp3aN5rMRyXEwJGmZvaWzeJFNTOXWlHQyBFCWrdj3fV/fsTOX8w==",
"dev": true,
"version": "11.0.13",
"resolved": "https://registry.npmjs.org/filesize/-/filesize-11.0.13.tgz",
"integrity": "sha512-mYJ/qXKvREuO0uH8LTQJ6v7GsUvVOguqxg2VTwQUkyTPXXRRWPdjuUPVqdBrJQhvci48OHlNGRnux+Slr2Rnvw==",
"license": "BSD-3-Clause",
"engines": {
"node": ">= 10.4.0"
"node": ">= 10.8.0"
}
},
"node_modules/fill-range": {

View file

@ -34,6 +34,7 @@
"@pinia/colada-plugin-delay": "^0.1.0",
"@vueuse/core": "^13.9.0",
"core-js": "^3.45.1",
"filesize": "^11.0.13",
"marked": "^16.3.0",
"openapi-fetch": "^0.14.1",
"pinia": "^3.0.3",

View file

@ -5,6 +5,7 @@
<SnackQueue />
<DialogConfirmEditInstance />
<DialogConfirmInstance />
<DialogSimpleInstance />
</v-app>
<PiniaColadaDevtools />

View file

@ -10,6 +10,11 @@ export function bookThumbnailUrl(bookId?: string): string | undefined {
return undefined
}
export function bookPageThumbnailUrl(bookId?: string, page?: number): string | undefined {
if (bookId && page) return `${API_BASE_URL}/api/v1/books/${bookId}/pages/${page}/thumbnail`
return undefined
}
export function pageHashKnownThumbnailUrl(hash?: string): string | undefined {
if (hash) return `${API_BASE_URL}/api/v1/page-hashes/${hash}/thumbnail`
return undefined

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

View file

@ -0,0 +1,67 @@
import { defineQueryOptions } from '@pinia/colada'
import { komgaClient } from '@/api/komga-client'
import type { PageHashAction } from '@/types/PageHashAction'
export const pageHashesKnownQuery = defineQueryOptions(
({
actions,
page,
size,
sort,
}: {
actions?: string[]
page?: number
size?: number
sort?: string[]
}) => ({
key: ['page-hashes-known', { actions: actions, page: page, size: size, sort: sort }],
query: () =>
komgaClient
.GET('/api/v1/page-hashes', {
params: {
query: {
page: page,
size: size,
sort: sort,
action: actions as PageHashAction[],
},
},
})
// unwrap the openapi-fetch structure on success
.then((res) => res.data),
placeholderData: (previousData: any) => previousData, // eslint-disable-line @typescript-eslint/no-explicit-any
}),
)
export const pageHashMatchesQuery = defineQueryOptions(
({
pageHash,
page,
size,
sort,
}: {
pageHash: string
page?: number
size?: number
sort?: string[]
}) => ({
key: ['page-hash-matches', pageHash, { page: page, size: size, sort: sort }],
query: () =>
komgaClient
.GET('/api/v1/page-hashes/{pageHash}', {
params: {
path: {
pageHash: pageHash,
},
query: {
page: page,
size: size,
sort: sort,
},
},
})
// unwrap the openapi-fetch structure on success
.then((res) => res.data),
placeholderData: (previousData: any) => previousData, // eslint-disable-line @typescript-eslint/no-explicit-any
}),
)

View file

@ -23,6 +23,8 @@ declare module 'vue' {
DialogConfirmInstance: typeof import('./components/dialog/ConfirmInstance.vue')['default']
DialogFileNamePicker: typeof import('./components/dialog/FileNamePicker.vue')['default']
DialogSeriesPicker: typeof import('./components/dialog/SeriesPicker.vue')['default']
DialogSimple: typeof import('./components/dialog/DialogSimple.vue')['default']
DialogSimpleInstance: typeof import('./components/dialog/DialogSimpleInstance.vue')['default']
EmptyStateNetworkError: typeof import('./components/EmptyStateNetworkError.vue')['default']
FormattedMessage: typeof import('./components/FormattedMessage.ts')['default']
HelloWorld: typeof import('./components/HelloWorld.vue')['default']
@ -47,6 +49,8 @@ declare module 'vue' {
LayoutAppDrawerMenuMedia: typeof import('./components/layout/app/drawer/menu/Media.vue')['default']
LayoutAppDrawerMenuServer: typeof import('./components/layout/app/drawer/menu/Server.vue')['default']
LocaleSelector: typeof import('./components/LocaleSelector.vue')['default']
PageHashKnownTable: typeof import('./components/pageHash/KnownTable.vue')['default']
PageHashMatchTable: typeof import('./components/pageHash/MatchTable.vue')['default']
ReleaseCard: typeof import('./components/release/Card.vue')['default']
RemoteFileList: typeof import('./components/RemoteFileList.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']

View file

@ -0,0 +1,67 @@
<template>
<v-dialog
v-model="showDialog"
:activator="activator"
:max-width="maxWidth"
:fullscreen="fullscreen"
:transition="fullscreen ? 'dialog-bottom-transition' : undefined"
:scrollable="scrollable"
:aria-label="title"
>
<template #default="{ isActive }">
<v-card
:title="title"
:subtitle="subtitle"
:loading="loading"
>
<template #text>
<slot name="text" />
</template>
<template #actions>
<v-spacer />
<v-btn
:text="
$formatMessage({
description: 'Simple dialog: Close button',
defaultMessage: 'Close',
id: 'Wivz5J',
})
"
@click="isActive.value = false"
/>
</template>
</v-card>
</template>
</v-dialog>
</template>
<script setup lang="ts">
const showDialog = defineModel<boolean>('dialog', { required: false, default: false })
export type DialogSimpleProps = {
/**
* Dialog title
* @type string
*/
title?: string
subtitle?: string
maxWidth?: string | number
activator?: Element | string
loading?: boolean
fullscreen?: boolean
scrollable?: boolean
shown?: boolean
}
const props = defineProps<DialogSimpleProps>()
const {
title = undefined,
subtitle = undefined,
maxWidth = undefined,
activator = undefined,
loading = false,
fullscreen = undefined,
scrollable = undefined,
} = toRefs(props)
</script>

View file

@ -0,0 +1,39 @@
<template>
<DialogSimple
v-model="showDialog"
v-bind="simple.dialogProps"
:loading="loading"
:activator="simple.activator"
>
<template #text>
<component
:is="simple.slot.component"
v-bind="simple.slot.props"
class="mt-1"
v-on="simple.slot.handlers"
/>
</template>
</DialogSimple>
</template>
<script setup lang="ts">
/**
* Single instance of DialogSimple, mounted under App.
* Communication from other components is done via useDialogsStore.simple
*/
import { useDialogsStore } from '@/stores/dialogs'
import { storeToRefs } from 'pinia'
import { syncRefs } from '@vueuse/core'
const showDialog = ref<boolean>(false)
const loading = ref<boolean>(false)
const { simple } = storeToRefs(useDialogsStore())
syncRefs(
toRef(() => simple.value.dialogProps.shown),
showDialog,
)
</script>
<style scoped></style>

View file

@ -0,0 +1,73 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import KnownTable from './KnownTable.vue'
import { delay, http } from 'msw'
import { response401Unauthorized } from '@/mocks/api/handlers'
import { httpTyped } from '@/mocks/api/httpTyped'
import { mockPage } from '@/mocks/api/pageable'
import { PageRequest } from '@/types/PageRequest'
import DialogSimpleInstance from '@/components/dialog/DialogSimpleInstance.vue'
import SnackQueue from '@/components/SnackQueue.vue'
const meta = {
component: KnownTable,
subcomponents: { SnackQueue },
render: (args: object) => ({
components: { KnownTable, DialogSimpleInstance, SnackQueue },
setup() {
return { args }
},
template: '<KnownTable v-bind="args" /><DialogSimpleInstance/><SnackQueue/>',
}),
parameters: {
// More on how to position stories at: https://storybook.js.org/docs/configure/story-layout
},
args: {},
} satisfies Meta<typeof KnownTable>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {},
}
export const NoData: Story = {
parameters: {
msw: {
handlers: [
httpTyped.get('/api/v1/page-hashes', ({ response }) =>
response(200).json(mockPage([], new PageRequest())),
),
],
},
},
}
export const Loading: Story = {
parameters: {
msw: {
handlers: [http.all('*/api/*', async () => await delay(2_000))],
},
},
}
export const Error: Story = {
parameters: {
msw: {
handlers: [http.all('*/api/v1/page-hashes', response401Unauthorized)],
},
},
}
export const ErrorOnDeletion: Story = {
parameters: {
msw: {
handlers: [
httpTyped.post('/api/v1/page-hashes/{pageHash}/delete-all', ({ response }) =>
response.untyped(response401Unauthorized()),
),
],
},
},
}

View file

@ -0,0 +1,442 @@
<template>
<v-data-table-server
v-model:sort-by="sortBy"
:loading="isLoading"
:items="data?.content"
:items-length="data?.totalElements || 0"
:items-per-page-options="[
{ value: 10, title: '10' },
{ value: 25, title: '25' },
{ value: 50, title: '50' },
{ value: 100, title: '100' },
]"
:headers="headers"
fixed-header
fixed-footer
select-strategy="page"
multi-sort
mobile-breakpoint="md"
@update:options="updateOptions"
>
<template #top>
<v-toolbar
v-if="display.smAndUp.value"
flat
>
<v-toolbar-title>
<v-icon
color="medium-emphasis"
icon="i-mdi:content-copy"
size="x-small"
start
/>
{{
$formatMessage({
description: 'Known Duplicate Page Table global header',
defaultMessage: 'Known Duplicates',
id: 'I9ub/l',
})
}}
</v-toolbar-title>
<v-spacer />
<v-chip-group
v-model="filterSelect"
multiple
class="ms-2"
>
<v-chip
v-for="f in filterOptions"
:key="f.value"
:value="f.value"
:text="f.title"
filter
rounded
color="primary"
/>
</v-chip-group>
</v-toolbar>
<v-select
v-else
v-model="filterSelect"
label="Filter"
multiple
:items="filterOptions"
chips
closable-chips
variant="underlined"
class="px-4"
/>
</template>
<template #no-data>
<EmptyStateNetworkError v-if="error" />
<template v-else>
{{
$formatMessage({
description: 'Known Duplicate Page Table: shown when table has no data',
defaultMessage: 'No data found',
id: '+6YDzS',
})
}}
</template>
</template>
<template #[`item.hash`]="{ value }">
<div>
<v-img
width="200"
height="200"
contain
style="cursor: zoom-in"
:src="pageHashKnownThumbnailUrl(value)"
lazy-src="@/assets/cover.svg"
class="my-1"
:alt="
$formatMessage({
description: 'Known Duplicate Page Table: alt description for thumbnail',
defaultMessage: 'Duplicate page',
id: 'P+WI/K',
})
"
@mouseenter="dialogSimple.activator = $event.currentTarget"
@click="showDialogImage(value)"
>
<template #placeholder>
<div class="d-flex align-center justify-center fill-height">
<v-progress-circular
color="grey"
indeterminate
/>
</div>
</template>
</v-img>
</div>
</template>
<template #[`item.action`]="{ item, value }">
<v-btn-toggle
:model-value="value"
variant="outlined"
divided
color="primary"
rounded="lg"
mandatory
@update:model-value="(v) => updateHashAction(item, v)"
>
<v-btn
size="small"
icon="i-mdi:robot"
:value="PageHashActionEnum.DELETE_AUTO"
color="success"
/>
<v-btn
size="small"
icon="i-mdi:hand-back-right"
:value="PageHashActionEnum.DELETE_MANUAL"
color="warning"
/>
<v-btn
size="small"
icon="i-mdi:cancel"
:value="PageHashActionEnum.IGNORE"
color=""
/>
</v-btn-toggle>
</template>
<template #[`item.size`]="{ value }">
{{ getFileSize(value) }}
</template>
<template #[`item.deleteSize`]="{ value }">
{{ getFileSize(value) }}
</template>
<template #[`item.matchCount`]="{ item, value }">
<v-btn-group
rounded="lg"
divided
variant="outlined"
>
<v-btn
:text="value"
append-icon="i-mdi:image-multiple-outline"
color=""
size="small"
:disabled="value == 0"
@mouseenter="dialogSimple.activator = $event.currentTarget"
@click="showDialogMatches(item.hash)"
/>
<v-btn
:icon="deletionRequests[item.hash]?.success ? 'i-mdi:timer-sand-empty' : 'i-mdi:delete'"
:color="deletionRequests[item.hash]?.success ? 'info' : 'error'"
size="small"
:disabled="
value == 0 ||
getPageHashAction(item) !== PageHashActionEnum.DELETE_MANUAL ||
deletionRequests[item.hash]?.success
"
:loading="deletionRequests[item.hash]?.isLoading"
@click="deletePageHashMatches(item.hash)"
/>
</v-btn-group>
</template>
<template #[`item.created`]="{ value }">
<div>{{ $formatDate(value, { dateStyle: 'short' }) }}</div>
<div>{{ $formatDate(value, { timeStyle: 'short' }) }}</div>
</template>
<template #[`item.lastModified`]="{ value }">
<div>{{ $formatDate(value, { dateStyle: 'short' }) }}</div>
<div>{{ $formatDate(value, { timeStyle: 'short' }) }}</div>
</template>
</v-data-table-server>
</template>
<script setup lang="ts">
import { useIntl } from 'vue-intl'
import { PageRequest, type SortItem } from '@/types/PageRequest'
import { useMutation, useQuery } from '@pinia/colada'
import { pageHashesKnownQuery } from '@/colada/page-hashes'
import type { components } from '@/generated/openapi/komga'
import { getFileSize } from '@/utils/utils'
import { pageHashKnownThumbnailUrl } from '@/api/images'
import { storeToRefs } from 'pinia'
import { useDialogsStore } from '@/stores/dialogs'
import { useDisplay } from 'vuetify'
import { VImg } from 'vuetify/components'
import MatchTable from '@/components/pageHash/MatchTable.vue'
import { type ErrorCause, komgaClient } from '@/api/komga-client'
import {
type PageHashAction,
PageHashActionEnum,
pageHashActionMessages,
} from '@/types/PageHashAction'
import { useMessagesStore } from '@/stores/messages'
import { commonMessages } from '@/utils/i18n/common-messages'
const intl = useIntl()
const display = useDisplay()
const messagesStore = useMessagesStore()
const { simple: dialogSimple } = storeToRefs(useDialogsStore())
const sortBy = ref<SortItem[]>([{ key: 'deleteSize', order: 'desc' }])
//region headers
const headers = [
{
title: intl.formatMessage({
description: 'Known Duplicate Page Table header: thumbnail',
defaultMessage: 'Thumbnail',
id: 'm9h2wk',
}),
key: 'hash',
sortable: false,
},
{
title: intl.formatMessage({
description: 'Known Duplicate Page Table header: action',
defaultMessage: 'Action',
id: 'XM8VQH',
}),
key: 'action',
value: (item: components['schemas']['PageHashKnownDto']) => {
return getPageHashAction(item)
},
},
{
title: intl.formatMessage({
description: 'Known Duplicate Page Table header: match count',
defaultMessage: 'Matches',
id: 'xfThoX',
}),
key: 'matchCount',
},
{
title: intl.formatMessage({
description: 'Known Duplicate Page Table header: size',
defaultMessage: 'Size',
id: 'syF0Ap',
}),
key: 'size',
},
{
title: intl.formatMessage({
description: 'Known Duplicate Page Table header: delete count',
defaultMessage: 'Delete count',
id: 'QM5+gW',
}),
key: 'deleteCount',
},
{
title: intl.formatMessage({
description: 'Known Duplicate Page Table header: space saved',
defaultMessage: 'Space saved',
id: 'WY7aQf',
}),
key: 'deleteSize',
value: (item: components['schemas']['PageHashKnownDto']) => (item.size || 0) * item.deleteCount,
},
{
title: intl.formatMessage({
description: 'Known Duplicate Page Table header: creation date',
defaultMessage: 'Created',
id: 'EzOmuX',
}),
key: 'created',
},
{
title: intl.formatMessage({
description: 'Known Duplicate Page Table header: modified date',
defaultMessage: 'Modified',
id: 'REmxIM',
}),
key: 'lastModified',
},
] as const
//endregion
//region Filtering
const filterSelect = ref<string[]>([
PageHashActionEnum.DELETE_AUTO,
PageHashActionEnum.DELETE_MANUAL,
])
const filterOptions = [
{
title: intl.formatMessage(pageHashActionMessages[PageHashActionEnum.DELETE_AUTO]),
value: PageHashActionEnum.DELETE_AUTO,
},
{
title: intl.formatMessage(pageHashActionMessages[PageHashActionEnum.DELETE_MANUAL]),
value: PageHashActionEnum.DELETE_MANUAL,
},
{
title: intl.formatMessage(pageHashActionMessages[PageHashActionEnum.IGNORE]),
value: PageHashActionEnum.IGNORE,
},
]
//endregion
const pageRequest = ref<PageRequest>(new PageRequest())
const { data, isLoading, error } = useQuery(pageHashesKnownQuery, () => ({
...pageRequest.value,
actions: filterSelect.value,
}))
function updateOptions({
page,
itemsPerPage,
sortBy,
}: {
page: number
itemsPerPage: number
sortBy: SortItem[]
}) {
pageRequest.value = PageRequest.FromVuetify(page - 1, itemsPerPage, sortBy)
}
function showDialogImage(hash: string) {
dialogSimple.value.dialogProps = {
fullscreen: display.xs.value,
scrollable: true,
}
dialogSimple.value.slot = {
component: markRaw(VImg),
props: {
src: pageHashKnownThumbnailUrl(hash),
contain: true,
style: 'cursor: zoom-out;',
},
handlers: {
click: () => {
dialogSimple.value.dialogProps.shown = false
},
},
}
}
function showDialogMatches(hash: string) {
dialogSimple.value.dialogProps = {
fullscreen: display.xs.value,
scrollable: true,
}
dialogSimple.value.slot = {
component: markRaw(MatchTable),
props: {
modelValue: hash,
},
}
}
//region Manual deletion
const deletionRequests = ref<Record<string, { success?: boolean; isLoading: boolean }>>({})
function deletePageHashMatches(hash: string) {
if (hash in deletionRequests.value && deletionRequests.value[hash]!.success) return
deletionRequests.value[hash] = {
isLoading: true,
}
useMutation({
mutation: () =>
komgaClient.POST('/api/v1/page-hashes/{pageHash}/delete-all', {
params: { path: { pageHash: hash } },
}),
})
.mutateAsync()
.then(
() =>
(deletionRequests.value[hash] = {
success: true,
isLoading: false,
}),
)
.catch((error) => {
deletionRequests.value[hash] = {
success: false,
isLoading: false,
}
messagesStore.messages.push({
text:
(error?.cause as ErrorCause)?.message || intl.formatMessage(commonMessages.networkError),
})
})
}
//endregion
//region Update action
const updateRequests = ref<Record<string, PageHashAction>>({})
function getPageHashAction(pageHash: components['schemas']['PageHashKnownDto']): PageHashAction {
return updateRequests.value[pageHash.hash] || pageHash.action
}
function updateHashAction(
pageHash: components['schemas']['PageHashKnownDto'],
newAction: PageHashAction,
) {
useMutation({
mutation: () =>
komgaClient.PUT('/api/v1/page-hashes', {
body: {
...pageHash,
action: newAction,
},
}),
})
.mutateAsync()
.then(() => (updateRequests.value[pageHash.hash] = newAction))
.catch((error) =>
messagesStore.messages.push({
text:
(error?.cause as ErrorCause)?.message || intl.formatMessage(commonMessages.networkError),
}),
)
}
//endregion
</script>
<script setup lang="ts"></script>

View file

@ -0,0 +1,58 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import MatchTable from './MatchTable.vue'
import { delay, http } from 'msw'
import { response401Unauthorized } from '@/mocks/api/handlers'
import { httpTyped } from '@/mocks/api/httpTyped'
import { mockPage } from '@/mocks/api/pageable'
import { PageRequest } from '@/types/PageRequest'
const meta = {
component: MatchTable,
render: (args: object) => ({
components: { MatchTable },
setup() {
return { args }
},
template: '<MatchTable v-bind="args" />',
}),
parameters: {
// More on how to position stories at: https://storybook.js.org/docs/configure/story-layout
},
args: {
modelValue: 'hash1',
},
} satisfies Meta<typeof MatchTable>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {}
export const NoData: Story = {
parameters: {
msw: {
handlers: [
httpTyped.get('/api/v1/page-hashes/{pageHash}', ({ response }) =>
response(200).json(mockPage([], new PageRequest())),
),
],
},
},
}
export const Loading: Story = {
parameters: {
msw: {
handlers: [http.all('*/api/*', async () => await delay(2_000))],
},
},
}
export const Error: Story = {
parameters: {
msw: {
handlers: [http.all('*/api/v1/page-hashes/*', response401Unauthorized)],
},
},
}

View file

@ -0,0 +1,151 @@
<template>
<v-data-table-server
v-model:sort-by="sortBy"
:loading="isLoading"
:items="data?.content"
:items-length="data?.totalElements || 0"
:items-per-page-options="[
{ value: 10, title: '10' },
{ value: 25, title: '25' },
{ value: 50, title: '50' },
{ value: 100, title: '100' },
]"
:headers="headers"
fixed-header
fixed-footer
select-strategy="page"
multi-sort
mobile-breakpoint="md"
@update:options="updateOptions"
>
<template #no-data>
<EmptyStateNetworkError v-if="error" />
<template v-else>
{{
$formatMessage({
description: 'Duplicate Page Hash Matches Table: shown when table has no data',
defaultMessage: 'No data found',
id: 'ggaOkE',
})
}}
</template>
</template>
<template #[`item.url`]="{ value }">
<div
v-if="display.xs.value"
v-tooltip="value"
class="text-truncate"
style="max-width: 200px"
>
{{ value }}
</div>
<template v-else>{{ value }}</template>
</template>
<template #[`item.bookId`]="{ item }">
<div>
<v-img
width="200"
height="200"
contain
style="cursor: zoom-in"
:src="bookPageThumbnailUrl(item.bookId, item.pageNumber)"
lazy-src="@/assets/cover.svg"
class="my-1"
:alt="
$formatMessage({
description: 'Duplicate Page Hash Matches Table: alt description for thumbnail',
defaultMessage: 'Duplicate page',
id: 'V3wgcu',
})
"
>
<template #placeholder>
<div class="d-flex align-center justify-center fill-height">
<v-progress-circular
color="grey"
indeterminate
/>
</div>
</template>
</v-img>
</div>
</template>
</v-data-table-server>
</template>
<script setup lang="ts">
import { useIntl } from 'vue-intl'
import { PageRequest, type SortItem } from '@/types/PageRequest'
import { useQuery } from '@pinia/colada'
import { pageHashMatchesQuery } from '@/colada/page-hashes'
import { bookPageThumbnailUrl } from '@/api/images'
import { useDisplay } from 'vuetify'
const pageHash = defineModel<string>({ required: true })
const intl = useIntl()
const display = useDisplay()
//region headers
const headers = [
{
title: intl.formatMessage({
description: 'Duplicate Page Hash Matches Table header: book file name',
defaultMessage: 'Book',
id: 'bOUy3X',
}),
key: 'url',
},
{
title: intl.formatMessage({
description: 'Duplicate Page Hash Matches Table header: File name',
defaultMessage: 'File name',
id: 'o+h0F+',
}),
key: 'fileName',
},
{
title: intl.formatMessage({
description: 'Duplicate Page Hash Matches Table header: page number',
defaultMessage: 'Page number',
id: 'm+yz6Z',
}),
key: 'pageNumber',
},
{
title: intl.formatMessage({
description: 'Duplicate Page Hash Matches Table header: page',
defaultMessage: 'Page',
id: '2Sx0J4',
}),
key: 'bookId',
sortable: false,
},
] as const
//endregion
//region Data loading
const sortBy = ref<SortItem[]>([])
const pageRequest = ref<PageRequest>(new PageRequest())
const { data, isLoading, error } = useQuery(pageHashMatchesQuery, () => ({
pageHash: pageHash.value,
...pageRequest.value,
}))
function updateOptions({
page,
itemsPerPage,
sortBy,
}: {
page: number
itemsPerPage: number
sortBy: SortItem[]
}) {
pageRequest.value = PageRequest.FromVuetify(page - 1, itemsPerPage, sortBy)
}
//endregion
</script>
<script setup lang="ts"></script>

View file

@ -13,6 +13,7 @@ import { booksHandlers } from '@/mocks/api/handlers/books'
import { filesystemHandlers } from '@/mocks/api/handlers/filesystem'
import { transientBooksHandlers } from '@/mocks/api/handlers/transient-books'
import { readListsHandlers } from '@/mocks/api/handlers/readlists'
import { pageHashesHandlers } from '@/mocks/api/handlers/page-hashes'
export const handlers = [
...actuatorHandlers,
@ -22,6 +23,7 @@ export const handlers = [
...filesystemHandlers,
...historyHandlers,
...librariesHandlers,
...pageHashesHandlers,
...readListsHandlers,
...referentialHandlers,
...releasesHandlers,

View file

@ -1,8 +1,6 @@
import { httpTyped } from '@/mocks/api/httpTyped'
import { mockPage } from '@/mocks/api/pageable'
import { PageRequest } from '@/types/PageRequest'
import { http, HttpResponse } from 'msw'
import mockThumbnailUrl from '@/assets/mock-thumbnail.jpg'
export const historyBookImported = {
id: 'H1',
@ -112,14 +110,4 @@ export const historyHandlers = [
),
),
),
http.get('*/api/v1/page-hashes/*/thumbnail', async () => {
// Get an ArrayBuffer from reading the file from disk or fetching it.
const buffer = await fetch(mockThumbnailUrl).then((response) => response.arrayBuffer())
return HttpResponse.arrayBuffer(buffer, {
headers: {
'content-type': 'image/jpg',
},
})
}),
]

View file

@ -0,0 +1,85 @@
import { httpTyped } from '@/mocks/api/httpTyped'
import { mockPage } from '@/mocks/api/pageable'
import { PageRequest } from '@/types/PageRequest'
import type { components } from '@/generated/openapi/komga'
import { HttpResponse } from 'msw'
import mockThumbnailUrl from '@/assets/mock-thumbnail.jpg'
import mockThumbnailLandscapeUrl from '@/assets/mock-thumbnail-landscape.jpg'
export function mockPageHashesKnown(count: number): components['schemas']['PageHashKnownDto'][] {
return [...Array(count).keys()].map((index) => {
const created = new Date(`19${String(index).slice(-2).padStart(2, '0')}-05-10`)
return {
hash: `HASH${index}`,
size: 1234 * (index + 1),
action: index % 3 === 0 ? 'DELETE_AUTO' : index % 3 === 1 ? 'DELETE_MANUAL' : 'IGNORE',
deleteCount: index % 3 === 0 ? 5 : index % 3 === 1 ? 2 : 0,
matchCount: index * 2,
created: created,
lastModified: created,
}
})
}
export function mockPageHashMatches(count: number): components['schemas']['PageHashMatchDto'][] {
return [...Array(count).keys()].map((index) => {
return {
bookId: `BOOK${index + 1}`,
url: '/books/Super Duck/Super_Duck_001__MLJ___Fall_1944___c2c___titansfan_editor_.cbz',
pageNumber: 25 + index,
fileName: `Page_${25 + index}.jpg`,
fileSize: 1234 * (index + 1),
mediaType: 'image/jpeg',
}
})
}
export const pageHashesHandlers = [
httpTyped.get('/api/v1/page-hashes', ({ query, response }) => {
let data = mockPageHashesKnown(50)
const actions = query.getAll('action')
if (actions.length > 0) data = data.filter((it) => actions.includes(it.action))
return response(200).json(
mockPage(
data,
new PageRequest(Number(query.get('page')), Number(query.get('size')), query.getAll('sort')),
),
)
}),
httpTyped.get('/api/v1/page-hashes/{pageHash}', ({ params, query, response }) => {
const hash = params.pageHash
const data = mockPageHashMatches(Number(hash.substring(4)) * 2)
return response(200).json(
mockPage(
data,
new PageRequest(Number(query.get('page')), Number(query.get('size')), query.getAll('sort')),
),
)
}),
httpTyped.put('/api/v1/page-hashes', ({ response }) => {
return response(202).empty()
}),
httpTyped.post('/api/v1/page-hashes/{pageHash}/delete-all', ({ response }) => {
return response(202).empty()
}),
httpTyped.get('/api/v1/page-hashes/{pageHash}/thumbnail', async ({ params, response }) => {
const hash = params.pageHash
// use landscape image for some images
const landscape = Number(hash.slice(-1)) % 2 === 0
// Get an ArrayBuffer from reading the file from disk or fetching it.
const buffer = await fetch(landscape ? mockThumbnailLandscapeUrl : mockThumbnailUrl).then(
(response) => response.arrayBuffer(),
)
return response.untyped(
HttpResponse.arrayBuffer(buffer, {
status: 200,
headers: {
'content-type': 'image/jpg',
},
}),
)
}),
]

View file

@ -4,12 +4,18 @@ export function mockPage<T>(data: T[], pageRequest: PageRequest) {
const page = Number(pageRequest.page) || 0
const size = Number(pageRequest.size) || 20
const unpaged = pageRequest.unpaged || false
const sort = pageRequest.sort
const start = page * size
const slice = unpaged ? data : data.slice(start, start + size)
let sortedSlice = slice
if (sort) {
sortedSlice = slice.sort(orderBy(parseSort(sort)))
}
return {
content: slice,
content: sortedSlice,
pageable: {
pageNumber: page,
pageSize: size,
@ -37,3 +43,36 @@ export function mockPage<T>(data: T[], pageRequest: PageRequest) {
empty: slice.length > 0,
}
}
function parseSort(sorts: string[]): OrderBy[] {
return sorts.map((sort) => {
const components = sort.split(',')
return {
property: components[0]!,
direction: components[1] === 'desc' ? 'desc' : 'asc',
}
})
}
type OrderBy = {
property: string
direction?: 'desc' | 'asc'
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const orderBy = (items: OrderBy[]) => (a: any, b: any) => {
const sortDirection: Record<string, number> = { asc: 1, desc: -1 }
const sortCollator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' })
const totalOrders = items.length
for (let index = 0; index < totalOrders; index++) {
const { property, direction = 'desc' } = items[index]!
const directionInt = sortDirection[direction]!
const compare = sortCollator.compare(a[property], b[property])
if (compare < 0) return directionInt
if (compare > 0) return -directionInt
}
return 0
}

View file

@ -1,5 +1,10 @@
<template>
<h1>Known</h1>
<v-container
fluid
class="pa-0 pa-sm-4 h-100 h-sm-auto"
>
<PageHashKnownTable />
</v-container>
</template>
<script lang="ts" setup>

View file

@ -2,6 +2,7 @@
import { defineStore } from 'pinia'
import type { DialogConfirmEditProps } from '@/components/dialog/ConfirmEdit.vue'
import type { DialogConfirmProps } from '@/components/dialog/Confirm.vue'
import type { DialogSimpleProps } from '@/components/dialog/DialogSimple.vue'
/**
* Reusable dialogs.
@ -14,6 +15,7 @@ export const useDialogsStore = defineStore('dialogs', {
slot: {
component: undefined,
props: {},
handlers: {},
},
record: undefined,
callback: () => {},
@ -23,9 +25,19 @@ export const useDialogsStore = defineStore('dialogs', {
slotWarning: {
component: undefined,
props: {},
handlers: {},
},
callback: () => {},
} as DialogConfirmActivation,
simple: {
dialogProps: {},
slot: {
component: undefined,
props: {},
handlers: {},
},
callback: () => {},
} as DialogSimpleActivation,
}),
})
@ -44,7 +56,12 @@ type DialogConfirmActivation = DialogActivation<DialogConfirmProps> & {
slotWarning: ComponentWithProps
}
type DialogSimpleActivation = DialogActivation<DialogSimpleProps> & {
slot: ComponentWithProps
}
type ComponentWithProps = {
component?: Component
props: object
props?: object
handlers?: object
}

View file

@ -0,0 +1,27 @@
import { defineMessages } from 'vue-intl'
export type PageHashAction = 'DELETE_AUTO' | 'DELETE_MANUAL' | 'IGNORE'
export enum PageHashActionEnum {
DELETE_AUTO = 'DELETE_AUTO',
DELETE_MANUAL = 'DELETE_MANUAL',
IGNORE = 'IGNORE',
}
export const pageHashActionMessages = defineMessages({
[PageHashActionEnum.DELETE_AUTO]: {
description: 'Page Hash Action: DELETE_AUTO',
defaultMessage: 'Auto delete',
id: 'enum.pageHashAction.DELETE_AUTO',
},
[PageHashActionEnum.DELETE_MANUAL]: {
description: 'Page Hash Action: DELETE_MANUAL',
defaultMessage: 'Manual delete',
id: 'enum.pageHashAction.DELETE_MANUAL',
},
[PageHashActionEnum.IGNORE]: {
description: 'Page Hash Action: IGNORE',
defaultMessage: 'Ignore',
id: 'enum.pageHashAction.IGNORE',
},
})

View file

@ -1 +1,9 @@
import { partial } from 'filesize'
export const delay = (ms: number) => new Promise((res) => setTimeout(res, ms))
const filesizePartial = partial({ round: 1 })
export function getFileSize(n?: number): string | undefined {
if (!n) return undefined
return filesizePartial(n)
}