create library

This commit is contained in:
Gauthier Roebroeck 2025-12-11 10:29:26 +08:00
parent b9ea59186b
commit 16e9f1a950
23 changed files with 1392 additions and 11 deletions

View file

@ -1,7 +1,13 @@
import { defineQuery, useQuery } from '@pinia/colada'
import { defineMutation, defineQuery, useMutation, useQuery, useQueryCache } from '@pinia/colada'
import { komgaClient } from '@/api/komga-client'
import { useClientSettingsUser } from '@/colada/client-settings'
import { combinePromises } from '@/colada/utils'
import type { components } from '@/generated/openapi/komga'
import { QUERY_KEYS_USERS } from '@/colada/users'
export const QUERY_KEYS_LIBRARIES = {
root: ['libraries'] as const,
}
export const useLibraries = defineQuery(() => {
const {
@ -10,7 +16,7 @@ export const useLibraries = defineQuery(() => {
refetch: refetchLibraries,
...rest
} = useQuery({
key: () => ['libraries'],
key: () => QUERY_KEYS_LIBRARIES.root,
query: () =>
komgaClient
.GET('/api/v1/libraries')
@ -54,3 +60,34 @@ export const useLibraries = defineQuery(() => {
...rest,
}
})
export const useCreateLibrary = defineMutation(() => {
const queryCache = useQueryCache()
return useMutation({
mutation: (library: components['schemas']['LibraryCreationDto']) =>
komgaClient.POST('/api/v1/libraries', {
body: library,
}),
onSuccess: () => {
void queryCache.invalidateQueries({ key: QUERY_KEYS_LIBRARIES.root })
},
})
})
export const useUpdateLibrary = defineMutation(() => {
const queryCache = useQueryCache()
return useMutation({
mutation: (library: components['schemas']['LibraryDto']) =>
komgaClient.PATCH('/api/v1/libraries/{libraryId}', {
params: {
path: {
libraryId: library.id,
},
},
body: library,
}),
onSuccess: () => {
void queryCache.invalidateQueries({ key: QUERY_KEYS_LIBRARIES.root })
},
})
})

View file

@ -54,6 +54,13 @@ 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']
LayoutAppDrawerReorderLibraries: typeof import('./components/layout/app/drawer/ReorderLibraries.vue')['default']
LibraryFormCreateEdit: typeof import('./components/library/form/CreateEdit.vue')['default']
LibraryFormGeneral: typeof import('./components/library/form/General.vue')['default']
LibraryFormStepGeneral: typeof import('./components/library/form/StepGeneral.vue')['default']
LibraryFormStepMetadata: typeof import('./components/library/form/StepMetadata.vue')['default']
LibraryFormStepOptions: typeof import('./components/library/form/StepOptions.vue')['default']
LibraryFormStepScanner: typeof import('./components/library/form/StepScanner.vue')['default']
LibraryMenuLibraries: typeof import('./components/library/MenuLibraries.vue')['default']
LocaleSelector: typeof import('./components/LocaleSelector.vue')['default']
MenuLibraries: typeof import('./components/menu/MenuLibraries.vue')['default']
PageHashKnownTable: typeof import('./components/pageHash/KnownTable.vue')['default']

View file

@ -64,7 +64,14 @@
<v-btn
:loading="loading"
:disabled="!formValid"
:text="okText"
:text="
okText ||
$formatMessage({
description: 'Confirmation dialog: Confirm button',
defaultMessage: 'Confirm',
id: '33t+CB',
})
"
type="submit"
variant="elevated"
rounded="xs"
@ -111,7 +118,7 @@ export type DialogConfirmProps = {
const {
title = undefined,
subtitle = undefined,
okText = 'Confirm',
okText = undefined,
validateText = 'confirm',
maxWidth = undefined,
activator = undefined,

View file

@ -26,7 +26,7 @@
:subtitle="subtitle"
:loading="loading"
>
<template #text>
<v-card-text :class="cardTextClass">
<slot
name="text"
:proxy-model="proxyModel"
@ -34,7 +34,7 @@
:save="save"
:is-pristine="isPristine"
/>
</template>
</v-card-text>
<template #actions>
<v-spacer />
@ -50,6 +50,7 @@
/>
<v-btn
:text="
okText ||
$formatMessage({
description: 'ConfirmEdit dialog: Save button',
defaultMessage: 'Save',
@ -86,6 +87,8 @@ export type DialogConfirmEditProps = {
*/
title?: string
subtitle?: string
okText?: string
cardTextClass?: string
maxWidth?: string | number
activator?: Element | string
loading?: boolean
@ -97,6 +100,8 @@ export type DialogConfirmEditProps = {
const {
title = undefined,
subtitle = undefined,
okText = undefined,
cardTextClass = undefined,
maxWidth = undefined,
activator = undefined,
loading = false,

View file

@ -7,15 +7,19 @@ import { expect, waitFor } from 'storybook/test'
import { CLIENT_SETTING_USER, type ClientSettingUserLibrary } from '@/types/ClientSettingsUser'
import type { components } from '@/generated/openapi/komga'
import { VList } from 'vuetify/components'
import DialogConfirmEditInstance from '@/components/dialog/ConfirmEditInstance.vue'
import SnackQueue from '@/components/SnackQueue.vue'
import { delay, http } from 'msw'
import { response401Unauthorized } from '@/mocks/api/handlers'
const meta = {
component: Libraries,
render: (args: object) => ({
components: { Libraries, VList },
components: { Libraries, VList, DialogConfirmEditInstance, SnackQueue },
setup() {
return { args }
},
template: '<v-list nav><Libraries /></v-list>',
template: '<v-list nav><Libraries /></v-list><DialogConfirmEditInstance/><SnackQueue/>',
}),
parameters: {
// More on how to position stories at: https://storybook.js.org/docs/configure/story-layout
@ -100,3 +104,23 @@ export const Ordered: Story = {
},
},
}
export const Loading: Story = {
parameters: {
msw: {
handlers: [http.all('*/api/*', async () => await delay(5_000))],
},
},
}
export const CreationError: Story = {
parameters: {
msw: {
handlers: [
httpTyped.post('/api/v1/libraries', ({ response }) =>
response.untyped(response401Unauthorized()),
),
],
},
},
}

View file

@ -20,6 +20,8 @@
id: '90yqRq',
})
"
@mouseenter="dialogConfirmEdit.activator = $event.currentTarget"
@click="createLibrary"
/>
<v-icon-btn
id="menu-libraries-drawer"
@ -32,7 +34,7 @@
})
"
/>
<MenuLibraries activator-id="#menu-libraries-drawer" />
<LibraryMenuLibraries activator-id="#menu-libraries-drawer" />
</template>
</v-list-item>
@ -99,12 +101,83 @@
</template>
<script setup lang="ts">
import { useLibraries } from '@/colada/libraries'
import { useCreateLibrary, useLibraries } from '@/colada/libraries'
import { useCurrentUser } from '@/colada/users'
import { storeToRefs } from 'pinia'
import { useDialogsStore } from '@/stores/dialogs'
import { useIntl } from 'vue-intl'
import { useDisplay } from 'vuetify'
import CreateEdit from '@/components/library/form/CreateEdit.vue'
import { getLibraryDefaults } from '@/modules/libraries'
import type { components } from '@/generated/openapi/komga'
import { useMessagesStore } from '@/stores/messages'
import type { ErrorCause } from '@/api/komga-client'
import { commonMessages } from '@/utils/i18n/common-messages'
const intl = useIntl()
const display = useDisplay()
const { unpinned, pinned, refresh } = useLibraries()
const { isAdmin } = useCurrentUser()
// ensure freshness, especially if libraries have been reordered
void refresh()
const { confirmEdit: dialogConfirmEdit } = storeToRefs(useDialogsStore())
const { mutateAsync: mutateCreateLibrary } = useCreateLibrary()
const messagesStore = useMessagesStore()
function createLibrary() {
dialogConfirmEdit.value.dialogProps = {
title: intl.formatMessage({
description: 'Create library dialog title',
defaultMessage: 'Create library',
id: 'nuoJ1n',
}),
maxWidth: 600,
okText: 'Create',
cardTextClass: 'px-0',
closeOnSave: false,
scrollable: true,
fullscreen: display.xs.value,
}
dialogConfirmEdit.value.slot = {
component: markRaw(CreateEdit),
props: { createMode: true },
}
dialogConfirmEdit.value.record = getLibraryDefaults()
dialogConfirmEdit.value.callback = handleDialogConfirmation
}
function handleDialogConfirmation(
hideDialog: () => void,
setLoading: (isLoading: boolean) => void,
) {
setLoading(true)
const newLib = dialogConfirmEdit.value.record as components['schemas']['LibraryCreationDto']
mutateCreateLibrary(newLib)
.then(() => {
hideDialog()
messagesStore.messages.push({
text: intl.formatMessage(
{
description: 'Snackbar notification shown upon successful library creation',
defaultMessage: 'Library created: {library}',
id: '+8++PW',
},
{
library: newLib.name,
},
),
})
})
.catch((error) => {
messagesStore.messages.push({
text:
(error?.cause as ErrorCause)?.message || intl.formatMessage(commonMessages.networkError),
})
setLoading(false)
})
}
</script>

View file

@ -0,0 +1,67 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import CreateEdit from './CreateEdit.vue'
import { ScanInterval } from '@/types/ScanInterval'
import { SeriesCover } from '@/types/SeriesCover'
import { getLibraryDefaults } from '@/modules/libraries'
const meta = {
component: CreateEdit,
render: (args: object) => ({
components: { CreateEdit },
setup() {
return { args }
},
template: '<CreateEdit :model-value="args.modelValue" v-bind="args"/>',
}),
parameters: {
// More on how to position stories at: https://storybook.js.org/docs/configure/story-layout
},
args: {},
} satisfies Meta<typeof CreateEdit>
export default meta
type Story = StoryObj<typeof meta>
export const Create: Story = {
args: {
createMode: true,
modelValue: getLibraryDefaults(),
},
}
export const Edit: Story = {
args: {
createMode: false,
modelValue: {
analyzeDimensions: false,
convertToCbz: false,
emptyTrashAfterScan: false,
hashFiles: false,
hashKoreader: false,
hashPages: false,
importBarcodeIsbn: false,
importComicInfoBook: false,
importComicInfoCollection: false,
importComicInfoReadList: false,
importComicInfoSeries: false,
importComicInfoSeriesAppendVolume: false,
importEpubBook: false,
importEpubSeries: false,
importLocalArtwork: false,
importMylarSeries: false,
name: 'Existing',
oneshotsDirectory: '_oneshots',
repairExtensions: false,
root: '/comics',
scanCbx: true,
scanDirectoryExclusions: [],
scanEpub: true,
scanForceModifiedTime: false,
scanInterval: ScanInterval.DAILY,
scanOnStartup: false,
scanPdf: true,
seriesCover: SeriesCover.FIRST,
},
},
}

View file

@ -0,0 +1,127 @@
<template>
<v-stepper-vertical
:hide-actions="editMode"
eager
flat
>
<template #default="{ step }">
<v-stepper-vertical-item
:title="
$formatMessage({
description: 'Form add/edit library: General',
defaultMessage: 'General',
id: 'h6C8/l',
})
"
value="1"
:complete="createMode && step > 1"
:editable="editMode"
>
<LibraryFormStepGeneral v-model="model" />
<template #next="{ next }">
<v-btn
color="primary"
@click="next"
></v-btn>
</template>
<template #prev></template>
</v-stepper-vertical-item>
<v-stepper-vertical-item
:title="
$formatMessage({
description: 'Form add/edit library: Scanner',
defaultMessage: 'Scanner',
id: 'yaa8so',
})
"
value="2"
:complete="createMode && step > 2"
:editable="editMode"
>
<LibraryFormStepScanner v-model="model" />
<template #next="{ next }">
<v-btn
color="primary"
@click="next"
></v-btn>
</template>
<template #prev="{ prev }">
<v-btn
variant="plain"
@click="prev"
></v-btn>
</template>
</v-stepper-vertical-item>
<v-stepper-vertical-item
:title="
$formatMessage({
description: 'Form add/edit library: Options',
defaultMessage: 'Options',
id: 'uGC9fD',
})
"
value="3"
:complete="createMode && step > 3"
:editable="editMode"
>
<LibraryFormStepOptions v-model="model" />
<template #next="{ next }">
<v-btn
color="primary"
@click="next"
></v-btn>
</template>
<template #prev="{ prev }">
<v-btn
variant="plain"
@click="prev"
></v-btn>
</template>
</v-stepper-vertical-item>
<v-stepper-vertical-item
:title="
$formatMessage({
description: 'Form add/edit library: Metadata',
defaultMessage: 'Metadata',
id: '0iT7Vf',
})
"
value="4"
:complete="createMode && step > 4"
:editable="editMode"
>
<LibraryFormStepMetadata v-model="model" />
<template #next=""></template>
<template #prev="{ prev }">
<v-btn
variant="plain"
@click="prev"
></v-btn>
</template>
</v-stepper-vertical-item>
</template>
</v-stepper-vertical>
</template>
<script setup lang="ts">
import type { components } from '@/generated/openapi/komga'
const { createMode } = defineProps<{
createMode: boolean
}>()
const editMode = computed(() => !createMode)
const model = defineModel<components['schemas']['LibraryCreationDto']>({ required: true })
</script>

View file

@ -0,0 +1,30 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import StepGeneral from './StepGeneral.vue'
const meta = {
component: StepGeneral,
render: (args: object) => ({
components: { StepGeneral },
setup() {
return { args }
},
template: '<StepGeneral :model-value="args.modelValue" v-bind="args"/>',
}),
parameters: {
// More on how to position stories at: https://storybook.js.org/docs/configure/story-layout
},
args: {},
} satisfies Meta<typeof StepGeneral>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
modelValue: {
name: '',
root: '',
},
},
}

View file

@ -0,0 +1,82 @@
<template>
<v-container class="px-0">
<v-row>
<v-col>
<v-text-field
v-model="model.name"
:rules="['required']"
:label="
$formatMessage({
description: 'Form add/edit library: General - library name',
defaultMessage: 'Library name',
id: 's1nzhU',
})
"
hide-details="auto"
/>
</v-col>
</v-row>
<v-row align="center">
<v-col>
<v-text-field
v-model="model.root"
:rules="['required']"
:label="
$formatMessage({
description: 'Form add/edit library: General - root directory',
defaultMessage: 'Root directory',
id: 'afXGQS',
})
"
hide-details="auto"
/>
</v-col>
<v-col cols="auto">
<v-btn
id="ID01KC5HANV8QMDAW8GW4HFZCY0B"
:text="
$formatMessage({
description: 'Form add/edit library: General - root folder browse button',
defaultMessage: 'Browse',
id: 'E1kQun',
})
"
/>
</v-col>
</v-row>
</v-container>
<DialogConfirmEdit
v-model:record="model.root"
:title="
$formatMessage({
description: 'Form add/edit library: General - root directory selection dialog title',
defaultMessage: 'Library root directory',
id: 'CJaS7j',
})
"
max-width="600"
close-on-save
scrollable
:fullscreen="display.xs.value"
activator="#ID01KC5HANV8QMDAW8GW4HFZCY0B"
@update:record="(val) => (model.root = val as string)"
>
<template #text="{ proxyModel }">
<RemoteFileList v-model="proxyModel.value as string" />
</template>
</DialogConfirmEdit>
</template>
<script setup lang="ts">
import type { components } from '@/generated/openapi/komga'
import RemoteFileList from '@/components/RemoteFileList.vue'
import { useDisplay } from 'vuetify'
const display = useDisplay()
type LibraryCreationGeneral = Pick<components['schemas']['LibraryCreationDto'], 'name' | 'root'>
const model = defineModel<LibraryCreationGeneral>({ required: true })
</script>

View file

@ -0,0 +1,38 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import StepMetadata from './StepMetadata.vue'
const meta = {
component: StepMetadata,
render: (args: object) => ({
components: { StepMetadata },
setup() {
return { args }
},
template: '<StepMetadata :model-value="args.modelValue" v-bind="args"/>',
}),
parameters: {
// More on how to position stories at: https://storybook.js.org/docs/configure/story-layout
},
args: {},
} satisfies Meta<typeof StepMetadata>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
modelValue: {
importBarcodeIsbn: false,
importComicInfoBook: false,
importComicInfoCollection: false,
importComicInfoReadList: false,
importComicInfoSeries: false,
importComicInfoSeriesAppendVolume: false,
importEpubBook: false,
importEpubSeries: false,
importLocalArtwork: false,
importMylarSeries: false,
},
},
}

View file

@ -0,0 +1,231 @@
<template>
<v-container class="px-0">
<v-row>
<v-col cols="auto">
<div class="text-subtitle-2 d-flex ga-2 align-center">
<div>
{{
$formatMessage({
description: 'Form add/edit library: Metadata - section header for comicinfo',
defaultMessage: 'ComicInfo.xml (CBR/CBZ)',
id: 'fVqdik',
})
}}
</div>
</div>
<v-checkbox
v-model="model.importComicInfoBook"
:label="
$formatMessage({
description: 'Form add/edit library: Metadata - comicinfo book metadata',
defaultMessage: 'Book metadata',
id: 'YHQNM8',
})
"
hide-details
/>
<v-checkbox
v-model="model.importComicInfoSeries"
:label="
$formatMessage({
description: 'Form add/edit library: Metadata - comicinfo series metadata',
defaultMessage: 'Series metadata',
id: '6zF2Um',
})
"
hide-details
/>
<v-checkbox
v-model="model.importComicInfoSeriesAppendVolume"
:label="
$formatMessage({
description:
'Form add/edit library: Metadata - comicinfo append volume to series title',
defaultMessage: 'Append volume to series title',
id: 'AjBiEw',
})
"
hide-details
/>
<v-checkbox
v-model="model.importComicInfoCollection"
:label="
$formatMessage({
description: 'Form add/edit library: Metadata - comcinfo collections',
defaultMessage: 'Collections',
id: 'Ahipg5',
})
"
hide-details
/>
<v-checkbox
v-model="model.importComicInfoReadList"
:label="
$formatMessage({
description: 'Form add/edit library: Metadata - comcinfo read lists',
defaultMessage: 'Read lists',
id: 'AV3Iae',
})
"
hide-details
/>
</v-col>
</v-row>
<v-divider class="mb-4" />
<v-row>
<v-col>
<div class="text-subtitle-2">
{{
$formatMessage({
description: 'Form add/edit library: Metadata - section header for epub',
defaultMessage: 'EPUB',
id: '4PDVX2',
})
}}
</div>
<v-checkbox
v-model="model.importEpubBook"
:label="
$formatMessage({
description: 'Form add/edit library: Metadata - epub book metadata',
defaultMessage: 'Book metadata',
id: '7bZ3Eg',
})
"
hide-details
/>
<v-checkbox
v-model="model.importEpubSeries"
:label="
$formatMessage({
description: 'Form add/edit library: Metadata - epub series metadata',
defaultMessage: 'Series metadata',
id: '8PXwjO',
})
"
hide-details
/>
</v-col>
</v-row>
<v-divider class="mb-4" />
<v-row>
<v-col>
<div class="text-subtitle-2">
{{
$formatMessage({
description: 'Form add/edit library: Metadata - section header for mylar series.json',
defaultMessage: 'series.json (Mylar)',
id: 'xofdkF',
})
}}
</div>
<v-checkbox
v-model="model.importMylarSeries"
:label="
$formatMessage({
description: 'Form add/edit library: Metadata - mylar series metadata',
defaultMessage: 'Series metadata',
id: 'JvSGEY',
})
"
hide-details
/>
</v-col>
</v-row>
<v-divider class="mb-4" />
<v-row>
<v-col>
<div class="text-subtitle-2">
{{
$formatMessage({
description:
'Form add/edit library: Metadata - section header for local media assets',
defaultMessage: 'Local media assets',
id: 'COn5A6',
})
}}
</div>
<v-checkbox
v-model="model.importLocalArtwork"
:label="
$formatMessage({
description: 'Form add/edit library: Metadata - local artwork',
defaultMessage: 'Local artwork',
id: 'iBj0mW',
})
"
hide-details
/>
</v-col>
</v-row>
<v-divider class="mb-4" />
<v-row>
<v-col>
<div class="text-subtitle-2 d-flex ga-2 align-center">
{{
$formatMessage({
description: 'Form add/edit library: Metadata - section header for ISBN barcode',
defaultMessage: 'ISBN within barcode',
id: 'eHS96Q',
})
}}
<v-icon
v-tooltip:bottom="$formatMessage(commonMessages.resourceIntensive)"
size="small"
color="warning"
icon="i-mdi:alert-circle-outline"
/>
</div>
<v-checkbox
v-model="model.importBarcodeIsbn"
:label="
$formatMessage({
description: 'Form add/edit library: Metadata - isbn barcode',
defaultMessage: 'ISBN',
id: 'cKUU8S',
})
"
hide-details
/>
</v-col>
</v-row>
</v-container>
</template>
<script setup lang="ts">
import { commonMessages } from '@/utils/i18n/common-messages'
import type { components } from '@/generated/openapi/komga'
type LibraryCreationMetadata = Pick<
components['schemas']['LibraryCreationDto'],
| 'importComicInfoBook'
| 'importComicInfoSeries'
| 'importComicInfoSeriesAppendVolume'
| 'importComicInfoCollection'
| 'importComicInfoReadList'
| 'importEpubBook'
| 'importEpubSeries'
| 'importMylarSeries'
| 'importLocalArtwork'
| 'importBarcodeIsbn'
>
const model = defineModel<LibraryCreationMetadata>({ required: true })
</script>

View file

@ -0,0 +1,37 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import StepOptions from './StepOptions.vue'
import { SeriesCover } from '@/types/SeriesCover'
import { fn } from 'storybook/test'
const meta = {
component: StepOptions,
render: (args: object) => ({
components: { StepOptions },
setup() {
return { args }
},
template: '<StepOptions :model-value="args.modelValue" v-bind="args"/>',
}),
parameters: {
// More on how to position stories at: https://storybook.js.org/docs/configure/story-layout
},
args: {},
} satisfies Meta<typeof StepOptions>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
modelValue: {
analyzeDimensions: false,
convertToCbz: false,
hashFiles: false,
hashKoreader: false,
hashPages: false,
repairExtensions: false,
seriesCover: SeriesCover.FIRST,
},
},
}

View file

@ -0,0 +1,217 @@
<template>
<v-container class="px-0">
<v-row>
<v-col cols="auto">
<div class="text-subtitle-2 d-flex ga-2 align-center">
<div>
{{
$formatMessage({
description: 'Form add/edit library: Options - section header for analysis',
defaultMessage: 'Analysis',
id: 'O/3awV',
})
}}
</div>
<v-icon
v-tooltip:bottom="$formatMessage(commonMessages.resourceIntensive)"
size="small"
color="warning"
icon="i-mdi:alert-circle-outline"
/>
</div>
<v-checkbox
v-model="model.hashFiles"
:label="
$formatMessage({
description: 'Form add/edit library: Options - hash files',
defaultMessage: 'Compute hash for files',
id: 'wJaip6',
})
"
hide-details
>
<template #append>
<v-icon
v-tooltip:bottom="
$formatMessage({
description: 'Form add/edit library: Options - hash files - information tooltip',
defaultMessage: 'Required to restore from trash and detect duplicate files',
id: '/8sSxS',
})
"
icon="i-mdi:information-outline"
></v-icon>
</template>
</v-checkbox>
<v-checkbox
v-model="model.hashPages"
:label="
$formatMessage({
description: 'Form add/edit library: Options - hash pages',
defaultMessage: 'Compute hash for pages',
id: '0qntYX',
})
"
hide-details
>
<template #append>
<v-icon
v-tooltip:bottom="
$formatMessage({
description: 'Form add/edit library: Options - hash pages - information tooltip',
defaultMessage: 'Required for detecting duplicate pages',
id: 'Pj29A+',
})
"
icon="i-mdi:information-outline"
></v-icon>
</template>
</v-checkbox>
<v-checkbox
v-model="model.hashKoreader"
:label="
$formatMessage({
description: 'Form add/edit library: Options - koreader hash',
defaultMessage: 'Compute hash for KOReader',
id: 'nXFVsQ',
})
"
hide-details
>
<template #append>
<v-icon
v-tooltip:bottom="
$formatMessage({
description:
'Form add/edit library: Options - koreader hash - information tooltip',
defaultMessage: 'Enable this if you use KOReader Sync',
id: 'DNmepU',
})
"
icon="i-mdi:information-outline"
></v-icon>
</template>
</v-checkbox>
<v-checkbox
v-model="model.analyzeDimensions"
:label="
$formatMessage({
description: 'Form add/edit library: Options - analyze page dimensions',
defaultMessage: 'Analyze pages dimensions',
id: 'STdfYg',
})
"
hide-details
>
<template #append>
<v-icon
v-tooltip:bottom="
$formatMessage({
description:
'Form add/edit library: Options - analyze page dimensions - information tooltip',
defaultMessage: 'Required for the WebReader to detect landscape pages',
id: 'ByRsV9',
})
"
icon="i-mdi:information-outline"
></v-icon>
</template>
</v-checkbox>
</v-col>
</v-row>
<v-divider class="mb-4" />
<v-row>
<v-col>
<div class="text-subtitle-2">
{{
$formatMessage({
description: 'Form add/edit library: Options - section header for file management',
defaultMessage: 'File management',
id: 'rks1H9',
})
}}
</div>
<v-checkbox
v-model="model.repairExtensions"
:label="
$formatMessage({
description: 'Form add/edit library: Options - repair extensions',
defaultMessage: 'Automatically repair incorrect file extensions',
id: 'RwuMl5',
})
"
hide-details
/>
<v-checkbox
v-model="model.convertToCbz"
:label="
$formatMessage({
description: 'Form add/edit library: Options - convert to cbz',
defaultMessage: 'Automatically convert CBR to CBZ',
id: 'b1hvh9',
})
"
hide-details
/>
</v-col>
</v-row>
<v-divider class="mb-4" />
<v-row>
<v-col>
<div class="text-subtitle-2">
{{
$formatMessage({
description: 'Form add/edit library: Options - section header for series cover',
defaultMessage: 'Series cover',
id: 'Bewgy6',
})
}}
</div>
</v-col>
</v-row>
<v-row>
<v-col>
<v-select
v-model="model.seriesCover"
:items="seriesCoverOptions"
/>
</v-col>
</v-row>
</v-container>
</template>
<script setup lang="ts">
import { useIntl } from 'vue-intl'
import { SeriesCover, seriesCoverMessages } from '@/types/SeriesCover'
import { commonMessages } from '@/utils/i18n/common-messages'
import type { components } from '@/generated/openapi/komga'
type LibraryCreationOptions = Pick<
components['schemas']['LibraryCreationDto'],
| 'hashFiles'
| 'hashPages'
| 'hashKoreader'
| 'analyzeDimensions'
| 'repairExtensions'
| 'convertToCbz'
| 'seriesCover'
>
const model = defineModel<LibraryCreationOptions>({ required: true })
const intl = useIntl()
const seriesCoverOptions = Object.values(SeriesCover).map((x) => ({
title: intl.formatMessage(seriesCoverMessages[x]),
value: x,
}))
</script>

View file

@ -0,0 +1,38 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import StepScanner from './StepScanner.vue'
import { ScanInterval } from '@/types/ScanInterval'
const meta = {
component: StepScanner,
render: (args: object) => ({
components: { StepScanner },
setup() {
return { args }
},
template: '<StepScanner :model-value="args.modelValue" v-bind="args"/>',
}),
parameters: {
// More on how to position stories at: https://storybook.js.org/docs/configure/story-layout
},
args: {},
} satisfies Meta<typeof StepScanner>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
modelValue: {
emptyTrashAfterScan: false,
oneshotsDirectory: '_oneshots',
scanCbx: true,
scanDirectoryExclusions: ['#recycle', '@eaDir', '@Recycle'],
scanEpub: true,
scanForceModifiedTime: false,
scanInterval: ScanInterval.DAILY,
scanOnStartup: false,
scanPdf: true,
},
},
}

