feat(webui): import books

Books can be imported directly into an existing Series
This commit is contained in:
Gauthier Roebroeck 2021-04-19 17:20:01 +08:00
parent d41dcefd3e
commit 13b304dd14
19 changed files with 1323 additions and 2 deletions

View File

@ -0,0 +1,262 @@
<template>
<tr v-if="book">
<td>
<slot></slot>
</td>
<td>{{ book.name }}</td>
<!-- Status icon -->
<td>
<template v-if="bookAnalyzed">
<v-tooltip bottom :disabled="!status.message">
<template v-slot:activator="{ on }">
<v-icon :color="status.color" v-on="on">{{ status.icon }}</v-icon>
</template>
{{ convertErrorCodes(status.message) }}
</v-tooltip>
</template>
<v-progress-circular
v-else
indeterminate
color="primary"
:size="20"
:width="2"
/>
</td>
<!-- Series picker -->
<td @click="modalSeriesPicker = true" style="cursor: pointer">
<template v-if="selectedSeries">{{ selectedSeries.metadata.title }}</template>
<template v-else>
<div style="height: 2em" class="missing"></div>
</template>
<series-picker-dialog v-model="modalSeriesPicker" :series.sync="selectedSeries"></series-picker-dialog>
</td>
<!-- Book number chooser -->
<td>
<v-text-field v-model.number="bookNumber"
type="number"
step="0.1"
dense
:disabled="!selectedSeries"
/>
</td>
<!-- Book details -->
<td class="px-1">
<v-btn icon elevation="1" :disabled="!bookAnalyzed" @click="modalBookDetails = true">
<v-icon v-if="bookToUpgrade">mdi-file-compare</v-icon>
<v-icon v-else>mdi-book-information-variant</v-icon>
</v-btn>
<transient-book-details-dialog
v-model="modalBookDetails"
:left-book="bookAnalyzed"
:right-book="bookToUpgrade"
:right-pages="bookToUpgradePages"
/>
</td>
<!-- Book viewer -->
<td class="px-1">
<v-btn icon
elevation="1"
@click="modalViewer = true"
:disabled="!bookAnalyzed || bookAnalyzed.status !== MediaStatus.READY"
>
<v-icon v-if="bookToUpgrade">mdi-compare</v-icon>
<v-icon v-else>mdi-image</v-icon>
</v-btn>
<transient-book-viewer-dialog
v-model="modalViewer"
:left-pages="leftPagesWithUrl"
:right-pages="rightPagesWithUrl"
/>
</td>
<!-- Destination name chooser -->
<td class="px-1">
<v-btn icon
elevation="1"
@click="modalNameChooser = true"
:disabled="!bookAnalyzed || bookAnalyzed.status !== MediaStatus.READY"
>
<v-icon>mdi-format-color-text</v-icon>
</v-btn>
<file-name-chooser-dialog
v-model="modalNameChooser"
:books="seriesBooks"
:name.sync="destinationName"
:existing="book.name"
/>
</td>
<td>
{{ destinationName }}
</td>
<td>
<template v-if="error">
<v-tooltip bottom>
<template v-slot:activator="{ on }">
<v-icon color="error" v-on="on">mdi-alert-circle</v-icon>
</template>
{{ error }}
</v-tooltip>
</template>
<template v-if="!error && bookToUpgrade">
<v-tooltip bottom>
<template v-slot:activator="{ on }">
<v-icon color="warning" v-on="on">mdi-comment-alert</v-icon>
</template>
{{ $t('book_import.row.warning_upgrade') }}
</v-tooltip>
</template>
</td>
</tr>
</template>
<script lang="ts">
import Vue, {PropType} from 'vue'
import {MediaStatus} from "@/types/enum-books";
import {SeriesDto} from "@/types/komga-series";
import {TransientBookDto} from "@/types/komga-transientbooks";
import {BookDto, BookImportDto, PageDto, PageDtoWithUrl} from "@/types/komga-books";
import SeriesPickerDialog from "@/components/dialogs/SeriesPickerDialog.vue";
import TransientBookDetailsDialog from "@/components/dialogs/TransientBookDetailsDialog.vue";
import TransientBookViewerDialog from "@/components/dialogs/TransientBookViewerDialog.vue";
import {bookPageUrl, transientBookPageUrl} from "@/functions/urls";
import {convertErrorCodes} from "@/functions/error-codes";
import FileNameChooserDialog from "@/components/dialogs/FileNameChooserDialog.vue";
export default Vue.extend({
name: 'FileImportRow',
components: {SeriesPickerDialog, TransientBookDetailsDialog, TransientBookViewerDialog, FileNameChooserDialog},
props: {
book: {
type: Object as PropType<TransientBookDto>,
required: true,
},
series: {
type: Object as PropType<SeriesDto>,
required: false,
},
payload: {
type: Object,
required: false,
},
},
watch: {
book: {
handler(val) {
this.analyze(val)
this.destinationName = val.name
},
immediate: true,
},
selectedSeries: {
handler(val) {
this.getSeriesBooks(val)
},
immediate: true,
},
series: {
handler(val) {
if (val) this.selectedSeries = this.$_.cloneDeep(val)
},
immediate: true,
},
bookNumber: {
handler(val) {
this.checkForUpgrade(val)
},
immediate: true,
},
importPayload: {
handler(val) {
this.$emit("update:payload", val)
},
immediate: true,
},
},
data: () => ({
MediaStatus,
convertErrorCodes,
innerSelect: false,
bookAnalyzed: undefined as unknown as TransientBookDto,
selectedSeries: undefined as SeriesDto | undefined,
seriesBooks: [] as BookDto[],
bookToUpgrade: undefined as BookDto | undefined,
bookToUpgradePages: [] as PageDto[],
modalSeriesPicker: false,
modalBookDetails: false,
modalViewer: false,
modalNameChooser: false,
bookNumber: undefined as number | undefined,
destinationName: '',
}),
computed: {
leftPagesWithUrl(): PageDtoWithUrl[] {
return this.bookAnalyzed ? this.bookAnalyzed.pages.map(p => ({
...p,
url: transientBookPageUrl(this.bookAnalyzed.id, p.number),
})) : []
},
rightPagesWithUrl(): PageDtoWithUrl[] {
return this.bookToUpgrade ? this.bookToUpgradePages.map(p => ({
...p,
url: bookPageUrl(this.bookToUpgrade!!.id, p.number),
})) : []
},
status(): object {
if (!this.bookAnalyzed) return {}
switch (this.bookAnalyzed.status) {
case MediaStatus.READY:
return {icon: 'mdi-check-circle', color: 'success', message: ''}
default:
return {icon: 'mdi-alert-circle', color: 'error', message: this.bookAnalyzed.comment}
}
},
existingFileNames(): string[] {
return this.seriesBooks.map(x => x.name)
},
error(): string {
if (!this.bookAnalyzed) return this.$t('book_import.row.error_analyze_first').toString()
if (this.bookAnalyzed.status != MediaStatus.READY) return this.$t('book_import.row.error_only_import_no_errors').toString()
if (!this.selectedSeries) return this.$t('book_import.row.error_choose_series').toString()
return ''
},
importPayload(): BookImportDto | undefined {
if (this.error || !this.selectedSeries) return undefined
return {
seriesId: this.selectedSeries?.id,
sourceFile: this.book.url,
upgradeBookId: this.bookToUpgrade?.id,
destinationName: this.destinationName,
}
},
},
methods: {
async analyze(book: TransientBookDto) {
this.bookAnalyzed = await this.$komgaTransientBooks.analyze(book.id)
},
async getSeriesBooks(series: SeriesDto) {
if (series) {
this.seriesBooks = (await this.$komgaSeries.getBooks(series.id, {unpaged: true})).content
this.checkForUpgrade(this.bookNumber)
}
},
async checkForUpgrade(number: number | undefined) {
this.bookToUpgrade = this.seriesBooks.find(b => b.metadata.numberSort === number)
if (this.bookToUpgrade) this.bookToUpgradePages = await this.$komgaBooks.getBookPages(this.bookToUpgrade.id)
},
},
})
</script>
<style scoped>
.missing {
border: 2px dashed red;
}
</style>

