unclean SPARC

This commit is contained in:
STEINNI
2025-08-27 07:03:09 +00:00
commit f308460931
430 changed files with 54426 additions and 0 deletions
+264
View File
@@ -0,0 +1,264 @@
/**
* @category MyEic
* @subcategory Libraries
*/
class ChatModule{
/**
*
* @param {*} appContent
*/
constructor(appContent) {
this.appContent = appContent;
this.unreadChats = {};
this.chatTargets = {};
this.onlineUsers = [];
this.recipientId = null;
this.currentHistory = [];
this.initView();
//TODO: inherit API entry_points
// this.APmeta.xxxx
this.APIEntries = {
'chatTargets' :'/api/chatTargets',
'':'',
};
//TODO: Do this on opening the chat-panel ? => when is it updated ?
this.getConversations()
.then( this.updateChanList.bind(this) )
.then( this.RequestOnlineUsers.bind(this));
this.addMsgBusEvents();
this.RequestUnreadMsgs();
}
addMsgBusEvents(){
app.events.addEvent('MessageBus.Connected', this.RequestUnreadMsgs.bind(this));
app.events.addEvent('MessageBus.Closed', this.updateUnreadBadge.bind(this));
app.events.addEvent('MessageBus.NOTIFS', this.updateUnreadBadge.bind(this));
app.events.addEvent('MessageBus.ISONLINE', this.updateOnlineBadges.bind(this));
app.events.addEvent('MessageBus.CHANHIST', this.replaceHisto.bind(this));
app.events.addEvent('MessageBus.CHATMSG', this.recvBus.bind(this));
}
RequestUnreadMsgs(){
return(
app.MessageBus.requestWssGwAction('NOTIFS', null).then(
(payload) => {
this.unreadChats = payload.unreadChats;
//TODO: share the other stuff with friends... (app.something ??)
this.updateUnreadBadge();
},
(err)=>{ console.warn('MSGBUS error:',err) })
);
}
RequestOnlineUsers(){
let usrlist = Object.keys(this.chatTargets.users);
// Request them now
app.MessageBus.requestWssGwAction('ISONLINE', usrlist);
// Watch them
app.MessageBus.requestWssGwAction('WATCHUSERS', usrlist);
}
getConversations(){
//TODO use app central fetcher ??? => use fake model like in myUser
return(fetch(this.APIEntries.chatTargets)
.then((response) => response.json()));
}
recvBus(e){
let histoDetail = {};
histoDetail[e.detail.msg.histId] = {
'msg' : e.detail.msg.msg,
'sender' : e.detail.msg.sender
};
this.replaceHisto({ 'detail': histoDetail });
}
replaceHisto(e){
let hids = Object.keys(e.detail);
for(let hid of hids){ // Insert without creating dups
if(!(hid in this.currentHistory)) this.currentHistory[hid] = e.detail[hid];
}
let html = ''; let day = '';
let user, acro, dat, timestp;
for(let hid of Object.keys(this.currentHistory).sort()){
user = this.chatTargets.users[this.currentHistory[hid].sender];
acro = user.given_name[0].toUpperCase()+'. '+user.family_name;
dat = new Date(1*hid.substring(0,hid.indexOf('-')));
timestp = dat.toLocaleString('fr').replace('/'+new Date().getFullYear(),''); // hide current year
if((day != '') && (day != dat.getDate())) {
html += `<div class="day-separator">${(dat.getDate()==(new Date()).getDate())?'(today)':''}</div>`;
}
html += `<div class="histo-entry ${(this.currentHistory[hid].sender==app.currentUser.userInfo.sub)?'me':''}">
<span class="sender">${acro} (${timestp})</span><br>
${this.currentHistory[hid].msg.replace(/\n/g, '<br>')}
</div><br>`;
day = dat.getDate();
}
this.chatView.el.querySelector('.history').innerHTML = html;
this.chatView.el.querySelector('.history').scrollTop = this.chatView.el.querySelector('.history').scrollHeight;
}
updateChanList(chatTargets){
this.chatTargets = chatTargets;
for(var uid in chatTargets.users){
if(uid != app.currentUser.userInfo.sub){
this.addUserChannel(uid, chatTargets.users[uid]);
}
}
for(var chid in chatTargets.lobbies){
this.addLobbyChannel(chid, chatTargets.lobbies[chid]);
}
}
addUserChannel(uid, user) {
let acro = user.given_name[0].toUpperCase()+'. '+user.family_name;
this.chanlist.addRow('P:'+uid, [ '<span eicbadge danger class="userled"></span>', acro ]);
}
addLobbyChannel(chid, lobby) {
this.chanlist.addRow('C:'+chid, [ '<span eicbadge info class="lobbyled"></span>', lobby.name]);
}
initView() {
this.chatView = {
el: null,
channels: [],
active: false,
currentChannel: null
};
this.chatView.el = new DropDown(ui.create(`
<div eicdropdown>
<button eicbutton rounded basic small primary class="icon-comment chat-menu"></button>
<menu eicmenu>
<li menuitem>
<div class="eic-chat">
<div class="chanlist"></div>
<div class="lobbyname">Lobby name...</div>
<div class="history"></div>
<textarea eictextarea class="message" placeholder="Type message here..."></textarea>
<button eicbutton small primary class="send" eicicon="icon-send"></button>
</div>
</li>
</menu>
</div>
`)).el;
ui.eicfy(this.chatView.el);
this.appContent.find('header .eic-session').prepend(this.chatView.el);
this.chanlist = new DataGrid(this.appContent.find('.chanlist'), {
headers: [
{label: '', sortable:true },
{label: 'name', filter: 'text', sortable:true},
],
height: '350px',
});
this.chanlist.onRowClick= this.selectChan.bind(this);
this.chatView.el.querySelector('.send').addEventListener('click', this.onSendMessage.bind(this));
this.chatView.el.querySelector('.message').addEventListener("keyup", this.onKeyUp.bind(this));
this.updateUnreadBadge();
this.chatView.tab = new Tab();
}
onKeyUp(event) {
if(this.aftertypeTo) clearTimeout(this.aftertypeTo);
if(event.which == 13 && !event.shiftKey) {
this.onSendMessage(event);
return
}
this.aftertypeTo = setTimeout(() => {
this.chatView.el.querySelector('.message').value = this.emotiAscii2Utf(this.chatView.el.querySelector('.message').value);
}, 900);
}
emotiAscii2Utf(txt) {
let conv = {
':-)':'🙂',':)':'🙂',';-)':'😉',';)':'😉',':-D':'😄','x-D':'😂',':-P':'😛',
':P':'😛',':-|':'😑',':|':'😑',':-(':'🙁',':(':'🙁',":'-)":'😂',":')":'😂',
":'-(":'😢',":'(":'😢','>:(':'😠','>:[':'😡',':-*':'😘',':*':'😘','O:-)':'😇',
'O:)':'😇',':-J':'😏'
}
for(let emoasci of Object.keys(conv)) {
if(emoasci == txt.substring(txt.length-emoasci.length)){
return(txt.substring(0,txt.length-emoasci.length)+conv[emoasci]);
}
}
return(txt);
}
selectChan(e){
let rawid = e.currentTarget.dataset.id;
if((rawid[0]!='P') && (rawid[0]!='C')) return;
this.recipientId = rawid;
for(let el of this.chatView.el.querySelectorAll(`.chanlist li.row`)){
el.style.backgroundColor = '';
el.style.color = '';
}
e.currentTarget.style.backgroundColor = 'var(--eicui-app-toolbar-bg-color)';
e.currentTarget.style.color = 'var(--eicui-base-color-white)';
this.currentHistory = {};
let realRecipientId = rawid.substring(2);
let lobbyname;
if(this.recipientId[0]=='P') {
lobbyname = this.chatTargets.users[realRecipientId].given_name+' '+this.chatTargets.users[realRecipientId].family_name
} else if(this.recipientId[0]=='C') {
lobbyname = this.chatTargets.lobbies[realRecipientId].name;
}
this.chatView.el.querySelector('.lobbyname').innerHTML = lobbyname;
app.MessageBus.requestWssGwAction('STARTCHAT', this.recipientId);
app.MessageBus.requestWssGwAction('CHANHIST', this.recipientId);
}
onSendMessage(e){
let txt = this.chatView.el.querySelector('.message').value;
app.MessageBus.requestWssGwAction('SENDCHAT', {
'recipient' : this.recipientId,
'msg': txt
})
this.chatView.el.querySelector('.message').value = '';
}
updateUnreadBadge(){
//updateUnreadBadge is decoupled from the msgbus via nbUnreadMsgs so you can redraw the UI anytime, not depending on a msgbus request
let totUnRead = 0;
for(let chan in this.unreadChats) totUnRead += this.unreadChats[chan];
if(!app.MessageBus.connected) {
this.chatView.el.querySelector('button.chat-menu').innerHTML = `<span class="icon-warning" warning xsmall></span>`;
} else if(totUnRead>0) {
this.chatView.el.querySelector('button.chat-menu').innerHTML = `<span eicbadge success xxsmall>${totUnRead}</span>`;
} else {
this.chatView.el.querySelector('button.chat-menu').innerHTML = '';
}
}
updateOnlineBadges(event){
this.onlineUsers = event.detail;
let el;
for(el of this.chatView.el.querySelectorAll(`.eic-chat .userled`)){
el.removeAttribute('success');
el.setAttribute('danger','');
}
for(let uid of this.onlineUsers){
el = this.chatView.el.querySelector(`[data-id="P:${uid}"] .userled`);
if(el) {
el.removeAttribute('danger');
el.setAttribute('success','');
}
}
}
}
app.registerClass('ChatModule', ChatModule);
//TODOs
// Lobbies
// Return to send, shift-return to CRLF
// Limit displayed / requested history
// Store my read / unread msg => real unread notifs
// Smileys
Binary file not shown.
+168
View File
@@ -0,0 +1,168 @@
@font-face {
font-family: 'NotoColorEmoji';
src: url('NotoColorEmoji.ttf') format('truetype');
}
.eic-chat {
min-width: 50vw;
padding: var(--eicui-base-spacing-m) !important;
}
.eic-chat section .conversations > div > div {
display: grid;
grid-gap: 10px;
grid-template-rows: auto min-content min-content;
}
.eic-chat .status {
float: right;
color: var(--eicui-base-color-white);
display: grid;
width: 24px;
height: 24px;
background: var(--eicui-base-color-danger-100);
border-radius: 24px;
font-size: 1rem;
align-content: center;
justify-content: center;
}
.eic-chat .status.active {
background: var(--eicui-base-color-success-100);
}
.eic-chat .chanlist {
width: 250px;
font-size: .8em;
border: 1px solid #ccc;
border-radius: 5px;
min-height:350px;
padding: 5px;
grid-area: chanlist;
}
.eic-chat .history{
grid-area: history;
border: 1px solid #ccc;
border-radius: 5px;
min-height: 300px;
width:100%;
text-transform: none;
max-height: 500px;
overflow: scroll;
}
.eic-chat .lobbyname {
grid-area: lobbyname;
background-color: var(--eicui-app-toolbar-bg-color);
color: var(--eicui-base-color-white);
padding: 5px;
text-transform: none;
}
.eic-chat .history .histo-entry{
font-family: sans-serif, 'NotoColorEmoji' !important;
background-color: #E8E8FF;
border-radius: 5px;
margin: 2px 5px;
padding: 2px 5px;
display: inline-block;
max-width: 500px;
float: left;
clear: both;
}
.eic-chat .history .day-separator{
clear: both;
height: 0;
border: 0px none transparent;
border-top: 2px dotted #777;
text-align: center;
line-height: 17px;
font-size: smaller;
}
.eic-chat .history .histo-entry.me{
float: right;
background-color: #D9F2D7;
}
.eic-chat .history .histo-entry .sender{
display: inline-block;
font-size: .85em;
font-weight: bold;
background-color: var(--eicui-app-toolbar-bg-color);
color: #FFF;
padding: 0 2px 2px 2px;
border-radius: 5px;
font-family: monospace;
}
.eic-chat .message{
grid-area: message;
width:550px;
font-family: sans-serif, 'NotoColorEmoji' !important;
}
.eic-chat .send{
grid-area: send;
}
.eic-chat {
display:grid;
grid-template-areas:
'chanlist lobbyname lobbyname'
'chanlist history history'
'chanlist message send';
}
.eic-chat .chanlist li.row{
grid-template-columns: 15px 2fr;
}
.eic-chat .chanlist span.userled{
position: absolute;
left: 5px;
height: calc(var(--eicui-base-spacing-s)*.8);
min-width: calc(var(--eicui-base-spacing-s)*.8);
border: 1px outset #aaa;
}
.eic-chat .chanlist span.lobbyled{
position: absolute;
left: 5px;
height: calc(var(--eicui-base-spacing-s)*.8);
min-width: calc(var(--eicui-base-spacing-s)*.8);
border: 2px outset #00A;
}
.eic-chat .output {
overflow-y: auto;
height: 350px;
background-color: var(--eicui-base-color-white);
width: 100%;
border: 1px solid var(--eicui-base-color-grey);
padding: 10px;
font-size: 0.8rem;
}
.eic-chat .output .emitter {
color: var(--eicui-base-color-primary);
padding-right: 4px;
}
.eic-chat .cols-2.form {
height: 60px;
align-items: flex-end;
}
.eic-chat .cols-2.form textarea {
resize: none;
}
.eic-chat .lobby {
display: grid;
grid-gap: var(--eicui-base-spacing-m);
}
#subchalst{
margin: 0 0 0 0;
padding: 0 0 0 5px;
}
#subchalst li{
font-size:12px;
}
.eic-chat .subscriptions,
.eic-chat .onlineusers{
min-width: 300px;
}
.eic-chat .subscriptions label,
.eic-chat .onlineusers label{
font-weight:bold;
}
+90
View File
@@ -0,0 +1,90 @@
'use strict'
/**
* @category MyEic
* @subcategory Libraries
*/
class MBRendezVous {
/**
*
* @param {*} appctrl
*/
constructor(appctrl){
if(!app.MessageBus) return
this.activeMeetChans = {}
this.myUserNotifChan = app.config.messageBus.userNotifChan.replace(/\{uid\}/g, app.User.identity.uuid)
app.MessageBus.addBusListener(
'rendezVous',
[ this.myUserNotifChan ],
this.onRendezVousRequest.bind(this)
)
}
/**
*
* @param {*} chan
* @param {*} payload
* @param {*} sender
*/
async onRendezVousRequest(chan, payload, sender){
if(! await this.userConfirmation(sender, payload.userMessage)) return
let rdvzchan = payload.chanPrefix + crypto.randomUUID()
//this.activeMeetChans[] = chan
}
/**
*
* @param {*} sender
* @param {*} userMessage
*/
async userConfirmation(sender, userMessage){
let options = {
title: 'Connexion request',
message: `<p>The user <b>${sender}</b> invites you to ${userMessage}.</p>
`,
cancelLabel: 'Refuse',
okLabel: 'Accept',
severity: 'danger',
okPromise: () => new Promise((ok,ko)=>ok())
}
await this.mainCtrl.loadContent('templates/dialogs/ConfirmDialog', options, options)
/*
let result = await this.mainView.confirmDialog({
title: 'Connexion request',
message: `<p>The user <b>${sender}</b> invites you to ${userMessage}.</p>
`,
cancelLabel: 'Refuse',
okLabel: 'Accept',
severity: 'danger',
okPromise: () => new Promise((ok,ko)=>ok())
})
if(result) {
console.log('you said YES')
}
*/
}
/**
*
* @param {*} hisUid
* @param {*} userMessage
* @param {*} timeout
* @returns {Promise}
*/
getRendezVousChan(hisUid, userMessage, timeout){
if(!app.MessageBus) return(
new Promise((resolve, reject) => reject('Cannot rendez-vous without messageBus !'))
)
return(
new Promise((resolve, reject) => {
})
)
}
}
app.registerClass('MBRendezVous', MBRendezVous);
+51
View File
@@ -0,0 +1,51 @@
/**
* @category MyEic
* @subcategory Libraries
* @extends EICModel
*/
class EICBusModel extends EICModel{
/**
* Makes a request to WSSGateway
*/
busWssGwRequest(action, payload = {}){
if(!app.MessageBus) return(new Promise((ok, ko) => ko))
if(!app.MessageBus.connected){ // bus not connected => Queue until connected, with 20 sec timeout
return(
app.MessageBus.whenConnectedP(20000).then(()=>{
return(app.MessageBus.requestWssGwAction(action, payload))
},
()=>{
console.log('Timeout waiting for connection !')
})
)
}
return(app.MessageBus.requestWssGwAction(action, payload))
}
/**
* Makes an action request on the bus
*/
busActionRequest(chan, action, payload = {}){
if(!app.MessageBus) return(new Promise((ok, ko) => ko))
if(!app.MessageBus.connected){ // bus not connected => Queue until connected, with 20 sec timeout
return(
app.MessageBus.whenConnectedP(20000).then(()=>{
return(app.MessageBus.requestBusAction(chan, action, payload))
},
()=>{
console.log('Timeout waiting for connection !')
})
)
}
return(app.MessageBus.requestBusAction(chan, action, payload))
}
busEvent(chan, event, payload) {
if( (!app.MessageBus) || (!app.MessageBus.connected) ) return(new Promise((ok, ko) => ko))
app.MessageBus.sendEvent(chan, event, payload)
}
}
app.registerClass('EICBusModel', EICBusModel)
+347
View File
@@ -0,0 +1,347 @@
/**
* @category MyEic
* @subcategory Libraries
* @extends Controller
*/
class EICController extends Controller {
constructor() {
super();
this.view2url = {};
}
getViewByClass(className) {
let content = Controller._contents.find(o => o.view._className == className)
return content ? content.view: null;
}
getViewByURL(url) {
let content = Controller._contents.find(o => o.view._url == url)
return content ? content.view: null;
}
loadContent(name, options, data) {
options = options || {};
options.onContentLoaded = this.createContent;
return super.loadView(name, options, data);
}
createContent(options, data, html) {
let container = ui.create('<div></div>');
container.innerHTML = Controller.processTemplate(options.name, html, data);
let view = new app.LoadedClasses[options.className](options);
view._className = options.name.replace('.html', '');
view._controller = this;
view.el = container;
view.DOMContentLoaded(data);
container.setAttribute('sparc-id', view._sparcId);
return view;
}
loadWindow(name, options, data) {
let url = app.Router.currentRoute.realUrl;
options = options || {};
// if static, redirect to existing instance
if(options.static) {
/*
MFA:
Using stored real url instead of classname allows different instance of window based on parameter:
eg:
having route "/window/{id}", calling "/window/345" won't replace "/window/123"
Makes me wonder: shouldn't all windows be static anyway then ?
*/
//let existing = this.getViewByClass(name);
let existing = this.getViewByURL(url);
if(existing) {
this.focus(existing._sparcId, data);
return;
}
}
options.onContentLoaded = this.createWindow;
super.loadView(name, options, data).then(
view => {
this.view2url[view._sparcId] = url;
view._url = url
}
);
}
createWindow(options, data, html) {
if(!(options.className in app.LoadedClasses)){
console.error(`Missing view ${options.className} !\nLoad explicitely it from your controller, or as set it as a dependency...`);
return null;
}
let view = new app.LoadedClasses[options.className]();
view._className = options.name.replace('.html', '');
view._controller = this;
Controller._contents.push({
view: view,
type: 'window',
expanded: options.expanded || false,
visible: true,
active: false
});
let content = ui.create(`<div class="window">
<header class="cols-2 right">
<h1>${options.title || ''}</h1>
<div class="controls">
<button eicbutton data-id="${view._sparcId}" basic primary rounded xsmall class="icon-copy shrink" title="shrink"></button>
<button eicbutton data-id="${view._sparcId}" basic primary rounded xsmall class="icon-square-o expand" title="expand"></button>
<button eicbutton data-id="${view._sparcId}" basic primary rounded xsmall class="icon-cancel close" title="close"></button>
</div>
</header>
<section></section>
</div>`);
if(options.expanded) content.setAttribute('expanded','');
let container = content.querySelector('section');
container.innerHTML = Controller.processTemplate(options.name, html, data);
view.el = content;
// setting up window controls
let expand = content.querySelector('header button.expand');
expand.addEventListener('click', view.expand.bind(view));
let shrink = content.querySelector('header button.shrink');
shrink.addEventListener('click', view.shrink.bind(view));
let close = content.querySelector('header button.close');
close.addEventListener('click', this.onclose.bind(this));
content.addEventListener('click', this.onFocusRequest.bind(this));
content.addEventListener('expanded', this.onFocusRequest.bind(this));
content.addEventListener('shrinked', this.onFocusRequest.bind(this));
content.setAttribute('sparc-id', view._sparcId);
let parent = Controller._template.view.find('.app-workspace');
parent.appendChild(content);
this.addThesaurus(view._sparcId, options.title || 'a window')
view.DOMContentLoaded(data);
view.initWindowEvents();
this.focus(view._sparcId);
return view;
}
openDialog(view) {
let promise = new Promise(function(resolve) {
if(typeof view === 'string') {
let message = view;
view = new EICDialogContent();
view.el = message;
}
let dialog = EICController.createDialog(view);
function commit(result) {
EICController.closeDialog(view._sparcId);
resolve(result);
}
function abort(result) {
EICController.closeDialog(view._sparcId);
resolve(result);
}
view.commit = commit;
view.abort = abort;
view.buttons.forEach(function(button) {
dialog.querySelector('[eicdialog] > [eiccard] > footer').append(button.el);
});
view.DOMContentFocused();
});
return promise;
}
/**
* Used by a view that needs to change the current URL
* Typically happens when the view allows you to switch to another instance of the business-object
* handled by this controiller. Doinbg a history.replaceState in the view is not enough, because
* the controller needs to keep track of his current URL (in view2url) for changing
* the Browser URL bar when -later- changing window focus.
* @param {string} newUrl
*/
changeUrl(view, newUrl) {
history.replaceState(null, null, newUrl)
this.view2url[view._sparcId] = newUrl
view._url = newUrl
}
static createDialog(dialog, options) {
options = options || {};
let container = ui.create(`<div eicdialog sparc-id="${dialog._sparcId}">
<article eiccard ${ options.closable || ''}>
${ dialog.options.title ? `<header><h1>${dialog.options.title}</h1>${dialog.options.subtitle ? `<h2>${dialog.options.subtitle}</h2>`: '' }</header>`: ''}
<section></section>
<footer></footer>
</article>
</div>`);
ui.eicfy(container);
container.querySelector('[eiccard] section').append(dialog.el);
dialog.parentContainer = container;
document.body.append(container)
return container;
}
static closeDialog(id) { document.body.querySelector(`[sparc-id="${id}"]`).remove(); }
addThesaurus(id, title) {
let container = Controller._template.view.find('.app-content-thesaurus');
container.querySelectorAll('[eicchip]').forEach(el => el.setAttribute('secondary', ''))
let chip = new Chip(null, {label: title, destroyable: true});
chip.el.setAttribute('data-id', id);
chip.addEventListener('click', this.onSelectThesaurus.bind(this));
chip.addEventListener('destroy', this.onRemoveThesaurus.bind(this));
container.append(chip.el);
}
removeThesaurus(id) {
let container = Controller._template.view.find('.app-content-thesaurus');
let chip = container.querySelector(`[data-id="${id}"]`)
if(chip) chip.remove();
let remaining = container.querySelectorAll('[eicchip]');
if(remaining.length > 0) {
remaining.item(remaining.length - 1).dispatchEvent(new MouseEvent('click'));
}
}
close(id) {
this.removeThesaurus(id);
this.unloadView(Controller.getContentById(id));
if(this.view2url.hasOwnProperty(id)) delete(this.view2url[id]);
}
focus(id, data) {
this.selectThesaurus(id);
let curUrl = new URL(document.location.href).pathname
if(this.view2url.hasOwnProperty(id)) {
history.replaceState(null, null, this.view2url[id]);
curUrl = this.view2url[id]
}
if(app.matomo) app.matomo.logAction('Focus window / '+curUrl)
let next = Controller.getContentById(id);
if(Controller._currentContent) {
if(next.expanded) {
this.blur(Controller._currentContent);
Controller._contents.filter(item => item.type == 'window').forEach(item => this.desactivate(item));
Controller._contents.filter(item => !item.expanded).forEach(item => this.blur(item));
} else {
Controller._contents.filter(item => !item.expanded && item.type == 'window').forEach(item => this.attach(item));
this.attach(Controller._currentContent);
let altexpand = Controller._contents.filter(item => item.expanded && item.visible && item.type == 'window');
if(altexpand.length == 0) {
let latest = Controller._contents.reverse().find(item => item.expanded);
if(latest) { this.attach(latest); }
}
}
}
Controller._currentContent = next;
this.activate(Controller._currentContent, data);
Controller._currentContent.view.DOMContentResized();
}
desactivate(content) {
content.view.el.classList.remove('active');
content.active = false;
if(content.expanded) this.blur(content);
}
activate(content, data) {
Controller._contents.forEach(o => o.active = false);
Controller._contents.forEach(o => o.view.el.classList.remove('active'));
content.view.el.classList.add('active');
content.active = true;
if(!content.visible) this.attach(content);
content.view.DOMContentFocused(data);
}
attach(content) {
ui.show(content.view.el,!content.expanded ? 'grid': 'block');
content.visible = true;
}
blur(content) {
ui.hide(content.view.el);
content.visible = false;
content.active = false;
content.view.DOMContentBlured();
}
onclose(event) {
event.preventDefault();
event.stopPropagation();
this.close(event.currentTarget.dataset.id)
}
onSelectThesaurus(event) {
event.stopPropagation();
event.preventDefault();
this.focus(event.currentTarget.dataset.id);
}
selectThesaurus(id) {
let container = Controller._template.view.find('.app-content-thesaurus');
container.querySelectorAll('[eicchip]').forEach(el => el.setAttribute('secondary', ''))
container.querySelector(`[eicchip][data-id="${id}"]`).removeAttribute('secondary');
}
onRemoveThesaurus(event) {
event.stopPropagation();
event.preventDefault();
this.close(event.currentTarget.dataset.id);
}
onFocusRequest(event) {
let view = event.currentTarget;
while(!view.hasAttribute('sparc-id') && !view.classList.contains('window')) view = view.parentElement;
let content = Controller.getContentById(view.getAttribute('sparc-id'));
if(!content.active || content.expanded != content.view.expanded || (Controller._currentContent.view._sparcId != content.view._sparcId)) {
content.expanded = content.view.expanded;
this.focus(view.getAttribute('sparc-id'));
}
}
static resize() {
Controller._contents.forEach(item => item.view.DOMContentResized())
}
}
app.registerClass('EICController', EICController);
+76
View File
@@ -0,0 +1,76 @@
/**
* @category MyEic
* @subcategory Libraries
* @extends View
*/
class EICDialogContent extends View {
icon = null;
actions = [
{
label: 'Cancel',
onclick: this.cancel.bind(this),
severity: 'secondary'
},
{
label: 'OK',
onclick: this.accept.bind(this),
severity: 'primary'
}
]
constructor(options) {
super(options);
this.options = options || {};
window.addEventListener('resize', this.DOMContentResized.bind(this));
}
DOMContentLoaded(options) {
}
get buttons() {
let list = [];
for(let descriptor of this.actions) {
if(!descriptor.button) {
let button = new Button(ui.create(`<button eicbutton>${descriptor.label}</>`), {
icon: descriptor.icon,
severity: descriptor.severity || '',
disabled: descriptor.disabled || false,
onclick: descriptor.onclick
});
descriptor.button = button;
}
list.push(descriptor.button) ;
}
return list;
}
cancel(event) {
event.stopPropagation();
event.preventDefault();
this.abort();
}
accept(event) {
event.stopPropagation();
event.preventDefault();
this.commit(true);
}
DOMContentResized() {
let limits = [
{ name: 'desktop', min: 768 },
{ name: 'tablet', min: 600 },
{ name: 'mobile', min: 0 }
];
let device = limits.find(item => item.min < window.innerWidth);
this.el.closest('[eicdialog], [eicdialog="eicdialog"]').setAttribute('device', device ? device.name: limits[0].name);
}
}
app.registerClass('EICDialogContent', EICDialogContent);
+239
View File
@@ -0,0 +1,239 @@
/**
* @category MyEic
* @subcategory Libraries
* @extends View
*/
class EICDomContent extends View {
icon = null;
/**
*
*/
loadContent(name, options, data) { return this._controller.loadContent(name, options, data); }
get expanded() { return this.el.hasAttribute('expanded'); }
/**
*
*/
expand() {
this.el.setAttribute('expanded','');
this.el.dispatchEvent(new CustomEvent('expanded'));
this.el.style.left = 'auto';
this.el.style.top = 'auto';
this.el.style.height = 'auto';
this.el.style.width = 'auto';
this.DOMContentResized()
}
/**
*
*/
shrink() {
this.el.removeAttribute('expanded');
this.el.dispatchEvent(new CustomEvent('shrinked'));
this.el.style.left = this.el.getBoundingClientRect().x;
this.el.style.top = this.el.getBoundingClientRect().y;
this.DOMContentResized();
}
openDialog(view) {
let promise = new Promise(function(resolve) {
if(typeof view === 'string') {
let content = view;
view = new EICDialogContent();
view.el = content;
}
let dialog = EICController.createDialog(view);
function commit(result) {
EICController.closeDialog(view._sparcId);
resolve(result);
}
function abort(result) {
EICController.closeDialog(view._sparcId);
resolve(result);
}
view.commit = commit;
view.abort = abort;
view.buttons.forEach(function(button) {
dialog.querySelector('[eicdialog] > [eiccard] > footer').append(button.el);
});
view.DOMContentFocused();
view.DOMContentResized();
});
return promise;
}
unload() {
let button = this.el.closest('.window').querySelector('.controls .close')
if(button) button.click();
}
/**
*
*/
initWindowEvents() {
this.draggable(this.el);
this.resizable(this.el, this.DOMContentResized.bind(this));
}
draggable(element) {
let mouseX, mouseY, offsetX = 0, offsetY = 0;
// mouse button down over the element
element.querySelector('header h1').addEventListener('mousedown', onMouseDown);
/**
* Listens to `mousedown` event.
*
* @param {Object} event - The event.
*/
function onMouseDown(event) {
event.stopPropagation();
event.preventDefault();
mouseX = event.clientX;
mouseY = event.clientY;
offsetX = element.getBoundingClientRect().x;
offsetY = element.getBoundingClientRect().y;
document.addEventListener('mousemove', onMouseMove);
}
element.addEventListener('mouseup', onMouseUp);
/**
* Listens to `mouseup` event.
*
* @param {Object} event - The event.
*/
function onMouseUp(event) {
offsetX = parseInt(element.style.left) || 0;
offsetY = parseInt(element.style.top) || 0;
document.removeEventListener('mousemove', onMouseMove);
}
/**
* Listens to `mousemove` event.
*
* @param {Object} event - The event.
*/
function onMouseMove(event) {
if(event.buttons != 1) {
document.removeEventListener('mousemove', onMouseMove);
return;
}
let dX = event.clientX - mouseX;
let dY = event.clientY - mouseY;
element.style.left = offsetX + dX + 'px';
element.style.top = offsetY + dY + 'px';
}
}
resizable(element, onResize) {
let mouseX, mouseY, offset = 0, offsetY = 0, grab = '';
// mouse button down over the element
element.addEventListener('mousedown', onMouseDown);
element.querySelector('section').addEventListener('mousedown', onBlockMouseDown);
function onBlockMouseDown(event) { event.stopPropagation(); }
/**
* mousedown event listener
*
* @param {Object} event - mouse event.
*/
function onMouseDown(event) {
event.stopPropagation();
event.preventDefault();
if(event.currentTarget.hasAttribute('expanded')) return false;
let limits = element.getBoundingClientRect();
mouseX = event.clientX;
mouseY = event.clientY;
grab = '';
grab += (Math.abs(mouseY - limits.y) < Math.abs(mouseY - (limits.bottom))) ? 'n':'s';
grab += (Math.abs(mouseX - limits.x) < Math.abs(mouseX - (limits.right))) ? 'e':'w';
offset = element.getBoundingClientRect();
document.addEventListener('mousemove', onMouseMove);
}
element.addEventListener('mouseup', onMouseUp);
/**
* mouseup event listener
*
* @param {Object} event - The event.
*/
function onMouseUp(event) { document.removeEventListener('mousemove', onMouseMove); }
/**
* Listens to `mousemove` event.
*
* @param {Object} event - The event.
*/
function onMouseMove(event) {
if(event.buttons != 1) {
document.removeEventListener('mousemove', onMouseMove);
return;
}
let dx = event.clientX - mouseX;
let dy = event.clientY - mouseY;
mouseX = event.clientX;
mouseY = event.clientY;
if(grab.indexOf('n') > -1) {
offset.y = offset.y + dy;
offset.height = offset.height - dy;
}
if(grab.indexOf('s') > -1) {
offset.height = offset.height + dy;
}
if(grab.indexOf('e') > -1) {
offset.x = offset.x + dx;
offset.width = offset.width - dx;
}
if(grab.indexOf('w') > -1) {
offset.width = offset.width + dx;
}
element.style.left = offset.x + 'px';
element.style.top = offset.y + 'px';
element.style.height = offset.height + 'px';
element.style.width = offset.width + 'px';
onResize();
}
}
DOMContentResized() {
let limits = [
{ name: 'desktop', min: 768 },
{ name: 'tablet', min: 600 },
{ name: 'mobile', min: 0 }
];
let bounds = this.el.getBoundingClientRect();
let device = limits.find(item => item.min < bounds.width);
this.el.setAttribute('device', device ? device.name: limits[0].name);
}
}
app.registerClass('EICDomContent', EICDomContent);
+123
View File
@@ -0,0 +1,123 @@
/**
* Meta data collections utility class for Sparc
*
* @author Michael Fallise
* @version 1.0
* @category MyEic
* @subcategory Libraries
*/
class EICMetaData {
collections = {};
/**
* Adds a collection to the metadata set
* @param {string} ref
* @param {array} collection
*/
add(ref, collection) { if(!this.collections.hasOwnProperty(ref)) this.collections[ref] = collection; }
/**
* Returns a collection
* @param {string} ref
* @returns {Array<object>}
*/
getCollection(ref) { return this.collections.hasOwnProperty(ref) ? this.collections[ref].content: []; }
/**
* Return a collection item object
* @param {*} ref
* @param {*} id
* @returns {object|null}
*/
getItem(collection, id) {
// if collection is a string ref, fetch matching collection
if(!Array.isArray(collection)) collection = this.getCollection(collection);
// parse recursively to find matching item id
return collection ? this.getItemRecurse(collection, id) : null;
}
/**
*
* @param {*} tree
* @param {*} id
* @returns {object|null}
*/
getItemRecurse(tree, id) {
if (Array.isArray(tree)) {
for(let i = 0; i < tree.length; i++) {
let item = this.getItemRecurse(tree[i], id);
if(item) { return item; }
}
} else if (typeof tree === 'object') {
if (tree.id && tree.id == id) return tree;
}
if (tree.children !== undefined && tree.children.length > 0) {
return this.getItemRecurse(tree.children, id);
} else {
return null;
}
}
/**
* Returns a collection item label
* @param {string} ref
* @param {string} id
* @returns {string}
*/
toString(ref, id) {
let item = this.getItem(ref, id);
return item ? item.label: `(${ref}.${id})`;
}
/**
* Converts a collection into a list of OPTION tags
* @param {string} ref
* @returns {Array<Element>}
*/
toOptions(collection, selectedIds=[], placeHolder) {
if(!Array.isArray(collection)) collection = this.getCollection(collection);
if(!Array.isArray(selectedIds)) selectedIds = [selectedIds];
let options = [];
if(placeHolder) options.push(ui.create(`<option value=""><option>`))
if(collection)
for (const item of collection) {
options.push(ui.create(`<option value="${item.id}" ${(selectedIds.indexOf(item.id)>-1) ? 'selected':''}>${item.label}</option>`));
}
return options;
}
/**
*
* @param {*} collection
* @param {*} selectedId
* @param {*} placeHolder
* @param {*} parentId
* @returns {Array<Element>}
*/
toOptionsRecurse(collection, selectedId, placeHolder, parentId) {
if(!Array.isArray(collection)) collection = this.getCollection(collection);
let options = [];
let children = [];
if(placeHolder) options.push(`<option value=""><option>`)
if(collection)
for (const item of collection) {
options.push(`<option ${item.children ? `data-ref="${item.id}"`: ''} value="${item.id}" ${selectedId ? (selectedId == item.id ? 'selected':'') :''}>${item.label}</option>`);
if(item.children) children.push({parent: item.id, children: item.children})
}
let html = parentId ? `<optgroup data-parent="${parentId}">${options.join('')}</optgroup>`: `${options.join('')}`;
for(let item of children) {
html += `${this.toOptionsRecurse(item.children, selectedId, false, item.parent)}`;
}
return html;
}
}
app.registerClass('EICMetaData',EICMetaData);
app.meta = new EICMetaData();
+167
View File
@@ -0,0 +1,167 @@
/**
base class for data models of the EIC platform
instanciation requires 2 parameters, businessObject and privileges.
- businessObject: a string reference to a set of endpoints provided by the API discovery service (API descriptr can be found in app.config.api)
- privileges: an array of endpoints references (aka actions) allowed specifically for a certain resource. Those privileges are provided by the middle tier prior accessing the resource. * @category MyEic
* @category MyEic
* @subcategory Libraries
* @extends Model
*/
class EICModel extends Model {
// data container for the item
itemData = {};
// list of available api services for this model
api = {};
// privileges granted on this model
privileges = [];
/**
*
* @param {string} resource A string reference to the resource (aka business obkect) to be handled (ex: "users" or "project")
* @param {array} privileges An array of allowed privileges (as string reference. ex: "save", "delete", etc...). Privileges should match a corresponding method in the controller
*/
constructor(resource, privileges) {
super();
// if((app.config.hasOwnProperty('api')) && (app.config.api.hasOwnProperty(resource))) {
// this.api = app.config.api[resource];
// } else this.config.api = {}
this.setPrivileges(resource, privileges);
}
/**
*
* @param {*} action
* @param {*} resource
* @returns {object}
*/
getApiEndpoint(action, resource=null) {
resource = resource || this.resource;
for(let ApiBizObj of Object.keys(app.config.api)) {
//If called without real ids
if(ApiBizObj==resource) return(JSON.parse(JSON.stringify(app.config.api[ApiBizObj][action])));
//If called with real ids
let re = new RegExp('^'+ApiBizObj.replace(/\//g,'\\/').replace(/\{\w+\}/gi,'([0-9a-zA-Z]+)')+'$', 'm')
if(resource.match(re)) {
if(app.config.api[ApiBizObj].hasOwnProperty(action)) return(JSON.parse(JSON.stringify(app.config.api[ApiBizObj][action])));
else console.error(`No API endpoint for action "${action}" on resource "${resource}"`)
}
}
console.error(`No API service for resource:${resource}`)
return({ "method":null, "uri": null });
}
setPrivileges(resource, privileges) {
this.resource = resource;
this.privileges = privileges || [];
}
/**
* Checks if a privilege is available for this profile
* @param {string} privilege ref
* @returns {boolean}
*/
hasPrivilege(privilege) {
if(this.privileges.indexOf(privilege) != -1) return(true);
else {
//console.warn('You do not have the privilege:',privilege);
return(false);
}
}
/**
* Fills the model item properties with provided object
* @param {object} data
* @returns {object}
*/
async loadData(data) {
// itemData should be cleared first
this.itemData = {}
Object.assign(this.itemData, data); // this way getters are natural
return this.itemData;
}
/**
* Generates a stringified JSON output of the itemData object
* Practical for cloning the model data
* @returns {string}
*/
toString() { return(JSON.stringify(this)); }
/**
* Deep copies updates object into target
* @param {object} target
* @param {object} updates
* @returns {object}
*/
merge(current, updates) {
for (let key of Object.keys(updates)) {
if (!current.hasOwnProperty(key) || typeof updates[key] !== 'object') current[key] = updates[key];
else this.merge(current[key], updates[key]);
}
return current;
}
/**
* Returns the current item id. Shortcut for itemData.id
* @returns {string}
*/
get id() { return(this.itemData.id); }
/**
* Tries to match the instance with given data
*
* @param {Object} matchValues - { {string} key: {string} valueToMatch }
* @param {Object} matchExact - { strin{key}: {Boolean} } Defaults to exact match if parameter absent or if key absent.
* Non-exact match uses valueToMatch as a REGEX. valueToMatch is then a string like "/regex/" or "/regex/flags".
* @return {boolean} true if match
*/
match(matchValues, matchExact = {}){
for(let key in matchValues){
let val = matchValues[key];
if((key in matchExact) && (matchExact[key])){ // equality
if(this.itemData[key]!=val) return(false);
} else { //Regex
let re = new RegExp(val.substring(1, val.lastIndexOf('/')), val.substring(val.lastIndexOf('/')+1));
if(!re.test(this.itemData[key])) return(false);
}
}
return(true);
}
/**
*
* @param {*} error
* @param {number} error.code
* @param {string} error.displayMessage
*/
onRequestError(error) {
if([400,404,405,409].indexOf(error.code) > -1) {
console.warn('Ajax request error:', error);
ui.growl.append(`${error.displayMessage}`, 'warning', 10000);
} else if(error.code > 499) {
console.error('Ajax request error:', error);
ui.growl.append(
`Unfortunately, there was a server error.<br>
Please contact the helpdesk if it persists
`, 'danger', 5000);
} else if(error.code==403){
console.warn(`Ajax request unauthorized in ${this.constructor.name}`, error);
ui.growl.append(`${error.displayMessage}`, 'danger', 10000);
} else if((error.code==401) && (app.User.isAuthenticated)){
console.warn(`Unauthenticated in ${this.constructor.name}`, error);
app.User.isAuthenticated = false
app.User.stopKeepAlive()
app.Router.route('/401', {'triggerUrl' :app.Router.currentRoute.realUrl } );
}
}
}
app.registerClass('EICModel',EICModel);
+246
View File
@@ -0,0 +1,246 @@
/**
* @author Nicolas Stein
* @category MyEic
* @subcategory Libraries
* @extends EICModel
*/
class EICPluralModel extends EICModel {
constructor(businessObject, privileges, singletonClass) {
super(businessObject, privileges);
this.collection = [];
//this.api = app.config.api.filter(item => item.businessObject == businessObject);
this.singletonClass = singletonClass;
}
/**
* Get all ids in the current collection
* @return {Array} Ids (keys) of the current collection.
*/
get ids() { return this.collection.map(item => item.itemData.id); }
/**
* Creates empty singletons at the end of the collection.
* @param {Array} ids - Ids of the singletons to create
*/
create(ids){
for(let id of ids){
this.collection[id] =new this.singletonClass;
}
}
/**
* Converts the data in a singleton & adds it to the collection.
* If the id exists, will just override keys from the server (preserving eventual overrides)
* @param {Object} data - key-value data used to create the singleton.
* @return {Object} The collection length after insertion.
*/
add(data, id=null){
id = data.id || null;
let item = this.collection.find(item => item.id == id);
if(item){
Object.assign(item.itemData, data);
} else {
item = new this.singletonClass();
item.itemData = data;
this.collection.push(item);
}
return(item);
}
/**
* Removes and returns a singleton out of the collection.
* @param {string} id - id of the singleton to pop.
* @return {Object} The popped singleton.
*/
pop(id) {
if(id in this.collection) {
let singleton = this.collection[id];
this.delete(this.collection[id]);
return(singleton);
} else return(null);
}
/**
* Returns the number of singletons in the collection.
* @return {Int} Number of singletons.
*/
get length() { return this.collection.length; }
/**
* Empties the collection
*/
empty() { this.collection = []; }
/**
* Search the collection for matching singletons
* @param {Object} matchValues - { {string} key: {string} valueToMatch }
* @param {Object} matchExact - { strin{key}: {Boolean} } Defaults to exact match if parameter absent or if key absent
* @return {Array} Array of matching singletons.
*/
localFind(matchValues, matchExact){
return(
this.collection.filter((singleton) => singleton.match(matchValues, matchExact))
);
}
/**
* Loads singletons from their IDs at the end of the current collection.
* if no ids given, loads all
* Expected server-contract:
* [ {"id":"xxx", ...other singleton data...}, {"id":"yyy", ...other singleton data...}, ...]
* @param {array} ids - singleton ids to be loaded
* @return {promise} Promise returning the model
*/
load(endpoint, payload){
return(
this.request(endpoint.uri, endpoint.method, payload)
.then(async serverData => {
this.fill(serverData);
return(this);
})
)
}
fill(serverData) {
if(serverData && serverData.length){
for(let singletonData of serverData) this.add(this.sanitize(singletonData));
}
}
sanitize(item) { return item; }
/**
* Loads singletons from search criteria at the end of the current collection.
* @param {Object} matchValues - { {string} key: {string} valueToMatch }
* @param {Object} matchExact - { strin{key}: {Boolean} } Defaults to exact match if parameter absent or if key absent
* @return {Array} Array of matching singletons.
*/
remotefind(matchValues, matchExact){
return(
this.post(this.baseUrl+'search', { 'matchValues': matchValues, 'matchExact': matchExact })
.then(async serverData => {
if(serverData){
for(let singletonData of serverData) this.add(singletonData);
}
return(this);
})
)
}
save(){
return(
this.put(this.baseUrl, this.collection)
.then(async serverData => {
if(serverData){
for(let singletonData of data) this.add(singletonData);
}
return(this);
})
)
}
/**
* Fills the collection from a raw CSV string.
* (adapted from https://stackoverflow.com/users/10549827/matthew-e-brown)
* @param {string} csv The raw CSV string.
* @param {string[]} headers An optional array of headers to use. If none are
* given, they are pulled from the first line of `text`.
* @param {string} idName The name of the column to be used as id.
* @param {string} quoteChar A character to use as the encapsulating character.
* @param {string} delimiter A character to use between columns.
*/
//TODO NIKE: test ! & understand why in accumulator, merge uses [key] instead of key
fromCsv(csv, headers = null, idName='id', quoteChar = '"', delimiter = ',') {
const regex = new RegExp(`\\s*(${quoteChar})?(.*?)\\1\\s*(?:${delimiter}|$)`, 'gs');
const match = line => [...line.matchAll(regex)]
.map(m => m[2]) // we only want the second capture group
.slice(0, -1); // cut off blank match at the end
const lines = csv.split('\n');
const firstLine = lines.shift();
const heads = headers || match(firstLine);
if(heads.indexOf(idName)<0) {
console.warn(`id column "${idName}" not found in CSV headers !`);
return;
}
heads[heads.indexOf(idName)]='id'; let cols;
for(let line of lines){
cols = match(line);
if(cols.length>=heads.length){
let singletonData = cols.reduce((acc, cur, i) => {
// Attempt to parse as a number; replace blank matches with `null`
const val = cur.length <= 0 ? null : Number(cur) || cur;
const key = heads[i] [[]] `extra_${i}`;
return { ...acc, [key]: val };
}, {});
this.add(singletonData);
}
};
}
/**
* Builds a CSV in a string from the collection.
* @param {Array} keys model properties you want to include in the csv. It defaults to the least common denominator of properties across the collection.
* @param {boolean} withHeaders If true, the first line has the properties names.
* @param {string} quoteChar Will surround all values (and properties if withHeader).
* @param {string} delimiter A character to use between columns.
*/
//TODO NIKE: test !
toCsv(keys=null, withHeader=true, quoteChar = '"', delimiter = ';', downloadName=null){
const _dwnldCsv = function(data, fname) {
const blob = new Blob([data], { type: 'text/csv' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.setAttribute('href', url);
a.setAttribute('download', fname+'.csv');
a.click();
}
if(!keys) { // take min. common set of keys in collection (take 1st then successively intersect to itself)
let ids = Object.keys(this.collection);
keys = Object.keys(this.collection[ids[0]].itemData);
for(let id of ids) {
keys = keys.filter(x => (this.collection[id].itemData.hasOwnProperty(x)))
}
}
let csv=''; let row=[];
if(withHeader===true){
for(let key of keys){
row.push(quoteChar+key+quoteChar);
}
csv += row.join(delimiter)+'\n';
} else if(Array.isArray(withHeader)) {
for(let title of withHeader){
row.push(quoteChar+title+quoteChar);
}
csv += row.join(delimiter)+'\n';
}
for(let id in this.collection){
row = [];
for(let key of keys){
let item = ''
if(typeof(key)=='string'){ // normal column
item = this.collection[id].itemData
for(let k of key.split('.')) { // Allow for sub-objects with dotted keys notation
if(!item) break
item = item[k]
}
} else if(typeof(key)=='function') { // computed column
item = key(this.collection[id].itemData)
} else console.warn('CSV: Bad column: ',key)
row.push(quoteChar+item+quoteChar);
}
csv += row.join(delimiter)+'\n';
}
if(downloadName) _dwnldCsv(csv, downloadName)
return(csv);
}
}
app.registerClass('EICPluralModel',EICPluralModel);
+115
View File
@@ -0,0 +1,115 @@
class FakeFileSystem {
constructor() {
this.currentPath='/'
this.path2dir = {}
}
/*
loadStructure : loads an object like dirname: [ subojbects ]
*/
loadStructure(struct, files) {
this._scanStruct('/', struct, files || []);
}
_scanStruct(locPath, struct, files){
this.path2dir[locPath]={ /* assign local files & local folers*/
files: [...files.filter(item=>{
let filePath = (item.fullPath.split('/').length>2) ? item.fullPath.substr(0,item.fullPath.lastIndexOf('/')) : '/'
return(filePath==locPath)
})],
folders: [...Object.keys(struct).map(item=> {
let folderPath = (locPath=='/') ? '/'+item: locPath+'/'+item
let childrenFiles = files.filter(item=>(item.object.path==folderPath))
return({
name:item,
path: folderPath,
isEmpty:( (Object.keys(struct[item]).length==0) && (childrenFiles.length==0) )
})
})]
}
if(locPath!='/') this.path2dir[locPath].folders.unshift({name:'..', path:'..'})
this.path2dir[locPath].files.sort((a,b)=>((a.object.name<b.object.name) ? -1 : 1))
this.path2dir[locPath].folders.sort((a,b)=>((a.name<b.name) ? -1 : 1))
for(let folderName of Object.keys(struct)){ // Now recur for each subfolder
let subPath = (locPath=='/') ? '/'+folderName : locPath+'/'+folderName
let childrenFiles = files.filter(item=>(item.fullPath.length>subPath.length))
this._scanStruct(
subPath,
struct[folderName],
childrenFiles
)
}
}
/*
getFolder : returns an alphabetically sorted array of folders at the current path
*/
getFolders(){
return(this.path2dir[this.currentPath].folders)
}
/*
getFiles : returns an alphabetically sorted array of files at the current path
*/
getFiles(){
return(this.path2dir[this.currentPath].files)
}
/*
findFile : returns the path of the first file than validates the comparison function.
The comparison function is given the file object as parameter, and should return a boolean.
*/
findFile(fn){
for(let path of Object.keys(this.path2dir)){
for(let file of this.path2dir[path].files){
if(fn(file.object)) return(path)
}
}
return(false)
}
/*
changeDir : changes the current path, relatively or absolutely
*/
changeDir(path){
if(path.startsWith('/')){ //Absolute
if(this.pathExists(path)) this.currentPath = path
} else { //relative
let newPath = this.normalizePath(this.currentPath+'/'+path)
if(newPath=='') newPath='/'
if(this.pathExists(newPath)) this.currentPath = newPath
}
}
/*
pathExists : Check if path (file excluded) exists in the structure
*/
pathExists(path){
return(Object.keys(this.path2dir).includes(path))
}
/*
normalizePath : cleans up and normalizes (executes all /../)
*/
normalizePath(path) {
path = path.replace(/\/+/g, '/')
if (path.startsWith("/")) path = path.substring(1)
if (path.endsWith("/")) path = path.slice(0, -1)
let segments = path.split("/")
let normalizedPath = "/"
for(let segment of segments) {
if (segment === "." || segment === "") continue
if (segment === "..") {
normalizedPath = normalizedPath.substring(0, normalizedPath.lastIndexOf("/") )
continue
}
if (!normalizedPath.endsWith("/")) normalizedPath = normalizedPath + "/"
normalizedPath = normalizedPath + segment
}
return(normalizedPath)
}
}
app.registerClass('FakeFileSystem', FakeFileSystem)
+352
View File
@@ -0,0 +1,352 @@
'use strict'
/**
__ __
( )( ) ___ ____ ____
)( )( / __)( ___)( _ \
)(__)( \__ \ )__) ) /
(______)(___/(____)(_)\_)
By Nike
This file is part of EIC implementation of SPARC.
* @category MyEic
* @subcategory Libraries
* @extends User
*/
class myUser extends app.LoadedClasses.User {
authUrl = '';
preferences = {}
/**
*
*/
constructor() { super(); }
/**
* Checks if user belongs to a role
* @param {string} role
* @returns {boolean}
*/
hasRole(role) { return(this.roles.indexOf(role)>-1); }
/**
*
* @returns {Array<string>}
*/
getRoles() { return(app.User.roles); }
/**
* @async
* @returns {string}
*
*/
fetchServices() {
let host = new URL(app.config.userLib.apiDiscoveryEndpoint).host
let stage = (host.split('.')[1] != 'eismea') ? '.'+host.split('.')[1] : ''
return(fetch('/app/assets/json/global/services.json?'+crypto.randomUUID(), {
method: 'GET'
})
.then(response=>response.text())
.then(response=>JSON.parse(response.replace(/__host__/g, host).replace(/__stage__/g, stage)))
)
}
/**
* Candidate for deprecation
* @returns {Promise}
*/
getApiServices() {
return(
this.fetchServices()
.then(response => {
if(response.success) {
// Was to much to ask to respect existing code & existing contract, so do the cleanup here
let api = {};
for(let entry of response.payload){
api[entry.resource] = entry.actions.reduce( (acc, v)=>{
acc[v.action]=v.availableMethod;
return(acc); }, {}
);
}
// Now override with exceptions from config. (also creates from exceptions)
for(let resource in app.config.userLib.apiStageExceptions){
if(!api.hasOwnProperty(resource)) api[resource] = [];
for(let action in app.config.userLib.apiStageExceptions[resource]) {
api[resource][action] = app.config.userLib.apiStageExceptions[resource][action];
console.warn(`Replacing / adding existing API with exception for resource: ${resource} action: ${action}`)
}
}
app.config.api = api;
}
}
)
)
}
/**
*
* @param {*} callBack
*/
checkAuthenticated(callBack){
let headers = {};
if(app.config.userLib.authForwardDomain) {
let url = new URL(document.location.href);
headers = { 'x-requested-path': url.pathname };
}
this.identity = {
uuid: 'nike',
email: 'info@nicsys.eu'
};
this.roles = ['admin']
return
fetch(app.config.userLib.authEndpoint+'?'+crypto.randomUUID(),{
headers: headers,
method: 'GET',
credentials: 'include'
})
.then(response => response.json())
.then(async resp => {
if(resp.success){
this.authenticationDone = true
this.isAuthenticated = resp.payload.isAuthenticated;
if(resp.payload.isAuthenticated) {
if((!this.identity) || (!this.identity.uuid)) {
this.logoutUrl = resp.payload.logoutUri;
this.parseUserInfo(resp.payload.userInfo);
}
this.platformRestrictions = resp.payload.platformRestrictions || null
if((this.platformRestrictions) && (!this.isVIP())){
this.ShowCurtain()
this.stopKeepAlive()
return // not triggering callback avoids any further ctrl loading by the router
}
if(!app.config.api) await this.getApiServices()
if(app.config.userLib.keepAliveSeconds && (app.config.userLib.keepAliveSeconds>0)) this.startKeepAlive()
callBack();
} else {
console.warn('Authorizer said User was not authenticated !');
this.authUrl = resp.payload.authUrl;
this.logoutUrl = resp.payload.logoutUri;
callBack();
}
} else {
console.error('Server error calling authorizer checkAuthenticated (success not true)');
this.stopKeepAlive() // Just in case KAL is active, because we arrive here from KAL itself
document.location.href = '/eulogin-error.html';
}
})
.catch((err) => {
console.error('Server error calling authorizer checkAuthenticated (Network error)',err);
document.location.href = '/eulogin-error.html';
});
}
/**
*
* @returns {string}
*/
getMessageBusUserInfo() { return(this.identity.uuid) }
/**
*
* @param {*} objects An array of URI to be checked
* @param {*} role
* @returns {Promise}
*/
getBusinessPermissions(objects, role) {
let requestedObjects = { resources: objects }
let uri = app.config.userLib.resourcePermissionsEndpoint.replace('{id}', this.identity.uuid);
// This is just to make use of the whole base model request mechanism (especially for the 401 flow)
let fakeModel = new EICModel(null, null)
return(
fakeModel.request(uri, 'POST', requestedObjects)
.then( async serverData => {
return(serverData.payload)
})
)
}
/**
*
* @param {string} jumpTo
*/
logout(jumpTo='') {
jumpTo = jumpTo ? jumpTo : this.logoutUrl
fetch(app.config.userLib.logoutEndpoint+'?'+crypto.randomUUID(),{
method: 'GET',
credentials: 'include'
}).then((resp) =>{
window.onbeforeunload = null // If user confirmed to logout, not need to have him confirm he's leaving the app !
document.location.href = jumpTo;
});
}
/**
* This is separated, so that upper layer has a chance to use a login button, or avoid redirection loops.
*/
gotoLogin() {
window.onbeforeunload = null // If user asks to relogin, not need to have him confirm he's leaving the app !
document.location.href = this.authUrl;
}
/**
*
*/
startKeepAlive() {
if((!app.config.userLib.keepAliveSeconds) || this.KALtimer) return
this.KALtimer = setInterval(this.doKeepAlive.bind(this), 1000*app.config.userLib.keepAliveSeconds)
}
/**
*
*/
stopKeepAlive() {
if(this.KALtimer) clearInterval(this.KALtimer)
this.KALtimer = null
}
/**
*
*/
doKeepAlive() {
this.checkAuthenticated(()=>{
// KAL was successfull... do we care ?
})
}
/**
*
* @returns {boolean}
*/
isVIP(){
if(Array.isArray(this.platformRestrictions.allowedRoles)){
let intersect = this.roles.filter(r => (this.platformRestrictions.allowedRoles.includes(r)) )
if(intersect.length>0) return(true)
}
if((Array.isArray(this.platformRestrictions.allowedUUIDs)) &&
(this.platformRestrictions.allowedUUIDs.includes(this.identity.uuid))){
return(true)
}
return(false)
}
/**
*
* @todo should be handled by an external view
*/
ShowCurtain() {
let link = document.createElement("link");
link.rel='stylesheet'
link.type='text/css'
link.href='/app/thirdparty/eicui/eicui-2.0.css'
document.head.appendChild(link)
let style = document.createElement('style')
style.innerHTML = ` .maintenance{ width: 40vw!important; text-align: center; margin: auto!important; } `
document.head.appendChild(style)
let content = document.createElement('div')
content.innerHTML = `
<article class="maintenance" eiccard="" aria-enabled="true" >
<header><h1>Maintenance</h1></header>
<section>
<alert eicalert="" danger="">
The site is currently under maintenance.</br>
Please come back later.
</alert>
</section>
</article>
`
document.body.appendChild(content)
}
/**
*
* @param {*} info
*/
async parseUserInfo(info) {
this.identity = {
uuid: info.euLoginId,
firstname: info.family_name,
lastname: info.given_name,
email: info.email
};
this.roles = info.userRoles || []
}
loadPreferences() {
if(app.MessageBus) {
app.MessageBus.requestWssGwAction('GET', { key: `${this.identity.uuid}:userPrefs`})
.then(settings => {
this.preferences = settings.value;
})
}
}
savePreferences() {
if(app.MessageBus) {
app.MessageBus.requestWssGwAction('SET', {
key: `${this.identity.uuid}:userPrefs`,
value: this.preferences
})
}
}
getPreference(path) {
let value = null;
if(app.MessageBus) {
let segments = path.split('.');
let pointer = this.preferences;
if(pointer) {
for(let segment of segments) {
if(pointer[segment]) {
if(typeof pointer[segment] == 'object') {
pointer = pointer[segment];
} else {
value = pointer[segment];
}
} else {
break;
}
}
}
}
return value;
}
setPreference(path, value) {
if(app.MessageBus) {
let segments = path.split('.');
let pointer = this.preferences;
for(let i = 0; i < segments.length - 1; i++) {
let segment = segments[i];
if(!pointer[segment]) {
pointer[segment] = {};
}
pointer = pointer[segment];
}
pointer[segments[segments.length - 1]] = value;
}
this.savePreferences();
}
}
app.registerClass('User', myUser, true); // for Sparc to use
app.registerClass('myUser', myUser, true); // Just to avoid double-loading if squeezed