CBL Import Polish 4 (#4576)

This commit is contained in:
Joe Milazzo
2026-03-31 15:23:01 -05:00
committed by GitHub
parent f6796583bc
commit c8bcbc3d58
35 changed files with 979 additions and 420 deletions
@@ -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;
+1
View File
@@ -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/'
}
@@ -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() {
@@ -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;
}
@@ -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;
}
@@ -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>
@@ -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'));
});
}
}
+26 -21
View File
@@ -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": {