Files
glm-hub/src/storage.ts
T

172 lines
4.9 KiB
TypeScript

import { createSeedData } from "./data";
import type { HubApp, HubData } from "./types";
const DATA_KEY = "glm-hub:state:v1";
export interface LoadDataResult {
data: HubData;
warning: string;
}
function isHubApp(value: unknown): value is HubApp {
if (!value || typeof value !== "object") return false;
const app = value as Partial<HubApp>;
return (
typeof app.id === "string" &&
typeof app.name === "string" &&
typeof app.description === "string" &&
typeof app.category === "string" &&
Array.isArray(app.keywords) &&
typeof app.url === "string" &&
typeof app.mark === "string" &&
typeof app.accent === "string" &&
typeof app.iconDataUrl === "string" &&
(app.visibility === "published" || app.visibility === "hidden")
);
}
function isHubData(value: unknown): value is HubData {
if (!value || typeof value !== "object") return false;
const data = value as Partial<HubData>;
return (
data.schemaVersion === 1 &&
Array.isArray(data.apps) &&
data.apps.every(isHubApp) &&
!!data.favoritesByUser &&
typeof data.favoritesByUser === "object" &&
!!data.recentByUser &&
typeof data.recentByUser === "object"
);
}
function keepExistingAppIds(groups: Record<string, string[]>, appIds: Set<string>): Record<string, string[]> {
return Object.fromEntries(
Object.entries(groups).map(([userId, ids]) => [userId, ids.filter((id) => appIds.has(id))]),
);
}
function migrateCategory(category: string): HubApp["category"] {
switch (category) {
case "Administración":
case "Recursos Humanos":
case "CDC":
return category;
case "Recursos":
return "Recursos Humanos";
case "Clientes":
case "Creatividad":
return "CDC";
case "Operaciones":
case "Analítica":
default:
return "Administración";
}
}
function migrateAppCategories(data: HubData): HubData {
let changed = false;
const apps = data.apps.map((app) => {
const category = migrateCategory(app.category);
if (category === app.category) return app;
changed = true;
return { ...app, category };
});
return changed
? { ...data, apps, updatedAt: new Date().toISOString() }
: data;
}
function removeAppsWithoutLogo(data: HubData): HubData {
const apps = data.apps.filter((app) => Boolean(app.iconDataUrl.trim()));
if (apps.length === data.apps.length) return data;
const appIds = new Set(apps.map((app) => app.id));
return {
...data,
apps,
favoritesByUser: keepExistingAppIds(data.favoritesByUser, appIds),
recentByUser: keepExistingAppIds(data.recentByUser, appIds),
updatedAt: new Date().toISOString(),
};
}
export function loadHubData(): LoadDataResult {
const fallback = createSeedData();
try {
const raw = localStorage.getItem(DATA_KEY);
if (!raw) {
localStorage.setItem(DATA_KEY, JSON.stringify(fallback));
return { data: fallback, warning: "" };
}
const parsed: unknown = JSON.parse(raw);
if (!isHubData(parsed)) {
localStorage.setItem(`${DATA_KEY}:backup`, raw);
localStorage.setItem(DATA_KEY, JSON.stringify(fallback));
return {
data: fallback,
warning: "Los datos locales no eran válidos. Restauramos el catálogo inicial.",
};
}
const migrated = migrateAppCategories(parsed);
const cleaned = removeAppsWithoutLogo(migrated);
if (cleaned !== parsed) {
localStorage.setItem(DATA_KEY, JSON.stringify(cleaned));
}
return { data: cleaned, warning: "" };
} catch {
return {
data: fallback,
warning: "No pudimos leer los datos guardados. Estás viendo el catálogo inicial.",
};
}
}
export function saveHubData(data: HubData): { ok: boolean; error: string } {
try {
localStorage.setItem(DATA_KEY, JSON.stringify(data));
return { ok: true, error: "" };
} catch (error) {
const isQuota =
error instanceof DOMException &&
(error.name === "QuotaExceededError" || error.name === "NS_ERROR_DOM_QUOTA_REACHED");
return {
ok: false,
error: isQuota
? "El almacenamiento está lleno. Prueba con un logo más liviano."
: "No pudimos guardar los cambios en este navegador.",
};
}
}
export function resetHubData(): HubData {
const next = createSeedData();
localStorage.setItem(DATA_KEY, JSON.stringify(next));
return next;
}
export function normalizeSearch(value: string): string {
return value
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.toLocaleLowerCase("es-DO")
.trim();
}
export function getSafeUrl(value: string): string | null {
if (!value.trim()) return null;
try {
const url = new URL(value.trim());
const isLocalHttp =
url.protocol === "http:" && (url.hostname === "localhost" || url.hostname === "127.0.0.1");
if (url.protocol !== "https:" && !isLocalHttp) return null;
if (url.username || url.password) return null;
return url.toString();
} catch {
return null;
}
}