Mise en place de l'import / export des données pour pouvoir sauvegarder les lores / campagnes

This commit is contained in:
2026-06-18 09:49:34 +02:00
parent 7dfa9c3655
commit 72fe5e6215
19 changed files with 1775 additions and 10 deletions

View File

@@ -66,6 +66,13 @@ export interface OllamaModelInfo {
context_length: number;
}
/** Resultat d'un import de donnees (compteurs par type + images). */
export interface ImportResult {
created: Record<string, number>;
imagesUploaded: number;
imagesReused: number;
}
@Injectable({ providedIn: 'root' })
export class SettingsService {
private readonly apiUrl = '/api/settings';
@@ -85,6 +92,21 @@ export class SettingsService {
return this.http.put<AppSettings>(this.apiUrl, patch, this.authOptions);
}
// --- Export / Import des donnees (sauvegarde & transfert d'instance) -------
/** Telecharge l'export complet du contenu (zip : data.json + images). */
exportData(): Observable<Blob> {
return this.http.get('/api/admin/data/export',
{ withCredentials: true, responseType: 'blob' });
}
/** Importe un zip d'export en mode FUSION (ajoute, ne remplace pas). */
importData(file: File): Observable<ImportResult> {
const form = new FormData();
form.append('file', file);
return this.http.post<ImportResult>('/api/admin/data/import', form, this.authOptions);
}
listOllamaModels(): Observable<{ models: string[] }> {
return this.http.get<{ models: string[] }>(`${this.apiUrl}/models/ollama`, this.authOptions);
}

View File

@@ -112,7 +112,7 @@
<label for="onemin-key">{{ 'settings.apiKey.label' | translate }}</label>
<input
id="onemin-key"
type="password"
type="password" autocomplete="new-password" data-1p-ignore data-lpignore="true" data-bwignore
[(ngModel)]="oneminApiKeyInput"
[placeholder]="(settings.onemin_api_key_set ? 'settings.apiKey.placeholderSet' : 'settings.apiKey.placeholderOnemin') | translate">
@if (settings.onemin_api_key_set) {
@@ -149,7 +149,7 @@
<label for="openrouter-key">{{ 'settings.apiKey.label' | translate }}</label>
<input
id="openrouter-key"
type="password"
type="password" autocomplete="new-password" data-1p-ignore data-lpignore="true" data-bwignore
[(ngModel)]="openrouterApiKeyInput"
[placeholder]="(settings.openrouter_api_key_set ? 'settings.apiKey.placeholderSet' : 'settings.apiKey.placeholderOpenrouter') | translate">
@if (settings.openrouter_api_key_set) {
@@ -183,7 +183,7 @@
<label for="mistral-key">{{ 'settings.apiKey.label' | translate }}</label>
<input
id="mistral-key"
type="password"
type="password" autocomplete="new-password" data-1p-ignore data-lpignore="true" data-bwignore
[(ngModel)]="mistralApiKeyInput"
[placeholder]="(settings.mistral_api_key_set ? 'settings.apiKey.placeholderSet' : 'settings.apiKey.placeholderMistral') | translate">
@if (settings.mistral_api_key_set) {
@@ -213,7 +213,7 @@
<label for="gemini-key">{{ 'settings.apiKey.label' | translate }}</label>
<input
id="gemini-key"
type="password"
type="password" autocomplete="new-password" data-1p-ignore data-lpignore="true" data-bwignore
[(ngModel)]="geminiApiKeyInput"
[placeholder]="(settings.gemini_api_key_set ? 'settings.apiKey.placeholderSet' : 'settings.apiKey.placeholderGemini') | translate">
@if (settings.gemini_api_key_set) {
@@ -285,7 +285,7 @@
<span class="key-state">{{ 'settings.apiKey.configured' | translate }}</span>
}
</label>
<input id="emb-mistral-key" type="password" [(ngModel)]="mistralApiKeyInput"
<input id="emb-mistral-key" type="password" autocomplete="new-password" data-1p-ignore data-lpignore="true" data-bwignore [(ngModel)]="mistralApiKeyInput"
[placeholder]="(settings.mistral_api_key_set ? 'settings.apiKey.placeholderSet' : 'settings.apiKey.placeholderMistral') | translate">
@if (settings.mistral_api_key_set) {
<label class="checkbox">
@@ -372,6 +372,33 @@
<!-- Mises a jour conteneurs + licence Patreon + canal beta (sous-composant) -->
<app-settings-updates-section></app-settings-updates-section>
<!-- Sauvegarde / Restauration des donnees (export & import) -->
<section class="card">
<h2>{{ 'settings.data.title' | translate }}</h2>
<p class="hint" [innerHTML]="'settings.data.hint' | translate"></p>
<div class="actions">
<button type="button" class="btn-secondary" (click)="exportData()" [disabled]="exporting || importing">
<span>{{ (exporting ? 'settings.data.exporting' : 'settings.data.exportBtn') | translate }}</span>
</button>
<button type="button" class="btn-secondary" (click)="importInput.click()" [disabled]="exporting || importing">
<span>{{ (importing ? 'settings.data.importing' : 'settings.data.importBtn') | translate }}</span>
</button>
<input #importInput type="file" accept=".zip,application/zip" hidden (change)="onImportFileSelected($event)">
</div>
@if (dataError) {
<div class="alert alert-error">
<lucide-icon [img]="AlertCircle" [size]="16"></lucide-icon>
<span>{{ dataError }}</span>
</div>
}
@if (dataMessage) {
<div class="alert alert-success">
<lucide-icon [img]="Check" [size]="16"></lucide-icon>
<span>{{ dataMessage }}</span>
</div>
}
</section>
@if (settings) {
<div class="actions">
<button class="btn-primary" (click)="save()" [disabled]="saving">

View File

@@ -4,7 +4,7 @@ import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { LucideAngularModule, ArrowLeft, RefreshCw, Save, Check, AlertCircle, Plus } from 'lucide-angular';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { SettingsService, AppSettings, AppSettingsUpdate, OneMinModelGroup, OpenRouterModel, MistralModel, GeminiModel } from '../services/settings.service';
import { SettingsService, AppSettings, AppSettingsUpdate, OneMinModelGroup, OpenRouterModel, MistralModel, GeminiModel, ImportResult } from '../services/settings.service';
import { LayoutService } from '../services/layout.service';
import { UpdatesSectionComponent } from './updates-section/updates-section.component';
import { OllamaModelManagerComponent } from './ollama-model-manager/ollama-model-manager.component';
@@ -332,6 +332,60 @@ export class SettingsComponent implements OnInit {
this.router.navigate(['/lore']);
}
// --- Sauvegarde / Restauration des donnees --------------------------------
exporting = false;
importing = false;
dataMessage = '';
dataError = '';
/** Telecharge l'export complet (zip) via un lien temporaire. */
exportData(): void {
this.exporting = true;
this.dataMessage = '';
this.dataError = '';
this.settingsService.exportData().subscribe({
next: (blob) => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'loremind-export.zip';
a.click();
URL.revokeObjectURL(url);
this.exporting = false;
},
error: (err) => {
this.dataError = this.extractError(err, this.translate.instant('settings.data.exportError'));
this.exporting = false;
}
});
}
/** Déclenché par le sélecteur de fichier : importe le zip choisi (fusion). */
onImportFileSelected(event: Event): void {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
input.value = ''; // permet de re-sélectionner le même fichier
if (!file) return;
if (!confirm(this.translate.instant('settings.data.importConfirm'))) return;
this.importing = true;
this.dataMessage = '';
this.dataError = '';
this.settingsService.importData(file).subscribe({
next: (result: ImportResult) => {
const total = Object.values(result.created).reduce((a, b) => a + b, 0);
this.dataMessage = this.translate.instant('settings.data.importSuccess', { count: total });
this.importing = false;
},
error: (err) => {
this.dataError = this.extractError(err, this.translate.instant('settings.data.importError'));
this.importing = false;
}
});
}
private extractError(err: any, fallback: string): string {
if (err?.error?.detail) return String(err.error.detail);
if (err?.message) return err.message;

View File

@@ -50,6 +50,18 @@
},
"settings": {
"title": "Settings",
"data": {
"title": "Data backup",
"hint": "Export all your content (game systems, lores, campaigns + images) to a file, to back it up or move it to another instance. Importing <strong>adds</strong> the content without deleting what's already there.",
"exportBtn": "Export data",
"exporting": "Exporting…",
"importBtn": "Import a file",
"importing": "Importing…",
"importConfirm": "Import this file? Its content will be added to your instance (nothing is deleted).",
"importSuccess": "Import successful: {{count}} item(s) added.",
"exportError": "Export failed.",
"importError": "Import failed (invalid file?)."
},
"language": {
"title": "Interface language",
"hint": "Choose the application display language. The change is applied instantly and remembered on this device."

View File

@@ -50,6 +50,18 @@
},
"settings": {
"title": "Paramètres",
"data": {
"title": "Sauvegarde des données",
"hint": "Exportez tout votre contenu (systèmes de jeu, lores, campagnes + images) dans un fichier, pour le sauvegarder ou le transférer vers une autre instance. L'import <strong>ajoute</strong> le contenu sans supprimer l'existant.",
"exportBtn": "Exporter les données",
"exporting": "Export en cours…",
"importBtn": "Importer un fichier",
"importing": "Import en cours…",
"importConfirm": "Importer ce fichier ? Le contenu sera ajouté à votre instance (rien n'est supprimé).",
"importSuccess": "Import réussi : {{count}} élément(s) ajouté(s).",
"exportError": "Échec de l'export.",
"importError": "Échec de l'import (fichier invalide ?)."
},
"language": {
"title": "Langue de l'interface",
"hint": "Choix de la langue d'affichage de l'application. Le changement est immédiat et mémorisé sur cet appareil."