View file

@ -0,0 +1,216 @@
<template>
<v-container class="px-0">
<v-row>
<v-col>
<v-select
v-model="model.scanInterval"
:items="scanIntervalOptions"
:label="
$formatMessage({
description: 'Form add/edit library: Scanner - scan interval',
defaultMessage: 'Scan interval',
id: 'DwDa04',
})
"
hide-details
/>
</v-col>
</v-row>
<v-row>
<v-col>
<v-text-field
v-model="model.oneshotsDirectory"
:label="
$formatMessage({
description: 'Form add/edit library: Scanner - one-shots directory',
defaultMessage: 'One-shots directory',
id: '6OXY1N',
})
"
:hint="
$formatMessage({
description: 'Form add/edit library: Scanner - one-shots directory - hint',
defaultMessage: 'Leave empty to disable',
id: 't5ZhnZ',
})
"
persistent-hint
clearable
/>
</v-col>
</v-row>
<v-row>
<v-col>
<v-combobox
v-model="model.scanDirectoryExclusions"
multiple
clearable
chips
closable-chips
hide-details
:label="
$formatMessage({
description: 'Form add/edit library: Scanner - directory exclusions',
defaultMessage: 'Directory exclusions',
id: 'bN2VbA',
})
"
/>
</v-col>
</v-row>
<v-row>
<v-col>
<div class="text-subtitle-1">
{{
$formatMessage({
description: 'Form add/edit library: Scanner - file types selection header',
defaultMessage: 'Scan for these file types',
id: 'K+fQO2',
})
}}
</div>
<v-chip-group
:model-value="scanTypes"
multiple
column
@update:model-value="updateScanTypes"
>
<v-chip
v-for="type in fileTypesOptions"
:key="type.value"
v-bind="type"
filter
rounded
variant="outlined"
/>
</v-chip-group>
</v-col>
</v-row>
<v-row>
<v-col cols="auto">
<v-checkbox
v-model="model.emptyTrashAfterScan"
:label="
$formatMessage({
description: 'Form add/edit library: Scanner - empty trash automatically',
defaultMessage: 'Empty trash automatically after every scan',
id: 'GySX8C',
})
"
hide-details
/>
<v-checkbox
v-model="model.scanForceModifiedTime"
:label="
$formatMessage({
description: 'Form add/edit library: Scanner - Force directory modified time',
defaultMessage: 'Force directory modified time',
id: 'Xbf2fj',
})
"
hide-details
>
<template #append>
<v-icon
v-tooltip:bottom="
$formatMessage({
description:
'Form add/edit library: Scanner - Force directory modified time - information tooltip',
defaultMessage: 'You should enable this if the library is hosted on Google Drive',
id: 'GyRV+/',
})
"
icon="i-mdi:information-outline"
></v-icon>
</template>
</v-checkbox>
<v-checkbox
v-model="model.scanOnStartup"
:label="
$formatMessage({
description: 'Form add/edit library: Scanner - scan on startup',
defaultMessage: 'Scan on application startup',
id: 'TUxJCd',
})
"
hide-details
/>
</v-col>
</v-row>
</v-container>
</template>
<script setup lang="ts">
import { ScanInterval, scanIntervalMessages } from '@/types/ScanInterval'
import { useIntl } from 'vue-intl'
import type { components } from '@/generated/openapi/komga'
type LibraryCreationScanner = Pick<
components['schemas']['LibraryCreationDto'],
| 'scanInterval'
| 'oneshotsDirectory'
| 'scanDirectoryExclusions'
| 'scanCbx'
| 'scanEpub'
| 'scanPdf'
| 'emptyTrashAfterScan'
| 'scanForceModifiedTime'
| 'scanOnStartup'
>
const model = defineModel<LibraryCreationScanner>({ required: true })
const intl = useIntl()
const scanIntervalOptions = Object.values(ScanInterval).map((x) => ({
title: intl.formatMessage(scanIntervalMessages[x]),
value: x,
}))
const scanTypes = computed(() => {
const r = []
if (model.value.scanCbx) r.push('cbx')
if (model.value.scanPdf) r.push('pdf')
if (model.value.scanEpub) r.push('epub')
return r
})
function updateScanTypes(val: string[]) {
model.value.scanCbx = val.includes('cbx')
model.value.scanPdf = val.includes('pdf')
model.value.scanEpub = val.includes('epub')
}
const fileTypesOptions = [
{
text: intl.formatMessage({
description: 'Form add/edit library: Scanner - file types: comic book archives',
defaultMessage: 'Comic Book archives',
id: 'iu31A4',
}),
value: 'cbx',
},
{
text: intl.formatMessage({
description: 'Form add/edit library: Scanner - file types: pdf',
defaultMessage: 'PDF',
id: 'EOrAHc',
}),
value: 'pdf',
},
{
text: intl.formatMessage({
description: 'Form add/edit library: Scanner - file types: epub',
defaultMessage: 'Epub',
id: '5WMvLb',
}),
value: 'epub',
},
]
</script>