View File

@ -0,0 +1,69 @@
<template>
<v-simple-table v-if="leftPages.length > 0">
<thead>
<tr>
<th>{{ $t('dialog.transient_book_details.pages_table.index') }}</th>
<th>{{ $t('dialog.transient_book_details.pages_table.filename') }}</th>
<th>{{ $t('dialog.transient_book_details.pages_table.media_type') }}</th>
<th>{{ $t('dialog.transient_book_details.pages_table.width') }}</th>
<th>{{ $t('dialog.transient_book_details.pages_table.height') }}</th>
<template v-if="rightPages.length > 0">
<th>{{ $t('dialog.transient_book_details.pages_table.filename') }}</th>
<th>{{ $t('dialog.transient_book_details.pages_table.media_type') }}</th>
<th>{{ $t('dialog.transient_book_details.pages_table.width') }}</th>
<th>{{ $t('dialog.transient_book_details.pages_table.height') }}</th>
</template>
</tr>
</thead>
<tbody>
<tr
v-for="(n, i) in numberRows"
:key="i"
>
<td>{{ n }}</td>
<td>{{ $_.get(leftPages[n-1], 'fileName', '') }}</td>
<td>{{ $_.get(leftPages[n-1], 'mediaType', '') }}</td>
<td>{{ $_.get(leftPages[n-1], 'width', '') }}</td>
<td>{{ $_.get(leftPages[n-1], 'height', '') }}</td>
<template v-if="rightPages.length > 0">
<td>{{ $_.get(rightPages[n-1], 'fileName', '') }}</td>
<td>{{ $_.get(rightPages[n-1], 'mediaType', '') }}</td>
<td>{{ $_.get(rightPages[n-1], 'width', '') }}</td>
<td>{{ $_.get(rightPages[n-1], 'height', '') }}</td>
</template>
</tr>
</tbody>
</v-simple-table>
</template>
<script lang="ts">
import Vue, {PropType} from 'vue'
import {PageDto} from "@/types/komga-books";
export default Vue.extend({
name: 'PagesTable',
props: {
leftPages: {
type: Array as PropType<PageDto[]>,
default: [],
},
rightPages: {
type: Array as PropType<PageDto[]>,
default: [],
},
},
computed: {
numberRows(): number {
if(this.leftPages) {
if(this.rightPages) return Math.max(this.leftPages.length, this.rightPages.length)
return this.leftPages.length
}
return 0
},
},
})
</script>
<style scoped>
</style>

