172 lines
4.9 KiB
TypeScript
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;
|
|
}
|
|
}
|