refactor: stricter domain: theme

This commit is contained in:
2026-04-11 15:01:39 +02:00
parent c8bb82feb5
commit cea3dccef1
19 changed files with 143 additions and 52 deletions

View File

@@ -0,0 +1,221 @@
import { Injectable, inject } from '@angular/core';
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
import type { SavedThemeFileDescriptor } from '../../../../core/platform/electron/electron-api.models';
import type { SavedThemeSummary } from '../../domain/models/theme.model';
import { validateThemeDocument } from '../../domain/logic/theme-validation.logic';
const THEME_LIBRARY_REQUEST_TIMEOUT_MS = 4000;
function sanitizeSavedThemeStem(value: string): string {
const normalized = value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
return normalized.length > 0
? normalized
: 'theme';
}
function buildSavedThemeFileName(stem: string, suffix?: number): string {
return suffix && suffix > 1
? `${stem}-${suffix}.json`
: `${stem}.json`;
}
function fallbackThemeName(fileName: string): string {
return fileName.replace(/\.json$/i, '');
}
async function withTimeout<T>(operation: Promise<T>, label: string): Promise<T> {
let timeoutId: ReturnType<typeof setTimeout> | null = null;
try {
return await Promise.race([
operation,
new Promise<T>((_resolve, reject) => {
timeoutId = setTimeout(() => {
reject(new Error(`${label} timed out.`));
}, THEME_LIBRARY_REQUEST_TIMEOUT_MS);
})
]);
} finally {
if (timeoutId) {
clearTimeout(timeoutId);
}
}
}
@Injectable({ providedIn: 'root' })
export class ThemeLibraryStorageService {
private readonly electronBridge = inject(ElectronBridgeService);
get isAvailable(): boolean {
return this.electronBridge.isAvailable;
}
async getSavedThemesPath(): Promise<string | null> {
const electronApi = this.electronBridge.getApi();
if (!electronApi) {
return null;
}
try {
return await withTimeout(electronApi.getSavedThemesPath(), 'Loading the saved themes path');
} catch {
return null;
}
}
async listThemes(): Promise<SavedThemeSummary[]> {
const electronApi = this.electronBridge.getApi();
if (!electronApi) {
return [];
}
try {
const files = await withTimeout(electronApi.listSavedThemes(), 'Loading saved themes');
const summaries = await Promise.all(files.map(async (file) => await this.readSavedThemeSummary(file)));
return summaries.sort((left, right) => {
const nameOrder = left.themeName.localeCompare(right.themeName, undefined, { sensitivity: 'base' });
return nameOrder !== 0
? nameOrder
: right.modifiedAt - left.modifiedAt;
});
} catch {
return [];
}
}
async readThemeText(fileName: string): Promise<string | null> {
const electronApi = this.electronBridge.getApi();
if (!electronApi) {
return null;
}
try {
return await withTimeout(electronApi.readSavedTheme(fileName), `Reading saved theme ${fileName}`);
} catch {
return null;
}
}
async saveNewTheme(preferredName: string, text: string): Promise<string | null> {
const electronApi = this.electronBridge.getApi();
if (!electronApi) {
return null;
}
try {
const existingFiles = await withTimeout(electronApi.listSavedThemes(), 'Loading saved themes');
const existingNames = new Set(existingFiles.map((file) => file.fileName.toLowerCase()));
const stem = sanitizeSavedThemeStem(preferredName);
let suffix = 1;
let fileName = buildSavedThemeFileName(stem);
while (existingNames.has(fileName.toLowerCase())) {
suffix += 1;
fileName = buildSavedThemeFileName(stem, suffix);
}
await withTimeout(electronApi.writeSavedTheme(fileName, text), `Saving theme ${fileName}`);
return fileName;
} catch {
return null;
}
}
async overwriteTheme(fileName: string, text: string): Promise<boolean> {
const electronApi = this.electronBridge.getApi();
if (!electronApi) {
return false;
}
try {
return await withTimeout(electronApi.writeSavedTheme(fileName, text), `Saving theme ${fileName}`);
} catch {
return false;
}
}
async deleteTheme(fileName: string): Promise<boolean> {
const electronApi = this.electronBridge.getApi();
if (!electronApi) {
return false;
}
try {
return await withTimeout(electronApi.deleteSavedTheme(fileName), `Deleting theme ${fileName}`);
} catch {
return false;
}
}
private async readSavedThemeSummary(file: SavedThemeFileDescriptor): Promise<SavedThemeSummary> {
const text = await this.readThemeText(file.fileName);
if (!text) {
return {
description: null,
error: 'File could not be read.',
fileName: file.fileName,
filePath: file.path,
isValid: false,
modifiedAt: file.modifiedAt,
themeName: fallbackThemeName(file.fileName),
version: null
};
}
try {
const parsed = JSON.parse(text) as unknown;
const result = validateThemeDocument(parsed);
if (!result.valid || !result.value) {
return {
description: null,
error: result.errors[0] ?? 'Theme JSON is invalid.',
fileName: file.fileName,
filePath: file.path,
isValid: false,
modifiedAt: file.modifiedAt,
themeName: fallbackThemeName(file.fileName),
version: null
};
}
return {
description: result.value.meta.description ?? null,
error: null,
fileName: file.fileName,
filePath: file.path,
isValid: true,
modifiedAt: file.modifiedAt,
themeName: result.value.meta.name,
version: result.value.meta.version
};
} catch (error) {
return {
description: null,
error: error instanceof Error ? error.message : 'Theme JSON could not be parsed.',
fileName: file.fileName,
filePath: file.path,
isValid: false,
modifiedAt: file.modifiedAt,
themeName: fallbackThemeName(file.fileName),
version: null
};
}
}
}