View File

@ -0,0 +1,26 @@
<template>
<v-icon v-if="$vuetify.rtl">{{ rtl }}</v-icon>
<v-icon v-else>{{ icon }}</v-icon>
</template>
<script lang="ts">
import Vue from 'vue'
export default Vue.extend({
name: 'RtlIcon',
props: {
icon: {
type: String,
required: true,
},
rtl: {
type: String,
required: true,
},
},
})
</script>
<style scoped>
</style>

View File

@ -0,0 +1,134 @@
<template>
<div>
<v-dialog v-model="modal"
max-width="600"
scrollable
>
<v-card>
<v-card-title>{{ $t('dialog.filename_chooser.title') }}</v-card-title>
<v-btn icon absolute top right @click="dialogClose">
<v-icon>mdi-close</v-icon>
</v-btn>
<v-divider/>
<v-card-text style="height: 50%">
<v-container fluid>
<v-row>
<v-col>
<div class="text-subtitle-1">{{ $t('dialog.filename_chooser.label_source_filename') }}</div>
<div style="cursor: pointer" @click="change(existing)">{{ existing }}</div>
</v-col>
</v-row>
<v-row align="center">
<v-col>
<v-text-field
v-model="nameInternal"
autofocus
:label="$t('dialog.filename_chooser.field_destination_filename')"
@keydown.enter="choose"
/>
</v-col>
<v-col cols="auto">
<v-btn @click="choose"
:disabled="!nameInternal"
>{{ $t('dialog.filename_chooser.button_choose') }}</v-btn>
</v-col>
</v-row>
<v-divider/>
<v-simple-table
v-if="books.length > 0"
fixed-header
:height="$vuetify.breakpoint.height / 2"
>
<thead>
<tr>
<th>{{ $t('dialog.filename_chooser.table.order') }}</th>
<th>{{ $t('dialog.filename_chooser.table.existing_file') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(b, index) in books"
:key="index"
>
<td>{{ b.number }}</td>
<td style="cursor: pointer" @click="change(b.name)">{{ b.name }}</td>
</tr>
</tbody>
</v-simple-table>
</v-container>
</v-card-text>
</v-card>
</v-dialog>
</div>
</template>
<script lang="ts">
import Vue from 'vue'
export default Vue.extend({
name: 'FileNameChooserDialog',
data: () => {
return {
modal: false,
nameInternal: '',
}
},
props: {
value: Boolean,
name: {
type: String,
required: true,
},
existing: {
type: String,
required: false,
},
books: {
type: Array,
required: true,
},
},
watch: {
value(val) {
this.modal = val
if (val) {
this.clear()
}
},
existing: {
handler(val) {
this.nameInternal = val
},
immediate: true,
},
},
methods: {
clear() {
this.nameInternal = this.name || this.existing || ''
},
change(val: string) {
this.nameInternal = val
},
choose(){
if(this.nameInternal) {
this.$emit('update:name', this.nameInternal)
this.dialogClose()
}
},
dialogClose() {
this.$emit('input', false)
},
},
})
</script>
<style scoped>
</style>

View File

@ -0,0 +1,127 @@
<template>
<div>
<v-dialog v-model="modal"
max-width="450"
scrollable
>
<v-card>
<v-card-title>{{ $t('dialog.series_picker.title') }}</v-card-title>
<v-btn icon absolute top right @click="dialogClose">
<v-icon>mdi-close</v-icon>
</v-btn>
<v-divider/>
<v-card-text style="height: 50%">
<v-container fluid>
<v-row align="center">
<v-col>
<v-text-field
v-model="search"
autofocus
:label="$t('dialog.series_picker.label_search_series')"
/>
</v-col>
</v-row>
<v-divider/>
<v-row v-if="results">
<v-col>
<v-list elevation="5" v-if="results.length > 0">
<div v-for="(s, index) in results"
:key="index"
>
<v-list-item @click="select(s)">
<v-list-item-content>
<v-list-item-title>{{ s.metadata.title }}</v-list-item-title>
</v-list-item-content>
</v-list-item>
<v-divider v-if="index !== results.length-1"/>
</div>
</v-list>
<v-alert
v-if="results.length === 0 && showResults"
type="info"
text
>No Series found
</v-alert>
</v-col>
</v-row>
</v-container>
</v-card-text>
</v-card>
</v-dialog>
</div>
</template>
<script lang="ts">
import Vue, {PropType} from 'vue'
import {SeriesDto} from "@/types/komga-series"
import {debounce} from 'lodash'
export default Vue.extend({
name: 'SeriesPickerDialog',
data: () => {
return {
modal: false,
results: [] as SeriesDto[],
search: '',
showResults: false,
}
},
props: {
value: Boolean,
series: {
type: Object as PropType<SeriesDto>,
required: false,
},
},
watch: {
value(val) {
this.modal = val
if (val) {
this.clear()
}
},
search(val) {
this.searchItems(val)
},
modal(val) {
!val && this.dialogClose()
},
},
methods: {
searchItems: debounce(async function (this: any, query: string) {
if (query) {
this.showResults = false
this.results = (await this.$komgaSeries.getSeries(undefined, {unpaged: true}, query)).content
this.showResults = true
} else {
this.clear()
}
}, 500),
clear() {
this.search = ''
this.showResults = false
this.results = []
},
select(s: SeriesDto) {
this.$emit("update:series", s)
this.dialogClose()
},
dialogClose() {
this.$emit('input', false)
},
},
})
</script>
<style scoped>
</style>

View File

@ -0,0 +1,117 @@
<template>
<div v-if="leftBook">
<v-dialog v-model="modal"
scrollable
>
<v-card>
<v-card-title v-if="!rightBook">{{ $t('dialog.transient_book_details.title') }}</v-card-title>
<v-card-title v-else>{{ $t('dialog.transient_book_details.title_comparison') }}</v-card-title>
<v-btn icon absolute top right @click="dialogClose">
<v-icon>mdi-close</v-icon>
</v-btn>
<v-divider/>
<v-card-text style="height: 50%">
<v-container fluid>
<v-simple-table class="body-2">
<thead v-if="rightBook">
<tr>
<th></th>
<th>{{ $t('dialog.transient_book_details.label_candidate') }}</th>
<th>{{ $t('dialog.transient_book_details.label_existing') }}</th>
</tr>
</thead>
<tbody>
<tr>
<td class="font-weight-medium">{{ $t('dialog.transient_book_details.label_name') }}</td>
<td>{{ leftBook.name }}</td>
<td v-if="rightBook">{{ rightBook.metadata.title }}</td>
</tr>
<tr>
<td class="font-weight-medium">{{ $t('dialog.transient_book_details.label_size') }}</td>
<td>{{ leftBook.size }}</td>
<td v-if="rightBook">{{ rightBook.size }}</td>
</tr>
<tr>
<td class="font-weight-medium">{{ $t('dialog.transient_book_details.label_format') }}</td>
<td>{{ getBookFormatFromMediaType(leftBook.mediaType).type }}</td>
<td v-if="rightBook">{{ getBookFormatFromMediaType(rightBook.media.mediaType).type }}</td>
</tr>
<tr>
<td class="font-weight-medium">{{ $t('dialog.transient_book_details.label_pages') }}</td>
<td>{{ leftBook.pages.length }}</td>
<td v-if="rightBook">{{ rightBook.media.pagesCount }}</td>
</tr>
<tr>
<td :colspan="rightBook ? 3 : 2" class="pa-0">
<pages-table :left-pages="leftBook.pages" :right-pages="rightPages"></pages-table>
</td>
</tr>
</tbody>
</v-simple-table>
</v-container>
</v-card-text>
</v-card>
</v-dialog>
</div>
</template>
<script lang="ts">
import Vue, {PropType} from 'vue'
import {TransientBookDto} from "@/types/komga-transientbooks";
import {BookDto, PageDto} from "@/types/komga-books";
import {getBookFormatFromMediaType} from "@/functions/book-format";
import PagesTable from "@/components/PagesTable.vue";
export default Vue.extend({
name: 'TransientBookDetailsDialog',
components: {PagesTable},
data: () => {
return {
modal: false,
getBookFormatFromMediaType,
}
},
props: {
value: Boolean,
leftBook: {
type: Object as PropType<TransientBookDto>,
required: false,
},
rightBook: {
type: Object as PropType<BookDto>,
required: false,
},
rightPages: {
type: Array as PropType<PageDto[]>,
required: false,
},
},
watch: {
async value(val) {
this.modal = val
},
modal(val) {
!val && this.dialogClose()
},
},
methods: {
dialogClose() {
this.$emit('input', false)
},
},
})
</script>
<style scoped>
</style>

View File

@ -0,0 +1,228 @@
<template>
<div>
<v-dialog v-model="modal"
scrollable
fullscreen
>
<v-card>
<v-card-title v-if="single">{{ $t('dialog.transient_book_viewer.title') }}</v-card-title>
<v-card-title v-else>{{ $t('dialog.transient_book_viewer.title_comparison') }}</v-card-title>
<v-btn icon absolute top right @click="dialogClose">
<v-icon>mdi-close</v-icon>
</v-btn>
<v-divider/>
<v-card-text style="height: 50%">
<v-container fluid class="pa-0">
<v-row justify="space-around" v-if="!single">
<v-col cols="auto" class="pa-1">{{ $t('dialog.transient_book_viewer.label_candidate') }}</v-col>
<v-col cols="auto" class="pa-1">{{ $t('dialog.transient_book_viewer.label_existing') }}</v-col>
</v-row>
<v-row justify="space-around">
<v-col cols="auto" class="pa-1">
<v-btn icon :disabled="!canPrevLeft" @click="firstPageLeft">
<rtl-icon icon="mdi-chevron-double-left" rtl="mdi-chevron-double-right"/>
</v-btn>
<v-btn icon :disabled="!canPrevLeft" @click="previousPageLeft">
<rtl-icon icon="mdi-chevron-left" rtl="mdi-chevron-right"/>
</v-btn>
{{ $t('dialog.transient_book_viewer.page_of_pages', {page: leftPageNumber, pages: leftPages.length}) }}
<v-btn icon :disabled="!canNextLeft" @click="nextPageLeft">
<rtl-icon icon="mdi-chevron-right" rtl="mdi-chevron-left"/>
</v-btn>
<v-btn icon :disabled="!canNextLeft" @click="lastPageLeft">
<rtl-icon icon="mdi-chevron-double-right" rtl="mdi-chevron-double-left"/>
</v-btn>
</v-col>
<v-col cols="auto" class="pa-1" v-if="!single">
<v-btn icon :disabled="!canPrevAny" @click="firstPageBoth">
<rtl-icon icon="mdi-chevron-double-left" rtl="mdi-chevron-double-right"/>
</v-btn>
<v-btn icon :disabled="!canPrevAny" @click="previousPageBoth">
<rtl-icon icon="mdi-chevron-left" rtl="mdi-chevron-right"/>
</v-btn>
<v-btn icon :disabled="!canNextAny" @click="nextPageBoth">
<rtl-icon icon="mdi-chevron-right" rtl="mdi-chevron-left"/>
</v-btn>
<v-btn icon :disabled="!canNextAny" @click="lastPageBoth">
<rtl-icon icon="mdi-chevron-double-right" rtl="mdi-chevron-double-left"/>
</v-btn>
</v-col>
<v-col cols="auto" class="pa-1" v-if="!single">
<v-btn icon :disabled="!canPrevRight" @click="firstPageRight">
<rtl-icon icon="mdi-chevron-double-left" rtl="mdi-chevron-double-right"/>
</v-btn>
<v-btn icon :disabled="!canPrevRight" @click="previousPageRight">
<rtl-icon icon="mdi-chevron-left" rtl="mdi-chevron-right"/>
</v-btn>
{{ $t('dialog.transient_book_viewer.page_of_pages', {page: rightPageNumber, pages: rightPages.length}) }}
<v-btn icon :disabled="!canNextRight" @click="nextPageRight">
<rtl-icon icon="mdi-chevron-right" rtl="mdi-chevron-left"/>
</v-btn>
<v-btn icon :disabled="!canNextRight" @click="lastPageRight">
<rtl-icon icon="mdi-chevron-double-right" rtl="mdi-chevron-double-left"/>
</v-btn>
</v-col>
</v-row>
<v-row justify="center">
<v-col cols="6" class="pa-0">
<v-img
:src="$_.get(pageLeft, 'url', '')"
contain
:max-height="$vuetify.breakpoint.height * .75"
>
</v-img>
</v-col>
<v-col cols="6" class="pa-0" v-if="!single">
<v-img
:src="$_.get(pageRight, 'url', '')"
:max-height="$vuetify.breakpoint.height * .75"
contain
>
</v-img>
</v-col>
</v-row>
<v-row justify="space-around">
<v-col cols="auto" class="pa-1">
w: {{ $_.get(pageLeft, 'width', '') }} h: {{ $_.get(pageLeft, 'height', '') }}
{{ $_.get(pageLeft, 'mediaType', '') }}
</v-col>
<v-col cols="auto" class="pa-1" v-if="!single">
w: {{ $_.get(pageRight, 'width', '') }} h: {{ $_.get(pageRight, 'height', '') }}
{{ $_.get(pageRight, 'mediaType', '') }}
</v-col>
</v-row>
</v-container>
</v-card-text>
</v-card>
</v-dialog>
</div>
</template>
<script lang="ts">
import Vue, {PropType} from 'vue'
import {PageDtoWithUrl} from "@/types/komga-books"
import RtlIcon from "@/components/RtlIcon.vue";
export default Vue.extend({
name: 'TransientBookViewerDialog',
components: {RtlIcon},
data: () => {
return {
modal: false,
leftPageNumber: 1,
rightPageNumber: 1,
}
},
props: {
value: Boolean,
leftPages: {
type: Array as PropType<PageDtoWithUrl[]>,
default: [],
},
rightPages: {
type: Array as PropType<PageDtoWithUrl[]>,
default: [],
},
},
watch: {
async value(val) {
this.modal = val
if (val) this.firstPageBoth()
},
modal(val) {
!val && this.dialogClose()
},
},
computed: {
pageLeft(): PageDtoWithUrl {
return this.leftPages[this.leftPageNumber - 1]
},
pageRight(): PageDtoWithUrl {
return this.rightPages[this.rightPageNumber - 1]
},
canPrevLeft(): boolean {
return this.leftPageNumber > 1
},
canPrevRight(): boolean {
return this.rightPageNumber > 1
},
canPrevAny(): boolean {
return this.canPrevLeft || this.canPrevRight
},
canNextLeft(): boolean {
return this.leftPageNumber < this.leftPages.length
},
canNextRight(): boolean {
return this.rightPageNumber < this.rightPages.length
},
canNextAny(): boolean {
return this.canNextLeft || this.canNextRight
},
single(): boolean {
return this.rightPages.length === 0
},
},
methods: {
firstPageLeft() {
this.leftPageNumber = 1
},
firstPageRight() {
this.rightPageNumber = 1
},
firstPageBoth() {
this.firstPageLeft()
this.firstPageRight()
},
previousPageLeft() {
if (this.canPrevLeft) this.leftPageNumber--
},
previousPageRight() {
if (this.canPrevRight) this.rightPageNumber--
},
nextPageLeft() {
if (this.canNextLeft) this.leftPageNumber++
},
nextPageRight() {
if (this.canNextRight) this.rightPageNumber++
},
previousPageBoth() {
this.previousPageLeft()
this.previousPageRight()
},
nextPageBoth() {
this.nextPageLeft()
this.nextPageRight()
},
lastPageLeft() {
this.leftPageNumber = this.leftPages.length
},
lastPageRight() {
this.rightPageNumber = this.rightPages.length
},
lastPageBoth() {
this.lastPageLeft()
this.lastPageRight()
},
dialogClose() {
this.$emit('input', false)
},
},
})
</script>
<style scoped>
</style>

View File

@ -47,3 +47,7 @@ export function collectionThumbnailUrl (collectionId: string): string {
export function readListThumbnailUrl (readListId: string): string {
return `${urls.originNoSlash}/api/v1/readlists/${readListId}/thumbnail`
}
export function transientBookPageUrl (transientBookId: string, page: number): string {
return `${urls.originNoSlash}/api/v1/transient-books/${transientBookId}/pages/${page}`
}

View File

@ -353,6 +353,44 @@
"shortcut_help": {
"label_description": "Description",
"label_key": "Key"
},
"series_picker": {
"title": "Select Series",
"label_search_series": "Search Series"
},
"filename_chooser": {
"title": "Destination File Name",
"label_source_filename": "Source File Name",
"field_destination_filename": "Destination file name",
"button_choose": "Choose",
"table": {
"order": "Order",
"existing_file": "Existing File"
}
},
"transient_book_details": {
"title": "Book Details",
"title_comparison": "Book Comparison",
"label_candidate": "Candidate",
"label_existing": "Existing",
"label_name": "Name",
"label_size": "Size",
"label_format": "Format",
"label_pages": "Pages",
"pages_table": {
"index": "Index",
"filename": "File name",
"media_type": "Media type",
"width": "Width",
"height": "Height"
}
},
"transient_book_viewer": {
"title": "Inspect Book",
"title_comparison": "Book Comparison",
"label_candidate": "Candidate",
"label_existing": "Existing",
"page_of_pages": "{page} / {pages}"
}
},
"enums": {
@ -374,6 +412,10 @@
"ENDED": "Ended",
"HIATUS": "Hiatus",
"ONGOING": "Ongoing"
},
"copy_mode": {
"HARDLINK": "Hardlink/Copy Files",
"MOVE": "Move Files"
}
},
"error_codes": {
@ -519,5 +561,25 @@
"add_library": "Add library",
"no_libraries_yet": "No libraries have been added yet!",
"welcome_message": "Welcome to Komga"
},
"book_import": {
"title": "Import",
"field_import_path": "Import from folder",
"button_browse": "Browse",
"button_scan": "Scan",
"table": {
"file_name": "File name",
"series": "Series",
"number": "Number",
"destination_name": "Destination name"
},
"button_select_series": "Select Series",
"button_import": "Import",
"row": {
"warning_upgrade": "Existing book will be upgraded",
"error_analyze_first": "Book needs to be analyzed first",
"error_only_import_no_errors": "Can only import books without errors",
"error_choose_series": "Choose a series"
}
}
}

