feat: Theme engine
big changes
This commit is contained in:
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user