mirror of
https://github.com/Kareadita/Kavita.git
synced 2026-05-27 01:52:36 -04:00
CBL Import Polish 4 (#4576)
This commit is contained in:
@@ -9,3 +9,7 @@ export enum CblMatchTier {
|
||||
UserDecision = 7,
|
||||
Unmatched = -1
|
||||
}
|
||||
|
||||
export const allCblMatchTiers = Object.keys(CblMatchTier)
|
||||
.filter(key => !isNaN(Number(key)) && parseInt(key, 10) >= 0)
|
||||
.map(key => parseInt(key, 10)) as CblMatchTier[];
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export enum CblRemapRuleKind {
|
||||
Series = 0,
|
||||
Volume = 1,
|
||||
Chapter = 2
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import {LibraryType} from '../../library/library';
|
||||
import {CblRemapRuleKind} from './cbl-remap-rule-kind.enum';
|
||||
|
||||
export interface RemapRule {
|
||||
id: number;
|
||||
@@ -8,7 +9,9 @@ export interface RemapRule {
|
||||
cblNumber: string | null;
|
||||
seriesId: number;
|
||||
volumeId: number | null;
|
||||
volumeNumber: string;
|
||||
chapterId: number | null;
|
||||
kind: CblRemapRuleKind;
|
||||
chapterRange: string;
|
||||
chapterTitleName: string;
|
||||
chapterIsSpecial: boolean;
|
||||
|
||||
@@ -23,4 +23,5 @@ export enum WikiLink {
|
||||
Guides = 'https://wiki.kavitareader.com/guides',
|
||||
ReadingProfiles = "https://wiki.kavitareader.com/guides/user-settings/reading-profiles/",
|
||||
EpubFontManager = "https://wiki.kavitareader.com/guides/epub-fonts/",
|
||||
CblImportModal = 'https://wiki.kavitareader.com/guides/features/cbl-import/'
|
||||
}
|
||||
|
||||
+13
-25
@@ -24,7 +24,7 @@ export class BrowseCblRepoModalComponent implements OnInit {
|
||||
private readonly cblService = inject(CblService);
|
||||
|
||||
items = signal<CblRepoItem[]>([]);
|
||||
selectedItems = signal<Set<string>>(new Set());
|
||||
selectedItems = signal<CblRepoItem[]>([]);
|
||||
loading = signal(false);
|
||||
rateLimit = signal<GithubRateLimit | null>(null);
|
||||
fromCache = signal(false);
|
||||
@@ -38,14 +38,14 @@ export class BrowseCblRepoModalComponent implements OnInit {
|
||||
|
||||
folders = computed(() => this.items().filter(i => i.isDirectory));
|
||||
files = computed(() => this.items().filter(i => !i.isDirectory));
|
||||
hasSelection = computed(() => this.selectedItems().size > 0);
|
||||
selectionCount = computed(() => this.selectedItems().size);
|
||||
hasSelection = computed(() => this.selectedItems().length > 0);
|
||||
selectionCount = computed(() => this.selectedItems().length);
|
||||
|
||||
allFilesSelected = computed(() => {
|
||||
const f = this.files();
|
||||
if (f.length === 0) return false;
|
||||
const sel = this.selectedItems();
|
||||
return f.every(file => sel.has(file.path));
|
||||
return f.every(file => sel.some(s => s.path === file.path));
|
||||
});
|
||||
|
||||
ngOnInit() {
|
||||
@@ -74,44 +74,32 @@ export class BrowseCblRepoModalComponent implements OnInit {
|
||||
|
||||
toggleFileSelection(file: CblRepoItem) {
|
||||
this.selectedItems.update(current => {
|
||||
const next = new Set(current);
|
||||
if (next.has(file.path)) {
|
||||
next.delete(file.path);
|
||||
} else {
|
||||
next.add(file.path);
|
||||
if (current.some(s => s.path === file.path)) {
|
||||
return current.filter(s => s.path !== file.path);
|
||||
}
|
||||
return next;
|
||||
return [...current, file];
|
||||
});
|
||||
}
|
||||
|
||||
toggleAllFiles() {
|
||||
const files = this.files();
|
||||
if (this.allFilesSelected()) {
|
||||
this.selectedItems.update(current => {
|
||||
const next = new Set(current);
|
||||
for (const file of files) {
|
||||
next.delete(file.path);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
const paths = new Set(files.map(f => f.path));
|
||||
this.selectedItems.update(current => current.filter(s => !paths.has(s.path)));
|
||||
} else {
|
||||
this.selectedItems.update(current => {
|
||||
const next = new Set(current);
|
||||
for (const file of files) {
|
||||
next.add(file.path);
|
||||
}
|
||||
return next;
|
||||
const existing = new Set(current.map(s => s.path));
|
||||
return [...current, ...files.filter(f => !existing.has(f.path))];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
isSelected(file: CblRepoItem): boolean {
|
||||
return this.selectedItems().has(file.path);
|
||||
return this.selectedItems().some(s => s.path === file.path);
|
||||
}
|
||||
|
||||
download() {
|
||||
const selected = this.items().filter(i => this.selectedItems().has(i.path));
|
||||
this.modal.close(selected);
|
||||
this.modal.close(this.selectedItems());
|
||||
}
|
||||
|
||||
close() {
|
||||
|
||||
+23
-17
@@ -19,6 +19,7 @@
|
||||
<p class="mt-2 text-muted">{{t('processing')}}</p>
|
||||
</div>
|
||||
} @else if (currentSummary(); as summary) {
|
||||
<p>{{t('description')}} <a [href]="WikiLink.CblImportModal" rel="noopener noreferrer">{{t('wiki')}}</a></p>
|
||||
<!-- File name -->
|
||||
<h5 class="mb-3">
|
||||
{{summary.cblName || currentFile().name}}
|
||||
@@ -31,15 +32,20 @@
|
||||
|
||||
<!-- Summary bar -->
|
||||
<div class="d-flex gap-3 mb-3 align-items-center">
|
||||
<span class="badge bg-success fs-6">{{t('matched-count', {count: matchedCount()})}}</span>
|
||||
@if (issueCount() > 0) {
|
||||
<span class="badge text-bg-warning fs-6">{{t('issues-count', {count: issueCount()})}}</span>
|
||||
}
|
||||
<button class="btn btn-sm" [class.btn-outline-secondary]="showSuccessful()"
|
||||
[class.btn-secondary]="!showSuccessful()"
|
||||
(click)="showSuccessful.update(v => !v)">
|
||||
<i class="fa" [class.fa-eye]="showSuccessful()" [class.fa-eye-slash]="!showSuccessful()" aria-hidden="true"></i>
|
||||
{{showSuccessful() ? t('hide-matched') : t('show-matched')}}
|
||||
<div class="btn-group" role="group">
|
||||
<input type="checkbox" class="btn-check" id="matched-btn" autocomplete="off" [checked]="showMatched()" (change)="toggleRowFilter('matched')">
|
||||
<label class="btn btn-outline-success" for="matched-btn">{{t('matched-count', {count: matchedCount()})}}</label>
|
||||
|
||||
<input type="checkbox" class="btn-check" id="warnings-btn" autocomplete="off" [checked]="showIssues()" (change)="toggleRowFilter('issues')">
|
||||
<label class="btn btn-outline-warning" for="warnings-btn">{{t('issues-count', {count: issueCount()})}}</label>
|
||||
|
||||
<input type="checkbox" class="btn-check" id="errors-btn" autocomplete="off" [checked]="showUnmatched()" (change)="toggleRowFilter('unmatched')">
|
||||
<label class="btn btn-outline-danger" for="errors-btn">{{t('unmatched-count', {count: unmatchedCount()})}}</label>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-sm btn-outline-secondary" (click)="validateCurrentFile()">
|
||||
<i class="fa fa-refresh me-1" aria-hidden="true"></i>
|
||||
{{t('refresh')}}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -90,15 +96,15 @@
|
||||
</ngx-datatable-column>
|
||||
|
||||
<!-- Status / Reason -->
|
||||
<ngx-datatable-column [width]="110" [sortable]="false" [draggable]="false" [resizeable]="false">
|
||||
<ngx-datatable-column [width]="100" [sortable]="false" [draggable]="false" [resizeable]="false">
|
||||
<ng-template ngx-datatable-header-template>{{t('col-status')}}</ng-template>
|
||||
<ng-template let-row="row" ngx-datatable-cell-template>
|
||||
@if (row.result.reason === CblImportReason.Success && row.result.matchTier === 0) {
|
||||
<span class="badge tier-badge tier-0" [ngbTooltip]="getRemapRuleTooltip(row)" container="body">
|
||||
@if (row.result.reason === CblImportReason.Success && row.result.matchTier === CblMatchTier.RemapRule) {
|
||||
<span class="badge tier-badge text-bg-{{getMatchBadgeClass(row.result.matchTier)}}" [ngbTooltip]="getRemapRuleTooltip(row)" container="body">
|
||||
{{row.result.matchTier | cblMatchTier}}
|
||||
</span>
|
||||
} @else if (row.result.reason === CblImportReason.Success) {
|
||||
<span class="badge tier-badge tier-{{row.result.matchTier}}">
|
||||
<span class="badge tier-badge text-bg-{{getMatchBadgeClass(row.result.matchTier)}}" >
|
||||
{{row.result.matchTier | cblMatchTier}}
|
||||
</span>
|
||||
} @else {
|
||||
@@ -118,10 +124,10 @@
|
||||
<div class="flex-grow-1">
|
||||
<app-typeahead [settings]="activeSeriesTypeahead()!"
|
||||
(selectedData)="onSeriesTypeaheadSelected(row, $event)">
|
||||
<ng-template #badgeItem let-item let-position="idx">
|
||||
<ng-template #badgeItem let-item>
|
||||
{{item.name}} ({{libraryNames()[item.libraryId]}})
|
||||
</ng-template>
|
||||
<ng-template #optionItem let-item let-position="idx" let-value="value">
|
||||
<ng-template #optionItem let-item let-value="value">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<app-image [imageUrl]="imageService.getSeriesCoverImage(item.seriesId)"
|
||||
height="40px" width="30px" />
|
||||
@@ -226,7 +232,7 @@
|
||||
</ngx-datatable-column>
|
||||
|
||||
<!-- Action Column -->
|
||||
<ngx-datatable-column [width]="30" [sortable]="false" [draggable]="false" [resizeable]="false">
|
||||
<ngx-datatable-column [width]="60" [sortable]="false" [draggable]="false" [resizeable]="false">
|
||||
<ng-template ngx-datatable-header-template>{{t('col-action')}}</ng-template>
|
||||
<ng-template let-row="row" ngx-datatable-cell-template>
|
||||
@if (row.result.reason !== CblImportReason.Success) {
|
||||
@@ -238,7 +244,7 @@
|
||||
</button>
|
||||
}
|
||||
<button class="btn btn-sm btn-outline-secondary" (click)="toggleSkip(row)"
|
||||
[attr.aria-label]="t('skip-item')">
|
||||
[attr.aria-label]="t('skip-item')" [ngbTooltip]="t('skip-item')">
|
||||
@if (row.skipped) {
|
||||
<i class="fa fa-undo" aria-hidden="true"></i>
|
||||
} @else {
|
||||
|
||||
@@ -4,14 +4,6 @@
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.tier-0 { background-color: var(--bs-info); color: white; cursor: help; }
|
||||
.tier-1 { background-color: var(--bs-primary); color: white; }
|
||||
.tier-2 { background-color: var(--bs-success); color: white; }
|
||||
.tier-3 { background-color: #6f42c1; color: white; }
|
||||
.tier-4 { background-color: #d63384; color: white; }
|
||||
.tier-5 { background-color: #fd7e14; color: white; }
|
||||
.tier-6 { background-color: var(--bs-secondary); color: white; }
|
||||
|
||||
:host ::ng-deep .skipped-row {
|
||||
color: var(--bs-secondary-color) !important;
|
||||
}
|
||||
|
||||
+84
-40
@@ -12,14 +12,12 @@ import {CblSeriesCandidate} from '../../../_models/reading-list/cbl/cbl-series-c
|
||||
import {Chapter} from '../../../_models/chapter';
|
||||
import {CblService} from '../../../_services/cbl.service';
|
||||
import {SearchService} from '../../../_services/search.service';
|
||||
import {ConfirmService} from '../../../shared/confirm.service';
|
||||
import {ToastrService} from 'ngx-toastr';
|
||||
import {TypeaheadSettings} from '../../../typeahead/_models/typeahead-settings';
|
||||
import {SearchResult} from '../../../_models/search/search-result';
|
||||
import {UtilityService} from '../../../shared/_services/utility.service';
|
||||
import {TypeaheadComponent} from '../../../typeahead/_components/typeahead.component';
|
||||
import {LoadingComponent} from '../../../shared/loading/loading.component';
|
||||
import {CblImportResult} from '../../../_models/reading-list/cbl/cbl-import-result.enum';
|
||||
import {CblMatchTierPipe} from '../../../_pipes/cbl-match-tier.pipe';
|
||||
import {CblImportReasonPipe} from '../../../_pipes/cbl-import-reason.pipe';
|
||||
import {ManageRemapRulesModalComponent} from '../manage-remap-rules-modal/manage-remap-rules-modal.component';
|
||||
@@ -37,6 +35,7 @@ import {CdkScrollable} from '@angular/cdk/scrolling';
|
||||
import {RouterLink} from '@angular/router';
|
||||
import {EntityTitleComponent} from '../../../cards/entity-title/entity-title.component';
|
||||
import {modalSaved} from "../../../_models/modal/modal-result";
|
||||
import {WikiLink} from "../../../_models/wiki";
|
||||
|
||||
export interface CblIssueRow {
|
||||
result: CblBookResult;
|
||||
@@ -71,15 +70,11 @@ export class ImportCblModalComponent implements OnInit {
|
||||
private readonly modalService = inject(NgbModal);
|
||||
private readonly cblService = inject(CblService);
|
||||
private readonly searchService = inject(SearchService);
|
||||
private readonly confirmService = inject(ConfirmService);
|
||||
private readonly toastr = inject(ToastrService);
|
||||
private readonly utilityService = inject(UtilityService);
|
||||
private readonly libraryService = inject(LibraryService);
|
||||
protected readonly imageService = inject(ImageService);
|
||||
|
||||
protected readonly CblImportReason = CblImportReason;
|
||||
protected readonly CblImportResult = CblImportResult;
|
||||
|
||||
savedFiles = input.required<CblSavedFile[]>();
|
||||
|
||||
currentFileIndex = signal(0);
|
||||
@@ -90,18 +85,34 @@ export class ImportCblModalComponent implements OnInit {
|
||||
|
||||
/** All rows (matched + issues) for the unified table */
|
||||
allRows = signal<CblIssueRow[]>([]);
|
||||
classifiedRows = computed(() =>
|
||||
this.allRows().map(r => ({
|
||||
...r,
|
||||
category: this.classifyRow(r)
|
||||
}))
|
||||
);
|
||||
libraryNames = signal<Record<number, string>>({});
|
||||
|
||||
showSuccessful = signal(true);
|
||||
showMatched = signal(true);
|
||||
showIssues = signal(true);
|
||||
showUnmatched = signal(true);
|
||||
visibleRows = computed(() => {
|
||||
const rows = this.allRows();
|
||||
return this.showSuccessful() ? rows : rows.filter(r => r.result.reason !== CblImportReason.Success);
|
||||
const active = new Set<string>();
|
||||
if (this.showMatched()) active.add('matched');
|
||||
if (this.showIssues()) active.add('issue');
|
||||
if (this.showUnmatched()) active.add('unmatched');
|
||||
|
||||
if (active.size === 0) return [];
|
||||
if (active.size === 3) return this.classifiedRows();
|
||||
|
||||
return this.classifiedRows().filter(r => active.has(r.category));
|
||||
});
|
||||
|
||||
matchedCount = computed(() => this.allRows().filter(r => r.result.reason === CblImportReason.Success).length);
|
||||
issueCount = computed(() => this.allRows().filter(r => r.result.reason !== CblImportReason.Success && !r.skipped).length);
|
||||
matchedCount = computed(() => this.classifiedRows().filter(r => r.category === 'matched').length);
|
||||
issueCount = computed(() => this.classifiedRows().filter(r => r.category === 'issue').length);
|
||||
unmatchedCount = computed(() => this.classifiedRows().filter(r => r.category === 'unmatched').length);
|
||||
|
||||
/** Lazy typeahead state — only one row can be resolving at a time */
|
||||
/** Lazy typeahead state, only one row can be resolving at a time */
|
||||
activeRow = signal<CblIssueRow | null>(null);
|
||||
activeSeriesTypeahead = signal<TypeaheadSettings<SearchResult> | null>(null);
|
||||
activeChapterTypeahead = signal<TypeaheadSettings<Chapter> | null>(null);
|
||||
@@ -109,12 +120,37 @@ export class ImportCblModalComponent implements OnInit {
|
||||
/** Track the CBL series name of the row being resolved, so we can auto-continue after re-validation */
|
||||
private pendingAutoEditSeries: string | null = null;
|
||||
|
||||
private classifyRow(r: CblIssueRow): 'matched' | 'issue' | 'unmatched' {
|
||||
if (r.result.reason === CblImportReason.Success) return 'matched';
|
||||
if (r.skipped) return 'matched';
|
||||
if (r.result.matchTier === CblMatchTier.Unmatched) return 'unmatched';
|
||||
return 'issue';
|
||||
}
|
||||
|
||||
getRowClass = (row: CblIssueRow) => {
|
||||
if (row.skipped) return 'skipped-row';
|
||||
if (row.result.reason === CblImportReason.Success) return 'matched-row';
|
||||
return 'issue-row';
|
||||
};
|
||||
|
||||
getMatchBadgeClass(matchTier: CblMatchTier) {
|
||||
switch (matchTier) {
|
||||
case CblMatchTier.RemapRule:
|
||||
case CblMatchTier.ExternalId:
|
||||
case CblMatchTier.ExactName:
|
||||
case CblMatchTier.ComicVineNaming:
|
||||
case CblMatchTier.ArticleStripped:
|
||||
case CblMatchTier.ReprintStripped:
|
||||
case CblMatchTier.AlternateSeries:
|
||||
return 'success';
|
||||
case CblMatchTier.UserDecision:
|
||||
return 'warning';
|
||||
case CblMatchTier.Unmatched:
|
||||
return 'danger';
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.cblService.getRemapRules().subscribe(rules => {
|
||||
this.remapRules.set(rules);
|
||||
@@ -190,24 +226,14 @@ export class ImportCblModalComponent implements OnInit {
|
||||
return row.result.reason === CblImportReason.SeriesCollision;
|
||||
}
|
||||
|
||||
needsAction(row: CblIssueRow): boolean {
|
||||
return row.result.reason !== CblImportReason.Success && !row.skipped;
|
||||
}
|
||||
|
||||
/** Whether this row needs a series typeahead */
|
||||
needsSeriesTypeahead(row: CblIssueRow): boolean {
|
||||
return this.isSeriesMissing(row) ||
|
||||
(this.isSeriesCollision(row) && (!row.result.candidates || row.result.candidates.length === 0));
|
||||
}
|
||||
|
||||
/** Whether this row is the active editing row showing a series typeahead */
|
||||
isEditingSeries(row: CblIssueRow): boolean {
|
||||
return this.activeRow() === row && this.activeSeriesTypeahead() !== null;
|
||||
return this.activeRow()?.result.order === row.result.order && this.activeSeriesTypeahead() !== null;
|
||||
}
|
||||
|
||||
/** Whether this row is the active editing row showing a chapter typeahead */
|
||||
isEditingChapter(row: CblIssueRow): boolean {
|
||||
return this.activeRow() === row && this.activeChapterTypeahead() !== null;
|
||||
return this.activeRow()?.result.order === row.result.order && this.activeChapterTypeahead() !== null;
|
||||
}
|
||||
|
||||
/** Build a minimal Chapter stub for entity-title rendering */
|
||||
@@ -269,7 +295,7 @@ export class ImportCblModalComponent implements OnInit {
|
||||
}
|
||||
|
||||
onCandidateSelected(row: CblIssueRow, candidate: CblSeriesCandidate) {
|
||||
this.handleSeriesSelection(row, candidate.seriesId, candidate.seriesName);
|
||||
this.handleSeriesSelection(row, candidate.seriesId);
|
||||
}
|
||||
|
||||
onSeriesTypeaheadSelected(row: CblIssueRow, event: SearchResult[]) {
|
||||
@@ -282,7 +308,7 @@ export class ImportCblModalComponent implements OnInit {
|
||||
return;
|
||||
}
|
||||
|
||||
this.handleSeriesSelection(row, selected.seriesId, selected.name);
|
||||
this.handleSeriesSelection(row, selected.seriesId);
|
||||
}
|
||||
|
||||
onChapterTypeaheadSelected(row: CblIssueRow, event: Chapter[]) {
|
||||
@@ -309,12 +335,22 @@ export class ImportCblModalComponent implements OnInit {
|
||||
this.allRows.set([...this.allRows()]);
|
||||
}
|
||||
|
||||
toggleRowFilter(category: 'matched' | 'issues' | 'unmatched') {
|
||||
switch (category) {
|
||||
case 'matched': this.showMatched.update(v => !v); break;
|
||||
case 'issues': this.showIssues.update(v => !v); break;
|
||||
case 'unmatched': this.showUnmatched.update(v => !v); break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
openRemapRulesModal() {
|
||||
const ref = this.modalService.open(ManageRemapRulesModalComponent, {size: 'lg'});
|
||||
ref.closed.subscribe((hasModifications: boolean) => {
|
||||
if (hasModifications) {
|
||||
this.cblService.getRemapRules().subscribe(rules => {
|
||||
this.remapRules.set(rules);
|
||||
this.remapRules.set([...rules]);
|
||||
this.validateCurrentFile();
|
||||
});
|
||||
}
|
||||
@@ -369,20 +405,21 @@ export class ImportCblModalComponent implements OnInit {
|
||||
this.allRows.set(rows);
|
||||
}
|
||||
|
||||
private async handleSeriesSelection(row: CblIssueRow, seriesId: number, seriesName: string) {
|
||||
const confirmed = await this.confirmService.confirm(
|
||||
translate('toasts.save-remap-rule', {from: row.result.series, to: seriesName})
|
||||
);
|
||||
if (!confirmed) return;
|
||||
|
||||
private async handleSeriesSelection(row: CblIssueRow, seriesId: number) {
|
||||
// Remember this series for auto-continue after re-validation
|
||||
this.pendingAutoEditSeries = row.result.series;
|
||||
|
||||
this.cblService.createRemapRule(row.result.series, seriesId).subscribe(rule => {
|
||||
this.cblService.createRemapRule(row.result.series, seriesId, {
|
||||
cblVolume: row.result.volume || undefined, // Pass the volume if it's available to ensure volume-level mapping works
|
||||
}).subscribe(rule => {
|
||||
row.remapRuleId = rule.id;
|
||||
this.remapRules.set([...this.remapRules(), rule]);
|
||||
this.cancelResolve();
|
||||
this.validateCurrentFile();
|
||||
|
||||
// The backend might have updated the ruleset, so refresh them
|
||||
this.cblService.getRemapRules().subscribe(rules => {
|
||||
this.remapRules.set([...rules]);
|
||||
this.cancelResolve();
|
||||
this.validateCurrentFile();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -394,9 +431,12 @@ export class ImportCblModalComponent implements OnInit {
|
||||
chapterId: chapter.id,
|
||||
}).subscribe(rule => {
|
||||
row.remapRuleId = rule.id;
|
||||
this.remapRules.set([...this.remapRules(), rule]);
|
||||
this.cancelResolve();
|
||||
this.validateCurrentFile();
|
||||
|
||||
this.cblService.getRemapRules().subscribe(rules => {
|
||||
this.remapRules.set([...rules]);
|
||||
this.cancelResolve();
|
||||
this.validateCurrentFile();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -460,4 +500,8 @@ export class ImportCblModalComponent implements OnInit {
|
||||
|
||||
return settings;
|
||||
}
|
||||
|
||||
protected readonly CblImportReason = CblImportReason;
|
||||
protected readonly CblMatchTier = CblMatchTier;
|
||||
protected readonly WikiLink = WikiLink;
|
||||
}
|
||||
|
||||
+25
-15
@@ -15,20 +15,30 @@
|
||||
<span>{{rule.seriesNameAtMapping}}</span>
|
||||
</div>
|
||||
<small class="text-muted">
|
||||
@if (rule.cblVolume) {
|
||||
{{t('volume-num', {num: rule.cblVolume})}}
|
||||
}
|
||||
@if (rule.cblNumber) {
|
||||
{{t('issue-num', {num: rule.cblNumber})}}
|
||||
}
|
||||
@if (!rule.cblVolume && !rule.cblNumber) {
|
||||
{{t('series-level')}}
|
||||
}
|
||||
@if (rule.chapterId) {
|
||||
<i class="fa fa-arrow-right mx-1" aria-hidden="true"></i>
|
||||
<app-entity-title [libraryType]="rule.libraryType"
|
||||
[prioritizeTitleName]="false"
|
||||
[entity]="buildChapterStub(rule)" />
|
||||
@switch (rule.kind) {
|
||||
@case (CblRemapRuleKind.Series) {
|
||||
{{t('series-level')}}
|
||||
@if (rule.cblVolume) {
|
||||
{{t('volume-num', {num: rule.cblVolume})}}
|
||||
}
|
||||
}
|
||||
@case (CblRemapRuleKind.Volume) {
|
||||
{{t('volume-num', {num: rule.cblVolume})}}
|
||||
<i class="fa fa-arrow-right mx-1" aria-hidden="true"></i>
|
||||
{{t('volume-target', {num: rule.volumeNumber})}}
|
||||
}
|
||||
@case (CblRemapRuleKind.Chapter) {
|
||||
@if (rule.cblVolume) {
|
||||
{{t('volume-num', {num: rule.cblVolume})}}
|
||||
}
|
||||
@if (rule.cblNumber) {
|
||||
{{t('issue-num', {num: rule.cblNumber})}}
|
||||
}
|
||||
<i class="fa fa-arrow-right mx-1" aria-hidden="true"></i>
|
||||
<app-entity-title [libraryType]="rule.libraryType"
|
||||
[prioritizeTitleName]="false"
|
||||
[entity]="buildChapterStub(rule)" />
|
||||
}
|
||||
}
|
||||
</small>
|
||||
</div>
|
||||
@@ -36,7 +46,7 @@
|
||||
@if (rule.isGlobal) {
|
||||
<span class="badge bg-info">{{t('global')}}</span>
|
||||
}
|
||||
@if (rule.appUserId === currentUserId()) {
|
||||
@if (rule.appUserId === currentUserId() && !accountService.hasReadOnlyRole()) {
|
||||
<button class="btn btn-sm btn-outline-danger" (click)="deleteRule(rule)">
|
||||
<i class="fa fa-trash" aria-hidden="true"></i>
|
||||
<span class="visually-hidden">{{t('delete-rule')}}</span>
|
||||
|
||||
+9
-3
@@ -1,11 +1,13 @@
|
||||
import {ChangeDetectionStrategy, Component, computed, inject, OnInit, signal} from '@angular/core';
|
||||
import {NgbActiveModal} from '@ng-bootstrap/ng-bootstrap';
|
||||
import {TranslocoDirective} from '@jsverse/transloco';
|
||||
import {translate, TranslocoDirective} from '@jsverse/transloco';
|
||||
import {CblService} from '../../../_services/cbl.service';
|
||||
import {AccountService} from '../../../_services/account.service';
|
||||
import {RemapRule} from '../../../_models/reading-list/cbl/remap-rule';
|
||||
import {CblRemapRuleKind} from '../../../_models/reading-list/cbl/cbl-remap-rule-kind.enum';
|
||||
import {Chapter} from '../../../_models/chapter';
|
||||
import {EntityTitleComponent} from '../../../cards/entity-title/entity-title.component';
|
||||
import {ConfirmService} from "../../../shared/confirm.service";
|
||||
|
||||
@Component({
|
||||
selector: 'app-manage-remap-rules-modal',
|
||||
@@ -20,7 +22,9 @@ import {EntityTitleComponent} from '../../../cards/entity-title/entity-title.com
|
||||
export class ManageRemapRulesModalComponent implements OnInit {
|
||||
private readonly modal = inject(NgbActiveModal);
|
||||
private readonly cblService = inject(CblService);
|
||||
private readonly accountService = inject(AccountService);
|
||||
protected readonly accountService = inject(AccountService);
|
||||
private readonly confirmService = inject(ConfirmService);
|
||||
protected readonly CblRemapRuleKind = CblRemapRuleKind;
|
||||
|
||||
rules = signal<RemapRule[]>([]);
|
||||
hasModifications = false;
|
||||
@@ -47,7 +51,9 @@ export class ManageRemapRulesModalComponent implements OnInit {
|
||||
this.cblService.getRemapRules().subscribe(rules => this.rules.set(rules));
|
||||
}
|
||||
|
||||
deleteRule(rule: RemapRule) {
|
||||
async deleteRule(rule: RemapRule) {
|
||||
if (!await this.confirmService.confirm(translate('toasts.confirm-delete-cbl-remap-rule'))) return;
|
||||
|
||||
this.cblService.deleteRemapRule(rule.id).subscribe(() => {
|
||||
this.rules.set(this.rules().filter(r => r.id !== rule.id));
|
||||
this.hasModifications = true;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<div class="position-relative">
|
||||
@if (!accountService.hasReadOnlyRole()) {
|
||||
<div class="position-absolute custom-position d-flex gap-2">
|
||||
<button class="btn btn-outline-primary" (click)="selectedList.set(undefined)" [title]="t('add')">
|
||||
<button class="btn btn-outline-primary" [disabled]="selectedList() === undefined" (click)="selectedList.set(undefined)" [title]="t('add')">
|
||||
<i class="fa fa-plus" aria-hidden="true"></i><span class="phone-hidden ms-1">{{t('add')}}</span>
|
||||
</button>
|
||||
<button class="btn btn-outline-primary" (click)="openBrowseModal()" [title]="t('browse-repo')">
|
||||
@@ -149,7 +149,7 @@
|
||||
</div>
|
||||
|
||||
@if (selectedItem.canSync) {
|
||||
<span class="pill p-1 me-1 provider">{{selectedItem.provider | readingListProvider}}</span>
|
||||
<span class="pill p-1 me-1 provider">{{t('can-sync')}}</span>
|
||||
}
|
||||
|
||||
<div class="text-muted mt-1" style="font-size: 0.9rem;">
|
||||
@@ -160,7 +160,7 @@
|
||||
@if (selectedItem.ageRating) {
|
||||
<span> · </span>
|
||||
}
|
||||
<span>{{selectedItem.startingMonth}}/{{selectedItem.startingYear}} – {{selectedItem.endingMonth}}/{{selectedItem.endingYear}}</span>
|
||||
<span>{{selectedItem.startingYear}} – {{selectedItem.endingYear}}</span>
|
||||
}
|
||||
@if (selectedItem.ageRating || selectedItem.startingYear > 0) {
|
||||
<span> · </span>
|
||||
@@ -192,7 +192,7 @@
|
||||
}
|
||||
|
||||
} @else {
|
||||
<p>You lack the ability to interact with this content.</p>
|
||||
<p>{{t('not-authorized')}}</p>
|
||||
}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -32,7 +32,7 @@ import {ReadMoreComponent} from '../../shared/read-more/read-more.component';
|
||||
import {ImageComponent} from '../../shared/image/image.component';
|
||||
import {AgeRatingPipe} from '../../_pipes/age-rating.pipe';
|
||||
import {RouterLink} from '@angular/router';
|
||||
import {fullscreenModal} from "../../_models/modal/modal-options";
|
||||
import {editModal} from "../../_models/modal/modal-options";
|
||||
import {ModalResult} from "../../_models/modal/modal-result";
|
||||
|
||||
@Component({
|
||||
@@ -194,7 +194,7 @@ export class CblManagerComponent implements OnInit {
|
||||
}
|
||||
|
||||
private openImportModal(savedFiles: CblSavedFile[]) {
|
||||
const ref = this.modalService.open(ImportCblModalComponent, fullscreenModal());
|
||||
const ref = this.modalService.open(ImportCblModalComponent, editModal());
|
||||
ref.setInput('savedFiles', savedFiles);
|
||||
ref.closed.subscribe((res: ModalResult) => {
|
||||
this.refreshLists();
|
||||
|
||||
@@ -113,34 +113,35 @@ export class ManageRemapRulesComponent implements OnInit {
|
||||
const selectedSeries = this.selectedSeries();
|
||||
if (!cblSeriesName?.trim() || !selectedSeries) return;
|
||||
|
||||
|
||||
const issueDetail = cblVolume?.trim() ? { cblVolume: cblVolume.trim() } : undefined;
|
||||
this.cblService.createRemapRule(cblSeriesName.trim(), selectedSeries.seriesId, issueDetail).subscribe(rule => {
|
||||
this.rules.update(rules => [...rules, rule]);
|
||||
this.showCreateForm.set(false);
|
||||
this.resetCreateForm();
|
||||
this.toastr.success(translate('manage-remap-rules.rule-created'));
|
||||
this.toastr.success(translate('toasts.cbl-remap-rule-created'));
|
||||
});
|
||||
}
|
||||
|
||||
async deleteRule(rule: RemapRule) {
|
||||
if (!await this.confirmService.confirm(translate('manage-remap-rules.confirm-delete'))) return;
|
||||
if (!await this.confirmService.confirm(translate('toasts.confirm-delete-cbl-remap-rule'))) return;
|
||||
this.cblService.deleteRemapRule(rule.id).subscribe(() => {
|
||||
this.rules.update(rules => rules.filter(r => r.id !== rule.id));
|
||||
this.toastr.success(translate('manage-remap-rules.rule-deleted'));
|
||||
this.toastr.success(translate('toasts.cbl-remap-rule-deleted'));
|
||||
});
|
||||
}
|
||||
|
||||
promoteRule(rule: RemapRule) {
|
||||
this.cblService.promoteRule(rule.id).subscribe(updated => {
|
||||
this.rules.update(rules => rules.map(r => r.id === updated.id ? updated : r));
|
||||
this.toastr.success(translate('manage-remap-rules.rule-promoted'));
|
||||
this.toastr.success(translate('toasts.cbl-remap-rule-promoted'));
|
||||
});
|
||||
}
|
||||
|
||||
demoteRule(rule: RemapRule) {
|
||||
this.cblService.demoteRule(rule.id).subscribe(updated => {
|
||||
this.rules.update(rules => rules.map(r => r.id === updated.id ? updated : r));
|
||||
this.toastr.success(translate('manage-remap-rules.rule-demoted'));
|
||||
this.toastr.success(translate('toasts.cbl-remap-rule-demoted'));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2099,34 +2099,37 @@
|
||||
"filter-all": "All",
|
||||
"filter-local": "Local",
|
||||
"no-results": "No matching reading lists",
|
||||
"add": "Add",
|
||||
"add": "{{common.add}}",
|
||||
"sync": "Sync",
|
||||
"delete": "Delete",
|
||||
"delete": "{{common.delete}}",
|
||||
"view-list": "View Reading List",
|
||||
"last-synced": "Last synced {{date}}",
|
||||
"source": "Source"
|
||||
"source": "Source",
|
||||
"can-sync": "Can Sync",
|
||||
"not-authorized": "You lack the ability to interact with this content"
|
||||
},
|
||||
|
||||
"import-cbl-modal": {
|
||||
"title": "CBL Importer",
|
||||
"description": "On this screen you can build remap rules to automatically map series or issues to Kavita entities. Remap rules will apply to this and future lists.",
|
||||
"close": "{{common.close}}",
|
||||
"matched-count": "{{count}} matched",
|
||||
"issues-count": "{{count}} issues",
|
||||
"unmatched-count": "{{count}} missing",
|
||||
"matched-items-header": "Matched Items",
|
||||
"issues-header": "Items Needing Attention",
|
||||
"remap-rules-header": "Remap Rules",
|
||||
"tier-label": "Matched via",
|
||||
"skip-item": "Skip",
|
||||
"select-series": "Search for series...",
|
||||
"select-series": "Search for series",
|
||||
"pick-candidate": "Select correct series",
|
||||
"save-remap-prompt": "Save this mapping as a remap rule for future imports?",
|
||||
"finalize": "Import All",
|
||||
"finalize-single": "Import",
|
||||
"previous-file": "Previous",
|
||||
"next-file": "Next",
|
||||
"file-counter": "File {{current}} of {{total}}",
|
||||
"no-remap-rules": "No remap rules",
|
||||
"delete-rule": "Delete",
|
||||
"delete-rule": "{{common.delete}}",
|
||||
"manage-rules": "Manage Remap Rules",
|
||||
"col-series": "Requested Series",
|
||||
"col-vol-issue": "Requested Vol / Issue",
|
||||
@@ -2135,30 +2138,31 @@
|
||||
"col-matched-issue": "Matched Issue",
|
||||
"col-action": "Action",
|
||||
"resolve": "Resolve",
|
||||
"select-chapter": "Select the matching chapter...",
|
||||
"hide-matched": "Hide Matched",
|
||||
"show-matched": "Show Matched",
|
||||
"select-chapter": "Select the matching chapter",
|
||||
"edit-match": "Edit match",
|
||||
"cancel": "Cancel",
|
||||
"cancel": "{{common.cancel}}",
|
||||
"undo": "Undo",
|
||||
"remap-rule-used": "Matched via remap rule",
|
||||
"import-success": "Successfully imported {{count}} reading list(s)",
|
||||
"processing": "Processing...",
|
||||
"processing": "Processing",
|
||||
"new-list": "New",
|
||||
"update-list": "Update",
|
||||
"volume-num": "{{common.volume-num-shorthand}}",
|
||||
"issue-num": "{{common.issue-num-shorthand}}"
|
||||
"issue-num": "{{common.issue-num-shorthand}}",
|
||||
"refresh": "Refresh",
|
||||
"wiki": "Wiki"
|
||||
},
|
||||
|
||||
"manage-remap-rules-modal": {
|
||||
"title": "Manage Remap Rules",
|
||||
"close": "{{common.close}}",
|
||||
"no-rules": "No remap rules saved",
|
||||
"delete-rule": "Delete",
|
||||
"delete-rule": "{{common.delete}}",
|
||||
"volume-num": "{{common.volume-num-shorthand}}",
|
||||
"issue-num": "{{common.issue-num-shorthand}}",
|
||||
"global": "Global",
|
||||
"series-level": "Series-level"
|
||||
"series-level": "Series-level",
|
||||
"volume-target": "{{common.volume-num-shorthand}}"
|
||||
},
|
||||
|
||||
"manage-remap-rules": {
|
||||
@@ -2184,11 +2188,6 @@
|
||||
"demote": "Demote",
|
||||
"demote-tooltip": "Demote to user rule",
|
||||
"by": "by",
|
||||
"rule-created": "Remap rule created",
|
||||
"rule-deleted": "Remap rule deleted",
|
||||
"rule-promoted": "Rule promoted to global",
|
||||
"rule-demoted": "Rule demoted to user scope",
|
||||
"confirm-delete": "Are you sure you want to delete this remap rule?",
|
||||
"volume-num": "{{common.volume-num-shorthand}}",
|
||||
"issue-num": "{{common.issue-num-shorthand}}",
|
||||
"cbl-volume-label": "CBL Volume",
|
||||
@@ -2213,7 +2212,8 @@
|
||||
"cancel": "{{common.cancel}}",
|
||||
"served-from-cache": "Served from Cache",
|
||||
"download": "Download",
|
||||
"owned-label": "Owned"
|
||||
"owned-label": "Owned",
|
||||
"close": "{{common.close}}"
|
||||
|
||||
},
|
||||
|
||||
@@ -3606,7 +3606,12 @@
|
||||
"font-in-use": "Cannot delete as the font is in use by one or more users.",
|
||||
"k+-resend-welcome-email-success": "An email was sent to your Kavita+ email",
|
||||
"profile-unauthorized": "This user is not sharing their profile",
|
||||
"confirm-delete-client-device": "Are you sure you want to delete this device?"
|
||||
"confirm-delete-client-device": "Are you sure you want to delete this device?",
|
||||
"confirm-delete-cbl-remap-rule": "Are you sure you want to delete this remap rule?",
|
||||
"cbl-remap-rule-created": "Remap rule created",
|
||||
"cbl-remap-rule-deleted": "Remap rule deleted",
|
||||
"cbl-remap-rule-promoted": "Rule promoted to global",
|
||||
"cbl-remap-rule-demoted": "Rule demoted to user scope"
|
||||
},
|
||||
|
||||
"read-time-pipe": {
|
||||
|
||||
Reference in New Issue
Block a user