View File

@ -17,6 +17,7 @@ import komgaLibraries from './plugins/komga-libraries.plugin'
import komgaReferential from './plugins/komga-referential.plugin'
import komgaSeries from './plugins/komga-series.plugin'
import komgaUsers from './plugins/komga-users.plugin'
import komgaTransientBooks from './plugins/komga-transientbooks.plugin'
import vuetify from './plugins/vuetify'
import './public-path'
import router from './router'
@ -35,6 +36,7 @@ Vue.use(komgaReadLists, {http: Vue.prototype.$http})
Vue.use(komgaBooks, {http: Vue.prototype.$http})
Vue.use(komgaReferential, {http: Vue.prototype.$http})
Vue.use(komgaClaim, {http: Vue.prototype.$http})
Vue.use(komgaTransientBooks, {http: Vue.prototype.$http})
Vue.use(komgaUsers, {store: store, http: Vue.prototype.$http})
Vue.use(komgaLibraries, {store: store, http: Vue.prototype.$http})
Vue.use(actuator, {http: Vue.prototype.$http})

View File

@ -0,0 +1,20 @@
import KomgaTransientBooksService from '@/services/komga-transientbooks.service'
import {AxiosInstance} from 'axios'
import _Vue from 'vue'
let service: KomgaTransientBooksService
export default {
install (
Vue: typeof _Vue,
{ http }: { http: AxiosInstance }) {
service = new KomgaTransientBooksService(http)
Vue.prototype.$komgaTransientBooks = service
},
}
declare module 'vue/types/vue' {
interface Vue {
$komgaTransientBooks: KomgaTransientBooksService;
}
}

