Commit b3bdd1a3 authored by Davide Gagliardi's avatar Davide Gagliardi
Browse files

added chat functionality

parent 8bcaa53d
here you'll find the chat daily backup files
...@@ -2,6 +2,7 @@ const express = require('express'); ...@@ -2,6 +2,7 @@ const express = require('express');
const bodyParser = require('body-parser'); const bodyParser = require('body-parser');
const server = require('https') const server = require('https')
const fs = require('fs') const fs = require('fs')
const moment = require('moment')
const ldapConfig = require('./auth/config.js') // verify if needed const ldapConfig = require('./auth/config.js') // verify if needed
const session = require('express-session') const session = require('express-session')
...@@ -15,6 +16,8 @@ var LdapStrategy = require('passport-ldapauth').Strategy; ...@@ -15,6 +16,8 @@ var LdapStrategy = require('passport-ldapauth').Strategy;
const { v4: uuid } = require('uuid'); const { v4: uuid } = require('uuid');
const port = process.env.PORT || 4040; const port = process.env.PORT || 4040;
var ExpressBrute = require('express-brute'); var ExpressBrute = require('express-brute');
var store = new ExpressBrute.MemoryStore(); // stores state locally, don't use this in production var store = new ExpressBrute.MemoryStore(); // stores state locally, don't use this in production
var bruteforce = new ExpressBrute(store,{ var bruteforce = new ExpressBrute(store,{
...@@ -25,11 +28,13 @@ var bruteforce = new ExpressBrute(store,{ ...@@ -25,11 +28,13 @@ var bruteforce = new ExpressBrute(store,{
passport.use(new LdapStrategy(ldapConfig)) passport.use(new LdapStrategy(ldapConfig))
passport.serializeUser((user, done) => { passport.serializeUser((user, done) => {
// console.log(user.cn + ' logged in')
// console.log('Inside serializeUser callback. User id is save to the session file store here') // console.log('Inside serializeUser callback. User id is save to the session file store here')
done(null, user.cn) done(null, user.cn)
}) })
passport.deserializeUser((id, done) => { passport.deserializeUser((id, done) => {
// console.log(id + ' logged out')
// should a double be implemented // should a double be implemented
done(null, id) done(null, id)
}) })
...@@ -52,17 +57,21 @@ app.use(express.static(__dirname + '/node_modules/file-saver/dist/')); ...@@ -52,17 +57,21 @@ app.use(express.static(__dirname + '/node_modules/file-saver/dist/'));
app.use(bodyParser.urlencoded({ extended: true })) // if login page not working app.use(bodyParser.urlencoded({ extended: true })) // if login page not working
app.use(bodyParser.json()); app.use(bodyParser.json());
app.use(session({ var sessionMware = session({
genid:(req) => { genid:(req) => {
return Date.now() + "_" + uuid() return Date.now() + "_" + uuid()
}, },
store: new FileStore(), store: new FileStore(),
// cookie: {maxAge: 10*360000},
secret: 'keyboard cat', secret: 'keyboard cat',
resave: false, resave: false,
saveUninitialized: true, saveUninitialized: true,
name: 'session_id', name: 'session_id',
maxAge : new Date(Date.now() + 10000) expires: new Date(Date.now() + 10000)
})) // maxAge : new Date(Date.now() + 10000)
})
app.use(sessionMware)
app.use(passport.initialize()); app.use(passport.initialize());
...@@ -77,18 +86,59 @@ app.use(passport.session()) ...@@ -77,18 +86,59 @@ app.use(passport.session())
// //
// next(); // next();
// }); // });
////////////////////////////////////////////////
////////////////// chat file ///////////////////
////////////////////////////////////////////////
// var chatHistory =
function getChatHistory(date){
var filename = "chat_history/"+date+'_chat.json'
try {
var file = fs.readFileSync(filename)
var data = JSON.parse(file)
} catch (e) {
var data = {date: date, messages: []};
data = JSON.stringify(data, null, 2)
fs.writeFile(filename, data, function(e){
if (e) throw e;
console.log('New Chat file created');
})
}
return data
}
var today = moment().format('YYYYMMDD');
var chatHistory = ""
////////////////////////////////////////////// //////////////////////////////////////////////
///////////// sockets handling /////////////// ///////////// sockets handling ///////////////
////////////////////////////////////////////// //////////////////////////////////////////////
const io = require('socket.io')(https); const io = require('socket.io')(https);
io.use(function(socket,next) {
sessionMware(socket.request, socket.request.res, next)
})
var participants = 0 // var participants = 0
var connectedUsers = [];
function countUser(arr){
let cnt = 0;
for(id in arr){
cnt += 1
}
return cnt;
}
function onConnection(socket){ function onConnection(socket){
participants += 1 var userId = socket.request.session.passport.user;
io.sockets.emit("connections", participants); chatHistory = getChatHistory(today)
connectedUsers[userId] = socket.id;
socket.emit('name', userId)
socket.emit('dump_chat', chatHistory)
io.sockets.emit("connections", countUser(connectedUsers));
// io.sockets.emit("update_board", "hello"); // io.sockets.emit("update_board", "hello");
// console.log("Users connected: ", participants); // console.log("Users connected: ", participants);
// socket.on('update_board', (data)) // socket.on('update_board', (data))
...@@ -99,10 +149,23 @@ function onConnection(socket){ ...@@ -99,10 +149,23 @@ function onConnection(socket){
socket.on('language', (data) => {socket.broadcast.emit('language', data); socket.broadcast.emit('notify', 'editor')}); socket.on('language', (data) => {socket.broadcast.emit('language', data); socket.broadcast.emit('notify', 'editor')});
socket.on('compiling', (bool) => {socket.broadcast.emit('compiling', bool); socket.broadcast.emit('notify', 'editor')}) socket.on('compiling', (bool) => {socket.broadcast.emit('compiling', bool); socket.broadcast.emit('notify', 'editor')})
socket.on('compiled_code', (json) => {socket.broadcast.emit('compiled_code', json); socket.broadcast.emit('notify', 'editor')}) socket.on('compiled_code', (json) => {socket.broadcast.emit('compiled_code', json); socket.broadcast.emit('notify', 'editor')})
socket.on('chat message', (msg) => {
socket.broadcast.emit('message', msg);
socket.broadcast.emit('notify', 'chat')
chatHistory.messages.push(msg);
console.log(chatHistory);
fs.writeFile(
"chat_history/"+today+"_chat.json",
JSON.stringify(chatHistory, null, 2),
(e) => {if(e) throw e}
)
});
socket.on('download-chat', () => {socket.emit("chat-history", chatHistory)})
socket.on('disconnect', (reason) => { socket.on('disconnect', (reason) => {
participants -= 1 // participants -= 1
io.sockets.emit("connections", participants) io.sockets.emit("connections", countUser(connectedUsers))
// console.log("Users connected: ", participants); // console.log("Users connected: ", participants);
if (reason === 'io server disconnect') { if (reason === 'io server disconnect') {
socket.connect(); socket.connect();
...@@ -123,7 +186,7 @@ app.get('/', (req, res) => { ...@@ -123,7 +186,7 @@ app.get('/', (req, res) => {
} }
else { else {
// console.log('redirecting'); // console.log('redirecting');
res.redirect('/musikinformatik/login'); res.redirect('/login');
} }
}) })
...@@ -134,12 +197,12 @@ app.get('/login', (req, res) => { ...@@ -134,12 +197,12 @@ app.get('/login', (req, res) => {
app.post('/login', (req, res, next) => { app.post('/login', (req, res, next) => {
passport.authenticate('ldapauth', (err,user, info) => { passport.authenticate('ldapauth', (err,user, info) => {
if(info) {return res.redirect('/musikinformatik/login?e=' + encodeURIComponent(info.message))}; if(info) {return res.redirect('/login?e=' + encodeURIComponent(info.message))};
if(err) {console.log("Error: " + err); return res.sendStatus(404)} if(err) {console.log("Error: " + err); return res.sendStatus(404)}
if(!user) { return res.redirect('/musikinformatik/login')} if(!user) { return res.redirect('/login')}
req.login(user, (err) => { req.login(user, (err) => {
if(err) { return next(err)} if(err) { return next(err)}
return res.redirect('/musikinformatik/') return res.redirect('/')
}) })
})(req, res, next); })(req, res, next);
}) })
...@@ -151,7 +214,7 @@ app.get('/logout', (req,res) => { ...@@ -151,7 +214,7 @@ app.get('/logout', (req,res) => {
})*/ })*/
// console.log(req.session); // console.log(req.session);
// res.clearCookie() // res.clearCookie()
res.redirect('/musikinformatik/login') res.redirect('/login')
}) })
////////////////////////////////////////////// //////////////////////////////////////////////
......
...@@ -761,8 +761,7 @@ ...@@ -761,8 +761,7 @@
"moment": { "moment": {
"version": "2.24.0", "version": "2.24.0",
"resolved": "https://registry.npmjs.org/moment/-/moment-2.24.0.tgz", "resolved": "https://registry.npmjs.org/moment/-/moment-2.24.0.tgz",
"integrity": "sha512-bV7f+6l2QigeBBZSM/6yTNq4P2fNpSWj/0e7jQcy87A8e7o2nAfP/34/2ky5Vw4B9S446EtIhodAzkFCcR4dQg==", "integrity": "sha512-bV7f+6l2QigeBBZSM/6yTNq4P2fNpSWj/0e7jQcy87A8e7o2nAfP/34/2ky5Vw4B9S446EtIhodAzkFCcR4dQg=="
"optional": true
}, },
"ms": { "ms": {
"version": "2.0.0", "version": "2.0.0",
...@@ -874,6 +873,14 @@ ...@@ -874,6 +873,14 @@
"resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz",
"integrity": "sha1-tVOaqPwiWj0a0XlHbd8ja0QPUuQ=" "integrity": "sha1-tVOaqPwiWj0a0XlHbd8ja0QPUuQ="
}, },
"passport.socketio": {
"version": "3.7.0",
"resolved": "https://registry.npmjs.org/passport.socketio/-/passport.socketio-3.7.0.tgz",
"integrity": "sha1-LuX6/paV1CgcjN3T/pdezRjmcm4=",
"requires": {
"xtend": "^4.0.0"
}
},
"path-is-absolute": { "path-is-absolute": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
...@@ -1260,6 +1267,11 @@ ...@@ -1260,6 +1267,11 @@
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-1.5.5.tgz", "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-1.5.5.tgz",
"integrity": "sha1-wodrBhaKrcQOV9l+gRkayPQ5iz4=" "integrity": "sha1-wodrBhaKrcQOV9l+gRkayPQ5iz4="
}, },
"xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="
},
"yallist": { "yallist": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
......
...@@ -6,7 +6,7 @@ ...@@ -6,7 +6,7 @@
"scripts": { "scripts": {
"test": "echo \"Error: no test specified\" && exit 1", "test": "echo \"Error: no test specified\" && exit 1",
"start": "node index.js", "start": "node index.js",
"dev:server": "nodemon --ignore sessions/ --ignore API/ index.js" "dev:server": "nodemon --ignore sessions/ --ignore API/ --ignore chat_history/ index.js"
}, },
"repository": { "repository": {
"type": "git", "type": "git",
...@@ -28,8 +28,10 @@ ...@@ -28,8 +28,10 @@
"express-session": "^1.17.0", "express-session": "^1.17.0",
"file-saver": "^2.0.2", "file-saver": "^2.0.2",
"ldapjs": "^1.0.2", "ldapjs": "^1.0.2",
"moment": "^2.24.0",
"passport": "^0.4.1", "passport": "^0.4.1",
"passport-ldapauth": "^2.1.4", "passport-ldapauth": "^2.1.4",
"passport.socketio": "^3.7.0",
"session-file-store": "^1.4.0", "session-file-store": "^1.4.0",
"socket.io": "^2.3.0", "socket.io": "^2.3.0",
"uuid": "^7.0.3" "uuid": "^7.0.3"
......
.chat-container {
height: 100%;
position: relative;
margin:30px;
border-top: 1px solid #f1f1f1
}
.chat-container > form {
/* position: absolute; */
/* bottom: 60px; */
/* width: 95%; */
/* padding: 30px */
/* margin:auto; */
/* margin-left: 0; */
}
.submit-message > input {
margin: 30px
/* margin:auto; */
/* margin-left: 0; */
}
#messages {
border: 1px solid #d1d1d1;
height: 60%;
overflow: auto;
margin:0
}
#messages > li {
width: 80%;
margin: 30px;
margin-top: 0;
margin-bottom: 45px
}
.my-message {
text-align: right;
padding: 15px;
padding-left: 30px;
padding-right: 30px;
border-radius: 30px;
background-color: #f1f1f1;
}
.client-message {
padding: 15px;
padding-left: 30px;
padding-right: 30px;
border-radius: 30px;
background-color: #e5f6d6
}
var name = "";
$(function () {
$('form').submit(function(e) {
var msg = $('#m').val();
var time = moment().format("HH:mm");
e.preventDefault(); // prevents page reloading
socket.emit('chat message', {'time':time,'user':name, 'msg':msg});
$('#messages').append(`<li class="uk-align-right"><div class="my-message"><p style="font-size: 10px; margin:0"><i>${time}</i></p><p style="font-size: 12px; margin:0">${msg} | <b> You</b></p></div></li>`);
$('#messages').animate({scrollTop: $('#messages').prop("scrollHeight")})
$('#m').val('');
return false;
});
});
socket.on('message', (msg) => {
var time = msg.time;
var mess = msg.msg;
var user = msg.user;
$('#messages').append(`<li class='uk-align-left'><div class="client-message"><p style="font-size: 10px; margin:0"><i>${time}</i></p><p style="font-size: 12px; margin:0"><b>${user} </b> | ${mess}</p></div></li>`)
$('#messages').animate({scrollTop: $('#messages').prop("scrollHeight")})
})
$("#save-chat").click( function() {
console.log('ciao');
socket.emit('download-chat', true)
// var filename = $("#input-fileName").val()
// var blob = new Blob([text], {type: "text/plain;charset=utf-8"});
// saveAs(blob, "session"+fileExt);
});
socket.on('chat-history', (json) => {
var mess = JSON.stringify(json.messages)
var blob = new Blob([mess], {type: "text/plain;charset=utf-8"});
saveAs(blob, json.date+"musikinformatikChatSession.txt");
})
...@@ -65,6 +65,9 @@ $($upload).on('click', function(){ ...@@ -65,6 +65,9 @@ $($upload).on('click', function(){
}) })
$($chat).on('click', function(){ $($chat).on('click', function(){
removeBadge(this) removeBadge(this)
setTimeout(function(){
$('#messages').animate({scrollTop: $('#messages').prop("scrollHeight")})
}, 1000)
}) })
......
var socket = io("https://x1.local:4040"); //io("https://selma.hfmdk-frankfurt.de:4040");
//your name
var today = moment().format('DD-MM-YYYY')
var name = "";
socket.on('name', (msg) => {
name = msg.split(" ")[0]
})
// init chat
socket.on('dump_chat', (chat) => {
var messages = chat.messages;
$('#welcome-chat > b').text(today)
messages.forEach((msg, ind) => {
if (msg.user == name ) {
$('#messages').append(`<li class="uk-align-right"><div class="my-message"><p style="font-size: 10px; margin:0"><i>${msg.time}</i></p><p style="font-size: 12px; margin:0">${msg.msg} | <b> You</b></p></div></li>`);
}
else {
$('#messages').append(`<li class='uk-align-left'><div class="client-message"><p style="font-size: 10px; margin:0"><i>${msg.time}</i></p><p style="font-size: 12px; margin:0"><b>${msg.user} </b> | ${msg.msg}</p></div></li>`)
}
})
$('#messages').animate({scrollTop: $('#messages').prop("scrollHeight")});
})
'use strict'; 'use strict';
var socket = io("https://selma.hfmdk-frankfurt.de:4040");
(function() { (function() {
var pencil = document.getElementById('pencil') var pencil = document.getElementById('pencil')
......
...@@ -7,41 +7,41 @@ ...@@ -7,41 +7,41 @@
<link rel="stylesheet" href="css/style.css"> <link rel="stylesheet" href="css/style.css">
<link rel="stylesheet" href="css/wb_style.css"> <link rel="stylesheet" href="css/wb_style.css">
<link rel="stylesheet" href="css/e_style.css"> <link rel="stylesheet" href="css/e_style.css">
<link rel="stylesheet" href="css/chat.css">
</head> </head>
<body> <body>
<div class=""> <div class="uk-container uk-container-expand">
<div class="uk-container uk-container-expand"> <nav class="uk-navbar" uk-navbar>
<nav class="uk-navbar" uk-navbar> <div class="uk-navbar-left">
<div class="uk-navbar-left"> <h1 class="uk-navbar-item uk-navbar-logo uk-text-bold uk-text-large">Musikinformatik</h1>
<h1 class="uk-navbar-item uk-navbar-logo uk-text-bold uk-text-large">Musikinformatik</h1>
</div>
<div class="uk-navbar-item uk-navbar-right">
<span id="people">0</span>
<a class="uk-margin-right" href="" uk-icon="icon: users; ratio: 1.3"></a>
<a href="/musikinformatik/logout"class="uk-margin-left" uk-icon="icon: sign-out; ratio: 1.3"></a>
</div>
</nav>
</div> </div>
<div class="sidebar uk-box uk-box-shadow-large uk-margin-auto uk-flex uk-flex-column uk-flex-between"> <div class="uk-navbar-item uk-navbar-right">
<div class="uk-margin-auto uk-padding-small" style="padding-top: 40px"> <span id="people">0</span>
<ul class="uk-align-center uk-list uk-tab-left uk-list-large" uk-tab uk-switcher="connect: #main-content; animation: uk-animation-fade"> <a class="uk-margin-right" href="" uk-icon="icon: users; ratio: 1.3"></a>
<li id="open-board" class="uk-margin-medium-bottom init-board"><a uk-icon="icon: pencil; ratio: 1.3" uk-tooltip="title: whiteboard; pos: right"></a><span class="uk-badge note"></li> <a href="/logout"class="uk-margin-left" uk-icon="icon: sign-out; ratio: 1.3"></a>
<li id="open-editor" class="uk-margin-medium-bottom"><a uk-icon="icon: laptop; ratio: 1.3" uk-tooltip="title: editor | interpreter | compiler; pos: right"></a><span class="uk-badge note"></li>
<li id="open-upload" class="uk-margin-medium-bottom"><a uk-icon="icon: upload; ratio: 1.3" uk-tooltip="title: upload; pos: right"></a><span class="uk-badge note"></li>
<li id="open-camera" class="uk-margin-medium-bottom init-video"><a uk-icon="icon: video-camera; ratio: 1.3" uk-tooltip="title: video; pos: right"></a><span class="uk-badge note"></span></li>
<li id="open-chat" ><a uk-icon="icon: commenting; ratio: 1.3" uk-tooltip="title: chat; pos: right"></a><span class="uk-badge note"></li>
</ul>
</div>
<div class="uk-margin-auto">
<ul class="uk-align-center uk-list ">
<li><a href="#" target="_blank" uk-icon="icon: git-branch; ratio: 1.3"></a></li>
</ul>
</div>
</div> </div>
<div class="main-view uk-box-shadow-xlarge"> </nav>
<ul class="uk-switcher window" id="main-content"> </div>
<!-- BOARD --> <div class="sidebar uk-box uk-box-shadow-large uk-margin-auto uk-flex uk-flex-column uk-flex-between">
<li class="window-content init-board uk-active"> <div class="uk-margin-auto uk-padding-small" style="padding-top: 40px">
<ul class="uk-align-center uk-list uk-tab-left uk-list-large" uk-tab uk-switcher="connect: #main-content; animation: uk-animation-fade">
<li id="open-board" class="uk-margin-medium-bottom init-board"><a uk-icon="icon: pencil; ratio: 1.3" uk-tooltip="title: whiteboard; pos: right"></a><span class="uk-badge note"></li>
<li id="open-editor" class="uk-margin-medium-bottom"><a uk-icon="icon: laptop; ratio: 1.3" uk-tooltip="title: editor | interpreter | compiler; pos: right"></a><span class="uk-badge note"></li>
<li id="open-upload" class="uk-margin-medium-bottom"><a uk-icon="icon: upload; ratio: 1.3" uk-tooltip="title: upload; pos: right"></a><span class="uk-badge note"></li>
<li id="open-camera" class="uk-margin-medium-bottom init-video"><a uk-icon="icon: video-camera; ratio: 1.3" uk-tooltip="title: video; pos: right"></a><span class="uk-badge note"></span></li>
<li id="open-chat" ><a uk-icon="icon: commenting; ratio: 1.3" uk-tooltip="title: chat; pos: right"></a><span class="uk-badge note"></li>
</ul>
</div>
<div class="uk-margin-auto">
<ul class="uk-align-center uk-list ">
<li><a href="#" target="_blank" uk-icon="icon: git-branch; ratio: 1.3"></a></li>
</ul>
</div>
</div>
<div class="main-view uk-box-shadow-xlarge">
<ul class="uk-switcher window" id="main-content">
<!-- BOARD -->
<li class="window-content init-board">
<div class="wb-container" style="position:relative"> <div class="wb-container" style="position:relative">
<span id="whiteboard-head">board</span> <span id="whiteboard-head">board</span>
<div class="layer1"> <div class="layer1">
...@@ -84,17 +84,36 @@ ...@@ -84,17 +84,36 @@
</div> </div>
</li> </li>
<!-- CHAT --> <!-- CHAT -->
<li class="window-content"><div><h1>CHAT</h1></div></li> <li class="window-content uk-active">
<div class="chat-container">
<!-- //<h2 class="">messanger</h1> -->
<ul id="messages" class="uk-list uk-list-large uk-padding">
<li class='' style="width: 100%; margin-left: 0; text-align: center">
<div class="client-message">
<p id="welcome-chat" style="font-size: 12px; margin:0"><b></b></p>
</div>
</li>
</ul>
<form class="chat-form" action="" method="post">
<div class="submit-message uk-flex uk-flex-between uk-flex-middle">
<input id="m" class="uk-input" type="text" autocomplete="off">
<a id="save-chat" uk-icon="icon: download; ratio: 2" uk-tooltip="title: download as txt; pos: top"></a>
<input class="uk-button" type="submit" name="" value="send">
</div>
</form>
</div>
</li>
</ul> </ul>
</div>
</div> </div>
<script <script
src="https://code.jquery.com/jquery-3.4.1.min.js" src="https://code.jquery.com/jquery-3.4.1.min.js"
integrity="sha256-CSXorXvZcTkaix6Yvo6HppcZGetbYMGWSFlBw8HfCJo=" integrity="sha256-CSXorXvZcTkaix6Yvo6HppcZGetbYMGWSFlBw8HfCJo="
crossorigin="anonymous"></script> crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.24.0/moment.min.js" charset="utf-8"></script>
<script src="js/uikit.min.js" type="text/javascript"></script> <script src="js/uikit.min.js" type="text/javascript"></script>
<script src="js/uikit-icons.min.js" type="text/javascript"></script> <script src="js/uikit-icons.min.js" type="text/javascript"></script>
<script src="socket.io/socket.io.js"></script> <script src="socket.io/socket.io.js"></script>
<script src="js/socket-init.js" charset="utf-8"></script>
<script src="js/wb_main.js"></script> <script src="js/wb_main.js"></script>
<script src="src-noconflict/ace.js" type="text/javascript" charset="utf-8"></script> <script src="src-noconflict/ace.js" type="text/javascript" charset="utf-8"></script>
<script src="src-noconflict/ext-language_tools.js"></script> <script src="src-noconflict/ext-language_tools.js"></script>
...@@ -103,6 +122,7 @@ ...@@ -103,6 +122,7 @@
<script src="FileSaver.min.js" charset="utf-8"></script> <script src="FileSaver.min.js" charset="utf-8"></script>