220 lines
13 KiB
JavaScript
220 lines
13 KiB
JavaScript
(function initTitleWorkspace(root, factory) {
|
|
const api = factory();
|
|
if (typeof module === 'object' && module.exports) module.exports = api;
|
|
if (root) root.chimeraTitleWorkspace = api;
|
|
})(typeof window !== 'undefined' ? window : globalThis, function createTitleWorkspace() {
|
|
'use strict';
|
|
|
|
const array = value => Array.isArray(value) ? value : [];
|
|
const text = value => value == null || value === '' ? null : String(value);
|
|
const first = (...values) => values.map(text).find(Boolean) || null;
|
|
const lower = value => String(value || '').trim().toLowerCase();
|
|
const missingStates = new Set(['missing', 'unavailable', 'disconnected', 'offline', 'not_mounted']);
|
|
|
|
function deviceState(item) {
|
|
return first(
|
|
item && item.deviceAvailabilityState,
|
|
item && item.availabilityState,
|
|
item && item.device && item.device.availabilityState,
|
|
item && item.storageDevice && item.storageDevice.availabilityState,
|
|
item && item.volume && item.volume.device && item.volume.device.availabilityState
|
|
);
|
|
}
|
|
|
|
function locationLabel(item, fallback) {
|
|
return first(
|
|
item && item.volume && item.volume.label,
|
|
item && item.volume && item.volume.name,
|
|
item && item.volumeLabel,
|
|
item && item.device && item.device.label,
|
|
item && item.storageDevice && item.storageDevice.label,
|
|
item && item.console && item.console.name,
|
|
item && item.consoleName,
|
|
item && item.path,
|
|
fallback
|
|
);
|
|
}
|
|
|
|
function isConfirmedInstall(install) {
|
|
const state = lower(first(install && install.presenceState, install && install.state, install && install.installState));
|
|
return state === 'present' || state === 'installed' || state === 'confirmed';
|
|
}
|
|
|
|
function isMissing(item) {
|
|
return missingStates.has(lower(deviceState(item)));
|
|
}
|
|
|
|
function collectActivity(title, locations) {
|
|
const entries = [];
|
|
const add = (kind, value, label) => {
|
|
if (!value) return;
|
|
const timestamp = new Date(value);
|
|
if (Number.isNaN(timestamp.getTime())) return;
|
|
entries.push({ kind, label, timestamp: timestamp.toISOString() });
|
|
};
|
|
add('title', title.updatedAt || title.updated_at, 'Title metadata updated');
|
|
add('title', title.createdAt || title.created_at, 'Title first recorded');
|
|
locations.forEach(location => {
|
|
add(location.kind, location.raw.lastSeenAt || location.raw.last_seen_at, `${location.label} last seen`);
|
|
add(location.kind, location.raw.verifiedAt || location.raw.verified_at, `${location.label} verified`);
|
|
add(location.kind, location.raw.detectedAt || location.raw.detected_at, `${location.label} detected`);
|
|
});
|
|
array(title.media).forEach(item => add('media', item.importedAt || item.provenance?.importedAt, `Imported ${item.kind || 'media'}: ${item.originalName || item.name || 'asset'}`));
|
|
return entries.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
|
|
}
|
|
|
|
function project(title = {}) {
|
|
const localCopies = array(title.localCopies);
|
|
const consoleInstalls = array(title.consoleInstalls);
|
|
const packages = array(title.packages);
|
|
const saves = array(title.saves);
|
|
const trophies = array(title.trophies);
|
|
const media = array(title.media);
|
|
const locations = [
|
|
...localCopies.map((raw, index) => ({
|
|
kind: 'local-copy',
|
|
label: locationLabel(raw, `Local copy ${index + 1}`),
|
|
state: deviceState(raw) || 'unknown',
|
|
version: first(raw.version && raw.version.value, raw.version, raw.appVersion),
|
|
format: first(raw.format, raw.copyType, raw.type),
|
|
path: first(raw.path, raw.relativePath),
|
|
missing: isMissing(raw),
|
|
raw
|
|
})),
|
|
...consoleInstalls.map((raw, index) => ({
|
|
kind: 'console-install',
|
|
label: locationLabel(raw, `Console install ${index + 1}`),
|
|
state: first(raw.presenceState, raw.state, raw.installState, 'unknown'),
|
|
version: first(raw.version && raw.version.value, raw.version, raw.appVersion),
|
|
format: first(raw.format, raw.packageType),
|
|
path: first(raw.path, raw.installPath),
|
|
missing: isMissing(raw),
|
|
confirmed: isConfirmedInstall(raw),
|
|
raw
|
|
}))
|
|
];
|
|
const missingDrive = Number(title.availability && title.availability.missingDeviceCount || 0) > 0 || locations.some(location => location.missing);
|
|
const installed = consoleInstalls.some(isConfirmedInstall) || Number(title.availability && title.availability.confirmedInstallCount || 0) > 0;
|
|
const protectionState = lower(first(title.protection && title.protection.state, title.protection && title.protection.status));
|
|
const protectedCopy = ['protected', 'healthy', 'verified', 'complete'].includes(protectionState)
|
|
|| Number(title.protection && title.protection.protectedCopyCount || 0) > 0;
|
|
const badges = [];
|
|
if (installed) badges.push({ code: 'installed', tone: 'positive' });
|
|
if (localCopies.length) badges.push({ code: 'local-copy', tone: 'info' });
|
|
if (missingDrive) badges.push({ code: 'missing-drive', tone: 'warning' });
|
|
if (protectedCopy) badges.push({ code: 'protected', tone: 'positive' });
|
|
if ((installed || localCopies.length) && !protectedCopy) badges.push({ code: 'needs-protection', tone: 'danger' });
|
|
if (!locations.length) badges.push({ code: 'metadata-only', tone: 'neutral' });
|
|
|
|
return Object.freeze({
|
|
title,
|
|
badges,
|
|
locations,
|
|
localCopies,
|
|
consoleInstalls,
|
|
packages,
|
|
saves,
|
|
trophies,
|
|
media,
|
|
missingDrive,
|
|
installed,
|
|
protectedCopy,
|
|
activity: collectActivity(title, locations)
|
|
});
|
|
}
|
|
|
|
function escapeHtml(value) {
|
|
return String(value == null ? '' : value)
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
function tr(key, fallback) {
|
|
if (typeof window === 'undefined' || typeof window.t !== 'function') return fallback;
|
|
const translated = window.t(key);
|
|
return translated && translated !== key ? translated : fallback;
|
|
}
|
|
|
|
function setCount(id, count) {
|
|
const node = document.getElementById(id);
|
|
if (!node) return;
|
|
node.textContent = count ? String(count) : '';
|
|
node.hidden = !count;
|
|
}
|
|
|
|
function emptyState(key, fallback, action) {
|
|
return `<div class="title-workspace-empty"><p>${escapeHtml(tr(key, fallback))}</p>${action || ''}</div>`;
|
|
}
|
|
|
|
function renderLocation(location) {
|
|
const kind = location.kind === 'console-install'
|
|
? tr('library.workspace.consoleInstall', 'Console install')
|
|
: tr('library.workspace.localCopy', 'Local copy');
|
|
const details = [location.version && `v${location.version}`, location.format, location.path].filter(Boolean);
|
|
return `<article class="title-location-card${location.missing ? ' is-missing' : ''}">
|
|
<div><span class="title-location-kind">${escapeHtml(kind)}</span><h4>${escapeHtml(location.label)}</h4></div>
|
|
<span class="title-location-state">${escapeHtml(location.missing ? tr('library.workspace.driveMissing', 'Drive unavailable') : location.state)}</span>
|
|
${details.length ? `<p>${details.map(escapeHtml).join(' · ')}</p>` : ''}
|
|
</article>`;
|
|
}
|
|
|
|
function renderList(id, items, emptyKey, emptyFallback, mapper, action) {
|
|
const node = document.getElementById(id);
|
|
if (!node) return;
|
|
node.innerHTML = items.length ? items.map(mapper).join('') : emptyState(emptyKey, emptyFallback, action);
|
|
}
|
|
|
|
function render(title) {
|
|
if (typeof document === 'undefined') return project(title);
|
|
const workspace = project(title);
|
|
const labels = {
|
|
installed: tr('library.badge.installed', 'Installed'),
|
|
'local-copy': tr('library.badge.localCopy', 'Local copy'),
|
|
'missing-drive': tr('library.badge.missingDrive', 'Drive unavailable'),
|
|
protected: tr('library.badge.protected', 'Protected'),
|
|
'needs-protection': tr('library.badge.needsProtection', 'Needs protection'),
|
|
'metadata-only': tr('library.badge.metadataOnly', 'Metadata only')
|
|
};
|
|
const badges = document.getElementById('modal-derived-badges');
|
|
if (badges) badges.innerHTML = workspace.badges.map(badge => `<span class="title-badge tone-${badge.tone}">${escapeHtml(labels[badge.code])}</span>`).join('');
|
|
|
|
renderList('modal-copies-content', workspace.locations, 'library.workspace.noCopies', 'No local copies or confirmed console installations recorded.', renderLocation);
|
|
renderList('modal-packages-content', workspace.packages, 'library.workspace.noPackages', 'No package evidence linked to this title.', item => `<article class="title-evidence-row"><strong>${escapeHtml(first(item.name, item.label, item.packageId, 'Package'))}</strong><span>${escapeHtml(first(item.state, item.status, item.type, 'recorded'))}</span></article>`);
|
|
window.chimeraRecipeEditor?.mount?.({ title, workspace });
|
|
renderList('modal-saves-content', workspace.saves, 'library.workspace.noSaves', 'No save evidence linked to this title.', item => `<article class="title-evidence-row"><strong>${escapeHtml(first(item.name, item.accountLabel, 'Save'))}</strong><span>${escapeHtml(first(item.state, item.status, 'recorded'))}</span></article>`, `<button class="btn" type="button" data-route="protection/saves">${escapeHtml(tr('library.workspace.openSaves', 'Open Save Vault'))}</button>`);
|
|
renderList('modal-trophies-content', workspace.trophies, 'library.workspace.noTrophies', 'No verified read-only trophy evidence imported.', item => `<article class="title-evidence-row"><strong>${escapeHtml(first(item.name, item.label, 'Trophy evidence'))}</strong><span>${escapeHtml(first(item.state, item.status, 'read-only'))}</span></article>`);
|
|
renderList('modal-media-content', workspace.media, 'library.workspace.noMedia', 'No media assets linked to this title.', item => `<article class="title-evidence-row"><strong>${escapeHtml(first(item.originalName, item.name, item.label, item.kind, 'Media'))}</strong><span>${escapeHtml(first(item.provenance?.provider, item.state, item.status, 'available'))}${item.preferred ? ` · ${escapeHtml(tr('media.import.preferredActive', 'Preferred'))}` : ''}</span></article>`, `<button class="btn" type="button" data-route="library/media">${escapeHtml(tr('library.workspace.openMedia', 'Open Media Library'))}</button>`);
|
|
renderList('modal-activity-content', workspace.activity, 'library.workspace.noActivity', 'No timestamped evidence recorded yet.', item => `<article class="title-evidence-row"><strong>${escapeHtml(item.label)}</strong><time datetime="${escapeHtml(item.timestamp)}">${escapeHtml(new Date(item.timestamp).toLocaleString())}</time></article>`);
|
|
|
|
const compatibility = document.getElementById('modal-compatibility-content');
|
|
if (compatibility) {
|
|
const versions = workspace.locations.map(item => item.version).filter(Boolean);
|
|
const formats = workspace.locations.map(item => item.format).filter(Boolean);
|
|
compatibility.innerHTML = `<dl class="title-fact-grid">
|
|
<div><dt>${escapeHtml(tr('library.workspace.availability', 'Availability'))}</dt><dd>${escapeHtml(workspace.missingDrive ? tr('library.workspace.partialAvailability', 'Partial - drive unavailable') : workspace.locations.length ? tr('library.workspace.availableEvidence', 'Evidence available') : tr('library.workspace.unknown', 'Unknown'))}</dd></div>
|
|
<div><dt>${escapeHtml(tr('library.workspace.versions', 'Observed versions'))}</dt><dd>${escapeHtml([...new Set(versions)].join(', ') || tr('library.workspace.unknown', 'Unknown'))}</dd></div>
|
|
<div><dt>${escapeHtml(tr('library.workspace.formats', 'Observed formats'))}</dt><dd>${escapeHtml([...new Set(formats)].join(', ') || tr('library.workspace.unknown', 'Unknown'))}</dd></div>
|
|
<div><dt>${escapeHtml(tr('library.workspace.protection', 'Protection'))}</dt><dd>${escapeHtml(workspace.protectedCopy ? tr('library.workspace.protected', 'Protected copy recorded') : tr('library.workspace.unprotected', 'No protected copy recorded'))}</dd></div>
|
|
</dl>`;
|
|
window.chimeraCompatibilityGuidance?.load?.(compatibility, {
|
|
titleId: first(title.externalTitleId, title.game_id, title.gameId),
|
|
version: versions[0] || '', firmware: first(title.consoleFirmware, title.firmware),
|
|
loader: first(title.loader, title.hen), format: formats[0] || ''
|
|
});
|
|
}
|
|
|
|
setCount('mtab-copies-count', workspace.locations.length);
|
|
setCount('mtab-packages-count', workspace.packages.length);
|
|
setCount('mtab-saves-count', workspace.saves.length);
|
|
setCount('mtab-trophies-count', workspace.trophies.length);
|
|
setCount('mtab-media-count', workspace.media.length);
|
|
setCount('mtab-activity-count', workspace.activity.length);
|
|
return workspace;
|
|
}
|
|
|
|
return Object.freeze({ project, render });
|
|
});
|