View file

@ -1,5 +1,6 @@
import { httpTyped } from '@/mocks/api/httpTyped'
import type { components } from '@/generated/openapi/komga'
import { response400BadRequest, response404NotFound } from '@/mocks/api/handlers'
export const libraries = [
{
@ -69,4 +70,30 @@ export const libraries = [
export const librariesHandlers = [
httpTyped.get('/api/v1/libraries', ({ response }) => response(200).json(libraries)),
httpTyped.post('/api/v1/libraries', async ({ request, response }) => {
const body = await request.json()
if (libraries.some((it) => it.id === body.name)) {
return response.untyped(response400BadRequest())
}
const lib = Object.assign({}, body, { unavailable: false, id: body.name })
libraries.push(lib)
return response(200).json(lib)
}),
httpTyped.patch('/api/v1/libraries/{libraryId}', async ({ request, params, response }) => {
const body = await request.json()
const libraryId = params['libraryId']
const existing = libraries.find((it) => it.id === libraryId)
if (!existing) {
return response.untyped(response404NotFound())
}
libraries[libraries.indexOf(existing)] = Object.assign({}, existing, body)
return response(204).empty()
}),
]

View file

@ -0,0 +1,36 @@
import { ScanInterval } from '@/types/ScanInterval'
import { SeriesCover } from '@/types/SeriesCover'
import type { components } from '@/generated/openapi/komga'
export function getLibraryDefaults(): components['schemas']['LibraryCreationDto'] {
return {
analyzeDimensions: true,
convertToCbz: false,
emptyTrashAfterScan: false,
hashFiles: true,
hashKoreader: false,
hashPages: false,
importBarcodeIsbn: false,
importComicInfoBook: true,
importComicInfoCollection: true,
importComicInfoReadList: true,
importComicInfoSeries: true,
importComicInfoSeriesAppendVolume: true,
importEpubBook: true,
importEpubSeries: true,
importLocalArtwork: true,
importMylarSeries: true,
name: '',
oneshotsDirectory: '_oneshots',
repairExtensions: false,
root: '',
scanCbx: true,
scanDirectoryExclusions: ['#recycle', '@eaDir', '@Recycle'],
scanEpub: true,
scanForceModifiedTime: false,
scanInterval: ScanInterval.EVERY_6H,
scanOnStartup: false,
scanPdf: true,
seriesCover: SeriesCover.FIRST,
}
}

View file

@ -14,7 +14,8 @@ import { md3 } from 'vuetify/blueprints'
// Labs
import { VFileUpload } from 'vuetify/labs/VFileUpload'
import { VIconBtn } from 'vuetify/labs/components'
import { VIconBtn } from 'vuetify/labs/VIconBtn'
import { VStepperVertical, VStepperVerticalItem } from 'vuetify/labs/VStepperVertical'
import { createRulesPlugin } from 'vuetify/labs/rules'
import { availableLocales, currentLocale, fallbackLocale } from '@/utils/i18n/locale-helper'
@ -72,6 +73,8 @@ export const vuetify = createVuetify({
components: {
VFileUpload,
VIconBtn,
VStepperVertical,
VStepperVerticalItem,
},
})

View file

@ -0,0 +1,43 @@
import { defineMessages } from 'vue-intl'
export enum ScanInterval {
DISABLED = 'DISABLED',
HOURLY = 'HOURLY',
EVERY_6H = 'EVERY_6H',
EVERY_12H = 'EVERY_12H',
DAILY = 'DAILY',
WEEKLY = 'WEEKLY',
}
export const scanIntervalMessages = defineMessages({
[ScanInterval.DISABLED]: {
description: 'Scan interval: DISABLED',
defaultMessage: 'Disabled',
id: '8M9T3g',
},
[ScanInterval.HOURLY]: {
description: 'Scan interval: HOURLY',
defaultMessage: 'Hourly',
id: 'rBFh/c',
},
[ScanInterval.EVERY_6H]: {
description: 'Scan interval: EVERY_6H',
defaultMessage: 'Every 6 hours',
id: '4d2F5w',
},
[ScanInterval.EVERY_12H]: {
description: 'Scan interval: EVERY_12H',
defaultMessage: 'Every 12 hours',
id: '5yu0g9',
},
[ScanInterval.DAILY]: {
description: 'Scan interval: DAILY',
defaultMessage: 'Daily',
id: 'qLk+cl',
},
[ScanInterval.WEEKLY]: {
description: 'Scan interval: WEEKLY',
defaultMessage: 'Weekly',
id: 'T6pXCK',
},
})

View file

@ -0,0 +1,31 @@
import { defineMessages } from 'vue-intl'
export enum SeriesCover {
FIRST = 'FIRST',
FIRST_UNREAD_OR_FIRST = 'FIRST_UNREAD_OR_FIRST',
FIRST_UNREAD_OR_LAST = 'FIRST_UNREAD_OR_LAST',
LAST = 'LAST',
}
export const seriesCoverMessages = defineMessages({
[SeriesCover.FIRST]: {
description: 'Series cover: FIRST',
defaultMessage: 'First',
id: 'j7cvLm',
},
[SeriesCover.FIRST_UNREAD_OR_FIRST]: {
description: 'Series cover: FIRST_UNREAD_OR_FIRST',
defaultMessage: 'First unread, else first',
id: 'woVEgl',
},
[SeriesCover.FIRST_UNREAD_OR_LAST]: {
description: 'Series cover: FIRST_UNREAD_OR_LAST',
defaultMessage: 'First unread, else last',
id: 'kLu/vI',
},
[SeriesCover.LAST]: {
description: 'Series cover: LAST',
defaultMessage: 'Last',
id: 'pkqPAO',
},
})

View file

@ -31,4 +31,9 @@ export const commonMessages = {
defaultMessage: 'Change Password',
id: 'dHyAgE',
}),
resourceIntensive: defineMessage({
description: 'Resource intensive analysis warning',
defaultMessage: 'Can consume lots of resources on large libraries or slow hardware',
id: 'uoc99F',
}),
}