View File

@ -133,6 +133,12 @@ const router = new Router({
name: 'search',
component: () => import(/* webpackChunkName: "search" */ './views/Search.vue'),
},
{
path: '/import',
name: 'import',
beforeEnter: adminGuard,
component: () => import(/* webpackChunkName: "book-import" */ './views/BookImport.vue'),
},
],
},
{

View File

@ -1,5 +1,5 @@
import { AxiosInstance } from 'axios'
import { BookDto, BookMetadataUpdateDto, PageDto, ReadProgressUpdateDto } from '@/types/komga-books'
import {AxiosInstance} from 'axios'
import {BookDto, BookImportBatchDto, BookMetadataUpdateDto, PageDto, ReadProgressUpdateDto} from '@/types/komga-books'
const qs = require('qs')
@ -173,4 +173,16 @@ export default class KomgaBooksService {
throw new Error(msg)
}
}
async importBooks(batch: BookImportBatchDto) {
try {
await this.http.post(`${API_BOOKS}/import`, batch)
} catch (e) {
let msg = `An error occurred while trying to submit book import batch`
if (e.response.data.message) {
msg += `: ${e.response.data.message}`
}
throw new Error(msg)
}
}
}

View File

@ -0,0 +1,39 @@
import {AxiosInstance} from 'axios'
import {TransientBookDto} from "@/types/komga-transientbooks";
const API_TRANSIENT_BOOKS = '/api/v1/transient-books'
export default class KomgaTransientBooksService {
private http: AxiosInstance
constructor (http: AxiosInstance) {
this.http = http
}
async scanForTransientBooks (path: string): Promise<TransientBookDto[]> {
try {
return (await this.http.post(API_TRANSIENT_BOOKS, {
path: path,
})).data
} catch (e) {
let msg = `An error occurred while trying to scan for transient book`
if (e.response.data.message) {
msg += `: ${e.response.data.message}`
}
throw new Error(msg)
}
}
async analyze (id: string): Promise<TransientBookDto> {
try {
return (await this.http.post(`${API_TRANSIENT_BOOKS}/${id}/analyze`)).data
} catch (e) {
let msg = `An error occurred while trying to analyze transient book`
if (e.response.data.message) {
msg += `: ${e.response.data.message}`
}
throw new Error(msg)
}
}
}

