Allow importing and exporting magic shelf JSON from the UI (#3183) (#3210)

This commit is contained in:
ACX
2026-03-06 17:29:32 -07:00
committed by GitHub
parent d337def504
commit 484da7936d
8 changed files with 117 additions and 2 deletions

View File

@@ -244,6 +244,17 @@ export class LibraryShelfMenuService {
this.dialogLauncherService.openMagicShelfEditDialog((entity?.id as number));
}
},
{
label: this.t.translate('book.shelfMenuService.magicShelf.exportJson'),
icon: 'pi pi-copy',
command: () => {
if (entity?.filterJson) {
navigator.clipboard.writeText(entity.filterJson).then(() => {
this.messageService.add({severity: 'success', summary: this.t.translate('common.success'), detail: this.t.translate('book.shelfMenuService.toast.magicShelfJsonCopiedDetail')});
});
}
}
},
{
separator: true
},

View File

@@ -72,6 +72,16 @@
}
</div>
@if (showImportPanel) {
<div class="import-panel">
<textarea pTextarea [(ngModel)]="importJson" [ngModelOptions]="{standalone: true}" rows="10" [placeholder]="t('importJson.placeholder')" class="import-textarea"></textarea>
<div class="import-actions">
<p-button [label]="t('actions.cancel')" severity="secondary" [outlined]="true" (onClick)="toggleImportPanel()" size="small"/>
<p-button [label]="t('importJson.apply')" icon="pi pi-check" severity="success" (onClick)="applyImportedJson()" size="small"/>
</div>
</div>
}
<div class="rules-container">
<ng-container *ngTemplateOutlet="groupTemplate; context: { group: form.get('group') }"></ng-container>
</div>
@@ -79,6 +89,16 @@
</div>
<div class="dialog-footer">
@if (!editMode) {
<p-button
icon="pi pi-file-import"
[label]="t('importJson.buttonLabel')"
[outlined]="true"
severity="info"
size="small"
(onClick)="toggleImportPanel()"
/>
}
<div class="footer-actions">
<p-button
[label]="t('actions.cancel')"

View File

