feat: Theme engine

big changes
This commit is contained in:
2026-04-02 00:08:38 +02:00
parent 65b9419869
commit bbb6deb0a2
48 changed files with 6150 additions and 235 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/theme.models';
import { validateThemeDocument } from '../domain/theme.validation';
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
};
}
}
}

View File

@@ -0,0 +1,44 @@
import {
STORAGE_KEY_THEME_ACTIVE,
STORAGE_KEY_THEME_DRAFT
} from '../../../core/constants';
export interface ThemeStorageSnapshot {
activeText: string | null;
draftText: string | null;
}
function readStoredThemeText(key: string): string | null {
try {
const raw = localStorage.getItem(key);
return typeof raw === 'string' && raw.length > 0
? raw
: null;
} catch {
return null;
}
}
function writeStoredThemeText(key: string, value: string): void {
try {
localStorage.setItem(key, value);
} catch {
/* storage can be unavailable in private contexts */
}
}
export function loadThemeStorageSnapshot(): ThemeStorageSnapshot {
return {
activeText: readStoredThemeText(STORAGE_KEY_THEME_ACTIVE),
draftText: readStoredThemeText(STORAGE_KEY_THEME_DRAFT)
};
}
export function saveActiveThemeText(text: string): void {
writeStoredThemeText(STORAGE_KEY_THEME_ACTIVE, text);
}
export function saveDraftThemeText(text: string): void {
writeStoredThemeText(STORAGE_KEY_THEME_DRAFT, text);
}