@ -1,2 +1,2 @@ |
|||
DTP Time Tracker Copyright (C) 2024 DTP Technologies, LLC |
|||
All Rights Reserved |
|||
DTP Base Copyright (C) 2024 DTP Technologies, LLC |
|||
All Rights Reserved |
@ -1,216 +0,0 @@ |
|||
// client.js
|
|||
// Copyright (C) 2024 DTP Technologies, LLC
|
|||
// All Rights Reserved
|
|||
|
|||
'use strict'; |
|||
|
|||
import express from 'express'; |
|||
|
|||
import { SiteController, SiteError } from '../../lib/site-lib.js'; |
|||
import { populateClientId, populateProjectId } from './lib/populators.js'; |
|||
|
|||
export default class ClientController extends SiteController { |
|||
|
|||
static get name ( ) { return 'ClientController'; } |
|||
static get slug ( ) { return 'client'; } |
|||
|
|||
constructor (dtp) { |
|||
super(dtp, ClientController.slug); |
|||
} |
|||
|
|||
async start ( ) { |
|||
const { dtp } = this; |
|||
const { |
|||
limiter: limiterService, |
|||
session: sessionService, |
|||
} = dtp.services; |
|||
|
|||
const limiterConfig = limiterService.config.client; |
|||
const authCheck = sessionService.authCheckMiddleware({ requireLogin: true }); |
|||
|
|||
function checkClientOwnership (req, res, next) { |
|||
if (!res.locals.client.user._id.equals(req.user._id)) { |
|||
throw new SiteError(401, 'This is not your client'); |
|||
} |
|||
return next(); |
|||
} |
|||
|
|||
function checkProjectOwnership (req, res, next) { |
|||
if (!res.locals.project.user._id.equals(req.user._id)) { |
|||
throw new SiteError(401, 'This is not your client'); |
|||
} |
|||
return next(); |
|||
} |
|||
|
|||
const router = express.Router(); |
|||
dtp.app.use('/client', authCheck, router); |
|||
|
|||
router.param('clientId', populateClientId(this)); |
|||
router.param('projectId', populateProjectId(this)); |
|||
|
|||
router.post( |
|||
'/:clientId/project/:projectId', |
|||
limiterService.create(limiterConfig.postProjectUpdate), |
|||
checkClientOwnership, |
|||
this.postProjectUpdate.bind(this), |
|||
); |
|||
|
|||
router.post( |
|||
'/:clientId/project', |
|||
limiterService.create(limiterConfig.postProjectCreate), |
|||
checkClientOwnership, |
|||
this.postProjectCreate.bind(this), |
|||
); |
|||
|
|||
router.post( |
|||
'/:clientId', |
|||
limiterService.create(limiterConfig.postClientUpdate), |
|||
checkClientOwnership, |
|||
this.postClientUpdate.bind(this), |
|||
); |
|||
|
|||
router.post( |
|||
'/', |
|||
limiterService.create(limiterConfig.postClientCreate), |
|||
this.postClientCreate.bind(this), |
|||
); |
|||
|
|||
router.get( |
|||
'/:clientId/project/create', |
|||
limiterService.create(limiterConfig.getProjectCreate), |
|||
checkClientOwnership, |
|||
this.getProjectCreate.bind(this), |
|||
); |
|||
|
|||
router.get( |
|||
'/:clientId/project/:projectId/edit', |
|||
limiterService.create(limiterConfig.getProjectEditor), |
|||
checkProjectOwnership, |
|||
this.getProjectEditor.bind(this), |
|||
); |
|||
|
|||
router.get( |
|||
'/:clientId/project/:projectId', |
|||
limiterService.create(limiterConfig.getProjectView), |
|||
checkProjectOwnership, |
|||
this.getProjectView.bind(this), |
|||
); |
|||
|
|||
router.get( |
|||
'/create', |
|||
limiterService.create(limiterConfig.getClientEditor), |
|||
this.getClientEditor.bind(this), |
|||
); |
|||
|
|||
router.get( |
|||
'/:clientId/edit', |
|||
limiterService.create(limiterConfig.getClientEditor), |
|||
checkClientOwnership, |
|||
this.getClientEditor.bind(this), |
|||
); |
|||
|
|||
router.get( |
|||
'/:clientId', |
|||
limiterService.create(limiterConfig.getClientView), |
|||
checkClientOwnership, |
|||
this.getClientView.bind(this), |
|||
); |
|||
|
|||
router.get( |
|||
'/', |
|||
limiterService.create(limiterConfig.getHome), |
|||
this.getHome.bind(this), |
|||
); |
|||
|
|||
return router; |
|||
} |
|||
|
|||
async postProjectUpdate (req, res, next) { |
|||
const { client: clientService } = this.dtp.services; |
|||
try { |
|||
await clientService.updateProject(res.locals.project, req.body); |
|||
res.redirect(`/client/${res.locals.client._id}/project/${res.locals.project._id}`); |
|||
} catch (error) { |
|||
this.log.error('failed to update client project', { error }); |
|||
return next(error); |
|||
} |
|||
} |
|||
|
|||
async postProjectCreate (req, res, next) { |
|||
const { client: clientService } = this.dtp.services; |
|||
try { |
|||
res.locals.project = await clientService.createProject(res.locals.client, req.body); |
|||
res.redirect(`/client/${res.locals.client._id}/project/${res.locals.project._id}`); |
|||
} catch (error) { |
|||
this.log.error('failed to create client project', { error }); |
|||
return next(error); |
|||
} |
|||
} |
|||
|
|||
async postClientUpdate (req, res, next) { |
|||
const { client: clientService } = this.dtp.services; |
|||
try { |
|||
await clientService.updateClient(res.locals.client, req.body); |
|||
res.redirect(`/client/${res.locals.client._id}`); |
|||
} catch (error) { |
|||
this.log.error('failed to create client', { error }); |
|||
return next(error); |
|||
} |
|||
} |
|||
|
|||
async postClientCreate (req, res, next) { |
|||
const { client: clientService } = this.dtp.services; |
|||
try { |
|||
res.locals.client = await clientService.createClient(req.user, req.body); |
|||
res.redirect(`/client/${res.locals.client._id}`); |
|||
} catch (error) { |
|||
this.log.error('failed to create client', { error }); |
|||
return next(error); |
|||
} |
|||
} |
|||
|
|||
async getProjectCreate (req, res) { |
|||
res.render('client/project/editor'); |
|||
} |
|||
|
|||
async getProjectEditor (req, res) { |
|||
res.render('client/project/editor'); |
|||
} |
|||
|
|||
async getProjectView (req, res, next) { |
|||
const { task: taskService } = this.dtp.services; |
|||
try { |
|||
res.locals.taskGrid = await taskService.getTaskGridForProject(res.locals.project); |
|||
res.render('client/project/view'); |
|||
} catch (error) { |
|||
this.log.error('failed to present project view', { error }); |
|||
return next(error); |
|||
} |
|||
} |
|||
|
|||
async getClientEditor (req, res) { |
|||
res.render('client/editor'); |
|||
} |
|||
|
|||
async getClientView (req, res, next) { |
|||
const { client: clientService } = this.dtp.services; |
|||
try { |
|||
res.locals.projects = await clientService.getProjectsForClient(res.locals.client); |
|||
res.render('client/view'); |
|||
} catch (error) { |
|||
this.log.error('failed to present client home view', { error }); |
|||
return next(error); |
|||
} |
|||
} |
|||
|
|||
async getHome (req, res, next) { |
|||
const { client: clientService } = this.dtp.services; |
|||
try { |
|||
res.locals.clients = await clientService.getClientsForUser(req.user); |
|||
res.render('client/dashboard'); |
|||
} catch (error) { |
|||
this.log.error('failed to present client home view', { error }); |
|||
return next(error); |
|||
} |
|||
} |
|||
} |
@ -1,43 +0,0 @@ |
|||
// manager.js
|
|||
// Copyright (C) 2024 DTP Technologies, LLC
|
|||
// All Rights Reserved
|
|||
|
|||
'use strict'; |
|||
|
|||
import express from 'express'; |
|||
|
|||
import { SiteController } from '../../lib/site-lib.js'; |
|||
import { populateClientId, populateProjectId } from './lib/populators.js'; |
|||
|
|||
export default class ManagerController extends SiteController { |
|||
|
|||
static get name ( ) { return 'ManagerController'; } |
|||
static get slug ( ) { return 'manager'; } |
|||
|
|||
constructor (dtp) { |
|||
super(dtp, ManagerController.slug); |
|||
} |
|||
|
|||
async start ( ) { |
|||
const { dtp } = this; |
|||
const { |
|||
// limiter: limiterService,
|
|||
session: sessionService, |
|||
} = dtp.services; |
|||
|
|||
// const limiterConfig = limiterService.config.manager;
|
|||
const authCheck = sessionService.authCheckMiddleware({ requireLogin: true }); |
|||
|
|||
const router = express.Router(); |
|||
dtp.app.use('/manager', authCheck, router); |
|||
|
|||
router.param('clientId', populateClientId(this)); |
|||
router.param('projectId', populateProjectId(this)); |
|||
|
|||
router.get('/', this.getDashboard.bind(this)); |
|||
} |
|||
|
|||
async getDashboard (req, res) { |
|||
res.render('manager/dashboard'); |
|||
} |
|||
} |
@ -1,52 +0,0 @@ |
|||
// report.js
|
|||
// Copyright (C) 2024 DTP Technologies, LLC
|
|||
// All Rights Reserved
|
|||
|
|||
'use strict'; |
|||
|
|||
import express from 'express'; |
|||
|
|||
import { SiteController } from '../../lib/site-lib.js'; |
|||
|
|||
export default class ReportController extends SiteController { |
|||
|
|||
static get name ( ) { return 'ReportController'; } |
|||
static get slug ( ) { return 'report'; } |
|||
|
|||
constructor (dtp) { |
|||
super(dtp, ReportController.slug); |
|||
} |
|||
|
|||
async start ( ) { |
|||
const { |
|||
// limiter: limiterService,
|
|||
session: sessionService, |
|||
} = this.dtp.services; |
|||
|
|||
// const limiterConfig = limiterService.config.report;
|
|||
|
|||
const authCheck = sessionService.authCheckMiddleware({ requireLogin: true }); |
|||
|
|||
const router = express.Router(); |
|||
this.dtp.app.use('/report', authCheck, router); |
|||
|
|||
router.get('/', this.getDashboard.bind(this)); |
|||
} |
|||
|
|||
async getDashboard (req, res, next) { |
|||
const { report: reportService } = this.dtp.services; |
|||
try { |
|||
res.locals.pageTitle = 'Weekly Summary Report'; |
|||
res.locals.pageDescription = 'A breakdown of project and contractor performance for the current week.'; |
|||
|
|||
res.locals.weekStartDate = reportService.startOfWeek(); |
|||
res.locals.weeklyEarnings = await reportService.getWeeklyEarnings(req.user); |
|||
res.locals.dailyTimeWorked = await reportService.getDailyHoursWorkedForUser(req.user); |
|||
|
|||
res.render('report/dashboard'); |
|||
} catch (error) { |
|||
this.log.error('failed to present report dashboard', { error }); |
|||
return next(error); |
|||
} |
|||
} |
|||
} |
@ -1,257 +0,0 @@ |
|||
// task.js
|
|||
// Copyright (C) 2024 DTP Technologies, LLC
|
|||
// All Rights Reserved
|
|||
|
|||
'use strict'; |
|||
|
|||
import express from 'express'; |
|||
|
|||
import { SiteController, SiteError } from '../../lib/site-lib.js'; |
|||
import { populateTaskId, populateSessionId } from './lib/populators.js'; |
|||
|
|||
export default class TaskController extends SiteController { |
|||
|
|||
static get name ( ) { return 'TaskController'; } |
|||
static get slug ( ) { return 'task'; } |
|||
|
|||
constructor (dtp) { |
|||
super(dtp, TaskController); |
|||
} |
|||
|
|||
async start ( ) { |
|||
const { dtp } = this; |
|||
|
|||
const { |
|||
limiter: limiterService, |
|||
session: sessionService, |
|||
} = dtp.services; |
|||
const limiterConfig = limiterService.config.task; |
|||
|
|||
const multer = this.createMulter(TaskController.slug, { |
|||
limits: { |
|||
fileSize: 1024 * 1000 * 5, |
|||
}, |
|||
}); |
|||
|
|||
const authCheck = sessionService.authCheckMiddleware({ requireLogin: true }); |
|||
|
|||
const router = express.Router(); |
|||
dtp.app.use('/task', authCheck, router); |
|||
|
|||
router.use(async (req, res, next) => { |
|||
res.locals.currentView = TaskController.name; |
|||
return next(); |
|||
}); |
|||
|
|||
async function checkTaskOwnership (req, res, next) { |
|||
if (Array.isArray(res.locals.task.project.managers) && (res.locals.task.project.managers.length > 0)) { |
|||
res.locals.manager = res.locals.task.project.managers.find((manager) => manager._id.equals(req.user._id)); |
|||
} |
|||
if (!res.locals.manager && !res.locals.task.user._id.equals(req.user._id)) { |
|||
return next(new SiteError(401, 'This is not your task')); |
|||
} |
|||
return next(); |
|||
} |
|||
|
|||
async function checkSessionOwnership (req, res, next) { |
|||
if (Array.isArray(res.locals.task.project.managers) && (res.locals.task.project.managers.length > 0)) { |
|||
res.locals.manager = res.locals.task.project.managers.find((manager) => manager._id.equals(req.user._id)); |
|||
} |
|||
if (!res.locals.manager && !res.locals.session.user._id.equals(req.user._id)) { |
|||
throw new SiteError(401, 'This is not your session'); |
|||
} |
|||
return next(); |
|||
} |
|||
|
|||
router.param('taskId', populateTaskId(this)); |
|||
router.param('sessionId', populateSessionId(this)); |
|||
|
|||
router.post( |
|||
'/:taskId/session/start', |
|||
limiterService.create(limiterConfig.postStartTaskSession), |
|||
this.postStartTaskSession.bind(this), |
|||
); |
|||
|
|||
router.post( |
|||
'/:taskId/session/:sessionId/screenshot', |
|||
checkSessionOwnership, |
|||
limiterService.create(limiterConfig.postTaskSessionScreenshot), |
|||
multer.single('image'), |
|||
this.postTaskSessionScreenshot.bind(this), |
|||
); |
|||
|
|||
router.post( |
|||
'/:taskId/session/:sessionId/status', |
|||
limiterService.create(limiterConfig.postTaskSessionStatus), |
|||
checkSessionOwnership, |
|||
this.postTaskSessionStatus.bind(this), |
|||
); |
|||
|
|||
router.post( |
|||
'/:taskId/session/:sessionId/close', |
|||
limiterService.create(limiterConfig.postCloseTaskSession), |
|||
checkSessionOwnership, |
|||
this.postCloseTaskSession.bind(this), |
|||
); |
|||
|
|||
router.post( |
|||
'/:taskId/start', |
|||
limiterService.create(limiterConfig.postStartTask), |
|||
checkTaskOwnership, |
|||
this.postStartTask.bind(this), |
|||
); |
|||
router.post( |
|||
'/:taskId/close', |
|||
limiterService.create(limiterConfig.postCloseTask), |
|||
checkTaskOwnership, |
|||
this.postCloseTask.bind(this), |
|||
); |
|||
|
|||
router.post( |
|||
'/', |
|||
limiterService.create(limiterConfig.postCreateTask), |
|||
this.postCreateTask.bind(this), |
|||
); |
|||
|
|||
router.get( |
|||
'/:taskId/session/:sessionId', |
|||
limiterService.create(limiterConfig.getTaskSessionView), |
|||
checkSessionOwnership, |
|||
this.getTaskSessionView.bind(this), |
|||
); |
|||
|
|||
router.get( |
|||
'/:taskId', |
|||
limiterService.create(limiterConfig.getTaskView), |
|||
checkTaskOwnership, |
|||
this.getTaskView.bind(this), |
|||
); |
|||
} |
|||
|
|||
async postStartTaskSession (req, res) { |
|||
const { task: taskService } = this.dtp.services; |
|||
try { |
|||
res.locals.session = await taskService.createTaskSession(res.locals.task); |
|||
res.status(200).json({ |
|||
success: true, |
|||
session: res.locals.session, |
|||
}); |
|||
} catch (error) { |
|||
this.log.error('failed to create task session', { error }); |
|||
res.status(error.statusCode || 500).json({ |
|||
success: false, |
|||
message: error.message, |
|||
}); |
|||
} |
|||
} |
|||
|
|||
async postTaskSessionScreenshot (req, res) { |
|||
const { task: taskService } = this.dtp.services; |
|||
try { |
|||
await taskService.addTaskSessionScreenshot(res.locals.session, req.file); |
|||
res.status(200).json({ success: true }); |
|||
} catch (error) { |
|||
this.log.error('failed to add task session screenshot', { error }); |
|||
res.status(error.statusCode || 500).json({ |
|||
success: false, |
|||
message: error.message, |
|||
}); |
|||
} |
|||
} |
|||
|
|||
async postTaskSessionStatus (req, res) { |
|||
const { task: taskService } = this.dtp.services; |
|||
try { |
|||
this.log.debug('updating task session status', { |
|||
sessionId: res.locals.session._id, |
|||
params: req.body, |
|||
}); |
|||
await taskService.setTaskSessionStatus(res.locals.session, req.body.status); |
|||
|
|||
const displayList = this.createDisplayList('set-session-status'); |
|||
displayList.showNotification( |
|||
'Session status updated', |
|||
'success', |
|||
'bottom-center', |
|||
5000, |
|||
); |
|||
|
|||
res.status(200).json({ success: true, displayList }); |
|||
} catch (error) { |
|||
this.log.error('failed to update task session status', { error }); |
|||
res.status(error.statusCode || 500).json({ |
|||
success: false, |
|||
message: error.message, |
|||
}); |
|||
} |
|||
} |
|||
|
|||
async postCloseTaskSession (req, res) { |
|||
const { task: taskService } = this.dtp.services; |
|||
try { |
|||
await taskService.closeTaskSession(res.locals.session); |
|||
res.status(200).json({ success: true }); |
|||
} catch (error) { |
|||
this.log.error('failed to close task session', { error }); |
|||
res.status(error.statusCode || 500).json({ |
|||
success: false, |
|||
message: error.message, |
|||
}); |
|||
} |
|||
} |
|||
|
|||
async postStartTask (req, res, next) { |
|||
const { task: taskService } = this.dtp.services; |
|||
try { |
|||
await taskService.startTask(res.locals.task); |
|||
res.redirect(`/task/${res.locals.task._id}`); |
|||
} catch (error) { |
|||
this.log.error('failed to close task session', { error }); |
|||
return next(error); |
|||
} |
|||
} |
|||
|
|||
async postCloseTask (req, res, next) { |
|||
const { task: taskService } = this.dtp.services; |
|||
try { |
|||
await taskService.closeTask(res.locals.task); |
|||
res.redirect('/'); |
|||
} catch (error) { |
|||
this.log.error('failed to close task', { error }); |
|||
return next(error); |
|||
} |
|||
} |
|||
|
|||
async postCreateTask (req, res, next) { |
|||
const { task: taskService } = this.dtp.services; |
|||
try { |
|||
res.locals.task = await taskService.createTask(req.user, req.body); |
|||
res.redirect(`/task/${res.locals.task._id}`); |
|||
} catch (error) { |
|||
this.log.error('failed to create new task', { error }); |
|||
return next(error); |
|||
} |
|||
} |
|||
|
|||
async getTaskSessionView (req, res) { |
|||
res.render('task/session/view'); |
|||
} |
|||
|
|||
async getTaskView (req, res, next) { |
|||
const { report: reportService, task: taskService } = this.dtp.services; |
|||
try { |
|||
res.locals.pagination = this.getPaginationParameters(req, 50); |
|||
res.locals.sessions = await taskService.getSessionsForTask( |
|||
res.locals.task, |
|||
res.locals.pagination, |
|||
); |
|||
|
|||
res.locals.weekStartDate = reportService.startOfWeek(); |
|||
|
|||
res.render('task/view'); |
|||
} catch (error) { |
|||
this.log.error('failed to present the Task view', { error }); |
|||
return next(error); |
|||
} |
|||
} |
|||
} |
@ -1,20 +0,0 @@ |
|||
// project.js
|
|||
// Copyright (C) 2024 DTP Technologies, LLC
|
|||
// All Rights Reserved
|
|||
|
|||
'use strict'; |
|||
|
|||
import mongoose from 'mongoose'; |
|||
const Schema = mongoose.Schema; |
|||
|
|||
const ClientProjectSchema = new Schema({ |
|||
created: { type: Date, default: Date.now, required: true, index: 1 }, |
|||
user: { type: Schema.ObjectId, required: true, index: 1, ref: 'User' }, |
|||
client: { type: Schema.ObjectId, required: true, index: 1, ref: 'Client' }, |
|||
managers: { type: [Schema.ObjectId], ref: 'User' }, |
|||
name: { type: String, required: true }, |
|||
description: { type: String }, |
|||
hourlyRate: { type: Number, required: true }, |
|||
}); |
|||
|
|||
export default mongoose.model('ClientProject', ClientProjectSchema); |
@ -1,23 +0,0 @@ |
|||
// client.js
|
|||
// Copyright (C) 2024 DTP Technologies, LLC
|
|||
// All Rights Reserved
|
|||
|
|||
'use strict'; |
|||
|
|||
import mongoose from 'mongoose'; |
|||
const Schema = mongoose.Schema; |
|||
|
|||
const MAX_HOURS_PER_WEEK = 7 * 24; |
|||
|
|||
const ClientSchema = new Schema({ |
|||
user: { type: Schema.ObjectId, required: true, index: 1, ref: 'User' }, |
|||
name: { type: String, required: true }, |
|||
description: { type: String }, |
|||
hoursLimit: { type: Number, default: 0, max: MAX_HOURS_PER_WEEK, required: true }, |
|||
weeklyTotals: { |
|||
timeWorked: { type: Number, default: 0, required: true }, |
|||
billable: { type: Number, default: 0, required: true }, |
|||
}, |
|||
}); |
|||
|
|||
export default mongoose.model('Client', ClientSchema); |
@ -1,4 +1,5 @@ |
|||
'use strict'; |
|||
|
|||
export const MIN_ROOM_CAPACITY = 4; |
|||
export const MAX_ROOM_CAPACITY = 25; |
|||
/* |
|||
* Declare model constants here and import them into whatever needs them. |
|||
*/ |
@ -1,37 +0,0 @@ |
|||
// task-session.js
|
|||
// Copyright (C) 2024 DTP Technologies, LLC
|
|||
// All Rights Reserved
|
|||
|
|||
'use strict'; |
|||
|
|||
import mongoose from 'mongoose'; |
|||
const Schema = mongoose.Schema; |
|||
|
|||
const STATUS_LIST = [ |
|||
'active', |
|||
'reconnecting', |
|||
'finished', |
|||
'expired', |
|||
]; |
|||
|
|||
const ScreenshotSchema = new Schema({ |
|||
created: { type: Date, required: true, default: Date.now, index: 1 }, |
|||
image: { type: Schema.ObjectId, required: true, ref: 'Image' }, |
|||
}); |
|||
|
|||
const TaskSessionSchema = new Schema({ |
|||
created: { type: Date, required: true, default: Date.now, index: 1 }, |
|||
lastUpdated: { type: Date, required: true, default: Date.now, index: 1 }, |
|||
finished: { type: Date, index: 1 }, |
|||
duration: { type: Number, default: 0, required: true }, |
|||
hourlyRate: { type: Number, required: true }, |
|||
user: { type: Schema.ObjectId, required: true, index: 1, ref: 'User' }, |
|||
client: { type: Schema.ObjectId, required: true, index: 1, ref: 'Client' }, |
|||
project: { type: Schema.ObjectId, required: true, index: 1, ref: 'ClientProject' }, |
|||
task: { type: Schema.ObjectId, required: true, index: 1, ref: 'Task' }, |
|||
status: { type: String, enum: STATUS_LIST, default: 'active', required: true }, |
|||
notes: { type: [String], default: [ ] }, |
|||
screenshots: { type: [ScreenshotSchema] }, |
|||
}); |
|||
|
|||
export default mongoose.model('TaskSession', TaskSessionSchema); |
@ -1,23 +0,0 @@ |
|||
// task.js
|
|||
// Copyright (C) 2024 DTP Technologies, LLC
|
|||
// All Rights Reserved
|
|||
|
|||
'use strict'; |
|||
|
|||
import mongoose from 'mongoose'; |
|||
const Schema = mongoose.Schema; |
|||
|
|||
const STATUS_LIST = ['pending', 'active', 'finished']; |
|||
|
|||
const TaskSchema = new Schema({ |
|||
created: { type: Date, required: true, default: Date.now, index: 1 }, |
|||
finished: { type: Date, index: 1 }, |
|||
duration: { type: Number, default: 0, required: true }, |
|||
user: { type: Schema.ObjectId, required: true, index: 1, ref: 'User' }, |
|||
client: { type: Schema.ObjectId, required: true, index: 1, ref: 'Client' }, |
|||
project: { type: Schema.ObjectId, required: true, index: 1, ref: 'ClientProject' }, |
|||
status: { type: String, enum: STATUS_LIST, default: 'pending', required: true }, |
|||
note: { type: String, required: true }, |
|||
}); |
|||
|
|||
export default mongoose.model('Task', TaskSchema); |
@ -1,293 +0,0 @@ |
|||
// client.js
|
|||
// Copyright (C) 2024 DTP Technologies, LLC
|
|||
// All Rights Reserved
|
|||
|
|||
'use strict'; |
|||
|
|||
import mongoose from 'mongoose'; |
|||
const Client = mongoose.model('Client'); |
|||
const ClientProject = mongoose.model('ClientProject'); |
|||
|
|||
import { SiteService, SiteError } from '../../lib/site-lib.js'; |
|||
|
|||
export default class ClientService extends SiteService { |
|||
|
|||
static get name ( ) { return 'ClientService'; } |
|||
static get slug () { return 'client'; } |
|||
|
|||
constructor (dtp) { |
|||
super(dtp, ClientService); |
|||
} |
|||
|
|||
async start ( ) { |
|||
const { user: userService } = this.dtp.services; |
|||
|
|||
this.populateClient = [ |
|||
{ |
|||
path: 'user', |
|||
select: userService.USER_SELECT, |
|||
}, |
|||
]; |
|||
|
|||
this.populateClientProject = [ |
|||
{ |
|||
path: 'user', |
|||
select: userService.USER_SELECT, |
|||
}, |
|||
{ |
|||
path: 'client', |
|||
populate: this.populateClient, |
|||
}, |
|||
{ |
|||
path: 'managers', |
|||
select: userService.USER_SELECT, |
|||
}, |
|||
]; |
|||
} |
|||
|
|||
async createClient (user, clientDefinition) { |
|||
const { text: textService } = this.dtp.services; |
|||
|
|||
const client = new Client(); |
|||
client.user = user._id; |
|||
client.name = textService.filter(clientDefinition.name); |
|||
if (clientDefinition.description && clientDefinition.description.length > 0) { |
|||
client.description = textService.filter(clientDefinition.description); |
|||
} |
|||
client.hoursLimit = clientDefinition.hoursLimit; |
|||
|
|||
await client.save(); |
|||
|
|||
return client.toObject(); |
|||
} |
|||
|
|||
async updateClient (client, clientDefinition) { |
|||
const { text: textService } = this.dtp.services; |
|||
const update = { $set: { }, $unset: { } }; |
|||
|
|||
let changed = false; |
|||
|
|||
if (clientDefinition.name !== client.name) { |
|||
update.$set.name = textService.filter(clientDefinition.name); |
|||
changed = true; |
|||
} |
|||
|
|||
if (clientDefinition.description && (clientDefinition.description.length > 0)) { |
|||
if (clientDefinition.description !== client.description) { |
|||
update.$set.description = textService.filter(clientDefinition.description); |
|||
changed = true; |
|||
} |
|||
} else { |
|||
update.$unset.description = 1; |
|||
changed = true; |
|||
} |
|||
|
|||
if (clientDefinition.hoursLimit !== client.hoursLimit) { |
|||
update.$set.hoursLimit = clientDefinition.hoursLimit; |
|||
changed = true; |
|||
} |
|||
|
|||
if (!changed) { |
|||
return; |
|||
} |
|||
|
|||
await Client.updateOne({ _id: client._id }, update); |
|||
} |
|||
|
|||
async getClientById (clientId) { |
|||
const client = Client |
|||
.findOne({ _id: clientId }) |
|||
.populate(this.populateClient) |
|||
.lean(); |
|||
return client; |
|||
} |
|||
|
|||
async getClientsForUser (user) { |
|||
const clients = Client |
|||
.find({ user: user._id }) |
|||
.sort({ name: 1 }) |
|||
.populate(this.populateClient) |
|||
.lean(); |
|||
return clients; |
|||
} |
|||
|
|||
async createProject (client, projectDefinition) { |
|||
const { text: textService } = this.dtp.services; |
|||
|
|||
const project = new ClientProject(); |
|||
project.user = client.user._id; |
|||
project.client = client._id; |
|||
|
|||
project.name = textService.filter(projectDefinition.name); |
|||
if (projectDefinition.description && (projectDefinition.description.length > 0)) { |
|||
project.description = textService.filter(projectDefinition.description); |
|||
} |
|||
|
|||
if (projectDefinition.managers && (projectDefinition.managers.length > 0)) { |
|||
project.managers = await this.parseManagerNames(projectDefinition.managers); |
|||
} |
|||
|
|||
project.hourlyRate = projectDefinition.hourlyRate; |
|||
|
|||
await project.save(); |
|||
|
|||
return project.toObject(); |
|||
} |
|||
|
|||
async updateProject (project, projectDefinition) { |
|||
const { text: textService } = this.dtp.services; |
|||
const update = { $set: { }, $unset: { } }; |
|||
let changed = false; |
|||
|
|||
if (projectDefinition.name !== project.name) { |
|||
update.$set.name = textService.filter(projectDefinition.name); |
|||
changed = true; |
|||
} |
|||
|
|||
if (projectDefinition.description !== project.description) { |
|||
update.$set.description = textService.filter(projectDefinition.description); |
|||
changed = true; |
|||
} |
|||
|
|||
if (projectDefinition.managers && (projectDefinition.managers.length > 0)) { |
|||
update.$set.managers = await this.parseManagerNames(projectDefinition.managers); |
|||
changed = true; |
|||
} else { |
|||
update.$unset.managers = 1; |
|||
} |
|||
|
|||
if (projectDefinition.hourlyRate !== project.hourlyRate) { |
|||
update.$set.hourlyRate = projectDefinition.hourlyRate; |
|||
changed = true; |
|||
} |
|||
|
|||
if (!changed) { |
|||
return; |
|||
} |
|||
|
|||
await ClientProject.updateOne({ _id: project._id }, update); |
|||
} |
|||
|
|||
async parseManagerNames (managerNames) { |
|||
const { user: userService } = this.dtp.services; |
|||
const managers = [ ]; |
|||
|
|||
managerNames = managerNames.split(',').map((m) => m.trim().toLowerCase()); |
|||
for (const name of managerNames) { |
|||
const user = await userService.getByUsername(name); |
|||
if (!user) { |
|||
throw new SiteError(400, `Trying to set unknown user "${name}" as project manager`); |
|||
} |
|||
managers.push(user._id); |
|||
} |
|||
|
|||
return managers; |
|||
} |
|||
|
|||
async getProjectById (projectId) { |
|||
const project = ClientProject |
|||
.findOne({ _id: projectId }) |
|||
.populate(this.populateClientProject) |
|||
.lean(); |
|||
return project; |
|||
} |
|||
|
|||
async getProjectsForUser (user) { |
|||
const projects = await ClientProject |
|||
.find({ user: user._id }) |
|||
.sort({ name: 1 }) |
|||
.populate(this.populateClientProject) |
|||
.lean(); |
|||
return projects; |
|||
} |
|||
|
|||
async getProjectsForClient (client) { |
|||
const projects = await ClientProject |
|||
.find({ client: client._id }) |
|||
.sort({ name: 1 }) |
|||
.populate(this.populateClientProject) |
|||
.lean(); |
|||
return projects; |
|||
} |
|||
|
|||
async getProjectsForManager (manager) { |
|||
const projects = await ClientProject |
|||
.find({ managers: manager._id }) |
|||
.sort({ name: 1 }) |
|||
.populate(this.populateClientProject) |
|||
.lean(); |
|||
return projects; |
|||
} |
|||
|
|||
async addTimeWorked (client, duration) { |
|||
const { task: taskService } = this.dtp.services; |
|||
|
|||
const durationLimit = client.hoursLimit * 3600; |
|||
if ((client.weeklyTotals.timeWorked + duration) > durationLimit) { |
|||
this.log.alert('clamping time worked to weekly hours limit', { |
|||
user: { |
|||
_id: client.user._id, |
|||
username: client.user.username, |
|||
}, |
|||
client: { |
|||
_id: client._id, |
|||
hoursLimit: client.hoursLimit, |
|||
}, |
|||
}); |
|||
await Client.updateOne( |
|||
{ _id: client._id }, |
|||
{ |
|||
$set: { 'weeklyTotals.timeWorked': durationLimit }, |
|||
}, |
|||
); |
|||
|
|||
this.log.alert('Ending work sessions for user due to hours limit reached', { |
|||
user: { |
|||
_id: client.user._id, |
|||
username: client.user.username, |
|||
}, |
|||
client: { |
|||
_id: client._id, |
|||
hoursLimit: client.hoursLimit, |
|||
}, |
|||
}); |
|||
await taskService.closeTaskSessionForUser(client.user); |
|||
|
|||
const displayList = this.createDisplayList('session-control'); |
|||
displayList.showNotification( |
|||
'Hours limit reached. The task work session has been closed.', |
|||
'danger', |
|||
'bottom-center', |
|||
5000, |
|||
); |
|||
|
|||
this.dtp.emitter |
|||
.to(client.user._id.toString()) |
|||
.emit('session-control', { |
|||
cmd: 'end-session', |
|||
displayList, |
|||
}); |
|||
|
|||
return; |
|||
} |
|||
|
|||
this.log.info('adding time worked to client weekly totals', { |
|||
user: { |
|||
_id: client.user._id, |
|||
username: client.user.username, |
|||
}, |
|||
client: { |
|||
_id: client._id, |
|||
hoursLimit: client.hoursLimit, |
|||
currentHours: client.weeklyTotals.timeWorked / 3600, |
|||
}, |
|||
hours: duration / 3600, |
|||
}); |
|||
await Client.updateOne( |
|||
{ _id: client._id }, |
|||
{ |
|||
$inc: { 'weeklyTotals.timeWorked': duration }, |
|||
}, |
|||
); |
|||
} |
|||
} |
@ -1,141 +0,0 @@ |
|||
// report.js
|
|||
// Copyright (C) 2024 DTP Technologies, LLC
|
|||
// All Rights Reserved
|
|||
|
|||
'use strict'; |
|||
|
|||
import mongoose from 'mongoose'; |
|||
const TaskSession = mongoose.model('TaskSession'); |
|||
|
|||
import dayjs from 'dayjs'; |
|||
|
|||
import { SiteService } from '../../lib/site-lib.js'; |
|||
|
|||
export default class ReportService extends SiteService { |
|||
|
|||
static get name ( ) { return 'ReportService'; } |
|||
static get slug () { return 'report'; } |
|||
|
|||
constructor (dtp) { |
|||
super(dtp, ReportService); |
|||
} |
|||
|
|||
async getWeeklyEarnings (user) { |
|||
const { client: clientService } = this.dtp.services; |
|||
const NOW = new Date(); |
|||
|
|||
const dateStart = this.startOfWeek(NOW); |
|||
const dateEnd = dayjs(dateStart).add(1, 'week').toDate(); |
|||
|
|||
const data = await TaskSession.aggregate([ |
|||
{ |
|||
$match: { |
|||
user: user._id, |
|||
$and: [ |
|||
{ created: { $gte: dateStart } }, |
|||
{ finished: { $lt: dateEnd } }, |
|||
], |
|||
}, |
|||
}, |
|||
{ |
|||
$group: { |
|||
_id: { project: '$project' }, |
|||
sessionCount: { $sum: 1 }, |
|||
duration: { $sum: '$duration' }, |
|||
billable: { |
|||
$sum: { |
|||
$multiply: [ |
|||
'$hourlyRate', |
|||
{ $divide: ['$duration', 3600 ] }, |
|||
], |
|||
}, |
|||
}, |
|||
}, |
|||
}, |
|||
{ |
|||
$project: { |
|||
_id: 0, |
|||
project: '$_id.project', |
|||
sessionCount: '$sessionCount', |
|||
duration: '$duration', |
|||
billable: '$billable', |
|||
}, |
|||
}, |
|||
]); |
|||
|
|||
const response = await TaskSession.populate(data, [ |
|||
{ |
|||
path: 'project', |
|||
populate: clientService.populateClientProject, |
|||
}, |
|||
]); |
|||
|
|||
return response; |
|||
} |
|||
|
|||
async getDailyHoursWorkedForUser (user) { |
|||
const NOW = new Date(); |
|||
|
|||
const dateStart = this.startOfWeek(NOW); |
|||
const dateEnd = dayjs(dateStart).add(1, 'week').toDate(); |
|||
|
|||
const response = await TaskSession.aggregate([ |
|||
{ |
|||
$match: { |
|||
$and: [ |
|||
{ user: user._id }, |
|||
{ finished: { $gt: dateStart } }, |
|||
{ created: { $lt: dateEnd } }, |
|||
], |
|||
} |
|||
}, |
|||
{ |
|||
$group: { |
|||
_id: { |
|||
year: { $year: '$created' }, |
|||
month: { $month: '$created' }, |
|||
day: { $dayOfMonth: '$created' }, |
|||
}, |
|||
workSessionCount: { $sum: 1 }, |
|||
timeWorked: { $sum: '$duration' }, |
|||
}, |
|||
}, |
|||
{ |
|||
$project: { |
|||
_id: false, |
|||
date: { |
|||
$dateFromParts: { |
|||
year: '$_id.year', |
|||
month: '$_id.month', |
|||
day: '$_id.day', |
|||
}, |
|||
}, |
|||
workSessionCount: '$workSessionCount', |
|||
hoursWorked: { $divide: ['$timeWorked', 3600] }, |
|||
}, |
|||
}, |
|||
]); |
|||
|
|||
if (response.length < 7) { |
|||
let currentDay = dayjs(dateStart).add(response.length, 'day'); |
|||
while (response.length < 7) { |
|||
response.push({ |
|||
date: currentDay, |
|||
workSessionCount: 0, |
|||
hoursWorked: 0, |
|||
}); |
|||
currentDay = dayjs(currentDay).add(1, 'day'); |
|||
} |
|||
} |
|||
|
|||
return response; |
|||
} |
|||
|
|||
startOfWeek (date) { |
|||
date = date || new Date(); |
|||
date.setHours(0,0,0,0); |
|||
|
|||
var diff = date.getDate() - date.getDay() + (date.getDay() === 0 ? -6 : 1); |
|||
return new Date(date.setDate(diff)); |
|||
} |
|||
} |
@ -1,329 +0,0 @@ |
|||
// task.js
|
|||
// Copyright (C) 2024 DTP Technologies, LLC
|
|||
// All Rights Reserved
|
|||
|
|||
'use strict'; |
|||
|
|||
import mongoose from 'mongoose'; |
|||
const Task = mongoose.model('Task'); |
|||
const TaskSession = mongoose.model('TaskSession'); |
|||
|
|||
import dayjs from 'dayjs'; |
|||
|
|||
import { SiteService, SiteError } from '../../lib/site-lib.js'; |
|||
|
|||
export default class TaskService extends SiteService { |
|||
|
|||
static get name ( ) { return 'TaskService'; } |
|||
static get slug () { return 'task'; } |
|||
|
|||
constructor (dtp) { |
|||
super(dtp, TaskService); |
|||
} |
|||
|
|||
async start ( ) { |
|||
const { client: clientService, user: userService } = this.dtp.services; |
|||
|
|||
// const { jobQueue: jobQueueService } = this.dtp.services;
|
|||
// this.linksQueue = jobQueueService.getJobQueue('links', this.dtp.config.jobQueues.links);
|
|||
|
|||
this.populateTask = [ |
|||
{ |
|||
path: 'user', |
|||
select: userService.USER_SELECT, |
|||
}, |
|||
{ |
|||
path: 'client', |
|||
populate: clientService.populateClient, |
|||
}, |
|||
{ |
|||
path: 'project', |
|||
populate: clientService.populateClientProject, |
|||
}, |
|||
]; |
|||
|
|||
this.populateTaskSession = [ |
|||
{ |
|||
path: 'user', |
|||
select: userService.USER_SELECT, |
|||
}, |
|||
{ |
|||
path: 'client', |
|||
populate: clientService.populateClient, |
|||
}, |
|||
{ |
|||
path: 'project', |
|||
populate: clientService.populateClientProject, |
|||
}, |
|||
{ |
|||
path: 'task', |
|||
populate: this.populateTask, |
|||
}, |
|||
{ |
|||
path: 'screenshots.image', |
|||
} |
|||
]; |
|||
} |
|||
|
|||
async createTask (user, taskDefinition) { |
|||
const { text: textService } = this.dtp.services; |
|||
const NOW = new Date(); |
|||
|
|||
const task = new Task(); |
|||
task.created = NOW; |
|||
task.user = user._id; |
|||
task.client = mongoose.Types.ObjectId.createFromHexString(taskDefinition.clientId); |
|||
task.project = mongoose.Types.ObjectId.createFromHexString(taskDefinition.projectId); |
|||
task.status = 'pending'; |
|||
task.note = textService.filter(taskDefinition.note); |
|||
|
|||
await task.save(); |
|||
|
|||
return task.toObject(); |
|||
} |
|||
|
|||
async startTask (task) { |
|||
if (task.status !== 'pending') { |
|||
throw new SiteError(400, 'The task is not in the pending state'); |
|||
} |
|||
await Task.updateOne( |
|||
{ _id: task._id }, |
|||
{ $set: { status: 'active' } }, |
|||
); |
|||
} |
|||
|
|||
async closeTask (task) { |
|||
await TaskSession |
|||
.find({ task: task._id, status: 'active' }) |
|||
.cursor() |
|||
.eachAsync(async (session) => { |
|||
await this.closeTaskSession(session); |
|||
}); |
|||
|
|||
await Task.updateOne( |
|||
{ _id: task._id }, |
|||
{ $set: { status: 'finished' } }, |
|||
); |
|||
} |
|||
|
|||
async getTasksForUser (user, options, pagination) { |
|||
const search = { user: user._id }; |
|||
|
|||
options = options || { }; |
|||
if (options.pending) { |
|||
search.status = 'pending'; |
|||
} else if (options.active) { |
|||
search.status = 'active'; |
|||
} else if (options.finished) { |
|||
search.status = 'finished'; |
|||
} |
|||
|
|||
const tasks = await Task |
|||
.find(search) |
|||
.sort({ created: -1 }) |
|||
.skip(pagination.skip) |
|||
.limit(pagination.cpp) |
|||
.populate(this.populateTask) |
|||
.lean(); |
|||
|
|||
return tasks.reverse(); |
|||
} |
|||
|
|||
async getTasksForProject (project, options, pagination) { |
|||
const search = { project: project._id }; |
|||
|
|||
options = options || { }; |
|||
if (options.pending) { |
|||
search.status = 'pending'; |
|||
} else if (options.active) { |
|||
search.status = 'active'; |
|||
} else if (options.finished) { |
|||
search.status = 'finished'; |
|||
} |
|||
|
|||
const tasks = await Task |
|||
.find(search) |
|||
.sort({ created: -1 }) |
|||
.skip(pagination.skip) |
|||
.limit(pagination.cpp) |
|||
.populate(this.populateTask) |
|||
.lean(); |
|||
|
|||
return tasks.reverse(); |
|||
} |
|||
|
|||
async getTaskGridForUser (user) { |
|||
const pagination = { skip: 0, cpp: 10 }; |
|||
const pendingTasks = await this.getTasksForUser(user, { pending: true }, pagination); |
|||
const activeTasks = await this.getTasksForUser(user, { active: true }, pagination); |
|||
const finishedTasks = await this.getTasksForUser(user, { finished: true }, pagination); |
|||
return { pendingTasks, activeTasks, finishedTasks }; |
|||
} |
|||
|
|||
async getTaskGridForProject (project) { |
|||
const pagination = { skip: 0, cpp: 10 }; |
|||
const pendingTasks = await this.getTasksForProject(project, { pending: true }, pagination); |
|||
const activeTasks = await this.getTasksForProject(project, { active: true }, pagination); |
|||
const finishedTasks = await this.getTasksForProject(project, { finished: true }, pagination); |
|||
return { pendingTasks, activeTasks, finishedTasks }; |
|||
} |
|||
|
|||
async getTaskById (taskId) { |
|||
const task = await Task |
|||
.findOne({ _id: taskId }) |
|||
.populate(this.populateTask) |
|||
.lean(); |
|||
return task; |
|||
} |
|||
|
|||
async createTaskSession (task) { |
|||
const NOW = new Date(); |
|||
|
|||
if (await TaskSession.findOne({ user: task.user._id, status: 'active' })) { |
|||
throw new SiteError(401, "Can't start new session with a currently active session."); |
|||
} |
|||
|
|||
let session = new TaskSession(); |
|||
session.created = NOW; |
|||
session.lastUpdated = NOW; |
|||
session.hourlyRate = task.project.hourlyRate; |
|||
session.user = task.user._id; |
|||
session.client = task.client._id; |
|||
session.project = task.project._id; |
|||
session.task = task._id; |
|||
session.status = 'active'; |
|||
|
|||
await session.save(); |
|||
session = await TaskSession.populate(session, this.populateTaskSession); |
|||
|
|||
this.log.info('task session created', { |
|||
user: { |
|||
_id: task.user._id, |
|||
username: task.user.username, |
|||
}, |
|||
session: { |
|||
_id: session._id, |
|||
}, |
|||
}); |
|||
|
|||
return session.toObject(); |
|||
} |
|||
|
|||
async addTaskSessionScreenshot (session, file) { |
|||
const NOW = new Date(); |
|||
const { image: imageService } = this.dtp.services; |
|||
const image = await imageService.create(session.user._id, { }, file); |
|||
await TaskSession.updateOne( |
|||
{ _id: session._id }, |
|||
{ |
|||
$push: { |
|||
screenshots: { |
|||
created: NOW, |
|||
image: image._id, |
|||
}, |
|||
}, |
|||
}, |
|||
); |
|||
|
|||
const displayList = this.createDisplayList('screenshot-accepted'); |
|||
displayList.showNotification( |
|||
'Screenshot accepted', |
|||
'success', |
|||
'bottom-center', |
|||
3000, |
|||
); |
|||
|
|||
this.dtp.emitter |
|||
.to(session.task._id.toString()) |
|||
.emit('session-control', { |
|||
displayList, |
|||
}); |
|||
} |
|||
|
|||
async setTaskSessionStatus (session, status) { |
|||
if (status === session.status) { |
|||
return; // do nothing
|
|||
} |
|||
if (!['active', 'reconnecting'].includes(status)) { |
|||
throw new SiteError(400, 'Can only set status to active or reconnecting'); |
|||
} |
|||
|
|||
this.log.info('updating task session status', { |
|||
user: { |
|||
_id: session.user._id, |
|||
username: session.user.username, |
|||
}, |
|||
session: { _id: session._id }, |
|||
status, |
|||
}); |
|||
|
|||
await TaskSession.updateOne({ _id: session._id }, { $set: { status } }); |
|||
} |
|||
|
|||
async closeTaskSession (session, status = 'finished') { |
|||
const { client: clientService } = this.dtp.services; |
|||
const NOW = new Date(); |
|||
|
|||
if (session.status !== 'active') { |
|||
throw new SiteError(400, 'The session is not currently active'); |
|||
} |
|||
|
|||
const duration = dayjs(NOW).diff(session.created, 'second'); |
|||
await TaskSession.updateOne( |
|||
{ _id: session._id }, |
|||
{ |
|||
$set: { |
|||
finished: NOW, |
|||
status, |
|||
duration, |
|||
}, |
|||
}, |
|||
); |
|||
|
|||
await Task.updateOne( |
|||
{ _id: session.task._id }, |
|||
{ |
|||
$inc: { duration }, |
|||
}, |
|||
); |
|||
|
|||
await clientService.addTimeWorked(session.client, duration); |
|||
|
|||
this.log.info('task session closed', { |
|||
user: { |
|||
_id: session.user._id, |
|||
username: session.user.username, |
|||
}, |
|||
duration, |
|||
}); |
|||
} |
|||
|
|||
async getSessionsForTask (task, pagination) { |
|||
const sessions = TaskSession |
|||
.find({ task: task._id }) |
|||
.sort({ created: 1 }) |
|||
.skip(pagination.skip) |
|||
.limit(pagination.cpp) |
|||
.populate(this.populateTaskSession) |
|||
.lean(); |
|||
return sessions; |
|||
} |
|||
|
|||
async getTaskSessionById (sessionId) { |
|||
const session = await TaskSession |
|||
.findOne({ _id: sessionId }) |
|||
.populate(this.populateTaskSession) |
|||
.lean(); |
|||
return session; |
|||
} |
|||
|
|||
async closeTaskSessionForUser (user) { |
|||
await TaskSession |
|||
.find({ user: user._id, status: 'active' }) |
|||
.populate(this.populateTaskSession) |
|||
.cursor() |
|||
.eachAsync(async (session) => { |
|||
await this.closeTaskSession(session); |
|||
}); |
|||
} |
|||
} |
@ -1,24 +0,0 @@ |
|||
extends ../layout/main |
|||
block view-content |
|||
|
|||
section.uk-section.uk-section-default.uk-section-small |
|||
.uk-container |
|||
|
|||
.uk-margin |
|||
div(uk-grid) |
|||
.uk-width-expand |
|||
h1 Clients |
|||
.uk-width-auto |
|||
a(href="/client/create").uk-button.uk-button-default.uk-border-rounded Create Client |
|||
|
|||
.uk-margin |
|||
if Array.isArray(clients) && (clients.length > 0) |
|||
ul.uk-list.uk-list-divider |
|||
each client in clients |
|||
li |
|||
a(href=`/client/${client._id}`).uk-link-reset.uk-display-block |
|||
.uk-text-lead= client.name |
|||
div= client.description || 'No description' |
|||
|
|||
else |
|||
div There are no clients |
@ -1,54 +0,0 @@ |
|||
extends ../layout/main |
|||
block view-content |
|||
|
|||
section.uk-section.uk-section-default.uk-section-small |
|||
.uk-container |
|||
|
|||
.uk-margin |
|||
- var actionUrl = !!client ? `/client/${client._id}` : "/client"; |
|||
form(method="POST", action= actionUrl).uk-form |
|||
.uk-card.uk-card-default.uk-card-small |
|||
.uk-card-header |
|||
h1.uk-card-title #[span= !!client ? "Edit" : "New"] Client |
|||
|
|||
.uk-card-body |
|||
.uk-margin |
|||
label(for="name").uk-form-label Client name |
|||
input( |
|||
id="name", |
|||
name="name", |
|||
type="text", |
|||
value= !!client ? client.name : undefined, |
|||
placeholder="Enter client name", |
|||
).uk-input |
|||
|
|||
.uk-margin |
|||
label(for="description").uk-form-label Client description |
|||
textarea( |
|||
id="name", |
|||
name="description", |
|||
rows=4, |
|||
placeholder="Enter client description", |
|||
).uk-textarea.uk-resize-vertical= !!client ? client.description : undefined |
|||
|
|||
.uk-margin |
|||
label(for="hours-limit").uk-form-label Max. hours per week |
|||
input( |
|||
id="hours-limit", |
|||
name="hoursLimit", |
|||
type="number", |
|||
value= !!client ? (client.hoursLimit || 40) : 40, |
|||
placeholder="Enter max. hours/week (0=unlimited)", |
|||
).uk-input |
|||
.uk-text-small.uk-text-muted Use zero or 168 (7 x 24) for unlimited hours per week. |
|||
|
|||
.uk-card-footer |
|||
div(uk-grid).uk-grid-small.uk-flex-right |
|||
.uk-width-auto |
|||
a(href=`/client/${client._id}`).uk-button.uk-button-default.uk-border-rounded |
|||
i.fa-solid.fa-chevron-left |
|||
span.uk-margin-small-left Back |
|||
.uk-width-auto |
|||
button(type="submit").uk-button.uk-button-default.uk-border-rounded |
|||
i.fa-solid.fa-save |
|||
span.uk-margin-small-left #{!!client ? "Update" : "Create"} Client |
@ -1,60 +0,0 @@ |
|||
extends ../../layout/main |
|||
block view-content |
|||
|
|||
section.uk-section.uk-section-default.uk-section-small |
|||
.uk-container |
|||
|
|||
.uk-margin |
|||
- var actionUrl = !!project ? `/client/${client._id}/project/${project._id}` : `/client/${client._id}/project`; |
|||
form(method="POST", action= actionUrl).uk-form |
|||
.uk-card.uk-card-default.uk-card-small |
|||
.uk-card-header |
|||
div(uk-grid).uk-grid-small.uk-flex-middle |
|||
.uk-width-expand |
|||
h1.uk-card-title #[span= !!project ? "Edit" : "New"] Project |
|||
.uk-width-auto |
|||
div #{client.name} |
|||
|
|||
.uk-card-body |
|||
.uk-margin |
|||
label(for="name").uk-form-label Project name |
|||
input( |
|||
id="name", |
|||
name="name", |
|||
type="text", |
|||
value= !!project ? project.name : undefined, |
|||
placeholder="Enter project name", |
|||
).uk-input |
|||
|
|||
.uk-margin |
|||
label(for="description").uk-form-label Project description |
|||
textarea( |
|||
id="name", |
|||
name="description", |
|||
rows=4, |
|||
placeholder="Enter project description" |
|||
).uk-textarea.uk-resize-vertical= !!project ? project.description : "" |
|||
|
|||
.uk-margin |
|||
label(for="managers").uk-form-label Managers |
|||
input( |
|||
id="managers", |
|||
name="managers", |
|||
type="text", |
|||
value= !!project ? (project.managers || [ ]).map((m) => m.username).join(',') : undefined, |
|||
placeholder="Enter comma-separated list of usernames", |
|||
).uk-input |
|||
.uk-text-small.uk-text-muted Managers can review work sessions and access billing information. |
|||
|
|||
.uk-margin |
|||
label(for="hourly-rate").uk-form-label Hourly rate |
|||
input( |
|||
id="hourly-rate", |
|||
name="hourlyRate", |
|||
type="number", |
|||
value= !!project ? project.hourlyRate : undefined, |
|||
placeholder="Enter hourly rate", |
|||
).uk-input |
|||
|
|||
.uk-card-footer.uk-flex.uk-flex-right |
|||
button(type="submit").uk-button.uk-button-default.uk-border-rounded #[span= !!project ? "Update" : "Create"] Project |
@ -1,39 +0,0 @@ |
|||
extends ../../layout/main |
|||
block view-content |
|||
|
|||
include ../../task/components/grid |
|||
|
|||
section.uk-section.uk-section-default.uk-section-small |
|||
.uk-container |
|||
|
|||
.uk-margin |
|||
div(uk-grid).uk-grid-small.uk-flex-middle |
|||
.uk-width-expand |
|||
h1.uk-margin-remove= project.name |
|||
.uk-width-auto |
|||
a(href=`/client/${client._id}/project/${project._id}/edit`).uk-button.uk-button-default.uk-button-small |
|||
i.fa-solid.fa-cog |
|||
span.uk-margin-small-left Settings |
|||
.uk-width-auto |
|||
.uk-text-bold |
|||
a(href=`/client/${client._id}`).uk-link-reset= client.name |
|||
.uk-text-small #{numeral(project.hourlyRate).format('$0,0.00')}/hr |
|||
|
|||
if project.description |
|||
div= project.description |
|||
|
|||
.uk-margin-medium |
|||
form(method="POST", action="/task").uk-form |
|||
input(type="hidden", name="clientId", value= client._id) |
|||
input(type="hidden", name="projectId", value= project._id) |
|||
.uk-card.uk-card-secondary.uk-card-small.uk-border-rounded |
|||
.uk-card-body.uk-border-rounded |
|||
label(for="note").uk-form-label.sr-only Task note |
|||
div(uk-grid).uk-grid-small |
|||
.uk-width-expand |
|||
input(id="note", name="note", placeholder="What will you be working on?").uk-input.uk-border-rounded |
|||
.uk-width-auto |
|||
button(type="submit").uk-button.uk-button-default.uk-border-rounded Create Task |
|||
|
|||
.uk-margin-medium |
|||
+renderTaskGrid(taskGrid.pendingTasks, taskGrid.activeTasks, taskGrid.finishedTasks) |
@ -1,32 +0,0 @@ |
|||
extends ../layout/main |
|||
block view-content |
|||
|
|||
section.uk-section.uk-section-default.uk-section-small |
|||
.uk-container |
|||
|
|||
.uk-margin-medium |
|||
div(uk-grid).uk-grid-small |
|||
.uk-width-expand |
|||
h1.uk-margin-remove= client.name |
|||
if client.description |
|||
div= client.description |
|||
.uk-width-auto |
|||
a(href=`/client/${client._id}/edit`).uk-button.uk-button-default.uk-button-small |
|||
i.fa-solid.fa-cog |
|||
span.uk-margin-small-left Settings |
|||
.uk-width-auto |
|||
a(href=`/client/${client._id}/project/create`).uk-button.uk-button-default.uk-button-small |
|||
i.fa-solid.fa-plus |
|||
span.uk-margin-small-left Add Project |
|||
|
|||
.uk-margin |
|||
h2 Projects |
|||
if Array.isArray(projects) && (projects.length > 0) |
|||
ul.uk-list.uk-list-divided |
|||
each project in projects |
|||
li |
|||
a(href=`/client/${client._id}/project/${project._id}`).uk-link-reset.uk-display-block |
|||
.uk-text-bold= project.name |
|||
.uk-text-small= project.description || 'No description' |
|||
else |
|||
div There are no projects |
@ -1,39 +1,8 @@ |
|||
extends layout/main |
|||
block view-content |
|||
|
|||
include task/components/grid |
|||
include report/components/weekly-summary |
|||
|
|||
if Array.isArray(projects) && (projects.length > 0) |
|||
section.uk-section.uk-section-muted.uk-section-small |
|||
.uk-container |
|||
h1 Your Projects |
|||
if weeklyEarnings.length > 0 |
|||
+renderWeeklySummaryReport(weeklyEarnings) |
|||
else |
|||
div No time worked this week. |
|||
|
|||
section.uk-section.uk-section-default.uk-section-small |
|||
.uk-container |
|||
+renderTaskGrid( |
|||
taskGrid.pendingTasks, |
|||
taskGrid.activeTasks, |
|||
taskGrid.finishedTasks, |
|||
) |
|||
|
|||
if Array.isArray(managedProjects) && (managedProjects.length > 0) |
|||
section.uk-section.uk-section-muted.uk-section-small |
|||
.uk-container |
|||
h1 Projects You Manage |
|||
|
|||
section.uk-section.uk-section-default.uk-section-small |
|||
.uk-container |
|||
ul.uk-list.uk-list-divider |
|||
each project in managedProjects |
|||
li |
|||
h2= project.name |
|||
+renderTaskGrid( |
|||
project.taskGrid.pendingTasks, |
|||
project.taskGrid.activeTasks, |
|||
project.taskGrid.finishedTasks, |
|||
) |
|||
section.uk-section.uk-section-default.uk-section-small |
|||
.uk-container |
|||
|
|||
h1= site.name |
|||
p Welcome to the authenticated home page. You are logged in. This app does nothing, so get to work building your features and ship often! |
@ -1,12 +0,0 @@ |
|||
extends ../layout/main |
|||
block view-content |
|||
|
|||
include ../user/components/profile-picture |
|||
|
|||
section.uk-section.uk-section-default.uk-section-small |
|||
.uk-container |
|||
|
|||
h1 Projects You Manage |
|||
if Array.isArray(projects) && (projects.length > 0) |
|||
else |
|||
div You don't manage any projects at this time. |
@ -1,20 +0,0 @@ |
|||
mixin renderWeeklySummaryReport (data) |
|||
.uk-overflow-auto |
|||
table.uk-table.uk-table-small.no-select |
|||
thead |
|||
tr |
|||
th Project |
|||
th Client |
|||
th Sessions |
|||
th Time Worked |
|||
th Billable |
|||
tbody |
|||
each row in data |
|||
tr |
|||
td.uk-table-expand |
|||
a(href=`/client/${row.project.client._id}/project/${row.project._id}`)=row.project.name |
|||
td |
|||
a(href=`/client/${row.project.client._id}`)= row.project.client.name |
|||
td= formatCount(row.sessionCount) |
|||
td= numeral(row.duration).format('0:00:00') |
|||
td= numeral(row.billable).format('$0,0.00') |
@ -1,41 +0,0 @@ |
|||
extends ../layout/main |
|||
block view-content |
|||
|
|||
include components/weekly-summary |
|||
|
|||
section.uk-section.uk-section-default.uk-section |
|||
.uk-container |
|||
h1 Week of #{dayjs(weekStartDate).format('MMMM DD')} |
|||
+renderWeeklySummaryReport(weeklyEarnings) |
|||
|
|||
.uk-margin-medium |
|||
h2 Daily Hours |
|||
.uk-overflow-auto |
|||
table.uk-table.uk-table-small.uk-table-divider |
|||
thead |
|||
tr |
|||
th.uk-table-expand Day of Week |
|||
th.uk-text-nowrap.uk-text-right.uk-table-shrink Session Count |
|||
th.uk-text-nowrap.uk-text-right.uk-table-shrink Hours Worked |
|||
th.uk-text-nowrap.uk-text-right.uk-table-shrink Time Worked |
|||
tbody |
|||
- |
|||
var totalHoursWorked = 0; |
|||
var totalSessionCount = 0; |
|||
|
|||
each day in dailyTimeWorked |
|||
- |
|||
totalHoursWorked += day.hoursWorked; |
|||
totalSessionCount += day.workSessionCount; |
|||
|
|||
tr |
|||
td.uk-table-expand= dayjs(day.date).format('dddd') |
|||
td.uk-text-right.uk-text-nowrap.uk-table-shrink= (day.workSessionCount > 0) ? formatCount(day.workSessionCount) : '---' |
|||
td.uk-text-right.uk-text-nowrap.uk-table-shrink= (day.hoursWorked > 0) ? numeral(day.hoursWorked).format('0.00') : '---' |
|||
td.uk-text-right.uk-text-nowrap.uk-table-shrink= (day.hoursWorked > 0) ? numeral(day.hoursWorked * 3600).format('0:00:00') : '---' |
|||
tfoot |
|||
tr |
|||
td.uk-table-expand TOTALS |
|||
td.uk-text-right.uk-text-nowrap.uk-table-shrink= formatCount(totalSessionCount) |
|||
td.uk-text-right.uk-text-nowrap.uk-table-shrink= numeral(totalHoursWorked).format('0.00') |
|||
td.uk-text-right.uk-text-nowrap.uk-table-shrink= numeral(totalHoursWorked * 3600).format('0:00:00') |
@ -1,23 +0,0 @@ |
|||
include list |
|||
mixin renderTaskGrid (pendingTasks, activeTasks, finishedTasks) |
|||
div(uk-grid).uk-grid-divider |
|||
div(class="uk-width1-1 uk-width-1-3@m") |
|||
h3 Pending Tasks |
|||
if Array.isArray(pendingTasks) && (pendingTasks.length > 0) |
|||
+renderTaskList(pendingTasks) |
|||
else |
|||
div No pending tasks |
|||
|
|||
div(class="uk-width1-1 uk-width-1-3@m") |
|||
h3 Active Tasks |
|||
if Array.isArray(activeTasks) && (activeTasks.length > 0) |
|||
+renderTaskList(activeTasks) |
|||
else |
|||
div No active tasks |
|||
|
|||
div(class="uk-width1-1 uk-width-1-3@m") |
|||
h3 Finished Tasks |
|||
if Array.isArray(finishedTasks) && (finishedTasks.length > 0) |
|||
+renderTaskList(finishedTasks) |
|||
else |
|||
div No finished tasks |
@ -1,11 +0,0 @@ |
|||
mixin renderTaskList (tasks) |
|||
ul.uk-list.uk-list-divider |
|||
each task in tasks |
|||
li |
|||
a(href=`/task/${task._id}`).uk-display-block.uk-link-reset |
|||
div(uk-grid).uk-grid-small.uk-flex-middle |
|||
.uk-width-expand= task.note |
|||
.uk-width-auto |
|||
.uk-text-small= numeral(task.duration).format('00:00:00') |
|||
.uk-text-small.uk-text-muted |
|||
span #{task.project.name}, #{task.client.name} |
@ -1,55 +0,0 @@ |
|||
extends ../../layout/main |
|||
block view-content |
|||
|
|||
include ../../user/components/profile-picture |
|||
|
|||
section.uk-section.uk-section-default.uk-section-small |
|||
.uk-container |
|||
|
|||
.uk-margin-medium |
|||
div(uk-grid).uk-grid-small |
|||
.uk-width-auto |
|||
+renderProfilePicture(user) |
|||
.uk-width-expand |
|||
.uk-text-large= user.displayName || user.username |
|||
div Work session #[code #{session._id}] for task "#{task.note}" |
|||
.uk-width-auto.uk-text-right |
|||
div= numeral(session.duration).format('HH:MM:SS') |
|||
.uk-text-small #{numeral(session.hourlyRate * (session.duration / 60 / 60)).format('$0,0.00')} |
|||
|
|||
.uk-margin-medium |
|||
.uk-flex.uk-flex-between.uk-flex-wrap |
|||
.uk-width-auto |
|||
.uk-margin |
|||
.uk-text-small.uk-text-bold Created |
|||
div= dayjs(session.created).format('MMM DD, YYYY [at] hh:mm:ss a') |
|||
.uk-width-auto |
|||
.uk-margin |
|||
.uk-text-small.uk-text-bold Last Updated |
|||
div= dayjs(session.lastUpdated).format('MMM DD, YYYY [at] hh:mm:ss a') |
|||
.uk-width-auto |
|||
.uk-margin |
|||
.uk-text-small.uk-text-bold Finished |
|||
div= dayjs(session.finished).format('MMM DD, YYYY [at] hh:mm:ss a') |
|||
.uk-width-auto |
|||
.uk-margin |
|||
.uk-text-small.uk-text-bold Project |
|||
div= session.project.name |
|||
.uk-width-auto |
|||
.uk-margin |
|||
.uk-text-small.uk-text-bold Client |
|||
div= session.client.name |
|||
|
|||
.uk-margin-medium |
|||
if Array.isArray(session.screenshots) && (session.screenshots.length > 0) |
|||
h3 Screenshots |
|||
div(class="uk-child-width-1-2 uk-child-width-1-3@s uk-child-width-1-4@m uk-child-width-1-5@l uk-child-width-1-6@xl", uk-grid, uk-lightbox="animation: slide").uk-grid-small |
|||
each screenshot in session.screenshots |
|||
a(href=`/image/${screenshot.image._id}`, |
|||
data-type="image", |
|||
data-caption= `${dayjs(screenshot.created).format('MMM DD [at] h:mm a')} | ${screenshot.image.metadata.format.toUpperCase()} | ${numeral(screenshot.image.size).format('0,0.0b')}`, |
|||
).uk-link-reset |
|||
img(src=`/image/${screenshot.image._id}`, width= screenshot.image.metadata.width, height= screenshot.image.metadata.height, alt="Image attachment") |
|||
.uk-text-small.uk-text-muted= dayjs(screenshot.created).format('MMM DD [at] h:mm a') |
|||
else |
|||
div No screenshots were filed by the session. |
@ -1,102 +0,0 @@ |
|||
extends ../layout/main |
|||
block view-content |
|||
|
|||
include ../user/components/profile-picture |
|||
|
|||
section.uk-section.uk-section-default.uk-section-small |
|||
.uk-container |
|||
|
|||
.uk-margin |
|||
div(uk-grid) |
|||
div(class="uk-width-1-1 uk-width-expand@m") |
|||
.uk-margin-medium |
|||
div(uk-grid).uk-grid-small |
|||
.uk-width-auto |
|||
+renderProfilePicture(user) |
|||
.uk-width-expand |
|||
div(style="line-height: 1;").uk-text-lead.uk-text-truncated.uk-margin-small= task.note |
|||
if !manager |
|||
case task.status |
|||
when 'pending' |
|||
.uk-margin-small |
|||
form(method="POST", action=`/task/${task._id}/start`).uk-form |
|||
button(type="submit").uk-button.uk-button-default.uk-button-small.uk-border-rounded Start Task |
|||
when 'active' |
|||
.uk-margin-small |
|||
form(method="POST", action=`/task/${task._id}/close`).uk-form |
|||
button(type="submit").uk-button.uk-button-default.uk-button-small.uk-border-rounded Finish Task |
|||
when 'finished' |
|||
.uk-text-success Finished task |
|||
else |
|||
div Task status: #{task.status} |
|||
|
|||
if task.status === 'active' && !manager |
|||
.uk-width-auto.uk-text-right |
|||
.pretty.p-switch.p-slim |
|||
input( |
|||
id="active-toggle", |
|||
type="checkbox", |
|||
data-task-id= task._id, |
|||
onchange="return dtp.app.taskActivityToggle(event);", |
|||
) |
|||
.state.p-success |
|||
label(for="active-toggle") |
|||
span.sr-only Active |
|||
#current-session-duration.uk-text-small.no-select= numeral(0).format('HH:MM:SS') |
|||
|
|||
- var timeRemaining = task.client.hoursLimit - (task.client.weeklyTotals.timeWorked / 3600); |
|||
.uk-text-small.no-select avail: #[span#time-remaining= numeral(timeRemaining).format('0,0.00')] |
|||
|
|||
.uk-margin-medium |
|||
|
|||
.uk-margin |
|||
h3(style="line-height: 1em;").uk-padding-remove.uk-margin-remove Work Sessions |
|||
small.uk-text-muted Week of #{dayjs(weekStartDate).format('MMMM DD')} |
|||
|
|||
if Array.isArray(sessions) && (sessions.length > 0) |
|||
- |
|||
var totalTimeWorked = 0; |
|||
var totalBillable = 0; |
|||
|
|||
table.uk-table.uk-table-small.uk-table-divider |
|||
thead |
|||
tr |
|||
th.uk-table-expand Start Time |
|||
th.uk-text-nowrap.uk-table-shrink Tracked |
|||
th.uk-text-nowrap.uk-table-shrink Billable |
|||
tbody |
|||
each session in sessions |
|||
- |
|||
totalTimeWorked += session.duration; |
|||
totalBillable += session.hourlyRate * (session.duration / 3600); |
|||
tr |
|||
td.uk-table-expand |
|||
a(href=`/task/${task._id}/session/${session._id}`, |
|||
onclick="return dtp.app.performSessionNavigation(event);", |
|||
).uk-link-reset.uk-display-block= dayjs(session.created).format('dddd [at] h:mm a') |
|||
td.uk-text-right.uk-text-nowrap.uk-table-shrink= numeral(session.duration).format('HH:MM:SS') |
|||
td.uk-text-right.uk-text-nowrap.uk-table-shrink= numeral(session.hourlyRate * (session.duration / 3600)).format('$0,00.00') |
|||
tfoot |
|||
tr |
|||
td.uk-table-expand TOTALS |
|||
td.uk-text-right.uk-text-nowrap.uk-table-shrink= numeral(totalTimeWorked).format('HH:MM:SS') |
|||
td.uk-text-right.uk-text-nowrap.uk-table-shrink #{numeral(totalBillable).format('$0,0.00')} |
|||
else |
|||
div No work sessions |
|||
|
|||
if !manager && (task.status === 'active') |
|||
div(class="uk-width-1-1 uk-flex-first uk-width-large@m uk-flex-last@m") |
|||
.uk-margin |
|||
video( |
|||
id="capture-preview", |
|||
poster="/img/default-poster.svg", |
|||
playsinline, muted, |
|||
).dtp-video.no-select |
|||
div(class="uk-visible@m").uk-margin.uk-text-small.uk-text-muted |
|||
p One image will be captured from this live preview every 10 minutes. It will be uploaded and stored in the work session with a timestamp. |
|||
p When you start a work session, you will select the screen, application, or browser tab to share. |
|||
|
|||
block viewjs |
|||
script. |
|||
window.dtp = window.dtp || { }; |
|||
window.dtp.task = !{JSON.stringify(task)}; |
@ -1,107 +0,0 @@ |
|||
// tracker-monitor.js
|
|||
// Copyright (C) 2024 DTP Technologies, LLC
|
|||
// All Rights Reserved
|
|||
|
|||
'use strict'; |
|||
|
|||
import 'dotenv/config'; |
|||
|
|||
import path, { dirname } from 'path'; |
|||
import dayjs from 'dayjs'; |
|||
|
|||
import { SiteRuntime } from '../../lib/site-lib.js'; |
|||
|
|||
import { CronJob } from 'cron'; |
|||
const CRON_TIMEZONE = 'America/New_York'; |
|||
|
|||
class TrackerMonitorWorker extends SiteRuntime { |
|||
|
|||
static get name ( ) { return 'TrackerMonitorWorker'; } |
|||
static get slug ( ) { return 'trackerMonitor'; } |
|||
|
|||
constructor (rootPath) { |
|||
super(TrackerMonitorWorker, rootPath); |
|||
} |
|||
|
|||
async start ( ) { |
|||
await super.start(); |
|||
|
|||
const mongoose = await import('mongoose'); |
|||
this.TaskSession = mongoose.model('TaskSession'); |
|||
|
|||
this.viewModel = { }; |
|||
await this.populateViewModel(this.viewModel); |
|||
|
|||
/* |
|||
* Cron jobs |
|||
*/ |
|||
|
|||
const sessionExpireSchedule = '* */5 * * * *'; // Every 5 minutes
|
|||
this.cronJob = new CronJob( |
|||
sessionExpireSchedule, |
|||
this.expireTaskSessions.bind(this), |
|||
null, |
|||
true, |
|||
CRON_TIMEZONE, |
|||
); |
|||
await this.expireTaskSessions(); |
|||
|
|||
/* |
|||
* Bull Queue job processors |
|||
*/ |
|||
|
|||
// this.log.info('registering queue job processor', { config: this.config.jobQueues.links });
|
|||
// this.linksProcessingQueue = this.services.jobQueue.getJobQueue('links', this.config.jobQueues.links);
|
|||
// this.linksProcessingQueue.process('link-ingest', 1, this.ingestLink.bind(this));
|
|||
|
|||
this.log.info('Tracker Monitor online'); |
|||
} |
|||
|
|||
async shutdown ( ) { |
|||
this.log.alert('ChatLinksWorker shutting down'); |
|||
await super.shutdown(); |
|||
} |
|||
|
|||
async expireTaskSessions ( ) { |
|||
const { task: taskService } = this.services; |
|||
|
|||
const NOW = new Date(); |
|||
const oldestDate = dayjs(NOW).subtract(10, 'minute'); |
|||
|
|||
this.log.debug('scanning for defunct sessions'); |
|||
await this.TaskSession |
|||
.find({ |
|||
$and: [ |
|||
{ status: { $in: ['active', 'reconnecting'] } }, |
|||
{ lastUpdated: { $lt: oldestDate } }, |
|||
], |
|||
}) |
|||
.cursor() |
|||
.eachAsync(async (session) => { |
|||
this.log.info('expiring defunct task work session', { |
|||
session: { |
|||
_id: session._id, |
|||
created: session.created, |
|||
lastUpdated: session.lastUpdated, |
|||
}, |
|||
}); |
|||
taskService.closeTaskSession(session, 'expired'); |
|||
}); |
|||
} |
|||
} |
|||
|
|||
(async ( ) => { |
|||
|
|||
try { |
|||
const { fileURLToPath } = await import('node:url'); |
|||
const __dirname = dirname(fileURLToPath(import.meta.url)); // jshint ignore:line
|
|||
|
|||
const worker = new TrackerMonitorWorker(path.resolve(__dirname, '..', '..')); |
|||
await worker.start(); |
|||
|
|||
} catch (error) { |
|||
console.error('failed to start tracker monitor worker', { error }); |
|||
process.exit(-1); |
|||
} |
|||
|
|||
})(); |
Before Width: | Height: | Size: 29 KiB |
Before Width: | Height: | Size: 5.0 KiB |
Before Width: | Height: | Size: 5.4 KiB |
Before Width: | Height: | Size: 6.7 KiB |
Before Width: | Height: | Size: 7.3 KiB |
Before Width: | Height: | Size: 7.1 KiB |
Before Width: | Height: | Size: 547 B |
Before Width: | Height: | Size: 8.6 KiB |
Before Width: | Height: | Size: 9.4 KiB |
Before Width: | Height: | Size: 14 KiB |
Before Width: | Height: | Size: 16 KiB |
Before Width: | Height: | Size: 1.2 KiB |
Before Width: | Height: | Size: 1.4 KiB |
Before Width: | Height: | Size: 24 KiB |
Before Width: | Height: | Size: 1.9 KiB |
Before Width: | Height: | Size: 38 KiB |
Before Width: | Height: | Size: 2.4 KiB |
Before Width: | Height: | Size: 2.5 KiB |
Before Width: | Height: | Size: 2.9 KiB |
Before Width: | Height: | Size: 3.0 KiB |
Before Width: | Height: | Size: 3.1 KiB |
Before Width: | Height: | Size: 4.1 KiB |
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 29 KiB |
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 7.5 KiB |
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 8.1 KiB |
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 10 KiB |
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 10 KiB |
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 11 KiB |
Before Width: | Height: | Size: 716 B After Width: | Height: | Size: 767 B |
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 13 KiB |
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 14 KiB |
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 22 KiB |
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 26 KiB |
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.8 KiB |
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.0 KiB |
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 37 KiB |
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 2.8 KiB |
Before Width: | Height: | Size: 123 KiB After Width: | Height: | Size: 60 KiB |
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 3.3 KiB |
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.5 KiB |
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 4.3 KiB |
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 4.4 KiB |
Before Width: | Height: | Size: 5.3 KiB After Width: | Height: | Size: 4.7 KiB |
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 6.1 KiB |
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 13 KiB |
@ -1,19 +1,17 @@ |
|||
// time-tracker-audio.js
|
|||
// base-audio.js
|
|||
// Copyright (C) 2024 DTP Technologies, LLC
|
|||
// All Rights Reserved
|
|||
|
|||
'use strict'; |
|||
|
|||
const DTP_COMPONENT_NAME = 'TimeTrackerAudio'; |
|||
|
|||
import DtpLog from 'lib/dtp-log'; |
|||
|
|||
const AudioContext = window.AudioContext || window.webkitAudioContext; |
|||
|
|||
export default class TimeTrackerAudio { |
|||
export default class BaseAudio { |
|||
|
|||
constructor ( ) { |
|||
this.log = new DtpLog(DTP_COMPONENT_NAME); |
|||
this.log = new DtpLog('BaseAudio'); |
|||
} |
|||
|
|||
start ( ) { |
@ -0,0 +1,340 @@ |
|||
// base-client.js
|
|||
// Copyright (C) 2024 DTP Technologies, LLC
|
|||
// All Rights Reserved
|
|||
|
|||
'use strict'; |
|||
|
|||
const dtp = window.dtp = window.dtp || { }; |
|||
|
|||
import DtpApp from 'lib/dtp-app.js'; |
|||
import BaseAudio from './base-audio.js'; |
|||
|
|||
import QRCode from 'qrcode'; |
|||
import Cropper from 'cropperjs'; |
|||
|
|||
import dayjs from 'dayjs'; |
|||
import dayjsRelativeTime from 'dayjs/plugin/relativeTime.js'; |
|||
dayjs.extend(dayjsRelativeTime); |
|||
|
|||
export class BaseApp extends DtpApp { |
|||
|
|||
static get SFX_SAMPLE_SOUND ( ) { return 'sample-sound'; } |
|||
|
|||
constructor (user) { |
|||
super('BaseApp', user); |
|||
|
|||
this.loadSettings(); |
|||
|
|||
this.notificationPermission = 'default'; |
|||
this.haveFocus = true; // hard to load the app w/o also being the focused app
|
|||
|
|||
window.addEventListener('dtp-load', this.onDtpLoad.bind(this)); |
|||
window.addEventListener('focus', this.onWindowFocus.bind(this)); |
|||
window.addEventListener('blur', this.onWindowBlur.bind(this)); |
|||
|
|||
/* |
|||
* Page Visibility API hooks |
|||
*/ |
|||
window.addEventListener('pageshow', this.onWindowPageShow.bind(this)); |
|||
window.addEventListener('pagehide', this.onWindowPageHide.bind(this)); |
|||
window.addEventListener('freeze', this.onWindowFreeze.bind(this)); |
|||
window.addEventListener('resume', this.onWindowResume.bind(this)); |
|||
|
|||
this.updateTimestamps(); |
|||
|
|||
this.log.info('constructor', 'BaseApp client online'); |
|||
} |
|||
|
|||
async onDtpLoad ( ) { |
|||
this.log.info('onDtpLoad', 'dtp-load event received. Connecting to platform.'); |
|||
|
|||
await this.connect({ |
|||
mode: 'User', |
|||
onSocketConnect: this.onBaseSocketConnect.bind(this), |
|||
onSocketDisconnect: this.onBaseSocketDisconnect.bind(this), |
|||
}); |
|||
} |
|||
|
|||
async onWindowFocus (event) { |
|||
this.log.debug('onWindowFocus', 'window has received focus', { event }); |
|||
this.haveFocus = true; |
|||
} |
|||
|
|||
async onWindowBlur (event) { |
|||
this.log.debug('onWindowBlur', 'window has lost focus', { event }); |
|||
this.haveFocus = false; |
|||
} |
|||
|
|||
async onWindowPageShow (event) { |
|||
this.log.debug('onWindowPageShow', 'the page is being shown', { event }); |
|||
} |
|||
|
|||
async onWindowPageHide (event) { |
|||
this.log.debug('onWindowPageHide', 'the page is being hidden', { event }); |
|||
if (!event.persisted) { |
|||
await this.socket.disconnect(); |
|||
} |
|||
} |
|||
|
|||
async onWindowFreeze (event) { |
|||
this.log.debug('onWindowFreeze', 'the page is being frozen', { event }); |
|||
} |
|||
|
|||
async onWindowResume (event) { |
|||
this.log.debug('onWindowResume', 'the page is being resumed', { event }); |
|||
} |
|||
|
|||
async startAudio ( ) { |
|||
this.log.info('startAudio', 'starting audio'); |
|||
this.audio = new BaseAudio(); |
|||
this.audio.start(); |
|||
try { |
|||
await Promise.all([ |
|||
this.audio.loadSound(BaseApp.SFX_SAMPLE_SOUND, '/static/sfx/sample-sound.mp3'), |
|||
]); |
|||
} catch (error) { |
|||
this.log.error('startAudio', 'failed to load sound', { error }); |
|||
// fall through
|
|||
} |
|||
} |
|||
|
|||
async onBaseSocketConnect (socket) { |
|||
this.log.debug('onSocketConnect', 'attaching socket events'); |
|||
|
|||
this.systemMessageHandler = this.onSystemMessage.bind(this); |
|||
socket.on('system-message', this.systemMessageHandler); |
|||
} |
|||
|
|||
async onBaseSocketDisconnect (socket) { |
|||
this.log.debug('onSocketDisconnect', 'detaching socket events'); |
|||
|
|||
socket.off('system-message', this.systemMessageHandler); |
|||
delete this.systemMessageHandler; |
|||
|
|||
if (this.taskSession) { |
|||
await this.setTaskSessionStatus('reconnecting'); |
|||
} |
|||
} |
|||
|
|||
async onSystemMessage (message) { |
|||
if (message.displayList) { |
|||
this.displayEngine.executeDisplayList(message.displayList); |
|||
} |
|||
} |
|||
|
|||
async confirmNavigation (event) { |
|||
const target = event.currentTarget || event.target; |
|||
event.preventDefault(); |
|||
event.stopPropagation(); |
|||
|
|||
const href = target.getAttribute('href'); |
|||
const hrefTarget = target.getAttribute('target'); |
|||
const text = target.textContent; |
|||
const whitelist = [ |
|||
'digitaltelepresence.com', |
|||
'www.digitaltelepresence.com', |
|||
]; |
|||
try { |
|||
const url = new URL(href); |
|||
if (!whitelist.includes(url.hostname)) { |
|||
await UIkit.modal.confirm(`<p>You are navigating to <code>${href}</code>, a link or button that was displayed as:</p><p>${text}</p><div class="uk-text-small uk-text-danger">Please only open links to destinations you trust and want to visit.</div>`); |
|||
} |
|||
window.open(href, hrefTarget); |
|||
} catch (error) { |
|||
this.log.info('confirmNavigation', 'navigation canceled', { error }); |
|||
} |
|||
|
|||
return true; |
|||
} |
|||
|
|||
async generateOtpQR (canvas, keyURI) { |
|||
QRCode.toCanvas(canvas, keyURI); |
|||
} |
|||
|
|||
async generateQRCanvas (canvas, uri) { |
|||
this.log.info('generateQRCanvas', 'creating QR code canvas', { uri }); |
|||
QRCode.toCanvas(canvas, uri, { width: 256 }); |
|||
} |
|||
|
|||
async closeAllDropdowns ( ) { |
|||
const dropdowns = document.querySelectorAll('.uk-dropdown.uk-open'); |
|||
for (const dropdown of dropdowns) { |
|||
this.log.info('closeAllDropdowns', 'closing dropdown', { dropdown }); |
|||
UIkit.dropdown(dropdown).hide(false); |
|||
} |
|||
} |
|||
|
|||
async initSettingsView ( ) { |
|||
this.log.info('initSettingsView', 'settings', { settings: this.settings }); |
|||
} |
|||
|
|||
loadSettings ( ) { |
|||
this.settings = { }; |
|||
if (window.localStorage) { |
|||
if (window.localStorage.settings) { |
|||
this.settings = JSON.parse(window.localStorage.settings); |
|||
} else { |
|||
this.saveSettings(); |
|||
} |
|||
} |
|||
} |
|||
|
|||
saveSettings ( ) { |
|||
if (!window.localStorage) { return; } |
|||
window.localStorage.settings = JSON.stringify(this.settings); |
|||
} |
|||
|
|||
async submitImageForm (event) { |
|||
event.preventDefault(); |
|||
event.stopPropagation(); |
|||
|
|||
const formElement = event.currentTarget || event.target; |
|||
const form = new FormData(formElement); |
|||
|
|||
this.cropper.getCroppedCanvas().toBlob(async (imageData) => { |
|||
try { |
|||
const imageId = formElement.getAttribute('data-image-id'); |
|||
form.append('imageFile', imageData, imageId); |
|||
|
|||
this.log.info('submitImageForm', 'uploading image', { event, action: formElement.action }); |
|||
const response = await fetch(formElement.action, { |
|||
method: formElement.method, |
|||
body: form, |
|||
}); |
|||
await this.processResponse(response); |
|||
} catch (error) { |
|||
UIkit.modal.alert(`Failed to upload image: ${error.message}`); |
|||
} |
|||
}); |
|||
|
|||
return; |
|||
} |
|||
|
|||
async selectImageFile (event) { |
|||
event.preventDefault(); |
|||
|
|||
const imageId = event.target.getAttribute('data-image-id'); |
|||
|
|||
let cropperOptions = event.target.getAttribute('data-cropper-options'); |
|||
if (cropperOptions) { |
|||
cropperOptions = JSON.parse(cropperOptions); |
|||
} |
|||
|
|||
const fileSelectContainerId = event.target.getAttribute('data-file-select-container'); |
|||
if (!fileSelectContainerId) { |
|||
UIkit.modal.alert('Missing file select container element ID information'); |
|||
return; |
|||
} |
|||
|
|||
const fileSelectContainer = document.getElementById(fileSelectContainerId); |
|||
if (!fileSelectContainer) { |
|||
UIkit.modal.alert('Missing file select element'); |
|||
return; |
|||
} |
|||
|
|||
const fileSelect = fileSelectContainer.querySelector('input[type="file"]'); |
|||
if (!fileSelect.files || (fileSelect.files.length === 0)) { |
|||
return; |
|||
} |
|||
|
|||
const selectedFile = fileSelect.files[0]; |
|||
if (!selectedFile) { |
|||
return; |
|||
} |
|||
|
|||
this.log.debug('selectImageFile', 'thumbnail file select', { event, selectedFile }); |
|||
|
|||
const filter = /^(image\/jpg|image\/jpeg|image\/png)$/i; |
|||
if (!filter.test(selectedFile.type)) { |
|||
UIkit.modal.alert(`Unsupported image file type selected: ${selectedFile.type}`); |
|||
return; |
|||
} |
|||
|
|||
const fileSizeId = event.target.getAttribute('data-file-size-element'); |
|||
const FILE_MAX_SIZE = parseInt(fileSelect.getAttribute('data-file-max-size'), 10); |
|||
const fileSize = document.getElementById(fileSizeId); |
|||
fileSize.textContent = numeral(selectedFile.size).format('0,0.0b'); |
|||
if (selectedFile.size > (FILE_MAX_SIZE)) { |
|||
UIkit.modal.alert(`File is too large: ${fileSize.textContent}. Custom thumbnail images may be up to ${numeral(FILE_MAX_SIZE).format('0,0.00b')} in size.`); |
|||
return; |
|||
} |
|||
|
|||
const reader = new FileReader(); |
|||
reader.onload = (e) => { |
|||
const img = document.getElementById(imageId); |
|||
img.onload = (e) => { |
|||
console.log('image loaded', e, img.naturalWidth, img.naturalHeight); |
|||
|
|||
fileSelectContainer.querySelector('#file-name').textContent = selectedFile.name; |
|||
fileSelectContainer.querySelector('#file-modified').textContent = dayjs(selectedFile.lastModifiedDate).fromNow(); |
|||
fileSelectContainer.querySelector('#image-resolution-w').textContent = img.naturalWidth.toString(); |
|||
fileSelectContainer.querySelector('#image-resolution-h').textContent = img.naturalHeight.toString(); |
|||
|
|||
fileSelectContainer.querySelector('#file-select').setAttribute('hidden', true); |
|||
fileSelectContainer.querySelector('#file-info').removeAttribute('hidden'); |
|||
|
|||
fileSelectContainer.querySelector('#file-save-btn').removeAttribute('hidden'); |
|||
}; |
|||
|
|||
img.src = e.target.result; |
|||
this.createImageCropper(img, cropperOptions); |
|||
}; |
|||
|
|||
reader.readAsDataURL(selectedFile); |
|||
} |
|||
|
|||
async createImageCropper (img, options) { |
|||
// https://github.com/fengyuanchen/cropperjs/blob/main/README.md#options
|
|||
options = Object.assign({ |
|||
aspectRatio: 1, |
|||
viewMode: 1, // restrict the crop box not to exceed the size of the canvas
|
|||
dragMode: 'move', |
|||
autoCropArea: 0.85, |
|||
restore: false, |
|||
guides: false, |
|||
center: false, |
|||
highlight: false, |
|||
cropBoxMovable: true, |
|||
cropBoxResizable: true, |
|||
toggleDragModeOnDblclick: false, |
|||
modal: true, |
|||
}, options); |
|||
this.log.info("createImageCropper", "Creating image cropper", { img }); |
|||
this.cropper = new Cropper(img, options); |
|||
} |
|||
|
|||
async removeImageFile (event) { |
|||
const target = event.target || event.currentTarget; |
|||
const imageType = target.getAttribute('data-image-type'); |
|||
|
|||
try { |
|||
this.log.info('removeImageFile', 'request to remove image', event); |
|||
|
|||
let imageUrl; |
|||
switch (imageType) { |
|||
case 'profile-picture-file': |
|||
imageUrl = `/user/${this.user._id}/profile-photo`; |
|||
break; |
|||
|
|||
default: |
|||
throw new Error(`Invalid image type: ${imageType}`); |
|||
} |
|||
|
|||
const response = await fetch(imageUrl, { method: 'DELETE' }); |
|||
if (!response.ok) { |
|||
throw new Error('Server error'); |
|||
} |
|||
|
|||
await this.processResponse(response); |
|||
} catch (error) { |
|||
this.log.error('removeImageFile', 'failed to remove image', { error }); |
|||
UIkit.modal.alert(`Failed to remove image: ${error.message}`); |
|||
} |
|||
} |
|||
|
|||
async onWindowResize ( ) { |
|||
if (this.chat.messageList && this.chat.isAtBottom) { |
|||
this.chat.messageList.scrollTo(0, this.chat.messageList.scrollHeight + 50000); |
|||
} |
|||
} |
|||
} |
@ -1,652 +0,0 @@ |
|||
// time-tracker-client.js
|
|||
// Copyright (C) 2024 DTP Technologies, LLC
|
|||
// All Rights Reserved
|
|||
|
|||
'use strict'; |
|||
|
|||
const DTP_COMPONENT_NAME = 'TimeTrackerApp'; |
|||
const dtp = window.dtp = window.dtp || { }; |
|||
|
|||
import DtpApp from 'lib/dtp-app.js'; |
|||
import TimeTrackerAudio from './time-tracker-audio.js'; |
|||
|
|||
import QRCode from 'qrcode'; |
|||
import Cropper from 'cropperjs'; |
|||
|
|||
import dayjs from 'dayjs'; |
|||
import dayjsRelativeTime from 'dayjs/plugin/relativeTime.js'; |
|||
dayjs.extend(dayjsRelativeTime); |
|||
|
|||
export class TimeTrackerApp extends DtpApp { |
|||
|
|||
static get SCREENSHOT_INTERVAL ( ) { return 1000 * 60 * 10; } |
|||
|
|||
static get SFX_TRACKER_START ( ) { return 'tracker-start'; } |
|||
static get SFX_TRACKER_UPDATE ( ) { return 'tracker-update'; } |
|||
static get SFX_TRACKER_STOP ( ) { return 'tracker-stop'; } |
|||
|
|||
constructor (user) { |
|||
super(DTP_COMPONENT_NAME, user); |
|||
this.loadSettings(); |
|||
this.log.info('constructor', 'TimeTrackerApp client online'); |
|||
|
|||
this.notificationPermission = 'default'; |
|||
this.haveFocus = true; // hard to load the app w/o also being the focused app
|
|||
|
|||
this.capturePreview = document.querySelector('video#capture-preview'); |
|||
this.dragFeedback = document.querySelector('.dtp-drop-feedback'); |
|||
|
|||
this.currentSessionStartTime = null; |
|||
this.currentSessionDuration = document.querySelector('#current-session-duration'); |
|||
this.currentSessionTimeRemaining = document.querySelector('#time-remaining'); |
|||
|
|||
window.addEventListener('dtp-load', this.onDtpLoad.bind(this)); |
|||
window.addEventListener('focus', this.onWindowFocus.bind(this)); |
|||
window.addEventListener('blur', this.onWindowBlur.bind(this)); |
|||
|
|||
/* |
|||
* Page Visibility API hooks |
|||
*/ |
|||
window.addEventListener('pageshow', this.onWindowPageShow.bind(this)); |
|||
window.addEventListener('pagehide', this.onWindowPageHide.bind(this)); |
|||
window.addEventListener('freeze', this.onWindowFreeze.bind(this)); |
|||
window.addEventListener('resume', this.onWindowResume.bind(this)); |
|||
|
|||
this.updateTimestamps(); |
|||
} |
|||
|
|||
async onDtpLoad ( ) { |
|||
this.log.info('onDtpLoad', 'dtp-load event received. Connecting to platform.'); |
|||
|
|||
await this.connect({ |
|||
mode: 'User', |
|||
onSocketConnect: this.onTrackerSocketConnect.bind(this), |
|||
onSocketDisconnect: this.onTrackerSocketDisconnect.bind(this), |
|||
}); |
|||
} |
|||
|
|||
async onWindowFocus (event) { |
|||
this.log.debug('onWindowFocus', 'window has received focus', { event }); |
|||
this.haveFocus = true; |
|||
} |
|||
|
|||
async onWindowBlur (event) { |
|||
this.log.debug('onWindowBlur', 'window has lost focus', { event }); |
|||
this.haveFocus = false; |
|||
} |
|||
|
|||
async onWindowPageShow (event) { |
|||
this.log.debug('onWindowPageShow', 'the page is being shown', { event }); |
|||
} |
|||
|
|||
async onWindowPageHide (event) { |
|||
this.log.debug('onWindowPageHide', 'the page is being hidden', { event }); |
|||
if (!event.persisted) { |
|||
await this.socket.disconnect(); |
|||
} |
|||
} |
|||
|
|||
async onWindowFreeze (event) { |
|||
this.log.debug('onWindowFreeze', 'the page is being frozen', { event }); |
|||
} |
|||
|
|||
async onWindowResume (event) { |
|||
this.log.debug('onWindowResume', 'the page is being resumed', { event }); |
|||
} |
|||
|
|||
async startAudio ( ) { |
|||
this.log.info('startAudio', 'starting audio'); |
|||
this.audio = new TimeTrackerAudio(); |
|||
this.audio.start(); |
|||
try { |
|||
await Promise.all([ |
|||
this.audio.loadSound(TimeTrackerApp.SFX_TRACKER_START, '/static/sfx/tracker-start.mp3'), |
|||
this.audio.loadSound(TimeTrackerApp.SFX_TRACKER_UPDATE, '/static/sfx/tracker-update.mp3'), |
|||
this.audio.loadSound(TimeTrackerApp.SFX_TRACKER_STOP, '/static/sfx/tracker-stop.mp3'), |
|||
]); |
|||
} catch (error) { |
|||
this.log.error('startAudio', 'failed to load sound', { error }); |
|||
// fall through
|
|||
} |
|||
} |
|||
|
|||
async onDragEnter (event) { |
|||
event.preventDefault(); |
|||
event.stopPropagation(); |
|||
|
|||
this.log.info('onDragEnter', 'something being dragged has entered the stage', { event }); |
|||
this.dragFeedback.classList.add('feedback-active'); |
|||
} |
|||
|
|||
async onDragLeave (event) { |
|||
event.preventDefault(); |
|||
event.stopPropagation(); |
|||
|
|||
this.log.info('onDragLeave', 'something being dragged has left the stage', { event }); |
|||
this.dragFeedback.classList.remove('feedback-active'); |
|||
} |
|||
|
|||
async onDragOver (event) { |
|||
/* |
|||
* Inform that we want "copy" as a drop effect and prevent all default |
|||
* processing so we'll actually get the files in the drop event. If this |
|||
* isn't done, you simply won't get the files in the drop. |
|||
*/ |
|||
event.preventDefault(); |
|||
event.stopPropagation(); // this ends now!
|
|||
event.dataTransfer.dropEffect = 'copy'; |
|||
|
|||
// this.log.info('onDragOver', 'something was dragged over the stage', { event });
|
|||
this.dragFeedback.classList.add('feedback-active'); |
|||
} |
|||
|
|||
async onDrop (event) { |
|||
event.preventDefault(); |
|||
event.stopPropagation(); |
|||
|
|||
for (const file of event.dataTransfer.files) { |
|||
this.log.info('onDrop', 'a file has been dropped', { file }); |
|||
} |
|||
this.log.info('onFileDrop', 'something was dropped on the stage', { event, files: event.files }); |
|||
this.dragFeedback.classList.remove('feedback-active'); |
|||
} |
|||
|
|||
async onTrackerSocketConnect (socket) { |
|||
this.log.debug('onSocketConnect', 'attaching socket events'); |
|||
|
|||
this.systemMessageHandler = this.onSystemMessage.bind(this); |
|||
socket.on('system-message', this.systemMessageHandler); |
|||
|
|||
this.sessionControlHandler = this.onSessionControl.bind(this); |
|||
socket.on('session-control', this.sessionControlHandler); |
|||
|
|||
if (dtp.task) { |
|||
await this.socket.joinChannel(dtp.task._id, 'Task'); |
|||
} |
|||
if (this.taskSession) { |
|||
await this.setTaskSessionStatus('active'); |
|||
} |
|||
} |
|||
|
|||
async onTrackerSocketDisconnect (socket) { |
|||
this.log.debug('onSocketDisconnect', 'detaching socket events'); |
|||
|
|||
socket.off('session-control', this.sessionControlHandler); |
|||
delete this.sessionControlHandler; |
|||
|
|||
socket.off('system-message', this.systemMessageHandler); |
|||
delete this.systemMessageHandler; |
|||
|
|||
if (this.taskSession) { |
|||
await this.setTaskSessionStatus('reconnecting'); |
|||
} |
|||
} |
|||
|
|||
async onSystemMessage (message) { |
|||
if (message.displayList) { |
|||
this.displayEngine.executeDisplayList(message.displayList); |
|||
} |
|||
} |
|||
|
|||
async onSessionControl (message) { |
|||
const activityToggle = document.querySelector(''); |
|||
if (message.cmd) { |
|||
switch (message.cmd) { |
|||
case 'end-session': |
|||
try { |
|||
await this.closeTaskSession(); |
|||
activityToggle.checked = false; |
|||
} catch (error) { |
|||
this.log.error('onSessionControl', 'failed to close task work session', { error }); |
|||
return; |
|||
} |
|||
break; |
|||
|
|||
default: |
|||
this.log.error('onSessionControl', 'invalid command received', { cmd: message.cmd }); |
|||
return; |
|||
} |
|||
} |
|||
if (message.displayList) { |
|||
this.displayEngine.executeDisplayList(message.displayList); |
|||
} |
|||
} |
|||
|
|||
async confirmNavigation (event) { |
|||
const target = event.currentTarget || event.target; |
|||
event.preventDefault(); |
|||
event.stopPropagation(); |
|||
|
|||
const href = target.getAttribute('href'); |
|||
const hrefTarget = target.getAttribute('target'); |
|||
const text = target.textContent; |
|||
const whitelist = [ |
|||
'chat.digitaltelepresence.com', |
|||
'digitaltelepresence.com', |
|||
'sites.digitaltelepresence.com', |
|||
'tracker.digitaltelepresence.com', |
|||
'www.digitaltelepresence.com', |
|||
]; |
|||
try { |
|||
const url = new URL(href); |
|||
if (!whitelist.includes(url.hostname)) { |
|||
await UIkit.modal.confirm(`<p>You are navigating to <code>${href}</code>, a link or button that was displayed as:</p><p>${text}</p><div class="uk-text-small uk-text-danger">Please only open links to destinations you trust and want to visit.</div>`); |
|||
} |
|||
window.open(href, hrefTarget); |
|||
} catch (error) { |
|||
this.log.info('confirmNavigation', 'navigation canceled', { error }); |
|||
} |
|||
|
|||
return true; |
|||
} |
|||
|
|||
async performSessionNavigation (event) { |
|||
const target = event.currentTarget || event.target; |
|||
|
|||
event.preventDefault(); |
|||
event.stopPropagation(); |
|||
|
|||
const href = target.getAttribute('href'); |
|||
const hrefTarget = target.getAttribute('target'); |
|||
if (this.taskSession || (hrefTarget && (hrefTarget.length > 0))) { |
|||
return window.open(href, hrefTarget); |
|||
} |
|||
|
|||
window.location = href; |
|||
return true; |
|||
} |
|||
|
|||
async generateOtpQR (canvas, keyURI) { |
|||
QRCode.toCanvas(canvas, keyURI); |
|||
} |
|||
|
|||
async generateQRCanvas (canvas, uri) { |
|||
this.log.info('generateQRCanvas', 'creating QR code canvas', { uri }); |
|||
QRCode.toCanvas(canvas, uri, { width: 256 }); |
|||
} |
|||
|
|||
async closeAllDropdowns ( ) { |
|||
const dropdowns = document.querySelectorAll('.uk-dropdown.uk-open'); |
|||
for (const dropdown of dropdowns) { |
|||
this.log.info('closeAllDropdowns', 'closing dropdown', { dropdown }); |
|||
UIkit.dropdown(dropdown).hide(false); |
|||
} |
|||
} |
|||
|
|||
async initSettingsView ( ) { |
|||
this.log.info('initSettingsView', 'settings', { settings: this.settings }); |
|||
} |
|||
|
|||
loadSettings ( ) { |
|||
this.settings = { }; |
|||
if (window.localStorage) { |
|||
if (window.localStorage.settings) { |
|||
this.settings = JSON.parse(window.localStorage.settings); |
|||
} else { |
|||
this.saveSettings(); |
|||
} |
|||
} |
|||
} |
|||
|
|||
saveSettings ( ) { |
|||
if (!window.localStorage) { return; } |
|||
window.localStorage.settings = JSON.stringify(this.settings); |
|||
} |
|||
|
|||
async submitImageForm (event) { |
|||
event.preventDefault(); |
|||
event.stopPropagation(); |
|||
|
|||
const formElement = event.currentTarget || event.target; |
|||
const form = new FormData(formElement); |
|||
|
|||
this.cropper.getCroppedCanvas().toBlob(async (imageData) => { |
|||
try { |
|||
const imageId = formElement.getAttribute('data-image-id'); |
|||
form.append('imageFile', imageData, imageId); |
|||
|
|||
this.log.info('submitImageForm', 'uploading image', { event, action: formElement.action }); |
|||
const response = await fetch(formElement.action, { |
|||
method: formElement.method, |
|||
body: form, |
|||
}); |
|||
await this.processResponse(response); |
|||
} catch (error) { |
|||
UIkit.modal.alert(`Failed to upload image: ${error.message}`); |
|||
} |
|||
}); |
|||
|
|||
return; |
|||
} |
|||
|
|||
async selectImageFile (event) { |
|||
event.preventDefault(); |
|||
|
|||
const imageId = event.target.getAttribute('data-image-id'); |
|||
|
|||
//z read the cropper options from the element on the page
|
|||
let cropperOptions = event.target.getAttribute('data-cropper-options'); |
|||
if (cropperOptions) { |
|||
cropperOptions = JSON.parse(cropperOptions); |
|||
} |
|||
this.log.debug('selectImageFile', 'cropper options', { cropperOptions }); //z remove when done
|
|||
|
|||
const fileSelectContainerId = event.target.getAttribute('data-file-select-container'); |
|||
if (!fileSelectContainerId) { |
|||
UIkit.modal.alert('Missing file select container element ID information'); |
|||
return; |
|||
} |
|||
|
|||
const fileSelectContainer = document.getElementById(fileSelectContainerId); |
|||
if (!fileSelectContainer) { |
|||
UIkit.modal.alert('Missing file select element'); |
|||
return; |
|||
} |
|||
|
|||
const fileSelect = fileSelectContainer.querySelector('input[type="file"]'); |
|||
if (!fileSelect.files || (fileSelect.files.length === 0)) { |
|||
return; |
|||
} |
|||
|
|||
const selectedFile = fileSelect.files[0]; |
|||
if (!selectedFile) { |
|||
return; |
|||
} |
|||
|
|||
this.log.debug('selectImageFile', 'thumbnail file select', { event, selectedFile }); |
|||
|
|||
const filter = /^(image\/jpg|image\/jpeg|image\/png)$/i; |
|||
if (!filter.test(selectedFile.type)) { |
|||
UIkit.modal.alert(`Unsupported image file type selected: ${selectedFile.type}`); |
|||
return; |
|||
} |
|||
|
|||
const fileSizeId = event.target.getAttribute('data-file-size-element'); |
|||
const FILE_MAX_SIZE = parseInt(fileSelect.getAttribute('data-file-max-size'), 10); |
|||
const fileSize = document.getElementById(fileSizeId); |
|||
fileSize.textContent = numeral(selectedFile.size).format('0,0.0b'); |
|||
if (selectedFile.size > (FILE_MAX_SIZE)) { |
|||
UIkit.modal.alert(`File is too large: ${fileSize.textContent}. Custom thumbnail images may be up to ${numeral(FILE_MAX_SIZE).format('0,0.00b')} in size.`); |
|||
return; |
|||
} |
|||
|
|||
// const IMAGE_WIDTH = parseInt(event.target.getAttribute('data-image-w'));
|
|||
// const IMAGE_HEIGHT = parseInt(event.target.getAttribute('data-image-h'));
|
|||
|
|||
const reader = new FileReader(); |
|||
reader.onload = (e) => { |
|||
const img = document.getElementById(imageId); |
|||
img.onload = (e) => { |
|||
console.log('image loaded', e, img.naturalWidth, img.naturalHeight); |
|||
// if (img.naturalWidth !== IMAGE_WIDTH || img.naturalHeight !== IMAGE_HEIGHT) {
|
|||
// UIkit.modal.alert(`This image must be ${IMAGE_WIDTH}x${IMAGE_HEIGHT}`);
|
|||
// img.setAttribute('hidden', '');
|
|||
// img.src = '';
|
|||
// return;
|
|||
// }
|
|||
|
|||
fileSelectContainer.querySelector('#file-name').textContent = selectedFile.name; |
|||
fileSelectContainer.querySelector('#file-modified').textContent = dayjs(selectedFile.lastModifiedDate).fromNow(); |
|||
fileSelectContainer.querySelector('#image-resolution-w').textContent = img.naturalWidth.toString(); |
|||
fileSelectContainer.querySelector('#image-resolution-h').textContent = img.naturalHeight.toString(); |
|||
|
|||
fileSelectContainer.querySelector('#file-select').setAttribute('hidden', true); |
|||
fileSelectContainer.querySelector('#file-info').removeAttribute('hidden'); |
|||
|
|||
fileSelectContainer.querySelector('#file-save-btn').removeAttribute('hidden'); |
|||
}; |
|||
|
|||
// set the image as the "src" of the <img> in the DOM.
|
|||
img.src = e.target.result; |
|||
|
|||
//z create cropper and set options here
|
|||
this.createImageCropper(img, cropperOptions); |
|||
}; |
|||
|
|||
// read in the file, which will trigger everything else in the event handler above.
|
|||
reader.readAsDataURL(selectedFile); |
|||
} |
|||
|
|||
async createImageCropper (img, options) { |
|||
// https://github.com/fengyuanchen/cropperjs/blob/main/README.md#options
|
|||
options = Object.assign({ |
|||
aspectRatio: 1, |
|||
viewMode: 1, // restrict the crop box not to exceed the size of the canvas
|
|||
dragMode: 'move', |
|||
autoCropArea: 0.85, |
|||
restore: false, |
|||
guides: false, |
|||
center: false, |
|||
highlight: false, |
|||
cropBoxMovable: true, |
|||
cropBoxResizable: true, |
|||
toggleDragModeOnDblclick: false, |
|||
modal: true, |
|||
}, options); |
|||
this.log.info("createImageCropper", "Creating image cropper", { img }); |
|||
this.cropper = new Cropper(img, options); |
|||
} |
|||
|
|||
async removeImageFile (event) { |
|||
const target = event.target || event.currentTarget; |
|||
const imageType = target.getAttribute('data-image-type'); |
|||
const channelId = dtp.channel ? dtp.channel._id : dtp.channel; |
|||
|
|||
try { |
|||
this.log.info('removeImageFile', 'request to remove image', event); |
|||
|
|||
let imageUrl; |
|||
switch (imageType) { |
|||
case 'channel-thumbnail-file': |
|||
imageUrl = `/channel/${channelId}/thumbnail`; |
|||
break; |
|||
|
|||
case 'profile-picture-file': |
|||
imageUrl = `/user/${this.user._id}/profile-photo`; |
|||
break; |
|||
|
|||
default: |
|||
throw new Error(`Invalid image type: ${imageType}`); |
|||
} |
|||
|
|||
const response = await fetch(imageUrl, { method: 'DELETE' }); |
|||
if (!response.ok) { |
|||
throw new Error('Server error'); |
|||
} |
|||
|
|||
await this.processResponse(response); |
|||
} catch (error) { |
|||
this.log.error('removeImageFile', 'failed to remove image', { error }); |
|||
UIkit.modal.alert(`Failed to remove image: ${error.message}`); |
|||
} |
|||
} |
|||
|
|||
async onWindowResize ( ) { |
|||
if (this.chat.messageList && this.chat.isAtBottom) { |
|||
this.chat.messageList.scrollTo(0, this.chat.messageList.scrollHeight + 50000); |
|||
} |
|||
} |
|||
|
|||
async taskActivityToggle (event) { |
|||
const target = event.currentTarget || event.target; |
|||
event.preventDefault(); |
|||
event.stopPropagation(); |
|||
|
|||
try { |
|||
if (target.checked) { |
|||
await this.startScreenCapture(); |
|||
return this.startTaskSession(); |
|||
} |
|||
await this.stopScreenCapture(); |
|||
this.closeTaskSession(); |
|||
} catch (error) { |
|||
if (target.checked) { |
|||
target.checked = false; |
|||
} |
|||
this.log.error('taskActivityToggle', 'failed to start task work session', { error }); |
|||
UIkit.modal.alert(`Failed to start work session: ${error.message}`); |
|||
} |
|||
} |
|||
|
|||
async startTaskSession ( ) { |
|||
try { |
|||
const url = `/task/${dtp.task._id}/session/start`; |
|||
const response = await fetch(url, { method: 'POST' }); |
|||
await this.checkResponse(response); |
|||
|
|||
const json = await response.json(); |
|||
if (!json.success) { |
|||
throw new Error(json.message); |
|||
} |
|||
|
|||
this.taskSession = json.session; |
|||
this.currentSessionStartTime = new Date(); |
|||
|
|||
this.screenshotInterval = setInterval(this.captureScreenshot.bind(this), TimeTrackerApp.SCREENSHOT_INTERVAL); |
|||
this.sessionDisplayUpdateInterval = setInterval(this.updateSessionDisplay.bind(this), 250); |
|||
this.currentSessionDuration.classList.add('uk-text-success'); |
|||
|
|||
this.log.info('startTaskSession', 'task session started', { session: this.taskSession }); |
|||
} catch (error) { |
|||
this.log.error('startTaskSession', 'failed to start task session', { error }); |
|||
UIkit.modal.alert(`Failed to start task session: ${error.message}`); |
|||
throw new Error('failed to start task session', { cause: error }); |
|||
} |
|||
} |
|||
|
|||
async setTaskSessionStatus (status) { |
|||
try { |
|||
const url = `/task/${dtp.task._id}/session/${this.taskSession._id}/status`; |
|||
const body = JSON.stringify({ status }); |
|||
const response = await fetch(url, { |
|||
method: 'POST', |
|||
headers: { |
|||
'Content-Type': 'application/json', |
|||
'Content-Length': body.length, |
|||
}, |
|||
body, |
|||
}); |
|||
await this.processResponse(response); |
|||
this.taskSession.status = status; |
|||
} catch (error) { |
|||
UIkit.notification({ |
|||
message: `Failed to update task session status: ${error.message}`, |
|||
status: 'danger', |
|||
pos: 'bottom-center', |
|||
timeout: 5000, |
|||
}); |
|||
} |
|||
} |
|||
|
|||
async closeTaskSession ( ) { |
|||
try { |
|||
const url = `/task/${dtp.task._id}/session/${this.taskSession._id}/close`; |
|||
const response = await fetch(url , { method: 'POST' }); |
|||
await this.processResponse(response); |
|||
|
|||
clearInterval(this.sessionDisplayUpdateInterval); |
|||
delete this.sessionDisplayUpdateInterval; |
|||
|
|||
clearInterval(this.screenshotInterval); |
|||
delete this.screenshotInterval; |
|||
|
|||
this.currentSessionDuration.classList.remove('uk-text-success'); |
|||
delete this.currentSessionStartTime; |
|||
} catch (error) { |
|||
this.log.error('closeTaskSession', 'failed to close task session', { |
|||
session: this.taskSession, |
|||
error, |
|||
}); |
|||
UIkit.modal.alert(`Failed to start task session: ${error.message}`); |
|||
throw new Error('failed to close task session', { cause: error }); |
|||
} |
|||
} |
|||
|
|||
async startScreenCapture ( ) { |
|||
this.captureStream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: false }); |
|||
this.capturePreview.srcObject = this.captureStream; |
|||
this.capturePreview.play(); |
|||
} |
|||
|
|||
async stopScreenCapture ( ) { |
|||
if (!this.captureStream) { |
|||
return; |
|||
} |
|||
|
|||
this.capturePreview.pause(); |
|||
this.capturePreview.srcObject = null; |
|||
|
|||
this.captureStream.getTracks().forEach(track => track.stop()); |
|||
delete this.captureStream; |
|||
} |
|||
|
|||
async updateSessionDisplay ( ) { |
|||
if (this.taskSession.status === 'reconnecting') { |
|||
this.currentSessionDuration.textContent = '---'; |
|||
this.currentSessionTimeRemaining.textContent = '---'; |
|||
return; |
|||
} |
|||
|
|||
const NOW = new Date(); |
|||
const duration = dayjs(NOW).diff(this.currentSessionStartTime, 'second'); |
|||
this.currentSessionDuration.textContent = numeral(duration).format('HH:MM:SS'); |
|||
|
|||
const timeRemaining = |
|||
this.taskSession.client.hoursLimit - |
|||
((this.taskSession.client.weeklyTotals.timeWorked + duration) / 3600) |
|||
; |
|||
this.currentSessionTimeRemaining.textContent = numeral(timeRemaining).format('0,0.00'); |
|||
} |
|||
|
|||
async captureScreenshot ( ) { |
|||
if (!this.captureStream || !this.taskSession) { |
|||
return; |
|||
} |
|||
try { |
|||
const tracks = this.captureStream.getVideoTracks(); |
|||
const constraints = tracks[0].getSettings(); |
|||
|
|||
this.log.info('startScreenCapture', 'creating capture canvas', { |
|||
width: constraints.width, |
|||
height: constraints.height, |
|||
}); |
|||
|
|||
const captureCanvas = document.createElement('canvas'); |
|||
captureCanvas.width = constraints.width; |
|||
captureCanvas.height = constraints.height; |
|||
|
|||
const captureContext = captureCanvas.getContext('2d'); |
|||
|
|||
/* |
|||
* Capture the current preview stream frame to the capture canvas |
|||
*/ |
|||
captureContext.drawImage( |
|||
this.capturePreview, |
|||
0, 0, |
|||
captureCanvas.width, |
|||
captureCanvas.height, |
|||
); |
|||
/* |
|||
* Generate a PNG Blob from the capture canvas |
|||
*/ |
|||
captureCanvas.toBlob( |
|||
async (blob) => { |
|||
const formData = new FormData(); |
|||
formData.append('image', blob, 'screenshot.png'); |
|||
|
|||
const url = `/task/${dtp.task._id}/session/${this.taskSession._id}/screenshot`; |
|||
const response = await fetch(url, { |
|||
method: 'POST', |
|||
body: formData, |
|||
}); |
|||
|
|||
await this.processResponse(response); |
|||
this.log.info('captureScreenshot', 'screenshot posted to task session'); |
|||
}, |
|||
'image/png', |
|||
); |
|||
} catch (error) { |
|||
this.log.error('captureScreenshot', 'failed to capture screenshot', { error }); |
|||
} |
|||
} |
|||
} |
@ -1,35 +0,0 @@ |
|||
'use strict'; |
|||
|
|||
/* globals db */ |
|||
|
|||
const clients = db.clients.find(); |
|||
while (clients.hasNext()) { |
|||
let timeWorked = 0, billable = 0; |
|||
|
|||
const client = clients.next(); |
|||
const sessions = db.tasksessions.find({ client: client._id }); |
|||
while (sessions.hasNext()) { |
|||
const session = sessions.next(); |
|||
timeWorked += session.duration; |
|||
billable += session.hourlyRate * (session.duration / 3600); |
|||
} |
|||
|
|||
/* |
|||
* Fix some JavaScript goofiness with numbers (round correctly to 2nd decimal |
|||
* digit). |
|||
*/ |
|||
billable = Math.round((billable + Number.EPSILON) * 100) / 100; |
|||
|
|||
print(`client: ${client._id}:${client.name} time:${timeWorked} bill:${billable}`); |
|||
db.clients.updateOne( |
|||
{ _id: client._id }, |
|||
{ |
|||
$set: { |
|||
weeklyTotals: { |
|||
timeWorked, |
|||
billable, |
|||
}, |
|||
}, |
|||
}, |
|||
); |
|||
} |
@ -1,4 +1,4 @@ |
|||
// dtp-chat.js
|
|||
// dtp-base.js
|
|||
// Copyright (C) 2024 DTP Technologies, LLC
|
|||
// All Rights Reserved
|
|||
|
@ -0,0 +1,119 @@ |
|||
// site-unzalgo.js
|
|||
// Copyright (C) 2024 DTP Technologies, LLC
|
|||
// All Rights Reserved
|
|||
|
|||
'use strict'; |
|||
|
|||
import { percentile } from "stats-lite"; |
|||
|
|||
const categories = /[\p{Mn}\p{Me}]+/u; |
|||
const DEFAULT_DETECTION_THRESHOLD = 0.55; |
|||
const DEFAULT_TARGET_DENSITY = 0; |
|||
const compose = string => string.normalize("NFC"); |
|||
const decompose = string => string.normalize("NFD"); |
|||
const computeZalgoDensity = string => [...string].filter(character => categories.test(character)).length / Math.max(string.length, 1); |
|||
const clamp = x => Math.max(Math.min(x, 1), 0); |
|||
|
|||
export class SiteUnzalgo { |
|||
|
|||
/** |
|||
* Computes a score ∈ [0, 1] for every word in the input string. Each score represents the ratio of combining characters to total characters in a word. |
|||
* @param {string} string |
|||
* The input string for which to compute scores. |
|||
* @return {number[]} |
|||
* An array of scores where each score describes the Zalgo ratio of a word. |
|||
*/ |
|||
static computeScores (string) { |
|||
const wordScores = []; |
|||
/** |
|||
* Trimming here allows us to return early. |
|||
* Without trimming, we risk dividing by `0` later when computing the score. |
|||
*/ |
|||
if (!string.trim().length) { |
|||
wordScores.push(0); |
|||
} |
|||
else { |
|||
for (const word of decompose(string).split(/\s+/)) { |
|||
let banned = 0; |
|||
for (const character of word) { |
|||
if (categories.test(character)) { |
|||
++banned; |
|||
} |
|||
} |
|||
const score = banned / word.length; |
|||
wordScores.push(score); |
|||
} |
|||
} |
|||
return wordScores; |
|||
} |
|||
|
|||
/** |
|||
* Determines if the string consists of Zalgo text. Note that the occurrence of a combining character is not enough to trigger the detection. Instead, it computes a ratio for the input string and checks if it exceeds a given threshold. Thus, internationalized strings aren't automatically classified as Zalgo text. |
|||
* @param {string} string |
|||
* A string for which a Zalgo text check is run. |
|||
* @param {number} detectionThreshold |
|||
* A threshold ∈ [0, 1]. The higher the threshold, the more combining characters are needed for it to be detected as Zalgo text. |
|||
* @return {boolean} |
|||
* Whether the string is a Zalgo text string. |
|||
*/ |
|||
static isZalgo (string, detectionThreshold = DEFAULT_DETECTION_THRESHOLD) { |
|||
const wordScores = SiteUnzalgo.computeScores(string); |
|||
const totalScore = percentile(wordScores, 0.75); |
|||
return totalScore >= clamp(detectionThreshold); |
|||
} |
|||
|
|||
/** |
|||
* Removes all combining characters for every word in a string if the word is classified as Zalgo text. |
|||
* If `targetDensity` is specified, not all the Zalgo characters will be removed. Instead, they will be thinned out uniformly. |
|||
* @param {string} string |
|||
* A string for which combining characters are removed for every word whose Zalgo property is met. |
|||
* @param {object} options |
|||
* Options for cleaning. |
|||
* @param {number} [options.detectionThreshold=DEFAULT_DETECTION_THRESHOLD] |
|||
* A threshold ∈ [0, 1]. The higher the threshold, the more combining characters are needed for it to be detected as Zalgo text. |
|||
* @param {number} [options.targetDensity=DEFAULT_TARGET_DENSITY] |
|||
* A threshold ∈ [0, 1]. The higher the density, the more Zalgo characters will be part of the resulting string. The result is guaranteed to have a Zalgo-character density that is less than or equal to the one provided. |
|||
* @return {string} |
|||
* A cleaned, more readable string. |
|||
*/ |
|||
static clean ( |
|||
string, |
|||
{ |
|||
detectionThreshold = DEFAULT_DETECTION_THRESHOLD, |
|||
targetDensity = DEFAULT_TARGET_DENSITY |
|||
} = { }, |
|||
) { |
|||
let cleaned = ""; |
|||
const effectiveTargetDensity = clamp(targetDensity); |
|||
for (const word of decompose(string).split(/(\s+)/)) { |
|||
if (SiteUnzalgo.isZalgo(word, detectionThreshold)) { |
|||
let cleanedWord = ""; |
|||
const letters = [...word].map(character => ({ |
|||
character, |
|||
isCandidate: categories.test(character) |
|||
})); |
|||
for (let i = 0; i < letters.length; ++i) { |
|||
const { |
|||
character, |
|||
isCandidate |
|||
} = letters[i]; |
|||
if (isCandidate) { |
|||
const admissionProjection = cleanedWord + word.substr(i); |
|||
const omissionProjection = cleanedWord + word.substr(i + 1); |
|||
const admissionDistance = effectiveTargetDensity - computeZalgoDensity(admissionProjection); |
|||
const omissionDistance = effectiveTargetDensity - computeZalgoDensity(omissionProjection); |
|||
if (Math.abs(omissionDistance) <= Math.abs(admissionDistance)) { |
|||
continue; |
|||
} |
|||
} |
|||
cleanedWord += character; |
|||
} |
|||
cleaned += cleanedWord; |
|||
} |
|||
else { |
|||
cleaned += word; |
|||
} |
|||
} |
|||
return compose(cleaned); |
|||
} |
|||
} |
@ -1,14 +1,14 @@ |
|||
{ |
|||
"name": "dtp-time-tracker", |
|||
"name": "dtp-base", |
|||
"type": "module", |
|||
"version": "1.0.21", |
|||
"version": "0.0.1", |
|||
"description": "", |
|||
"main": "dtp-time-tracker.js", |
|||
"main": "dtp-base.js", |
|||
"scripts": { |
|||
"dev": "nodemon dtp-time-tracker.js", |
|||
"dev": "nodemon dtp-base.js", |
|||
"build": "NODE_ENV=production pnpm webpack --config webpack.config.js" |
|||
}, |
|||
"repository": "[email protected]:digital-telepresence/dtp-time-tracker.git", |
|||
"repository": "[email protected]:digital-telepresence/dtp-base.git", |
|||
"author": "Rob Colbert", |
|||
"license": "LicenseRef-LICENSE", |
|||
"private": true, |
|||
@ -38,7 +38,6 @@ |
|||
"ioredis": "^5.3.2", |
|||
"jsdom": "^24.0.0", |
|||
"marked": "^12.0.1", |
|||
"mediasoup": "^3.13.24", |
|||
"mime": "^4.0.1", |
|||
"minio": "^7.1.3", |
|||
"mongoose": "^8.3.1", |
|||
@ -59,11 +58,11 @@ |
|||
"shoetest": "^1.2.2", |
|||
"slug": "^9.0.0", |
|||
"socket.io": "^4.7.5", |
|||
"stats-lite": "^2.2.0", |
|||
"striptags": "^3.2.0", |
|||
"svg-captcha": "^1.4.0", |
|||
"systeminformation": "^5.22.7", |
|||
"uikit": "^3.19.4", |
|||
"unzalgo": "^3.0.0", |
|||
"user-agents": "^1.1.174", |
|||
"uuid": "^9.0.1" |
|||
}, |
|||
@ -85,4 +84,4 @@ |
|||
"workbox-webpack-plugin": "^7.0.0" |
|||
}, |
|||
"packageManager": "[email protected]+sha256.caa915eaae9d9aefccf50ee8aeda25a2f8684d8f9d5c6e367eaf176d97c1f89e" |
|||
} |
|||
} |
@ -80,9 +80,6 @@ dependencies: |
|||
marked: |
|||
specifier: ^12.0.1 |
|||
version: 12.0.1 |
|||
mediasoup: |
|||
specifier: ^3.13.24 |
|||
version: 3.14.1 |
|||
mime: |
|||
specifier: ^4.0.1 |
|||
version: 4.0.1 |
|||
@ -143,6 +140,9 @@ dependencies: |
|||
socket.io: |
|||
specifier: ^4.7.5 |
|||
version: 4.7.5 |
|||
stats-lite: |
|||
specifier: ^2.2.0 |
|||
version: 2.2.0 |
|||
striptags: |
|||
specifier: ^3.2.0 |
|||
version: 3.2.0 |
|||
@ -155,9 +155,6 @@ dependencies: |
|||
uikit: |
|||
specifier: ^3.19.4 |
|||
version: 3.19.4 |
|||
unzalgo: |
|||
specifier: ^3.0.0 |
|||
version: 3.0.0 |
|||
user-agents: |
|||
specifier: ^1.1.174 |
|||
version: 1.1.178 |
|||
@ -262,7 +259,7 @@ packages: |
|||
'@babel/traverse': 7.24.1 |
|||
'@babel/types': 7.24.0 |
|||
convert-source-map: 2.0.0 |
|||
debug: 4.3.4([email protected]) |
|||
debug: 4.3.4 |
|||
gensync: 1.0.0-beta.2 |
|||
json5: 2.2.3 |
|||
semver: 6.3.1 |
|||
@ -343,7 +340,7 @@ packages: |
|||
'@babel/core': 7.24.4 |
|||
'@babel/helper-compilation-targets': 7.23.6 |
|||
'@babel/helper-plugin-utils': 7.24.0 |
|||
debug: 4.3.4([email protected]) |
|||
debug: 4.3.4 |
|||
lodash.debounce: 4.0.8 |
|||
resolve: 1.22.8 |
|||
transitivePeerDependencies: |
|||
@ -1393,7 +1390,7 @@ packages: |
|||
'@babel/helper-split-export-declaration': 7.22.6 |
|||
'@babel/parser': 7.24.4 |
|||
'@babel/types': 7.24.0 |
|||
debug: 4.3.4([email protected]) |
|||
debug: 4.3.4 |
|||
globals: 11.12.0 |
|||
transitivePeerDependencies: |
|||
- supports-color |
|||
@ -1809,7 +1806,7 @@ packages: |
|||
peerDependencies: |
|||
socket.io-adapter: ^2.5.4 |
|||
dependencies: |
|||
debug: 4.3.4([email protected]) |
|||
debug: 4.3.4 |
|||
notepack.io: 3.0.1 |
|||
socket.io-adapter: 2.5.4 |
|||
uid2: 1.0.0 |
|||
@ -1820,7 +1817,7 @@ packages: |
|||
/@socket.io/[email protected]: |
|||
resolution: {integrity: sha512-QQUFPBq6JX7JIuM/X1811ymKlAfwufnQ8w6G2/59Jaqp09hdF1GJ/+e8eo/XdcmT0TqkvcSa2TT98ggTXa5QYw==} |
|||
dependencies: |
|||
debug: 4.3.4([email protected]) |
|||
debug: 4.3.4 |
|||
notepack.io: 3.0.1 |
|||
socket.io-parser: 4.2.4 |
|||
transitivePeerDependencies: |
|||
@ -1844,12 +1841,6 @@ packages: |
|||
dependencies: |
|||
'@types/node': 20.12.7 |
|||
|
|||
/@types/[email protected]: |
|||
resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} |
|||
dependencies: |
|||
'@types/ms': 0.7.34 |
|||
dev: false |
|||
|
|||
/@types/[email protected]: |
|||
resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==} |
|||
dependencies: |
|||
@ -1872,10 +1863,6 @@ packages: |
|||
resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==} |
|||
dev: true |
|||
|
|||
/@types/[email protected]: |
|||
resolution: {integrity: sha512-mTehMtc+xtnWBBvqizcqYCktKDBH2WChvx1GU3Sfe4PysFDXiNe+1YwtpVX1MDtCa4NQrSPw2+3HmvXHY3gt1w==} |
|||
dev: false |
|||
|
|||
/@types/[email protected]: |
|||
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} |
|||
dev: true |
|||
@ -1884,10 +1871,6 @@ packages: |
|||
resolution: {integrity: sha512-TifLZlFudklWlMBfhubvgqTXRzLDI5pCbGa4P8a3wPyUQSW+1xQ5eDsreP9DWHX3tjq1ke96uYG/nwundroWcA==} |
|||
dev: false |
|||
|
|||
/@types/[email protected]: |
|||
resolution: {integrity: sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g==} |
|||
dev: false |
|||
|
|||
/@types/[email protected]: |
|||
resolution: {integrity: sha512-wq0cICSkRLVaf3UGLMGItu/PtdY7oaXaI/RVU+xliKVOtRna3PRY57ZDfztpDL0n11vfymMUnXv8QwYCO7L1wg==} |
|||
dependencies: |
|||
@ -2111,7 +2094,7 @@ packages: |
|||
resolution: {integrity: sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==} |
|||
engines: {node: '>= 14'} |
|||
dependencies: |
|||
debug: 4.3.4([email protected]) |
|||
debug: 4.3.4 |
|||
transitivePeerDependencies: |
|||
- supports-color |
|||
dev: false |
|||
@ -2607,11 +2590,6 @@ packages: |
|||
fsevents: 2.3.3 |
|||
dev: true |
|||
|
|||
/[email protected]: |
|||
resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==} |
|||
engines: {node: '>=10'} |
|||
dev: false |
|||
|
|||
/[email protected]: |
|||
resolution: {integrity: sha512-p3KULyQg4S7NIHixdwbGX+nFHkoBiA4YQmyWtjb8XngSKV124nJmRysgAeujbUVb15vh+RvFUfCPqU7rXk+hZg==} |
|||
engines: {node: '>=6.0'} |
|||
@ -2936,11 +2914,6 @@ packages: |
|||
rrweb-cssom: 0.6.0 |
|||
dev: false |
|||
|
|||
/[email protected]: |
|||
resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} |
|||
engines: {node: '>= 12'} |
|||
dev: false |
|||
|
|||
/[email protected]: |
|||
resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} |
|||
engines: {node: '>=18'} |
|||
@ -3006,7 +2979,7 @@ packages: |
|||
supports-color: 5.5.0 |
|||
dev: true |
|||
|
|||
/[email protected]([email protected]): |
|||
/[email protected]: |
|||
resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==} |
|||
engines: {node: '>=6.0'} |
|||
peerDependencies: |
|||
@ -3016,7 +2989,6 @@ packages: |
|||
optional: true |
|||
dependencies: |
|||
ms: 2.1.2 |
|||
supports-color: 9.4.0 |
|||
|
|||
/[email protected]: |
|||
resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} |
|||
@ -3220,7 +3192,7 @@ packages: |
|||
resolution: {integrity: sha512-9Z0qLB0NIisTRt1DZ/8U2k12RJn8yls/nXMZLn+/N8hANT3TcYjKFKcwbw5zFQiN4NTde3TSY9zb79e1ij6j9Q==} |
|||
dependencies: |
|||
'@socket.io/component-emitter': 3.1.1 |
|||
debug: 4.3.4([email protected]) |
|||
debug: 4.3.4 |
|||
engine.io-parser: 5.2.2 |
|||
ws: 8.11.0 |
|||
xmlhttprequest-ssl: 2.0.0 |
|||
@ -3245,7 +3217,7 @@ packages: |
|||
base64id: 2.0.0 |
|||
cookie: 0.4.2 |
|||
cors: 2.8.5 |
|||
debug: 4.3.4([email protected]) |
|||
debug: 4.3.4 |
|||
engine.io-parser: 5.2.2 |
|||
ws: 8.11.0 |
|||
transitivePeerDependencies: |
|||
@ -3540,14 +3512,6 @@ packages: |
|||
engines: {node: '>= 4.9.1'} |
|||
dev: true |
|||
|
|||
/[email protected]: |
|||
resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} |
|||
engines: {node: ^12.20 || >= 14.13} |
|||
dependencies: |
|||
node-domexception: 1.0.0 |
|||
web-streams-polyfill: 3.3.3 |
|||
dev: false |
|||
|
|||
/[email protected]: |
|||
resolution: {integrity: sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==} |
|||
dependencies: |
|||
@ -3608,10 +3572,6 @@ packages: |
|||
hasBin: true |
|||
dev: true |
|||
|
|||
/[email protected]: |
|||
resolution: {integrity: sha512-3HDgPbgiwWMI9zVB7VYBHaMrbOO7Gm0v+yD2FV/sCKj+9NDeVL7BOBYUuhWAQGKWOzBo8S9WdMvV0eixO233XQ==} |
|||
dev: false |
|||
|
|||
/[email protected]: |
|||
resolution: {integrity: sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==} |
|||
engines: {node: '>=4.0'} |
|||
@ -3636,13 +3596,6 @@ packages: |
|||
mime-types: 2.1.35 |
|||
dev: false |
|||
|
|||
/[email protected]: |
|||
resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} |
|||
engines: {node: '>=12.20.0'} |
|||
dependencies: |
|||
fetch-blob: 3.2.0 |
|||
dev: false |
|||
|
|||
/[email protected]: |
|||
resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} |
|||
engines: {node: '>= 0.6'} |
|||
@ -3670,13 +3623,6 @@ packages: |
|||
universalify: 2.0.1 |
|||
dev: true |
|||
|
|||
/[email protected]: |
|||
resolution: {integrity: sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==} |
|||
engines: {node: '>= 8'} |
|||
dependencies: |
|||
minipass: 3.3.6 |
|||
dev: false |
|||
|
|||
/[email protected]: |
|||
resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} |
|||
dev: true |
|||
@ -3786,16 +3732,6 @@ packages: |
|||
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} |
|||
dev: true |
|||
|
|||
/[email protected]([email protected]): |
|||
resolution: {integrity: sha512-X4CLryVbVA0CtjTExS4G5U1gb2Z4wa32AF8ukVmFuLdw2JRq2aHisor7SY5SYTUUrUSqq0KdPIO18sql6IWIQw==} |
|||
engines: {node: '>=16'} |
|||
dependencies: |
|||
'@types/debug': 4.1.12 |
|||
debug: 4.3.4([email protected]) |
|||
transitivePeerDependencies: |
|||
- supports-color |
|||
dev: false |
|||
|
|||
/[email protected]: |
|||
resolution: {integrity: sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==} |
|||
dev: true |
|||
@ -3882,7 +3818,7 @@ packages: |
|||
engines: {node: '>= 14'} |
|||
dependencies: |
|||
agent-base: 7.1.1 |
|||
debug: 4.3.4([email protected]) |
|||
debug: 4.3.4 |
|||
transitivePeerDependencies: |
|||
- supports-color |
|||
dev: false |
|||
@ -3903,7 +3839,7 @@ packages: |
|||
engines: {node: '>= 14'} |
|||
dependencies: |
|||
agent-base: 7.1.1 |
|||
debug: 4.3.4([email protected]) |
|||
debug: 4.3.4 |
|||
transitivePeerDependencies: |
|||
- supports-color |
|||
dev: false |
|||
@ -3973,11 +3909,6 @@ packages: |
|||
/[email protected]: |
|||
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} |
|||
|
|||
/[email protected]: |
|||
resolution: {integrity: sha512-AMB1mvwR1pyBFY/nSevUX6y8nJWS63/SzUKD3JyQn97s4xgIdgQPT75IRouIiBAN4yLQBUShNYVW0+UG25daCw==} |
|||
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} |
|||
dev: false |
|||
|
|||
/[email protected]: |
|||
resolution: {integrity: sha512-NGnrKwXzSms2qUUih/ILZ5JBqNTSa1+ZmP6flaIp6KmSElgE9qdndzS3cqjrDovwFdmwsGsLdeFgB6suw+1e9g==} |
|||
engines: {node: '>= 0.4'} |
|||
@ -3998,7 +3929,7 @@ packages: |
|||
dependencies: |
|||
'@ioredis/commands': 1.2.0 |
|||
cluster-key-slot: 1.1.2 |
|||
debug: 4.3.4([email protected]) |
|||
debug: 4.3.4 |
|||
denque: 2.1.0 |
|||
lodash.defaults: 4.2.0 |
|||
lodash.isarguments: 3.1.0 |
|||
@ -4542,21 +4473,6 @@ packages: |
|||
engines: {node: '>= 0.6'} |
|||
dev: false |
|||
|
|||
/[email protected]: |
|||
resolution: {integrity: sha512-gzfF4HH/g2+SzDp0wgOcgxoL8d4e4+BN7jvy4ydiG+5p2ZmVTHPz4wrvYXfQNHDRlp3jtkjLoFWTPvFTWvfoTg==} |
|||
engines: {node: '>=18'} |
|||
requiresBuild: true |
|||
dependencies: |
|||
'@types/ini': 4.1.0 |
|||
debug: 4.3.4([email protected]) |
|||
flatbuffers: 24.3.25 |
|||
h264-profile-level-id: 2.0.0([email protected]) |
|||
ini: 4.1.2 |
|||
node-fetch: 3.3.2 |
|||
supports-color: 9.4.0 |
|||
tar: 6.2.1 |
|||
dev: false |
|||
|
|||
/[email protected]: |
|||
resolution: {integrity: sha512-j4WKth315edViMBGkHW6NTF0QBjsTrcRDmYNcGsPq+ozMEyCCCIlX2d2mJ5wuh6iHvJ3FevUrr48v58YRqVdYg==} |
|||
engines: {node: '>= 4.0.0'} |
|||
@ -4677,26 +4593,6 @@ packages: |
|||
xml2js: 0.5.0 |
|||
dev: false |
|||
|
|||
/[email protected]: |
|||
resolution: {integrity: sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==} |
|||
engines: {node: '>=8'} |
|||
dependencies: |
|||
yallist: 4.0.0 |
|||
dev: false |
|||
|
|||
/[email protected]: |
|||
resolution: {integrity: sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==} |
|||
engines: {node: '>=8'} |
|||
dev: false |
|||
|
|||
/[email protected]: |
|||
resolution: {integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==} |
|||
engines: {node: '>= 8'} |
|||
dependencies: |
|||
minipass: 3.3.6 |
|||
yallist: 4.0.0 |
|||
dev: false |
|||
|
|||
/[email protected]: |
|||
resolution: {integrity: sha512-r6lj77KlwqLhIUku9UWYes7KJtsczvolZkzp8hbaDPPaE24OmWl5s539Mytlj22siEQKosZ26qCBgda2PKwoJw==} |
|||
dev: true |
|||
@ -4708,12 +4604,6 @@ packages: |
|||
minimist: 1.2.8 |
|||
dev: false |
|||
|
|||
/[email protected]: |
|||
resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} |
|||
engines: {node: '>=10'} |
|||
hasBin: true |
|||
dev: false |
|||
|
|||
/[email protected]: |
|||
resolution: {integrity: sha512-t1Vf+m1I5hC2M5RJx/7AtxgABy1cZmIPQRMXw+gEIPn/cZNF3Oiy+l0UIypUwVB5trcWHq3crg2g3uAR9aAwsQ==} |
|||
dependencies: |
|||
@ -4797,7 +4687,7 @@ packages: |
|||
resolution: {integrity: sha512-iQMncpmEK8R8ncT8HJGsGc9Dsp8xcgYMVSbs5jgnm1lFHTZqMJTUWTDx1LBO8+mK3tPNZWFLBghQEIOULSTHZg==} |
|||
engines: {node: '>=14.0.0'} |
|||
dependencies: |
|||
debug: 4.3.4([email protected]) |
|||
debug: 4.3.4 |
|||
transitivePeerDependencies: |
|||
- supports-color |
|||
dev: false |
|||
@ -4871,20 +4761,6 @@ packages: |
|||
resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} |
|||
dev: true |
|||
|
|||
/[email protected]: |
|||
resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} |
|||
engines: {node: '>=10.5.0'} |
|||
dev: false |
|||
|
|||
/[email protected]: |
|||
resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} |
|||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} |
|||
dependencies: |
|||
data-uri-to-buffer: 4.0.1 |
|||
fetch-blob: 3.2.0 |
|||
formdata-polyfill: 4.0.10 |
|||
dev: false |
|||
|
|||
/[email protected]: |
|||
resolution: {integrity: sha512-YlCCc6Wffkx0kHkmam79GKvDQ6x+QZkMjFGrIMxgFNILFvGSbCp2fCBC55pGTT9gVaz8Na5CLmxt/urtzRv36w==} |
|||
hasBin: true |
|||
@ -5915,7 +5791,7 @@ packages: |
|||
/[email protected]: |
|||
resolution: {integrity: sha512-wDNHGXGewWAjQPt3pyeYBtpWSq9cLE5UW1ZUPL/2eGK9jtse/FpXib7epSTsz0Q0m+6sg6Y4KtcFTlah1bdOVg==} |
|||
dependencies: |
|||
debug: 4.3.4([email protected]) |
|||
debug: 4.3.4 |
|||
ws: 8.11.0 |
|||
transitivePeerDependencies: |
|||
- bufferutil |
|||
@ -5927,7 +5803,7 @@ packages: |
|||
engines: {node: '>=10.0.0'} |
|||
dependencies: |
|||
'@socket.io/component-emitter': 3.1.1 |
|||
debug: 4.3.4([email protected]) |
|||
debug: 4.3.4 |
|||
engine.io-client: 6.5.3 |
|||
socket.io-parser: 4.2.4 |
|||
transitivePeerDependencies: |
|||
@ -5941,7 +5817,7 @@ packages: |
|||
engines: {node: '>=10.0.0'} |
|||
dependencies: |
|||
'@socket.io/component-emitter': 3.1.1 |
|||
debug: 4.3.4([email protected]) |
|||
debug: 4.3.4 |
|||
transitivePeerDependencies: |
|||
- supports-color |
|||
|
|||
@ -5952,7 +5828,7 @@ packages: |
|||
accepts: 1.3.8 |
|||
base64id: 2.0.0 |
|||
cors: 2.8.5 |
|||
debug: 4.3.4([email protected]) |
|||
debug: 4.3.4 |
|||
engine.io: 6.5.4 |
|||
socket.io-adapter: 2.5.4 |
|||
socket.io-parser: 4.2.4 |
|||
@ -6181,10 +6057,6 @@ packages: |
|||
has-flag: 4.0.0 |
|||
dev: true |
|||
|
|||
/[email protected]: |
|||
resolution: {integrity: sha512-VL+lNrEoIXww1coLPOmiEmK/0sGigko5COxI09KzHc2VJXJsQ37UaQ+8quuxjDeA7+KnLGTWRyOXSLLR2Wb4jw==} |
|||
engines: {node: '>=12'} |
|||
|
|||
/[email protected]: |
|||
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} |
|||
engines: {node: '>= 0.4'} |
|||
@ -6212,18 +6084,6 @@ packages: |
|||
engines: {node: '>=6'} |
|||
dev: true |
|||
|
|||
/[email protected]: |
|||
resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==} |
|||
engines: {node: '>=10'} |
|||
dependencies: |
|||
chownr: 2.0.0 |
|||
fs-minipass: 2.1.0 |
|||
minipass: 5.0.0 |
|||
minizlib: 2.1.2 |
|||
mkdirp: 1.0.4 |
|||
yallist: 4.0.0 |
|||
dev: false |
|||
|
|||
/[email protected]: |
|||
resolution: {integrity: sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg==} |
|||
engines: {node: '>=8'} |
|||
@ -6503,13 +6363,6 @@ packages: |
|||
resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} |
|||
engines: {node: '>= 0.8'} |
|||
|
|||
/[email protected]: |
|||
resolution: {integrity: sha512-yRSDlFaYpFJK2VO0iI4I2E3l1CF8puFNL00nh7beZ/q4XSxd9XPNIlsTvfOz/fF2P6tMBLWNVLWpLBvJ9/11ZQ==} |
|||
deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. |
|||
dependencies: |
|||
stats-lite: 2.2.0 |
|||
dev: false |
|||
|
|||
/[email protected]: |
|||
resolution: {integrity: sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg==} |
|||
engines: {node: '>=4'} |
|||
@ -6616,11 +6469,6 @@ packages: |
|||
'@zxing/text-encoding': 0.9.0 |
|||
dev: false |
|||
|
|||
/[email protected]: |
|||
resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} |
|||
engines: {node: '>= 8'} |
|||
dev: false |
|||
|
|||
/[email protected]: |
|||
resolution: {integrity: sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==} |
|||
dev: true |
|||
|