unknown page hashes

This commit is contained in:
Gauthier Roebroeck 2025-12-02 10:27:58 +08:00
parent 78428f137a
commit 376bce3324
7 changed files with 540 additions and 2 deletions

View file

@ -19,3 +19,8 @@ export function pageHashKnownThumbnailUrl(hash?: string): string | undefined {
if (hash) return `${API_BASE_URL}/api/v1/page-hashes/${hash}/thumbnail`
return undefined
}
export function pageHashUnknownThumbnailUrl(hash?: string): string | undefined {
if (hash) return `${API_BASE_URL}/api/v1/page-hashes/unknown/${hash}/thumbnail`
return undefined
}

View file

@ -2,6 +2,10 @@ import { defineQueryOptions } from '@pinia/colada'
import { komgaClient } from '@/api/komga-client'
import type { PageHashAction } from '@/types/PageHashAction'
export const QUERY_KEYS_PAGE_HASHES = {
unknown: ['page-hashes-unknown'] as const,
}
export const pageHashesKnownQuery = defineQueryOptions(
({
actions,
@ -33,6 +37,26 @@ export const pageHashesKnownQuery = defineQueryOptions(
}),
)
export const pageHashesUnknownQuery = defineQueryOptions(
({ page, size, sort }: { page?: number; size?: number; sort?: string[] }) => ({
key: [QUERY_KEYS_PAGE_HASHES.unknown, { page: page, size: size, sort: sort }],
query: () =>
komgaClient
.GET('/api/v1/page-hashes/unknown', {
params: {
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
}),
)
export const pageHashMatchesQuery = defineQueryOptions(
({
pageHash,

View file

@ -54,6 +54,7 @@ declare module 'vue' {
LocaleSelector: typeof import('./components/LocaleSelector.vue')['default']
PageHashKnownTable: typeof import('./components/pageHash/KnownTable.vue')['default']
PageHashMatchTable: typeof import('./components/pageHash/MatchTable.vue')['default']
PageHashUnknownTable: typeof import('./components/pageHash/UnknownTable.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,61 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import UnknownTable from './UnknownTable.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: UnknownTable,
subcomponents: { SnackQueue },
render: (args: object) => ({
components: { UnknownTable, DialogSimpleInstance, SnackQueue },
setup() {
return { args }
},
template: '<UnknownTable 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 UnknownTable>
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/unknown', ({ 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/unknown', response401Unauthorized)],
},
},
}

View file

@ -0,0 +1,393 @@
<template>
<v-data-table-server
v-model="selectedHashes"
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"
show-select
return-object
item-value="hash"
multi-sort
mobile-breakpoint="md"
@update:options="updateOptions"
>
<template #top>
<v-toolbar flat>
<v-toolbar-title
v-if="display.smAndUp.value || (display.xs.value && selectedHashes.length == 0)"
>
<v-icon
color="medium-emphasis"
icon="i-mdi:content-copy"
size="x-small"
start
/>
{{
$formatMessage({
description: 'Unknown Duplicate Page Table global header',
defaultMessage: 'Unknown Duplicates',
id: 'XuqK4C',
})
}}
</v-toolbar-title>
<v-spacer v-if="display.smAndUp.value || (display.xs.value && selectedHashes.length > 0)" />
<v-btn
v-if="selectedHashes.length > 0"
variant="elevated"
class="mx-2"
append-icon="i-mdi:menu-down"
>
{{
$formatMessage({
description: 'Unknown Duplicate Page: selection action button',
defaultMessage: 'Mark as',
id: 'lFTdQ+',
})
}}
<v-menu activator="parent">
<v-list
density="compact"
slim
>
<v-list-item
v-for="(item, index) in actionOptions"
:key="index"
:title="item.title"
:prepend-icon="item.icon"
@click="updateHashActions(selectedHashes, item.value)"
>
</v-list-item>
</v-list>
</v-menu>
</v-btn>
</v-toolbar>
</template>
<template #no-data>
<EmptyStateNetworkError v-if="error" />
<template v-else>
{{
$formatMessage({
description: 'Unknown Duplicate Page Table: shown when table has no data',
defaultMessage: 'No data found',
id: 'hPo41m',
})
}}
</template>
</template>
<template #[`item.hash`]="{ value }">
<div>
<v-img
width="200"
height="200"
contain
style="cursor: zoom-in"
:src="pageHashUnknownThumbnailUrl(value)"
lazy-src="@/assets/cover.svg"
class="my-1"
:alt="
$formatMessage({
description: 'Unknown Duplicate Page Table: alt description for thumbnail',
defaultMessage: 'Duplicate page',
id: 'IXhDH6',
})
"
@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
rounded="lg"
:disabled="value"
@update:model-value="(v) => updateHashAction(item, v)"
>
<v-btn
v-tooltip:bottom="
intl.formatMessage(pageHashActionMessages[PageHashActionEnum.DELETE_AUTO])
"
size="small"
icon="i-mdi:robot"
:value="PageHashActionEnum.DELETE_AUTO"
:loading="value === PageHashActionEnum.DELETE_AUTO"
color="success"
/>
<v-btn
v-tooltip:bottom="
intl.formatMessage(pageHashActionMessages[PageHashActionEnum.DELETE_MANUAL])
"
size="small"
icon="i-mdi:hand-back-right"
:value="PageHashActionEnum.DELETE_MANUAL"
:loading="value === PageHashActionEnum.DELETE_MANUAL"
color="warning"
/>
<v-btn
v-tooltip:bottom="intl.formatMessage(pageHashActionMessages[PageHashActionEnum.IGNORE])"
size="small"
icon="i-mdi:cancel"
:value="PageHashActionEnum.IGNORE"
:loading="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-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, useQueryCache } from '@pinia/colada'
import { pageHashesUnknownQuery, QUERY_KEYS_PAGE_HASHES } from '@/colada/page-hashes'
import type { components } from '@/generated/openapi/komga'
import { getFileSize } from '@/utils/utils'
import { pageHashUnknownThumbnailUrl } 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 selectedHashes = ref<components['schemas']['PageHashUnknownDto'][]>([])
const sortBy = ref<SortItem[]>([{ key: 'deleteSize', order: 'desc' }])
//region headers
const headers = [
{
title: intl.formatMessage({
description: 'Unknown Duplicate Page Table header: thumbnail',
defaultMessage: 'Thumbnail',
id: 'oyeyK/',
}),
key: 'hash',
sortable: false,
},
{
title: intl.formatMessage({
description: 'Unknown Duplicate Page Table header: action',
defaultMessage: 'Action',
id: 'gxZjIe',
}),
key: 'action',
value: (item: components['schemas']['PageHashUnknownDto']) => {
return getPageHashAction(item)
},
},
{
title: intl.formatMessage({
description: 'Unknown Duplicate Page Table header: match count',
defaultMessage: 'Matches',
id: 'hdoWGT',
}),
key: 'matchCount',
},
{
title: intl.formatMessage({
description: 'Unknown Duplicate Page Table header: size',
defaultMessage: 'Size',
id: 'zRDVnR',
}),
key: 'size',
},
] as const
//endregion
const pageRequest = ref<PageRequest>(new PageRequest())
const { data, isLoading, error } = useQuery(pageHashesUnknownQuery, () => ({
...pageRequest.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: pageHashUnknownThumbnailUrl(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 Update action
const updateRequests = ref<Record<string, PageHashAction>>({})
function getPageHashAction(
pageHash: components['schemas']['PageHashUnknownDto'],
): PageHashAction | undefined {
return updateRequests.value[pageHash.hash]
}
async function updateHashAction(
pageHash: components['schemas']['PageHashUnknownDto'],
newAction: PageHashAction,
invalidateCache: boolean = true,
) {
updateRequests.value[pageHash.hash] = newAction
return useMutation({
mutation: () =>
komgaClient.PUT('/api/v1/page-hashes', {
body: {
...pageHash,
action: newAction,
},
}),
})
.mutateAsync()
.then(() => {
if (selectedHashes.value.includes(pageHash))
selectedHashes.value.splice(selectedHashes.value.indexOf(pageHash), 1)
if (invalidateCache)
void useQueryCache().invalidateQueries({ key: [QUERY_KEYS_PAGE_HASHES.unknown] })
})
.catch((error) =>
messagesStore.messages.push({
text:
(error?.cause as ErrorCause)?.message || intl.formatMessage(commonMessages.networkError),
}),
)
}
async function updateHashActions(
pageHashes: components['schemas']['PageHashUnknownDto'][],
newAction: PageHashAction,
) {
const updates = pageHashes.map((it) => updateHashAction(it, newAction, false))
await Promise.allSettled(updates)
void useQueryCache().invalidateQueries({ key: [QUERY_KEYS_PAGE_HASHES.unknown] })
}
const actionOptions = [
{
title: intl.formatMessage(pageHashActionMessages[PageHashActionEnum.DELETE_AUTO]),
value: PageHashActionEnum.DELETE_AUTO,
icon: 'i-mdi:robot',
},
{
title: intl.formatMessage(pageHashActionMessages[PageHashActionEnum.DELETE_MANUAL]),
value: PageHashActionEnum.DELETE_MANUAL,
icon: 'i-mdi:hand-back-right',
},
{
title: intl.formatMessage(pageHashActionMessages[PageHashActionEnum.IGNORE]),
value: PageHashActionEnum.IGNORE,
icon: 'i-mdi:cancel',
},
]
//endregion
</script>
<script setup lang="ts"></script>

View file

@ -21,6 +21,18 @@ export function mockPageHashesKnown(count: number): components['schemas']['PageH
})
}
export function mockPageHashesUnknown(
count: number,
): components['schemas']['PageHashUnknownDto'][] {
return [...Array(count).keys()].map((index) => {
return {
hash: `UNKN${index}`,
size: 1234 * (index + 1),
matchCount: index * 2,
}
})
}
export function mockPageHashMatches(count: number): components['schemas']['PageHashMatchDto'][] {
return [...Array(count).keys()].map((index) => {
return {
@ -34,6 +46,8 @@ export function mockPageHashMatches(count: number): components['schemas']['PageH
})
}
const knownHashes: string[] = []
export const pageHashesHandlers = [
httpTyped.get('/api/v1/page-hashes', ({ query, response }) => {
let data = mockPageHashesKnown(50)
@ -46,6 +60,15 @@ export const pageHashesHandlers = [
),
)
}),
httpTyped.get('/api/v1/page-hashes/unknown', ({ query, response }) => {
const data = mockPageHashesUnknown(50).filter((it) => !knownHashes.includes(it.hash))
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)
@ -56,7 +79,10 @@ export const pageHashesHandlers = [
),
)
}),
httpTyped.put('/api/v1/page-hashes', ({ response }) => {
httpTyped.put('/api/v1/page-hashes', async ({ request, response }) => {
const body = await request.json()
knownHashes.push(body.hash)
return response(202).empty()
}),
httpTyped.post('/api/v1/page-hashes/{pageHash}/delete-all', ({ response }) => {
@ -82,4 +108,27 @@ export const pageHashesHandlers = [
}),
)
}),
httpTyped.get(
'/api/v1/page-hashes/unknown/{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

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