@@ -15,6 +15,28 @@
border-bottom: none;
}
.import-panel {
margin-bottom: 0.75rem;
padding: 1rem;
background: var(--overlay-background);
border: 1px solid var(--border-color);
border-radius: 8px;
.import-textarea {
width: 100%;
font-family: monospace;
font-size: 0.85rem;
resize: vertical;
}
.import-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 0.75rem;
}
}
.magic-shelf-content {
padding: 2rem;
background: var(--card-background);
@@ -190,6 +212,7 @@
.dialog-footer {
@include panel.dialog-footer-end;
justify-content: space-between;
}
.save-button-section {

View File

@@ -1,5 +1,5 @@
import {Component, inject, OnInit} from '@angular/core';
import {AbstractControl, FormArray, FormControl, FormGroup, ReactiveFormsModule, Validators} from '@angular/forms';
import {AbstractControl, FormArray, FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators} from '@angular/forms';
import {Button} from 'primeng/button';
import {NgTemplateOutlet} from '@angular/common';
import {InputText} from 'primeng/inputtext';
@@ -24,6 +24,7 @@ import {BookService} from '../../book/service/book.service';
import {ShelfService} from '../../book/service/shelf.service';
import {Shelf} from '../../book/model/shelf.model';
import {TranslocoDirective, TranslocoService} from '@jsverse/transloco';
import {TextareaModule} from 'primeng/textarea';
export type RuleOperator =
| 'equals'
@@ -230,6 +231,7 @@ const READ_STATUS_KEYS: Record<string, string> = {
standalone: true,
imports: [
ReactiveFormsModule,
FormsModule,
NgTemplateOutlet,
InputText,
Select,
@@ -241,7 +243,8 @@ const READ_STATUS_KEYS: Record<string, string> = {
CheckboxModule,
IconDisplayComponent,
Tooltip,
TranslocoDirective
TranslocoDirective,
TextareaModule
]
})
export class MagicShelfComponent implements OnInit {
@@ -450,6 +453,8 @@ export class MagicShelfComponent implements OnInit {
shelfId: number | null = null;
isAdmin: boolean = false;
editMode!: boolean;
showImportPanel = false;
importJson = '';
libraryService = inject(LibraryService);
shelfService = inject(ShelfService);
@@ -794,6 +799,40 @@ export class MagicShelfComponent implements OnInit {
this.form.get('isPublic')?.setValue(checked);
}
toggleImportPanel() {
this.showImportPanel = !this.showImportPanel;
if (this.showImportPanel) {
this.importJson = '';
}
}
applyImportedJson() {
const trimmed = this.importJson.trim();
if (!trimmed) {
this.messageService.add({severity: 'warn', summary: this.t.translate('magicShelf.toast.validationErrorSummary'), detail: this.t.translate('magicShelf.importJson.emptyError')});
return;
}
let parsed: GroupRule;
try {
parsed = JSON.parse(trimmed);
} catch {
this.messageService.add({severity: 'error', summary: this.t.translate('magicShelf.toast.errorSummary'), detail: this.t.translate('magicShelf.importJson.parseError')});
return;
}
if (parsed.type !== 'group' || !Array.isArray(parsed.rules)) {
this.messageService.add({severity: 'error', summary: this.t.translate('magicShelf.toast.errorSummary'), detail: this.t.translate('magicShelf.importJson.structureError')});
return;
}
const builtGroup = this.buildGroupFromData(parsed);
this.form.setControl('group', builtGroup);
this.showImportPanel = false;
this.importJson = '';
this.messageService.add({severity: 'success', summary: this.t.translate('magicShelf.toast.successSummary'), detail: this.t.translate('magicShelf.importJson.successDetail')});
}
submit() {
if (!this.hasAtLeastOneValidRule(this.group)) {
this.messageService.add({severity: 'warn', summary: this.t.translate('magicShelf.toast.validationErrorSummary'), detail: this.t.translate('magicShelf.toast.validationErrorDetail')});

View File

@@ -279,6 +279,7 @@
"magicShelf": {
"optionsLabel": "Options",
"editMagicShelf": "Edit Magic Shelf",
"exportJson": "Copy JSON",
"deleteMagicShelf": "Delete Magic Shelf"
},
"confirm": {
@@ -298,6 +299,7 @@
"shelfDeleteFailedDetail": "Failed to delete shelf",
"magicShelfDeletedDetail": "Magic shelf was deleted",
"magicShelfDeleteFailedDetail": "Failed to delete shelf",
"magicShelfJsonCopiedDetail": "Magic shelf JSON copied to clipboard",
"failedSummary": "Failed"
},
"loading": {

View File

@@ -265,6 +265,15 @@
"comicCoverArtists": "Cover Artists",
"comicEditors": "Editors"
},
"importJson": {
"buttonLabel": "Import",
"placeholder": "{\n \"type\": \"group\",\n \"join\": \"and\",\n \"rules\": [\n { \"field\": \"readStatus\", \"operator\": \"equals\", \"value\": \"READING\" }\n ]\n}",
"apply": "Apply",
"emptyError": "Please paste a JSON configuration first.",
"parseError": "Invalid JSON. Please check the syntax and try again.",
"structureError": "Invalid structure. The JSON must have \"type\": \"group\" and a \"rules\" array.",
"successDetail": "Rules imported successfully. Review and save when ready."
},
"toast": {
"validationErrorSummary": "Validation Error",
"validationErrorDetail": "You must add at least one valid rule before saving.",

View File

@@ -279,6 +279,7 @@
"magicShelf": {
"optionsLabel": "Opciones",
"editMagicShelf": "Editar estante mágico",
"exportJson": "Copiar JSON",
"deleteMagicShelf": "Eliminar estante mágico"
},
"confirm": {
@@ -298,6 +299,7 @@
"shelfDeleteFailedDetail": "Error al eliminar el estante",
"magicShelfDeletedDetail": "Estante mágico eliminado",
"magicShelfDeleteFailedDetail": "Error al eliminar el estante",
"magicShelfJsonCopiedDetail": "JSON del estante mágico copiado al portapapeles",
"failedSummary": "Error"
},
"loading": {

View File

@@ -265,6 +265,15 @@
"comicCoverArtists": "Artistas de Portada",
"comicEditors": "Editores"
},
"importJson": {
"buttonLabel": "Importar",
"placeholder": "{\n \"type\": \"group\",\n \"join\": \"and\",\n \"rules\": [\n { \"field\": \"readStatus\", \"operator\": \"equals\", \"value\": \"READING\" }\n ]\n}",
"apply": "Aplicar",
"emptyError": "Pegue una configuración JSON primero.",
"parseError": "JSON inválido. Verifique la sintaxis e intente de nuevo.",
"structureError": "Estructura inválida. El JSON debe tener \"type\": \"group\" y un arreglo \"rules\".",
"successDetail": "Reglas importadas exitosamente. Revise y guarde cuando esté listo."
},
"toast": {
"validationErrorSummary": "Error de Validación",
"validationErrorDetail": "Debe agregar al menos una regla válida antes de guardar.",