View File

@ -18,3 +18,9 @@ export enum ReadStatus {
IN_PROGRESS = 'IN_PROGRESS',
READ = 'READ'
}
export enum CopyMode {
MOVE = 'MOVE',
COPY = 'COPY',
HARDLINK = 'HARDLINK',
}

View File

@ -1,4 +1,5 @@
import {Context} from '@/types/context'
import {CopyMode} from "@/types/enum-books";
export interface BookDto {
id: string,
@ -103,3 +104,15 @@ export interface BookFormat {
type: string,
color: string
}
export interface BookImportBatchDto{
books: BookImportDto[],
copyMode: CopyMode,
}
export interface BookImportDto {
sourceFile: string,
seriesId: string,
upgradeBookId?: string,
destinationName?: string,
}

View File

@ -0,0 +1,19 @@
import {PageDto} from "@/types/komga-books";
export interface ScanRequestDto {
path: string,
}
export interface TransientBookDto {
id: string,
name: string,
url: string,
fileLastModified: string,
sizeBytes: number,
size: string,
status: string,
mediaType: string,
pages: PageDto[],
files: string[],
comment: string,
}

View File

@ -0,0 +1,166 @@
<template>
<v-container fluid class="pa-6">
<v-row align="center">
<v-col>
<v-text-field
v-model="importPath"
:label="$t('book_import.field_import_path')"
/>
</v-col>
<v-col cols="auto">
<file-browser-dialog
v-model="modalFileBrowser"
:path.sync="importPath"
/>
<v-btn @click="modalFileBrowser = true">{{ $t('book_import.button_browse') }}</v-btn>
</v-col>
<v-col cols="auto">
<v-btn
color="primary"
:disabled="!importPath"
@click="scanBooks"
>{{ $t('book_import.button_scan') }}
</v-btn>
</v-col>
</v-row>
<template v-if="transientBooks.length > 0">
<v-divider/>
<v-simple-table>
<thead class="font-weight-medium">
<tr>
<td>
<v-checkbox v-model="globalSelect" :indeterminate="globalSelect === 1"></v-checkbox>
</td>
<td>{{ $t('book_import.table.file_name') }}</td>
<td></td>
<td>{{ $t('book_import.table.series') }}</td>
<td>{{ $t('book_import.table.number') }}</td>
<td></td>
<td></td>
<td></td>
<td>{{ $t('book_import.table.destination_name') }}</td>
<td>
<v-icon>mdi-alert-circle-outline</v-icon>
</td>
</tr>
</thead>
<tbody
v-for="(book, i) in transientBooks"
:key="i"
>
<file-import-row :book="book" :series="selected.includes(i) ? selectedSeries : undefined"
:payload.sync="payloads[i]">
<v-checkbox v-model="selected" :value="i"/>
</file-import-row>
</tbody>
</v-simple-table>
<v-row align="center">
<v-col cols="3">
<v-select v-model="copyMode" :items="copyModes"></v-select>
</v-col>
<v-col cols="auto">
<v-btn @click="modalSeriesPicker = true" :disabled="globalSelect === 0">
{{ $t('book_import.button_select_series') }}
</v-btn>
<series-picker-dialog v-model="modalSeriesPicker" :series.sync="selectedSeries"></series-picker-dialog>
</v-col>
<v-spacer/>
<v-col cols="auto">
<v-btn :color="importFinished ? 'success': 'primary'"
:disabled="payloadBatch.books.length === 0"
@click="performImport"
>
<v-icon left v-if="importFinished">mdi-check</v-icon>
{{ $t('book_import.button_import') }}
</v-btn>
</v-col>
</v-row>
</template>
</v-container>
</template>
<script lang="ts">
import Vue from 'vue'
import FileBrowserDialog from "@/components/dialogs/FileBrowserDialog.vue";
import FileImportRow from "@/components/FileImportRow.vue";
import {TransientBookDto} from "@/types/komga-transientbooks";
import SeriesPickerDialog from "@/components/dialogs/SeriesPickerDialog.vue";
import {SeriesDto} from "@/types/komga-series";
import {BookImportBatchDto, BookImportDto} from "@/types/komga-books";
import {CopyMode} from "@/types/enum-books";
export default Vue.extend({
name: 'BookImport',
components: {FileBrowserDialog, FileImportRow, SeriesPickerDialog},
data: () => ({
modalFileBrowser: false,
modalSeriesPicker: false,
selected: [] as number[],
selectedSeries: undefined as SeriesDto | undefined,
payloads: [] as BookImportDto[],
importPath: '',
transientBooks: [] as TransientBookDto[],
copyMode: CopyMode.HARDLINK,
importFinished: false,
}),
computed: {
globalSelect: {
get: function (): number {
if (this.selected.length === 0) return 0
if (this.selected.length === this.transientBooks.length) return 2
return 1
},
set: function (val: boolean): void {
if (val) this.selected = this.$_.range(this.transientBooks.length)
else this.selected = []
},
},
copyModes(): object[] {
return [
{text: this.$t('enums.copy_mode.HARDLINK').toString(), value: CopyMode.HARDLINK},
{text: this.$t('enums.copy_mode.MOVE').toString(), value: CopyMode.MOVE},
]
},
payloadBatch(): BookImportBatchDto {
return {
books: this.selected.map(x => this.payloads[x]).filter(Boolean),
copyMode: this.copyMode,
}
},
},
watch: {
selectedSeries(val) {
if (val) setTimeout(() => {
this.selectedSeries = undefined
}, 100)
},
},
methods: {
async scanBooks() {
this.transientBooks = []
this.transientBooks = await this.$komgaTransientBooks.scanForTransientBooks(this.importPath)
this.selected = this.$_.range(this.transientBooks.length)
this.payloads = this.payloads.splice(this.transientBooks.length, this.payloads.length)
this.importFinished = false
},
performImport() {
if (!this.importFinished) {
this.$komgaBooks.importBooks(this.payloadBatch)
this.importFinished = true
}
},
},
})
</script>
<style scoped>
</style>

View File

@ -69,6 +69,15 @@
</v-list-item-action>
</v-list-item>
<v-list-item :to="{name: 'import'}" v-if="isAdmin">
<v-list-item-icon>
<v-icon>mdi-import</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title>{{ $t('book_import.title') }}</v-list-item-title>
</v-list-item-content>
</v-list-item>
<v-list-item :to="{name: 'settings'}" v-if="isAdmin">
<v-list-item-action>
<v-icon>mdi-cog</v-icon>