mirror of
https://github.com/gotson/komga.git
synced 2025-01-09 04:08:00 +08:00
feat(webui): import books
Books can be imported directly into an existing Series
This commit is contained in:
parent
d41dcefd3e
commit
13b304dd14
262
komga-webui/src/components/FileImportRow.vue
Normal file
262
komga-webui/src/components/FileImportRow.vue
Normal 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>
|
69
komga-webui/src/components/PagesTable.vue
Normal file
69
komga-webui/src/components/PagesTable.vue
Normal 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>
|
26
komga-webui/src/components/RtlIcon.vue
Normal file
26
komga-webui/src/components/RtlIcon.vue
Normal 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>
|
134
komga-webui/src/components/dialogs/FileNameChooserDialog.vue
Normal file
134
komga-webui/src/components/dialogs/FileNameChooserDialog.vue
Normal 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>
|
127
komga-webui/src/components/dialogs/SeriesPickerDialog.vue
Normal file
127
komga-webui/src/components/dialogs/SeriesPickerDialog.vue
Normal 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>
|
@ -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>
|
228
komga-webui/src/components/dialogs/TransientBookViewerDialog.vue
Normal file
228
komga-webui/src/components/dialogs/TransientBookViewerDialog.vue
Normal 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>
|
@ -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}`
|
||||
}
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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})
|
||||
|
20
komga-webui/src/plugins/komga-transientbooks.plugin.ts
Normal file
20
komga-webui/src/plugins/komga-transientbooks.plugin.ts
Normal 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;
|
||||
}
|
||||
}
|
@ -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'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
39
komga-webui/src/services/komga-transientbooks.service.ts
Normal file
39
komga-webui/src/services/komga-transientbooks.service.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -18,3 +18,9 @@ export enum ReadStatus {
|
||||
IN_PROGRESS = 'IN_PROGRESS',
|
||||
READ = 'READ'
|
||||
}
|
||||
|
||||
export enum CopyMode {
|
||||
MOVE = 'MOVE',
|
||||
COPY = 'COPY',
|
||||
HARDLINK = 'HARDLINK',
|
||||
}
|
||||
|
@ -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,
|
||||
}
|
||||
|
19
komga-webui/src/types/komga-transientbooks.ts
Normal file
19
komga-webui/src/types/komga-transientbooks.ts
Normal 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,
|
||||
}
|
166
komga-webui/src/views/BookImport.vue
Normal file
166
komga-webui/src/views/BookImport.vue
Normal 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>
|
@ -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>
|
||||
|
Loading…
Reference in New Issue
Block a user