Files
P42_UI/core/Sparc-core-1.0.js
T
2025-09-25 20:47:58 +00:00

569 lines
24 KiB
JavaScript
Executable File

'use strict'
/**
___ ____ __ ____ ___
/ __)( _ \ /__\ ( _ \ / __)
___\__ \ )___//(__)\ ) /( (__
(_______/(__) (__)(__)(_)\_) \___) SPA Rational Code
By Mike & Nike
This file is part of Sparc by Mike & Nike.
Sparc is free software:
- you can redistribute it and/or modify it under the terms of the GNU General Public License,
as published by the Free Software Foundation, either version 3 of the License,
or (at your option) any later version.
- Sparc is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
See the GNU General Public License for more details.
Get your copy of the GNU General Public License at <https://www.gnu.org/licenses/>.
* @category Core
* @hideconstructor
* @tutorial app-01-overview
*/
class Sparc {
/**
* Genaral app configuration, as soon as it is loaded
* @tutorial app-02-configuration
* */
config = {};
/** Classes definitions for MVC + Sparc libs follow the rule script-name = instantiable class name */
LoadedClasses = {};
/** Thirdparties and other libs don't necessarily have a class, or this naming conv. then store path only. */
LoadedScripts = [];
/** @type User */
User = null
/** @type Assets */
Assets = null
/** @type Router */
Router = null
/** @type Events */
events = null
/** @type Array<Object> */
latestErrors = [];
/**
*
* @param {*} ref
* @param {*} cls
* @param {*} replace
*/
registerClass(ref, cls, replace=false) {
if(replace || (!this.LoadedClasses.hasOwnProperty(ref))){
this.LoadedClasses[ref] = cls;
} else {
console.warn('Attempting to register already defined class ', ref);
}
}
/**
*
*/
onBootReady() {
console.log('Sparc boot loaded...');
// Instances shortcuts
this.User = new this.LoadedClasses.User(); // This is User base class (no login)
// Static classes shortcuts
this.Assets = Assets;
// General app config are loaded here
this.Assets.loadJson({ 'name': 'config.json',
'path': '/app/config/',
}).then(this.onConfigLoaded.bind(this));
// Instances shortcuts
this.events = new this.LoadedClasses.Events(); // This is User base class (no login)
this.addEvent = this.events.addEvent.bind(this.events);
}
/**
*
*/
onWindowReady() {
// MVC not yet loaded here, see remark below about loading base classes.
Loader.loadScripts({ 'basepath':'/core/libs/',
'scripts': ['Router', 'Logger', 'Assets','Events'],
'dependencies':{ 'Router': ['../baseClasses/User'] },
}).then(this.onBootReady.bind(this));
}
/**
*
* @param {*} config
*/
onConfigLoaded(config){
console.log('App config loaded...');
if(!config) {
console.error('Could not load SPARC config file !?');
return;
}
this.config = config;
if(('logger' in config) && ('enabled' in config.logger) && (config.logger.enabled)
&& ('levels' in config.logger) && ('postUrl' in config.logger)){
window.console = this.logger(window.console, config.logger);
}
this.Assets.defaults = config.assets;
if( ('userLib' in config) && ('className' in config.userLib) && (config.userLib.className!='')) {
Loader.loadScripts({ 'basepath':'/app/libs/',
'scripts': [config.userLib.className],
'dependencies':{ }, // Dependencies of the User class from config ?
}).then(()=>{
this.User = new this.LoadedClasses.User(); // Re-instantiate extnded class
// Upon creation, we are ourselves, but it might change later. (no OTS yet) Philosophical, isn't it :D
this.currentUser = this.User;
this.onUserClassReady();
});
} else {
this.onUserClassReady();
}
}
/**
*
*/
onUserClassReady(){
this.User.checkAuthenticated(()=>{
console.log('back from checkAuthenticated')
if(this.User.isAuthenticated) {
console.log('authenticated OK')
window.onbeforeunload = () => "Do you really want to leave this application ?";
// Loading base Classes, who can now use app.config.
// If later we do multi-app then one config per app, and baseclass instantiated with its own config.
Loader.loadScripts({
'basepath':'/core/baseClasses/',
'scripts': [
'Model',
'View',
'MasterController'
], // Look momy: sounds just like MVC ;-)
'dependencies': {
'MasterController': ['Controller']
}, // Dependencies of the base classes ?
}).then(this.loadLibraries.bind(this));
// Now load & start messageBus (must be authenticated, but can by done aside of loading the MVC core)
if((this.config.messageBus.enabled) && ("Worker" in window) && ('WebSocket' in window)) {
Loader.loadScripts({ 'basepath':'/core/libs/',
'scripts': ['MessageBus'],
}).then(() => {
this.MessageBus = new this.LoadedClasses.MessageBus(this.config.messageBus, this.User.getMessageBusUserInfo());
});
} else if(this.config.messageBus.enabled){
console.warn("Could not register MessageBus worker: Browser too old ?");
}
} else {
console.log('User is not Authenticated, redirect to Login page');
this.User.gotoLogin();
}
});
}
/**
*
*/
loadLibraries(){
// Loading custom libs & intermediary classes
let allPromises = [];
let lib, libclass;
if('libsBaseClasses' in this.config.router){
for(lib of this.config.router.libsBaseClasses) {
if(Array.isArray(lib.onlyIfClasses) && !lib.onlyIfClasses.every(cls => (cls in this.LoadedClasses))) continue
libclass = lib.classes[0]; // Let all assets be dependencies of the first script (if many)
Loader.AssetsDependencies[libclass] = lib.assets;
allPromises.push( Loader.loadScripts({
'basepath':'/app/libs'+lib.path,
'scripts': lib.classes,
'dependencies': lib.dependencies,
})
);
}
}
Promise.allSettled(allPromises).then(this.onMVCReady.bind(this));
}
/**
*
*/
onMVCReady() {
// Now that we are authenticated, we have the config and the MVC
// load the top routes and then route !
this.Assets.loadJson({ 'name': 'baseRoutes.json', 'path': '/app/config/' })
.then(
(topRoutes) =>{
if(!this.Router) {
//console.log('Top routes loaded...', app.config);
var routerOptions = {
'defaults' : this.config.router,
'routes': topRoutes,
'callback': () =>{ } // Called when *routed* (not when instanciated)
};
if('getRolesFrom' in this.config.router){ //Role can be an array of string or string representing a function
var isCallable = Function([],"return(typeof("+this.config.router.getRolesFrom+")=='function');")
if(isCallable()) routerOptions['roles'] = Function([],"return("+this.config.router.getRolesFrom+"());");
else routerOptions['roles'] = this.config.router.getRolesFrom;
} else routerOptions['roles']='';
this.Router = new Router(routerOptions);
}
this.Router.route();
}
);
}
}
// Let the browser cache boot loader components or not ? (true in prod or if you don't work on core)
const bootBrowserCache = false;
/* Here is Sparc bootstrap, depends only on Loader core class defined below */
if(typeof(app)=='undefined') var app = new Sparc();
window.onload = app.onWindowReady.bind(app);
/**
___ _____ __ ____ ____ ____
( ) ( _ ) /__\ ( _ \( ___)( _ \
) (_ )(_)( /(__)\ )(_) ))__) ) /
(____)(_____)(__)(__)(____/(____)(_)\_) for SPARC
By Mike & Nike
This static class contains the mechanics for loading javascripts. <br>
It will load any needed dependancies for you (with the help of the dependancies file) <br>
It can be called just boot-load you application and all its dependancies , <br>
and/or, alternatively, you can load widgets yourself anytime.
* @static
* @hideconstructor
* @category Core
* @todo found some direct reference to the app global var which is a bit sloppy
*/
class Loader {
static Dependencies = {};
static LoadedDepFiles = [];
static AssetsDependencies = {};
/**
* @private
*/
static resolveDeps(imps, deps){
const leveler = (levels, root, l, trail=[]) => {
if(deps.hasOwnProperty(root)){
for(var dep of deps[root]){
if(!levels.hasOwnProperty(dep)) levels[dep] = -1;
if(levels[dep]<l) levels[dep]=l;
if(trail.indexOf(dep)<0){ // detect circular dependency to avoid infinite recursion
let curtrail = [...trail];
trail.push(dep);
leveler(levels, dep , l+1, trail);
trail = curtrail;
} else console.error('Circular depencency with '+dep, trail);
}
}
}
var levels={};
// Build the dep tree of things we need, noting the depth.
for(var imp of imps) { leveler(levels, imp, 1); } // Level starts at 1, zero reserved for roots
// Add requested imports as last, unless already with higher priority (tree shaking)
for(var imp of imps) { if(!levels.hasOwnProperty(imp)) levels[imp] = 0; }
// Group by levels, as their loading can be paralellized (whereas on level must wait on the prev. one)
var name, level; var byLevel = {};
for([name, level] of Object.entries(levels)) {
if(!byLevel.hasOwnProperty(level)) { byLevel[level]=[]; }
byLevel[level].push(name);
}
return(byLevel);
}
/**
* @private
*/
static Import(scriptNames, deps, importIdPrfx, basePath = './'){ // Will start with real import (_import) only when / if dependencies are loaded.
var CurrentImportChain = {};
if(typeof(deps)=='string') { // deps is a path to a json file containing the dep tree
if( (deps!='') && (Loader.LoadedDepFiles.indexOf(deps)<0) ){
Loader.DependenciesLoader = new Promise((resolve, reject) => {
fetch(basePath+deps, { headers: { "Content-Type": "application/json; charset=utf-8" }})
.then(res => res.json())
.then(response => {
Loader.Dependencies = { ...Loader.Dependencies, ...response}; // Merge with known deps
Loader.LoadedDepFiles.push(deps);
resolve();
})
.catch(err => { console.error('Error loading dependencies'); Loader.Dependencies = {}; reject(); });
});
} else {
Loader.DependenciesLoader = new Promise((resolve, reject) => { resolve(); }); //Already loaded, nothing to do here
}
} else if(typeof(deps)=='object') { // deps is the dep-tree object
Loader.DependenciesLoader = new Promise((resolve, reject) => {
Loader.Dependencies = { ...Loader.Dependencies, ...deps }; // Merge with known deps
resolve(); // already ready ! ;-)
});
} else { console.error('Bad dependencies !?'); return(false); }
// Prepare a synchronous chain of asynchronous loads
Loader.DependenciesLoader.then(()=>{
var byLevelImps = Loader.resolveDeps(scriptNames, Loader.Dependencies);
var total = Object.values(byLevelImps).reduce((tot,v)=> (tot+v.length), 0);
document.dispatchEvent(new CustomEvent("LoaderProgressAddTodo", {'detail': { 'importID':importIdPrfx, 'addValue': total }}));
for(var key in Object.getOwnPropertyNames(byLevelImps).sort().reverse()){
CurrentImportChain[importIdPrfx+'_'+key] = {'imps': byLevelImps[key], 'path':basePath };
}
var firstImportId = importIdPrfx+'_'+key;
document.addEventListener('LoaderAllReady', function(evt) { // Next async imports in the sync chain
var nextImpNb = evt.detail.substr(0,evt.detail.lastIndexOf('_')+1)+(parseInt(evt.detail.substr(evt.detail.lastIndexOf('_')+1))-1);
if(evt.detail != importIdPrfx+'_0'){
if(CurrentImportChain.hasOwnProperty(nextImpNb)) {
Loader._import(CurrentImportChain[nextImpNb]['imps'], nextImpNb,CurrentImportChain[nextImpNb]['path']);
}
}
});
// First load all assets
Loader.LoadAssetDependencies(byLevelImps).then( () => {
// Start the synchronous chain
Loader._import(CurrentImportChain[firstImportId]['imps'], firstImportId,CurrentImportChain[firstImportId]['path']);
});
});
}
/**
*
* @param {*} byLevelImps
* @returns {Array<Promise>}
*/
static LoadAssetDependencies(byLevelImps){
let allImports = [];
for(var level in byLevelImps) allImports=[...allImports, ...byLevelImps[level]];
allImports = Array.from(new Set(allImports)); //remove dups
allImports = allImports.map(this.path2ClassName);
let allPromises = [];
for(var classname of allImports){
for(var assetType in Loader.AssetsDependencies[classname]){
if(!Array.isArray(Loader.AssetsDependencies[classname][assetType])) {
console.warn(`Bad config: Asset dependencies for ${classname} - ${assetType} is not an array !?`)
continue;
}
for(var assetObj of Loader.AssetsDependencies[classname][assetType]){
if((typeof(assetObj) != 'object') || (!('name' in assetObj))) {
console.warn(`Bad config: One asset dependency for ${classname} - ${assetType} has no name !?`)
continue;
}
switch(assetType){
case 'fonts': allPromises.push(app.Assets.loadFont(assetObj));
break;
case 'styles': allPromises.push(app.Assets.loadCss(assetObj));
break;
case 'html': allPromises.push(app.Assets.loadHtml(assetObj));
break;
case 'views': assetObj.path = '/app/views/';
allPromises.push(app.Assets.loadHtml(assetObj));
break;
case 'images': allPromises.push(app.Assets.getImage(assetObj));
break;
case 'json': allPromises.push(app.Assets.loadJson(assetObj));
break;
case 'sfx': allPromises.push(app.Assets.loadSound(assetObj));
break;
}
}
}
}
return(Promise.allSettled(allPromises));
}
/**
*
* @param {*} src
* @returns {Promise}
*/
static loadScript(src) {
return new Promise((resolve, reject) => {
const script = document.createElement('script')
script.type = src.split('?')[0].endsWith('.module.js')? 'module' : 'text/javascript'
script.onload = resolve
script.onerror = reject
script.src = src
document.head.append(script)
});
}
/**
* @private
*/
static path2ClassName(scriptName){
scriptName = scriptName.substr(scriptName.lastIndexOf('/')+1);
if(scriptName.indexOf('.')==-1) scriptName+='.'; // also pure scriptname or path without extension compatible !
return(scriptName.substr(0,scriptName.lastIndexOf('.')));
}
/**
* @private
*/
static url2Path(url){
var x = new URL(url);
return(x.pathname);
}
/**
* @private
*/
static _import(names, importID, basePath){ // Here, all imports are made asynchronously, in parallel.
var allLoaded = true;
var nbLeft = names.length;
for(var name of names){
var className = this.path2ClassName(name);
if(name.substring(0,4)!='http') var scriptFp = (basePath + '/' + name).replace(/\/+/g,'/')+'.js';
else var scriptFp = name;
if( (!(className in app.LoadedClasses)) && (app.LoadedScripts.indexOf(scriptFp)<0) ) {
allLoaded = false;
if( !bootBrowserCache ) scriptFp += '?'+importID;
Loader.loadScript(scriptFp)
.then((result) => {
var scriptName= this.url2Path(result.currentTarget.src);
// MCV classes and internal ones put themselves in app.loadedClasses,
// Non-Sparc scripts don't so save their path.
if( ('router' in app.config) &&
!(scriptName.startsWith(app.config.router.controllersPath)) &&
!(scriptName.startsWith(app.config.router.modelsPath)) &&
!(scriptName.startsWith(app.config.router.viewsPath)) &&
!(scriptName.startsWith('/core/'))
) app.LoadedScripts.push(scriptName);
var className = this.path2ClassName(result.currentTarget.src);
document.dispatchEvent(new CustomEvent('Loader'+className+'Ready', { 'detail': importID}));
document.dispatchEvent(new CustomEvent("LoaderProgressIncrement", {'detail': { 'importID': importID, 'success': true }}));
nbLeft--;
if(nbLeft<=0){
document.dispatchEvent(new CustomEvent("LoaderAllReady", { 'detail': importID }));
}
})
.catch((error) => {
if((typeof(error)!='undefined') && (typeof(error.currentTarget)!='undefined')){
var scriptName= error.currentTarget.src;
var className = this.path2ClassName(error.currentTarget.src);
console.error('Could not import Class '+className);
document.dispatchEvent(new CustomEvent("LoaderError", { 'detail': {'importID': importID, 'className': className }}));
} else console.error('Script loading error '+error);
document.dispatchEvent(new CustomEvent("LoaderProgressIncrement", {'detail': { 'importID': importID, 'success': false }}));
nbLeft--;
});
} else nbLeft--;
}
if(allLoaded){
for(var name of names) document.dispatchEvent(new CustomEvent('Class'+name+'Ready', { 'detail':importID }));
document.dispatchEvent(new CustomEvent("LoaderAllReady", { 'detail':importID }));
}
}
/**
* Load a list of scripts.
* @static
* @param {string} basepath - base path (something like /sccripts/)
* @param {Array<string>} scripts - array of scripts to load. to load a module instead of a JS, just end the filename with '.module.js) like: "mysuperlib.module.js"
* @param {(string|object)} dependencies - Last minute Dependancies :tree object ( like {'scriptName': ['deps1', 'dep2,...]} ) or path to its json file.
* @param {function} callback - Called when everything is loaded
* @returns {Promise} A promise resolved whn-en all is loaded
*/
static loadScripts(args){
// Start by filtering out scripts we already have
if(!args.refresh) {
args.scripts = args.scripts.filter((sname)=>{
var className = this.path2ClassName(sname);
return(!(className in app.LoadedClasses) && (!(sname in app.LoadedScripts)));
});
if(args.scripts.length==0) {
if( (args.hasOwnProperty('callback')) && (typeof(args.callback)=='function') ){
args.callback({});
}
return(new Promise((resolve) => { resolve(); }));
}
}
var myImportID = crypto.randomUUID();
if(!args.hasOwnProperty('dependencies')) args.dependencies = [];
if(!args.hasOwnProperty('basepath')) args.basepath = '';
return(new Promise((resolve, fail) => {
document.addEventListener('LoaderAllReady', (evt) => { if(evt.detail==myImportID+'_0') resolve(); } );
document.addEventListener('LoaderError', (evt) => { if(evt.detail==myImportID+'_0') fail(); } );
Loader.Import(args.scripts, args.dependencies, myImportID, args.basepath);
})
);
}
/**
* Load a list of views.
* @static
* @param {array} Views - array of Views to load
* @returns {string} The unique ID of the import
*/
static async loadViews(views){
// Start with views templates
let allPromises = []
let templates = []
let scripts = []
let dependencies = {}
for(var dep of views){
if(typeof(dep) == 'string') {// just add view as dependency of controller, with correct path
templates.push(dep+'.html')
scripts.push(dep)
} else if(typeof(dep) == 'object'){ // This view depends on other views
scripts.push(dep.view);
dependencies[dep.view]=dep.dependencies//.map(x=>'/app/views/'+x)
templates.push(dep.view+'.html')
}
}
for(let view of templates){
let assetObj = { path: '/app/views/', name: view }
allPromises.push(app.Assets.loadHtml(assetObj));
}
await Promise.allSettled(allPromises);
// Finish with views scripts
return(this.loadScripts({
basepath:'/app/views/',
scripts: scripts,
dependencies: dependencies,
}));
}
}
/**
* POLYFILLS
*/
if(typeof(crypto.randomUUID)!='function'){
crypto.randomUUID = () => {
var buf = new Uint8Array(14);
crypto.getRandomValues(buf);
var uuid = Array.from(buf, byte => ('0' + (byte & 0xFF).toString(16)).slice(-2)).join('');
return(
uuid.substring(0,8) + '-' +
uuid.substring(10,4) + '-' +
uuid.substring(14,4) + '-' +
uuid.substring(18,4) + '-' +
uuid.substring(22)
);
}
}
if(typeof(typeof(Promise.allSettled))!='function'){
Promise.allSettled = ((promises) => Promise.all(
promises.map(p => p
.then(value => ({
status: "fulfilled",
value
}))
.catch(reason => ({
status: "rejected",
reason
}))
)
));
}