Merge remote-tracking branch 'upstream/v3.0.x-release' into cleanup-base-component

This commit is contained in:
Tainan Felipe 2024-04-03 08:58:03 -03:00
commit c1955f17cb
120 changed files with 4503 additions and 1985 deletions

View File

@ -5,7 +5,7 @@ import org.slf4j.LoggerFactory
import java.io.{ ByteArrayInputStream, File }
import scala.io.BufferedSource
import scala.util.{ Failure, Success, Try }
import scala.util.{ Failure, Success }
object ClientSettings extends SystemConfiguration {
var clientSettingsFromFile: Map[String, Object] = Map("" -> "")
@ -82,6 +82,24 @@ object ClientSettings extends SystemConfiguration {
}
}
def getConfigPropertyValueByPathAsListOfStringOrElse(map: Map[String, Any], path: String, alternativeValue: List[String]): List[String] = {
getConfigPropertyValueByPath(map, path) match {
case Some(configValue: List[String]) => configValue
case _ =>
logger.debug(s"Config `$path` with type List[String] not found in clientSettings.")
alternativeValue
}
}
def getConfigPropertyValueByPathAsListOfIntOrElse(map: Map[String, Any], path: String, alternativeValue: List[Int]): List[Int] = {
getConfigPropertyValueByPath(map, path) match {
case Some(configValue: List[Int]) => configValue
case _ =>
logger.debug(s"Config `$path` with type List[Int] not found in clientSettings.")
alternativeValue
}
}
def getConfigPropertyValueByPath(map: Map[String, Any], path: String): Option[Any] = {
val keys = path.split("\\.")

View File

@ -4,6 +4,7 @@ import org.apache.pekko.actor.ActorContext
class AudioCaptionsApp2x(implicit val context: ActorContext)
extends UpdateTranscriptPubMsgHdlr
with TranscriptionProviderErrorMsgHdlr
with AudioFloorChangedVoiceConfEvtMsgHdlr {
}

View File

@ -0,0 +1,31 @@
package org.bigbluebutton.core.apps.audiocaptions
import org.bigbluebutton.common2.msgs._
import org.bigbluebutton.core.bus.MessageBus
import org.bigbluebutton.core.db.UserTranscriptionErrorDAO
import org.bigbluebutton.core.models.AudioCaptions
import org.bigbluebutton.core.running.LiveMeeting
trait TranscriptionProviderErrorMsgHdlr {
this: AudioCaptionsApp2x =>
def handleTranscriptionProviderErrorMsg(msg: TranscriptionProviderErrorMsg, liveMeeting: LiveMeeting, bus: MessageBus): Unit = {
val meetingId = liveMeeting.props.meetingProp.intId
def broadcastEvent(userId: String, errorCode: String, errorMessage: String): Unit = {
val routing = Routing.addMsgToClientRouting(MessageTypes.DIRECT, meetingId, "nodeJSapp")
val envelope = BbbCoreEnvelope(TranscriptionProviderErrorEvtMsg.NAME, routing)
val header = BbbClientMsgHeader(TranscriptionProviderErrorEvtMsg.NAME, meetingId, userId)
val body = TranscriptionProviderErrorEvtMsgBody(errorCode, errorMessage)
val event = TranscriptionProviderErrorEvtMsg(header, body)
val msgEvent = BbbCommonEnvCoreMsg(envelope, event)
bus.outGW.send(msgEvent)
}
broadcastEvent(msg.header.userId, msg.body.errorCode, msg.body.errorMessage)
UserTranscriptionErrorDAO.insert(msg.header.userId, msg.header.meetingId, msg.body.errorCode, msg.body.errorMessage)
}
}

View File

@ -1,12 +1,13 @@
package org.bigbluebutton.core.apps.audiocaptions
import org.bigbluebutton.ClientSettings.getConfigPropertyValueByPathAsStringOrElse
import org.bigbluebutton.common2.msgs._
import org.bigbluebutton.core.bus.MessageBus
import org.bigbluebutton.core.db.CaptionDAO
import org.bigbluebutton.core.models.{AudioCaptions, Users2x}
import org.bigbluebutton.core.models.{AudioCaptions, UserState, Users2x}
import org.bigbluebutton.core.running.LiveMeeting
import java.sql.Timestamp
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
trait UpdateTranscriptPubMsgHdlr {
this: AudioCaptionsApp2x =>
@ -25,6 +26,17 @@ trait UpdateTranscriptPubMsgHdlr {
bus.outGW.send(msgEvent)
}
def sendPadUpdatePubMsg(userId: String, defaultPad: String, text: String, transcript: Boolean): Unit = {
val routing = Routing.addMsgToClientRouting(MessageTypes.DIRECT, meetingId, "nodeJSapp")
val envelope = BbbCoreEnvelope(PadUpdatePubMsg.NAME, routing)
val header = BbbClientMsgHeader(PadUpdatePubMsg.NAME, meetingId, userId)
val body = PadUpdatePubMsgBody(defaultPad, text, transcript)
val event = PadUpdatePubMsg(header, body)
val msgEvent = BbbCommonEnvCoreMsg(envelope, event)
bus.outGW.send(msgEvent)
}
// Adapt to the current captions' recording process
def editTranscript(
userId: String,
@ -80,6 +92,28 @@ trait UpdateTranscriptPubMsgHdlr {
msg.body.locale,
msg.body.result,
)
if(msg.body.result) {
val userName = Users2x.findWithIntId(liveMeeting.users2x, msg.header.userId).get match {
case u: UserState => u.name
case _ => "???"
}
val now = LocalDateTime.now()
val formatter = DateTimeFormatter.ofPattern("HH:mm:ss")
val formattedTime = now.format(formatter)
val userSpoke = s"\n $userName ($formattedTime): $transcript"
val defaultPad = getConfigPropertyValueByPathAsStringOrElse(
liveMeeting.clientSettings,
"public.captions.defaultPad",
alternativeValue = ""
)
sendPadUpdatePubMsg(msg.header.userId, defaultPad, userSpoke, transcript = true)
}
}
}
}

View File

@ -21,7 +21,7 @@ trait PadUpdatePubMsgHdlr {
bus.outGW.send(msgEvent)
}
if (Pads.hasAccess(liveMeeting, msg.body.externalId, msg.header.userId)) {
if (Pads.hasAccess(liveMeeting, msg.body.externalId, msg.header.userId) || msg.body.transcript == true) {
Pads.getGroup(liveMeeting.pads, msg.body.externalId) match {
case Some(group) => broadcastEvent(group.groupId, msg.body.externalId, msg.body.text)
case _ =>

View File

@ -0,0 +1,41 @@
package org.bigbluebutton.core.apps.users
import org.bigbluebutton.common2.msgs._
import org.bigbluebutton.core.models.{ UserState, Users2x }
import org.bigbluebutton.core.running.{ LiveMeeting, OutMsgRouter }
import org.bigbluebutton.core.apps.{ PermissionCheck, RightsManagementTrait }
import org.bigbluebutton.core.domain.MeetingState2x
trait SetUserSpeechOptionsMsgHdlr extends RightsManagementTrait {
this: UsersApp =>
val liveMeeting: LiveMeeting
val outGW: OutMsgRouter
def handleSetUserSpeechOptionsReqMsg(msg: SetUserSpeechOptionsReqMsg): Unit = {
log.info("handleSetUserSpeechOptionsReqMsg: partialUtterances={} minUtteranceLength={} userId={}", msg.body.partialUtterances, msg.body.minUtteranceLength, msg.header.userId)
def broadcastUserSpeechOptionsChanged(user: UserState, partialUtterances: Boolean, minUtteranceLength: Int): Unit = {
val routingChange = Routing.addMsgToClientRouting(
MessageTypes.BROADCAST_TO_MEETING,
liveMeeting.props.meetingProp.intId, user.intId
)
val envelopeChange = BbbCoreEnvelope(UserSpeechOptionsChangedEvtMsg.NAME, routingChange)
val headerChange = BbbClientMsgHeader(UserSpeechOptionsChangedEvtMsg.NAME, liveMeeting.props.meetingProp.intId, user.intId)
val bodyChange = UserSpeechOptionsChangedEvtMsgBody(partialUtterances, minUtteranceLength)
val eventChange = UserSpeechOptionsChangedEvtMsg(headerChange, bodyChange)
val msgEventChange = BbbCommonEnvCoreMsg(envelopeChange, eventChange)
outGW.send(msgEventChange)
}
for {
user <- Users2x.findWithIntId(liveMeeting.users2x, msg.header.userId)
} yield {
var changeLocale: Option[UserState] = None;
//changeLocale = Users2x.setUserSpeechLocale(liveMeeting.users2x, msg.header.userId, msg.body.locale)
broadcastUserSpeechOptionsChanged(user, msg.body.partialUtterances, msg.body.minUtteranceLength)
}
}
}

View File

@ -1,9 +1,10 @@
package org.bigbluebutton.core.apps.users
import org.bigbluebutton.ClientSettings.{ getConfigPropertyValueByPathAsListOfIntOrElse, getConfigPropertyValueByPathAsListOfStringOrElse }
import org.bigbluebutton.common2.msgs._
import org.bigbluebutton.core.apps.RightsManagementTrait
import org.bigbluebutton.core.db.UserConnectionStatusDAO
import org.bigbluebutton.core.models.{ UserState, Users2x }
import org.bigbluebutton.core.models.Users2x
import org.bigbluebutton.core.running.{ LiveMeeting, OutMsgRouter }
trait UserConnectionAliveReqMsgHdlr extends RightsManagementTrait {
@ -13,13 +14,42 @@ trait UserConnectionAliveReqMsgHdlr extends RightsManagementTrait {
val outGW: OutMsgRouter
def handleUserConnectionAliveReqMsg(msg: UserConnectionAliveReqMsg): Unit = {
log.info("handleUserConnectionAliveReqMsg: userId={}", msg.body.userId)
log.info("handleUserConnectionAliveReqMsg: networkRttInMs={} userId={}", msg.body.networkRttInMs, msg.body.userId)
for {
user <- Users2x.findWithIntId(liveMeeting.users2x, msg.body.userId)
} yield {
UserConnectionStatusDAO.updateUserAlive(user.intId)
val rtt: Option[Double] = msg.body.networkRttInMs match {
case 0 => None
case rtt: Double => Some(rtt)
}
val status = getLevelFromRtt(msg.body.networkRttInMs)
UserConnectionStatusDAO.updateUserAlive(user.intId, rtt, status)
}
}
def getLevelFromRtt(networkRttInMs: Double): String = {
val levelOptions = getConfigPropertyValueByPathAsListOfStringOrElse(
liveMeeting.clientSettings,
"public.stats.level",
List("warning", "danger", "critical")
)
val rttOptions = getConfigPropertyValueByPathAsListOfIntOrElse(
liveMeeting.clientSettings,
"public.stats.rtt",
List(500, 1000, 2000)
)
val statusRttXLevel = levelOptions.zip(rttOptions).reverse
val statusFound = statusRttXLevel.collectFirst {
case (level, rtt) if networkRttInMs > rtt => level
}
statusFound.getOrElse("normal")
}
}

View File

@ -1,25 +0,0 @@
package org.bigbluebutton.core.apps.users
import org.bigbluebutton.common2.msgs._
import org.bigbluebutton.core.apps.RightsManagementTrait
import org.bigbluebutton.core.db.UserConnectionStatusDAO
import org.bigbluebutton.core.models.{ UserState, Users2x }
import org.bigbluebutton.core.running.{ LiveMeeting, OutMsgRouter }
trait UserConnectionUpdateRttReqMsgHdlr extends RightsManagementTrait {
this: UsersApp =>
val liveMeeting: LiveMeeting
val outGW: OutMsgRouter
def handleUserConnectionUpdateRttReqMsg(msg: UserConnectionUpdateRttReqMsg): Unit = {
log.info("handleUserConnectionUpdateRttReqMsg: networkRttInMs={} userId={}", msg.body.networkRttInMs, msg.body.userId)
for {
user <- Users2x.findWithIntId(liveMeeting.users2x, msg.body.userId)
} yield {
UserConnectionStatusDAO.updateUserRtt(user.intId, msg.body.networkRttInMs)
}
}
}

View File

@ -158,6 +158,7 @@ class UsersApp(
with RegisterUserReqMsgHdlr
with ChangeUserRoleCmdMsgHdlr
with SetUserSpeechLocaleMsgHdlr
with SetUserSpeechOptionsMsgHdlr
with SyncGetUsersMeetingRespMsgHdlr
with LogoutAndEndMeetingCmdMsgHdlr
with SetRecordingStatusCmdMsgHdlr
@ -168,7 +169,6 @@ class UsersApp(
with ChangeUserPinStateReqMsgHdlr
with ChangeUserMobileFlagReqMsgHdlr
with UserConnectionAliveReqMsgHdlr
with UserConnectionUpdateRttReqMsgHdlr
with ChangeUserReactionEmojiReqMsgHdlr
with ChangeUserRaiseHandReqMsgHdlr
with ChangeUserAwayReqMsgHdlr

View File

@ -101,7 +101,8 @@ object PluginDataChannelMessageDAO {
DatabaseConnection.db.run(
sqlu"""UPDATE "pluginDataChannelMessage" SET
"deletedAt" = current_timestamp
WHERE "meetingId" = ${meetingId}
WHERE "deletedAt" is null
AND "meetingId" = ${meetingId}
AND "pluginName" = ${pluginName}
AND "dataChannel" = ${dataChannel}
AND "messageId" = ${messageId}"""

View File

@ -5,22 +5,24 @@ import scala.concurrent.ExecutionContext.Implicits.global
import scala.util.{ Failure, Success }
case class UserConnectionStatusDbModel(
userId: String,
meetingId: String,
connectionAliveAt: Option[java.sql.Timestamp],
userClientResponseAt: Option[java.sql.Timestamp],
networkRttInMs: Option[Double]
userId: String,
meetingId: String,
connectionAliveAt: Option[java.sql.Timestamp],
networkRttInMs: Option[Double],
status: String,
statusUpdatedAt: Option[java.sql.Timestamp]
)
class UserConnectionStatusDbTableDef(tag: Tag) extends Table[UserConnectionStatusDbModel](tag, None, "user_connectionStatus") {
override def * = (
userId, meetingId, connectionAliveAt, userClientResponseAt, networkRttInMs
userId, meetingId, connectionAliveAt, networkRttInMs, status, statusUpdatedAt
) <> (UserConnectionStatusDbModel.tupled, UserConnectionStatusDbModel.unapply)
val userId = column[String]("userId", O.PrimaryKey)
val meetingId = column[String]("meetingId")
val connectionAliveAt = column[Option[java.sql.Timestamp]]("connectionAliveAt")
val userClientResponseAt = column[Option[java.sql.Timestamp]]("userClientResponseAt")
val networkRttInMs = column[Option[Double]]("networkRttInMs")
val status = column[String]("status")
val statusUpdatedAt = column[Option[java.sql.Timestamp]]("statusUpdatedAt")
}
object UserConnectionStatusDAO {
@ -32,8 +34,9 @@ object UserConnectionStatusDAO {
userId = userId,
meetingId = meetingId,
connectionAliveAt = None,
userClientResponseAt = None,
networkRttInMs = None
networkRttInMs = None,
status = "normal",
statusUpdatedAt = None
)
)
).onComplete {
@ -42,28 +45,23 @@ object UserConnectionStatusDAO {
}
}
def updateUserAlive(userId: String) = {
def updateUserAlive(userId: String, rtt: Option[Double], status: String) = {
DatabaseConnection.db.run(
TableQuery[UserConnectionStatusDbTableDef]
.filter(_.userId === userId)
.map(t => (t.connectionAliveAt))
.update(Some(new java.sql.Timestamp(System.currentTimeMillis())))
.map(t => (t.connectionAliveAt, t.networkRttInMs, t.status, t.statusUpdatedAt))
.update(
(
Some(new java.sql.Timestamp(System.currentTimeMillis())),
rtt,
status,
Some(new java.sql.Timestamp(System.currentTimeMillis())),
)
)
).onComplete {
case Success(rowsAffected) => DatabaseConnection.logger.debug(s"$rowsAffected row(s) updated connectionAliveAt on UserConnectionStatus table!")
case Failure(e) => DatabaseConnection.logger.debug(s"Error updating connectionAliveAt on UserConnectionStatus: $e")
}
}
def updateUserRtt(userId: String, networkRttInMs: Double) = {
DatabaseConnection.db.run(
TableQuery[UserConnectionStatusDbTableDef]
.filter(_.userId === userId)
.map(t => (t.networkRttInMs, t.userClientResponseAt))
.update((Some(networkRttInMs), Some(new java.sql.Timestamp(System.currentTimeMillis()))))
).onComplete {
case Success(rowsAffected) => DatabaseConnection.logger.debug(s"$rowsAffected row(s) updated networkRttInMs on UserConnectionStatus table!")
case Failure(e) => DatabaseConnection.logger.debug(s"Error updating networkRttInMs on UserConnectionStatus: $e")
}
}
}

View File

@ -0,0 +1,48 @@
package org.bigbluebutton.core.db
import org.bigbluebutton.core.models.{ VoiceUserState }
import slick.jdbc.PostgresProfile.api._
import scala.concurrent.ExecutionContext.Implicits.global
import scala.util.{ Failure, Success }
case class UserTranscriptionErrorDbModel(
userId: String,
meetingId: String,
errorCode: String,
errorMessage: String,
lastUpdatedAt: java.sql.Timestamp = new java.sql.Timestamp(System.currentTimeMillis())
)
class UserTranscriptionErrorDbTableDef(tag: Tag) extends Table[UserTranscriptionErrorDbModel](tag, None, "user_transcriptionError") {
override def * = (
userId, meetingId, errorCode, errorMessage, lastUpdatedAt
) <> (UserTranscriptionErrorDbModel.tupled, UserTranscriptionErrorDbModel.unapply)
val userId = column[String]("userId", O.PrimaryKey)
val meetingId = column[String]("meetingId")
val errorCode = column[String]("errorCode")
val errorMessage = column[String]("errorMessage")
val lastUpdatedAt = column[java.sql.Timestamp]("lastUpdatedAt")
}
object UserTranscriptionErrorDAO {
def insert(userId: String, meetingId: String, errorCode: String, errorMessage: String) = {
DatabaseConnection.db.run(
TableQuery[UserTranscriptionErrorDbTableDef].insertOrUpdate(
UserTranscriptionErrorDbModel(
userId = userId,
meetingId = meetingId,
errorCode = errorCode,
errorMessage = errorMessage,
lastUpdatedAt = new java.sql.Timestamp(System.currentTimeMillis()),
)
)
).onComplete {
case Success(rowsAffected) => {
DatabaseConnection.logger.debug(s"$rowsAffected row(s) inserted on user_transcriptionError table!")
}
case Failure(e) => DatabaseConnection.logger.debug(s"Error inserting user_transcriptionError: $e")
}
}
}

View File

@ -7,12 +7,10 @@ import org.bigbluebutton.SystemConfiguration
object AudioCaptions extends SystemConfiguration {
def setFloor(audioCaptions: AudioCaptions, userId: String) = audioCaptions.floor = userId
def isFloor(audioCaptions: AudioCaptions, userId: String) = audioCaptions.floor == userId
def isFloor(audioCaptions: AudioCaptions, userId: String) = true
def parseTranscript(transcript: String): String = {
val words = transcript.split("\\s+") // Split on whitespaces
val lines = words.grouped(transcriptWords).toArray // Group each X words into lines
lines.takeRight(transcriptLines).map(l => l.mkString(" ")).mkString("\n") // Join the last X lines
transcript
}
/*

View File

@ -113,10 +113,10 @@ class ReceivedJsonMsgHandlerActor(
routeGenericMsg[ChangeUserMobileFlagReqMsg](envelope, jsonNode)
case UserConnectionAliveReqMsg.NAME =>
routeGenericMsg[UserConnectionAliveReqMsg](envelope, jsonNode)
case UserConnectionUpdateRttReqMsg.NAME =>
routeGenericMsg[UserConnectionUpdateRttReqMsg](envelope, jsonNode)
case SetUserSpeechLocaleReqMsg.NAME =>
routeGenericMsg[SetUserSpeechLocaleReqMsg](envelope, jsonNode)
case SetUserSpeechOptionsReqMsg.NAME =>
routeGenericMsg[SetUserSpeechOptionsReqMsg](envelope, jsonNode)
// Poll
case StartCustomPollReqMsg.NAME =>
@ -406,6 +406,8 @@ class ReceivedJsonMsgHandlerActor(
// AudioCaptions
case UpdateTranscriptPubMsg.NAME =>
routeGenericMsg[UpdateTranscriptPubMsg](envelope, jsonNode)
case TranscriptionProviderErrorMsg.NAME =>
routeGenericMsg[TranscriptionProviderErrorMsg](envelope, jsonNode)
// GroupChats
case GetGroupChatsReqMsg.NAME =>

View File

@ -405,8 +405,8 @@ class MeetingActor(
case m: ChangeUserPinStateReqMsg => usersApp.handleChangeUserPinStateReqMsg(m)
case m: ChangeUserMobileFlagReqMsg => usersApp.handleChangeUserMobileFlagReqMsg(m)
case m: UserConnectionAliveReqMsg => usersApp.handleUserConnectionAliveReqMsg(m)
case m: UserConnectionUpdateRttReqMsg => usersApp.handleUserConnectionUpdateRttReqMsg(m)
case m: SetUserSpeechLocaleReqMsg => usersApp.handleSetUserSpeechLocaleReqMsg(m)
case m: SetUserSpeechOptionsReqMsg => usersApp.handleSetUserSpeechOptionsReqMsg(m)
// Client requested to eject user
case m: EjectUserFromMeetingCmdMsg =>
@ -590,6 +590,7 @@ class MeetingActor(
// AudioCaptions
case m: UpdateTranscriptPubMsg => audioCaptionsApp2x.handle(m, liveMeeting, msgBus)
case m: TranscriptionProviderErrorMsg => audioCaptionsApp2x.handleTranscriptionProviderErrorMsg(m, liveMeeting, msgBus)
// GroupChat
case m: CreateGroupChatReqMsg =>

View File

@ -79,7 +79,6 @@ class AnalyticsActor(val includeChat: Boolean) extends Actor with ActorLogging {
case m: ChangeUserPinStateReqMsg => logMessage(msg)
case m: ChangeUserMobileFlagReqMsg => logMessage(msg)
case m: UserConnectionAliveReqMsg => logMessage(msg)
case m: UserConnectionUpdateRttReqMsg => logMessage(msg)
case m: ScreenshareRtmpBroadcastStartedVoiceConfEvtMsg => logMessage(msg)
case m: ScreenshareRtmpBroadcastStoppedVoiceConfEvtMsg => logMessage(msg)
case m: ScreenshareRtmpBroadcastStartedEvtMsg => logMessage(msg)

View File

@ -1,5 +1,12 @@
package org.bigbluebutton.common2.msgs
object TranscriptionProviderErrorMsg { val NAME = "TranscriptionProviderErrorMsg" }
case class TranscriptionProviderErrorMsg(header: BbbClientMsgHeader, body: TranscriptionProviderErrorMsgBody) extends StandardMsg
case class TranscriptionProviderErrorMsgBody(
errorCode: String,
errorMessage: String,
)
// In messages
object UpdateTranscriptPubMsg { val NAME = "UpdateTranscriptPubMsg" }
case class UpdateTranscriptPubMsg(header: BbbClientMsgHeader, body: UpdateTranscriptPubMsgBody) extends StandardMsg
@ -14,6 +21,10 @@ case class UpdateTranscriptPubMsgBody(
)
// Out messages
object TranscriptionProviderErrorEvtMsg { val NAME = "TranscriptionProviderErrorEvtMsg" }
case class TranscriptionProviderErrorEvtMsg(header: BbbClientMsgHeader, body: TranscriptionProviderErrorEvtMsgBody) extends BbbCoreMsg
case class TranscriptionProviderErrorEvtMsgBody(errorCode: String, errorMessage: String)
object TranscriptUpdatedEvtMsg { val NAME = "TranscriptUpdatedEvtMsg" }
case class TranscriptUpdatedEvtMsg(header: BbbClientMsgHeader, body: TranscriptUpdatedEvtMsgBody) extends BbbCoreMsg
case class TranscriptUpdatedEvtMsgBody(transcriptId: String, transcript: String, locale: String, result: Boolean)

View File

@ -107,7 +107,7 @@ case class PadTailEvtMsgBody(externalId: String, tail: String)
// client -> apps
object PadUpdatePubMsg { val NAME = "PadUpdatePubMsg" }
case class PadUpdatePubMsg(header: BbbClientMsgHeader, body: PadUpdatePubMsgBody) extends StandardMsg
case class PadUpdatePubMsgBody(externalId: String, text: String)
case class PadUpdatePubMsgBody(externalId: String, text: String, transcript: Boolean)
// apps -> pads
object PadUpdateCmdMsg { val NAME = "PadUpdateCmdMsg" }

View File

@ -298,14 +298,7 @@ case class ChangeUserMobileFlagReqMsgBody(userId: String, mobile: Boolean)
*/
object UserConnectionAliveReqMsg { val NAME = "UserConnectionAliveReqMsg" }
case class UserConnectionAliveReqMsg(header: BbbClientMsgHeader, body: UserConnectionAliveReqMsgBody) extends StandardMsg
case class UserConnectionAliveReqMsgBody(userId: String)
/**
* Sent from client to inform the RTT (time it took to send the Alive and receive confirmation).
*/
object UserConnectionUpdateRttReqMsg { val NAME = "UserConnectionUpdateRttReqMsg" }
case class UserConnectionUpdateRttReqMsg(header: BbbClientMsgHeader, body: UserConnectionUpdateRttReqMsgBody) extends StandardMsg
case class UserConnectionUpdateRttReqMsgBody(userId: String, networkRttInMs: Double)
case class UserConnectionAliveReqMsgBody(userId: String, networkRttInMs: Double)
/**
* Sent to all clients about a user mobile flag.
@ -532,3 +525,11 @@ case class SetUserSpeechLocaleReqMsgBody(locale: String, provider: String)
object UserSpeechLocaleChangedEvtMsg { val NAME = "UserSpeechLocaleChangedEvtMsg" }
case class UserSpeechLocaleChangedEvtMsg(header: BbbClientMsgHeader, body: UserSpeechLocaleChangedEvtMsgBody) extends BbbCoreMsg
case class UserSpeechLocaleChangedEvtMsgBody(locale: String, provider: String)
object SetUserSpeechOptionsReqMsg { val NAME = "SetUserSpeechOptionsReqMsg" }
case class SetUserSpeechOptionsReqMsg(header: BbbClientMsgHeader, body: SetUserSpeechOptionsReqMsgBody) extends StandardMsg
case class SetUserSpeechOptionsReqMsgBody(partialUtterances: Boolean, minUtteranceLength: Int)
object UserSpeechOptionsChangedEvtMsg { val NAME = "UserSpeechOptionsChangedEvtMsg" }
case class UserSpeechOptionsChangedEvtMsg(header: BbbClientMsgHeader, body: UserSpeechOptionsChangedEvtMsgBody) extends BbbCoreMsg
case class UserSpeechOptionsChangedEvtMsgBody(partialUtterances: Boolean, minUtteranceLength: Int)

View File

@ -9,10 +9,8 @@ module.exports = {
],
'parserOptions': {
'ecmaVersion': 'latest',
'sourceType': 'module',
},
'rules': {
'require-jsdoc': 0,
'camelcase': 0,
'max-len': 0,
},
};

View File

@ -1,4 +0,0 @@
const settings = require('./settings');
const config = settings;
module.exports = config;

View File

@ -1,41 +1,40 @@
{
"log": {
"level": "info",
"msgName": "PresAnnStatusMsg"
},
"shared": {
"presDir": "/var/bigbluebutton",
"presAnnDropboxDir": "/tmp/pres-ann-dropbox",
"cairosvg": "/usr/bin/cairosvg",
"ghostscript": "/usr/bin/gs",
"imagemagick": "/usr/bin/convert",
"pdftocairo": "/usr/bin/pdftocairo"
},
"collector": {
"pngWidthRasterizedSlides": 2560
},
"process": {
"whiteboardTextEncoding": "utf-8",
"maxImageWidth": 1440,
"maxImageHeight": 1080,
"textScaleFactor": 2,
"pointsPerInch": 72,
"pixelsPerInch": 96
},
"notifier": {
"pod_id": "DEFAULT_PRESENTATION_POD",
"is_downloadable": "false",
"msgName": "NewPresFileAvailableMsg"
},
"bbbWebAPI": "http://127.0.0.1:8090",
"bbbPadsAPI": "http://127.0.0.1:9002",
"redis": {
"host": "127.0.0.1",
"port": 6379,
"password": null,
"channels": {
"queue": "exportJobs",
"publish": "to-akka-apps-redis-channel"
}
"log": {
"level": "info",
"msgName": "PresAnnStatusMsg"
},
"shared": {
"presAnnDropboxDir": "/tmp/pres-ann-dropbox",
"cairosvg": "/usr/bin/cairosvg",
"ghostscript": "/usr/bin/gs"
},
"process": {
"maxImageWidth": 1440,
"maxImageHeight": 1080,
"pointsPerInch": 72,
"pixelsPerInch": 96,
"cairoSVGUnsafeFlag": false
},
"notifier": {
"pod_id": "DEFAULT_PRESENTATION_POD",
"is_downloadable": "false",
"msgName": "NewPresFileAvailableMsg"
},
"bbbWebAPI": "http://127.0.0.1:8090",
"bbbPadsAPI": "http://127.0.0.1:9002",
"redis": {
"host": "127.0.0.1",
"port": 6379,
"password": null,
"channels": {
"queue": "exportJobs",
"publish": "to-akka-apps-redis-channel"
}
},
"fonts": {
"draw": "/usr/local/share/fonts/CaveatBrush-Regular-2015-09-23.ttf",
"sans": "/usr/local/share/fonts/CrimsonPro[wght]-1.003.ttf",
"serif": "/usr/local/share/fonts/SourceSansPro-Regular-2.045.ttf",
"mono": "/usr/local/share/fonts/SourceCodePro-Regular-2.038.ttf"
}
}

View File

@ -1,4 +1,5 @@
const config = require('../../config');
import fs from 'fs';
const config = JSON.parse(fs.readFileSync('./config/settings.json', 'utf8'));
const {level} = config.log;
const trace = level.toLowerCase() === 'trace';
@ -14,7 +15,7 @@ const parse = (messages) => {
});
};
module.exports = class Logger {
export default class Logger {
constructor(context) {
this.context = context;
}

View File

@ -1,4 +1,5 @@
const config = require('../../config');
import fs from 'fs';
const config = JSON.parse(fs.readFileSync('./config/settings.json', 'utf8'));
const EXPORT_STATUSES = Object.freeze({
COLLECTING: 'COLLECTING',
@ -86,7 +87,4 @@ class NewPresFileAvailableMsg {
};
};
module.exports = {
PresAnnStatusMsg,
NewPresFileAvailableMsg,
};
export {PresAnnStatusMsg, NewPresFileAvailableMsg};

View File

@ -1,5 +1,6 @@
const {Worker} = require('worker_threads');
const path = require('path');
import {Worker} from 'worker_threads';
import {fileURLToPath} from 'url';
import path from 'path';
const WorkerTypes = Object.freeze({
Collector: 'collector',
@ -9,19 +10,27 @@ const WorkerTypes = Object.freeze({
const kickOffWorker = (workerType, workerData) => {
return new Promise((resolve, reject) => {
const workerPath = path.join(__dirname, '..', '..', 'workers', `${workerType}.js`);
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const workerPath = path.join(
__dirname,
'..',
'..',
'workers',
`${workerType}.js`);
const worker = new Worker(workerPath, {workerData});
worker.on('message', resolve);
worker.on('error', reject);
worker.on('exit', (code) => {
if (code !== 0) {
reject(new Error(`Worker '${workerType}' stopped with exit code ${code}`));
reject(
new Error(`Worker '${workerType}' stopped with exit code ${code}`));
}
});
});
};
module.exports = class WorkerStarter {
export default class WorkerStarter {
constructor(workerData) {
this.workerData = workerData;
}

View File

@ -1,19 +1,21 @@
const Logger = require('./lib/utils/logger');
const WorkerStarter = require('./lib/utils/worker-starter');
const config = require('./config');
const fs = require('fs');
const redis = require('redis');
const {commandOptions} = require('redis');
const path = require('path');
import Logger from './lib/utils/logger.js';
import WorkerStarter from './lib/utils/worker-starter.js';
import fs from 'fs';
import redis, {commandOptions} from 'redis';
import path from 'path';
const logger = new Logger('presAnn Master');
const config = JSON.parse(fs.readFileSync('./config/settings.json', 'utf8'));
logger.info('Running bbb-export-annotations');
(async () => {
const client = redis.createClient({
host: config.redis.host,
port: config.redis.port,
password: config.redis.password,
socket: {
host: config.redis.host,
port: config.redis.port
}
});
await client.connect();

View File

@ -1,20 +1,22 @@
{
"name": "bbb-export-annotations",
"version": "0.0.1",
"version": "2.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "bbb-export-annotations",
"version": "0.0.1",
"version": "2.0",
"dependencies": {
"@svgdotjs/svg.js": "^3.2.0",
"axios": "^1.6.5",
"form-data": "^4.0.0",
"opentype.js": "^1.3.4",
"perfect-freehand": "^1.0.16",
"probe-image-size": "^7.2.3",
"redis": "^4.0.3",
"sanitize-filename": "^1.6.3",
"xmlbuilder2": "^3.0.2"
"svgdom": "^0.1.17"
},
"devDependencies": {
"eslint": "^8.20.0",
@ -165,55 +167,24 @@
"@node-redis/client": "^1.0.0"
}
},
"node_modules/@oozcitak/dom": {
"version": "1.15.10",
"resolved": "https://registry.npmjs.org/@oozcitak/dom/-/dom-1.15.10.tgz",
"integrity": "sha512-0JT29/LaxVgRcGKvHmSrUTEvZ8BXvZhGl2LASRUgHqDTC1M5g1pLmVv56IYNyt3bG2CUjDkc67wnyZC14pbQrQ==",
"node_modules/@svgdotjs/svg.js": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/@svgdotjs/svg.js/-/svg.js-3.2.0.tgz",
"integrity": "sha512-Tr8p+QVP7y+QT1GBlq1Tt57IvedVH8zCPoYxdHLX0Oof3a/PqnC/tXAkVufv1JQJfsDHlH/UrjcDfgxSofqSNA==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Fuzzyma"
}
},
"node_modules/@swc/helpers": {
"version": "0.4.36",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.4.36.tgz",
"integrity": "sha512-5lxnyLEYFskErRPenYItLRSge5DjrJngYKdVjRSrWfza9G6KkgHEXi0vUZiyUeMU5JfXH1YnvXZzSp8ul88o2Q==",
"dependencies": {
"@oozcitak/infra": "1.0.8",
"@oozcitak/url": "1.0.4",
"@oozcitak/util": "8.3.8"
},
"engines": {
"node": ">=8.0"
"legacy-swc-helpers": "npm:@swc/helpers@=0.4.14",
"tslib": "^2.4.0"
}
},
"node_modules/@oozcitak/infra": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@oozcitak/infra/-/infra-1.0.8.tgz",
"integrity": "sha512-JRAUc9VR6IGHOL7OGF+yrvs0LO8SlqGnPAMqyzOuFZPSZSXI7Xf2O9+awQPSMXgIWGtgUf/dA6Hs6X6ySEaWTg==",
"dependencies": {
"@oozcitak/util": "8.3.8"
},
"engines": {
"node": ">=6.0"
}
},
"node_modules/@oozcitak/url": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@oozcitak/url/-/url-1.0.4.tgz",
"integrity": "sha512-kDcD8y+y3FCSOvnBI6HJgl00viO/nGbQoCINmQ0h98OhnGITrWR3bOGfwYCthgcrV8AnTJz8MzslTQbC3SOAmw==",
"dependencies": {
"@oozcitak/infra": "1.0.8",
"@oozcitak/util": "8.3.8"
},
"engines": {
"node": ">=8.0"
}
},
"node_modules/@oozcitak/util": {
"version": "8.3.8",
"resolved": "https://registry.npmjs.org/@oozcitak/util/-/util-8.3.8.tgz",
"integrity": "sha512-T8TbSnGsxo6TDBJx/Sgv/BlVJL3tshxZP7Aq5R1mSnM5OcHY2dQaxLMu2+E8u3gN0MLOzdjurqN4ZRVuzQycOQ==",
"engines": {
"node": ">=8.0"
}
},
"node_modules/@types/node": {
"version": "17.0.16",
"resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.16.tgz",
"integrity": "sha512-ydLaGVfQOQ6hI1xK2A5nVh8bl0OGoIfYMxPWHqqYe9bTkWCfqiVvZoh2I/QF2sNSkZzZyROBoTefIEI+PB6iIA=="
},
"node_modules/acorn": {
"version": "8.8.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.0.tgz",
@ -302,6 +273,25 @@
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
]
},
"node_modules/brace-expansion": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
@ -312,6 +302,14 @@
"concat-map": "0.0.1"
}
},
"node_modules/brotli": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz",
"integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==",
"dependencies": {
"base64-js": "^1.1.2"
}
},
"node_modules/callsites": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
@ -337,6 +335,14 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/clone": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
"integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
"engines": {
"node": ">=0.8"
}
},
"node_modules/cluster-key-slot": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.0.tgz",
@ -416,6 +422,11 @@
"node": ">=0.4.0"
}
},
"node_modules/dfa": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz",
"integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q=="
},
"node_modules/doctrine": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
@ -590,18 +601,6 @@
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
}
},
"node_modules/esprima": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
"integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
"bin": {
"esparse": "bin/esparse.js",
"esvalidate": "bin/esvalidate.js"
},
"engines": {
"node": ">=4"
}
},
"node_modules/esquery": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/esquery/-/esquery-1.4.0.tgz",
@ -647,8 +646,7 @@
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
},
"node_modules/fast-json-stable-stringify": {
"version": "2.1.0",
@ -712,6 +710,22 @@
}
}
},
"node_modules/fontkit": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.2.tgz",
"integrity": "sha512-jc4k5Yr8iov8QfS6u8w2CnHWVmbOGtdBtOXMze5Y+QD966Rx6PEVWXSEGwXlsDlKtu1G12cJjcsybnqhSk/+LA==",
"dependencies": {
"@swc/helpers": "^0.4.2",
"brotli": "^1.3.2",
"clone": "^2.1.2",
"dfa": "^1.2.0",
"fast-deep-equal": "^3.1.3",
"restructure": "^3.0.0",
"tiny-inflate": "^1.0.3",
"unicode-properties": "^1.4.0",
"unicode-trie": "^2.0.0"
}
},
"node_modules/form-data": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
@ -821,6 +835,20 @@
"node": ">= 4"
}
},
"node_modules/image-size": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/image-size/-/image-size-1.0.2.tgz",
"integrity": "sha512-xfOoWjceHntRb3qFCrh5ZFORYH8XCdYpASltMhZ/Q0KZiOwjdE/Yl2QCiWdwD+lygV5bMCvauzgu5PxBX/Yerg==",
"dependencies": {
"queue": "6.0.2"
},
"bin": {
"image-size": "bin/image-size.js"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/import-fresh": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
@ -859,8 +887,7 @@
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"dev": true
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
},
"node_modules/is-extglob": {
"version": "2.1.1",
@ -913,6 +940,15 @@
"integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
"dev": true
},
"node_modules/legacy-swc-helpers": {
"name": "@swc/helpers",
"version": "0.4.14",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.4.14.tgz",
"integrity": "sha512-4C7nX/dvpzB7za4Ql9K81xK3HPxCpHMgwTZVyf+9JQ6VUbn9jjZVN7/Nkdz/Ugzs2CSjqnL/UPXroiVBVHUWUw==",
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/levn": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
@ -998,6 +1034,21 @@
"wrappy": "1"
}
},
"node_modules/opentype.js": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/opentype.js/-/opentype.js-1.3.4.tgz",
"integrity": "sha512-d2JE9RP/6uagpQAVtJoF0pJJA/fgai89Cc50Yp0EJHk+eLp6QQ7gBoblsnubRULNY132I0J1QKMJ+JTbMqz4sw==",
"dependencies": {
"string.prototype.codepointat": "^0.2.1",
"tiny-inflate": "^1.0.3"
},
"bin": {
"ot": "bin/ot"
},
"engines": {
"node": ">= 8.0.0"
}
},
"node_modules/optionator": {
"version": "0.9.1",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz",
@ -1015,6 +1066,11 @@
"node": ">= 0.8.0"
}
},
"node_modules/pako": {
"version": "0.2.9",
"resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
"integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA=="
},
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@ -1083,6 +1139,14 @@
"node": ">=6"
}
},
"node_modules/queue": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz",
"integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==",
"dependencies": {
"inherits": "~2.0.3"
}
},
"node_modules/redis": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/redis/-/redis-4.0.3.tgz",
@ -1136,6 +1200,11 @@
"node": ">=4"
}
},
"node_modules/restructure": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.0.tgz",
"integrity": "sha512-Xj8/MEIhhfj9X2rmD9iJ4Gga9EFqVlpMj3vfLnV2r/Mh5jRMryNV+6lWh9GdJtDBcBSPIqzRdfBQ3wDtNFv/uw=="
},
"node_modules/rimraf": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
@ -1190,11 +1259,6 @@
"node": ">=8"
}
},
"node_modules/sprintf-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
"integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw="
},
"node_modules/stream-parser": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/stream-parser/-/stream-parser-0.3.1.tgz",
@ -1216,6 +1280,11 @@
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
},
"node_modules/string.prototype.codepointat": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/string.prototype.codepointat/-/string.prototype.codepointat-0.2.1.tgz",
"integrity": "sha512-2cBVCj6I4IOvEnjgO/hWqXjqBGsY+zwPmHl12Srk9IXSZ56Jwwmy+66XO5Iut/oQVR7t5ihYdLB0GMa4alEUcg=="
},
"node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
@ -1252,12 +1321,31 @@
"node": ">=8"
}
},
"node_modules/svgdom": {
"version": "0.1.17",
"resolved": "https://registry.npmjs.org/svgdom/-/svgdom-0.1.17.tgz",
"integrity": "sha512-lhmZmXF0T2mCCrHnuNxgXnS72H3NJzTXz2oNoWxp+XGWpeuwlkJFko44ci9XEcrY1+zIE+8v99I561V010ZJyQ==",
"dependencies": {
"fontkit": "^2.0.2",
"image-size": "^1.0.2",
"sax": "^1.2.4"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Fuzzyma"
}
},
"node_modules/text-table": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
"integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==",
"dev": true
},
"node_modules/tiny-inflate": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
"integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="
},
"node_modules/truncate-utf8-bytes": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz",
@ -1266,6 +1354,11 @@
"utf8-byte-length": "^1.0.1"
}
},
"node_modules/tslib": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz",
"integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q=="
},
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
@ -1290,6 +1383,24 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/unicode-properties": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz",
"integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==",
"dependencies": {
"base64-js": "^1.3.0",
"unicode-trie": "^2.0.0"
}
},
"node_modules/unicode-trie": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz",
"integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==",
"dependencies": {
"pako": "^0.2.5",
"tiny-inflate": "^1.0.0"
}
},
"node_modules/uri-js": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
@ -1340,41 +1451,6 @@
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"dev": true
},
"node_modules/xmlbuilder2": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/xmlbuilder2/-/xmlbuilder2-3.0.2.tgz",
"integrity": "sha512-h4MUawGY21CTdhV4xm3DG9dgsqyhDkZvVJBx88beqX8wJs3VgyGQgAn5VreHuae6unTQxh115aMK5InCVmOIKw==",
"dependencies": {
"@oozcitak/dom": "1.15.10",
"@oozcitak/infra": "1.0.8",
"@oozcitak/util": "8.3.8",
"@types/node": "*",
"js-yaml": "3.14.0"
},
"engines": {
"node": ">=12.0"
}
},
"node_modules/xmlbuilder2/node_modules/argparse": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
"dependencies": {
"sprintf-js": "~1.0.2"
}
},
"node_modules/xmlbuilder2/node_modules/js-yaml": {
"version": "3.14.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.0.tgz",
"integrity": "sha512-/4IbIeHcD9VMHFqDR/gQ7EdZdLimOvW2DdcxFjdyyZ9NsbS+ccrXqVWDtab/lRl5AlUqmpBx8EhPaWR+OtY17A==",
"dependencies": {
"argparse": "^1.0.7",
"esprima": "^4.0.0"
},
"bin": {
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
@ -1491,43 +1567,20 @@
"integrity": "sha512-+nTn6EewVj3GlUXPuD3dgheWqo219jTxlo6R+pg24OeVvFHx9aFGGiyOgj3vBPhWUdRZ0xMcujXV5ki4fbLyMw==",
"requires": {}
},
"@oozcitak/dom": {
"version": "1.15.10",
"resolved": "https://registry.npmjs.org/@oozcitak/dom/-/dom-1.15.10.tgz",
"integrity": "sha512-0JT29/LaxVgRcGKvHmSrUTEvZ8BXvZhGl2LASRUgHqDTC1M5g1pLmVv56IYNyt3bG2CUjDkc67wnyZC14pbQrQ==",
"@svgdotjs/svg.js": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/@svgdotjs/svg.js/-/svg.js-3.2.0.tgz",
"integrity": "sha512-Tr8p+QVP7y+QT1GBlq1Tt57IvedVH8zCPoYxdHLX0Oof3a/PqnC/tXAkVufv1JQJfsDHlH/UrjcDfgxSofqSNA=="
},
"@swc/helpers": {
"version": "0.4.36",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.4.36.tgz",
"integrity": "sha512-5lxnyLEYFskErRPenYItLRSge5DjrJngYKdVjRSrWfza9G6KkgHEXi0vUZiyUeMU5JfXH1YnvXZzSp8ul88o2Q==",
"requires": {
"@oozcitak/infra": "1.0.8",
"@oozcitak/url": "1.0.4",
"@oozcitak/util": "8.3.8"
"legacy-swc-helpers": "npm:@swc/helpers@=0.4.14",
"tslib": "^2.4.0"
}
},
"@oozcitak/infra": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@oozcitak/infra/-/infra-1.0.8.tgz",
"integrity": "sha512-JRAUc9VR6IGHOL7OGF+yrvs0LO8SlqGnPAMqyzOuFZPSZSXI7Xf2O9+awQPSMXgIWGtgUf/dA6Hs6X6ySEaWTg==",
"requires": {
"@oozcitak/util": "8.3.8"
}
},
"@oozcitak/url": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@oozcitak/url/-/url-1.0.4.tgz",
"integrity": "sha512-kDcD8y+y3FCSOvnBI6HJgl00viO/nGbQoCINmQ0h98OhnGITrWR3bOGfwYCthgcrV8AnTJz8MzslTQbC3SOAmw==",
"requires": {
"@oozcitak/infra": "1.0.8",
"@oozcitak/util": "8.3.8"
}
},
"@oozcitak/util": {
"version": "8.3.8",
"resolved": "https://registry.npmjs.org/@oozcitak/util/-/util-8.3.8.tgz",
"integrity": "sha512-T8TbSnGsxo6TDBJx/Sgv/BlVJL3tshxZP7Aq5R1mSnM5OcHY2dQaxLMu2+E8u3gN0MLOzdjurqN4ZRVuzQycOQ=="
},
"@types/node": {
"version": "17.0.16",
"resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.16.tgz",
"integrity": "sha512-ydLaGVfQOQ6hI1xK2A5nVh8bl0OGoIfYMxPWHqqYe9bTkWCfqiVvZoh2I/QF2sNSkZzZyROBoTefIEI+PB6iIA=="
},
"acorn": {
"version": "8.8.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.0.tgz",
@ -1595,6 +1648,11 @@
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true
},
"base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="
},
"brace-expansion": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
@ -1605,6 +1663,14 @@
"concat-map": "0.0.1"
}
},
"brotli": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz",
"integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==",
"requires": {
"base64-js": "^1.1.2"
}
},
"callsites": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
@ -1621,6 +1687,11 @@
"supports-color": "^7.1.0"
}
},
"clone": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
"integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w=="
},
"cluster-key-slot": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.0.tgz",
@ -1685,6 +1756,11 @@
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk="
},
"dfa": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz",
"integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q=="
},
"doctrine": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
@ -1811,11 +1887,6 @@
"eslint-visitor-keys": "^3.3.0"
}
},
"esprima": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
"integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="
},
"esquery": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/esquery/-/esquery-1.4.0.tgz",
@ -1849,8 +1920,7 @@
"fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
},
"fast-json-stable-stringify": {
"version": "2.1.0",
@ -1894,6 +1964,22 @@
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.5.tgz",
"integrity": "sha512-vSFWUON1B+yAw1VN4xMfxgn5fTUiaOzAJCKBwIIgT/+7CuGy9+r+5gITvP62j3RmaD5Ph65UaERdOSRGUzZtgw=="
},
"fontkit": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.2.tgz",
"integrity": "sha512-jc4k5Yr8iov8QfS6u8w2CnHWVmbOGtdBtOXMze5Y+QD966Rx6PEVWXSEGwXlsDlKtu1G12cJjcsybnqhSk/+LA==",
"requires": {
"@swc/helpers": "^0.4.2",
"brotli": "^1.3.2",
"clone": "^2.1.2",
"dfa": "^1.2.0",
"fast-deep-equal": "^3.1.3",
"restructure": "^3.0.0",
"tiny-inflate": "^1.0.3",
"unicode-properties": "^1.4.0",
"unicode-trie": "^2.0.0"
}
},
"form-data": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
@ -1973,6 +2059,14 @@
"integrity": "sha512-CmxgYGiEPCLhfLnpPp1MoRmifwEIOgjcHXxOBjv7mY96c+eWScsOP9c112ZyLdWHi0FxHjI+4uVhKYp/gcdRmQ==",
"dev": true
},
"image-size": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/image-size/-/image-size-1.0.2.tgz",
"integrity": "sha512-xfOoWjceHntRb3qFCrh5ZFORYH8XCdYpASltMhZ/Q0KZiOwjdE/Yl2QCiWdwD+lygV5bMCvauzgu5PxBX/Yerg==",
"requires": {
"queue": "6.0.2"
}
},
"import-fresh": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
@ -2002,8 +2096,7 @@
"inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"dev": true
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
},
"is-extglob": {
"version": "2.1.1",
@ -2047,6 +2140,14 @@
"integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
"dev": true
},
"legacy-swc-helpers": {
"version": "npm:@swc/helpers@0.4.14",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.4.14.tgz",
"integrity": "sha512-4C7nX/dvpzB7za4Ql9K81xK3HPxCpHMgwTZVyf+9JQ6VUbn9jjZVN7/Nkdz/Ugzs2CSjqnL/UPXroiVBVHUWUw==",
"requires": {
"tslib": "^2.4.0"
}
},
"levn": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
@ -2114,6 +2215,15 @@
"wrappy": "1"
}
},
"opentype.js": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/opentype.js/-/opentype.js-1.3.4.tgz",
"integrity": "sha512-d2JE9RP/6uagpQAVtJoF0pJJA/fgai89Cc50Yp0EJHk+eLp6QQ7gBoblsnubRULNY132I0J1QKMJ+JTbMqz4sw==",
"requires": {
"string.prototype.codepointat": "^0.2.1",
"tiny-inflate": "^1.0.3"
}
},
"optionator": {
"version": "0.9.1",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz",
@ -2128,6 +2238,11 @@
"word-wrap": "^1.2.3"
}
},
"pako": {
"version": "0.2.9",
"resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
"integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA=="
},
"parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@ -2181,6 +2296,14 @@
"integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==",
"dev": true
},
"queue": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz",
"integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==",
"requires": {
"inherits": "~2.0.3"
}
},
"redis": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/redis/-/redis-4.0.3.tgz",
@ -2219,6 +2342,11 @@
"integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
"dev": true
},
"restructure": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.0.tgz",
"integrity": "sha512-Xj8/MEIhhfj9X2rmD9iJ4Gga9EFqVlpMj3vfLnV2r/Mh5jRMryNV+6lWh9GdJtDBcBSPIqzRdfBQ3wDtNFv/uw=="
},
"rimraf": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
@ -2261,11 +2389,6 @@
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"dev": true
},
"sprintf-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
"integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw="
},
"stream-parser": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/stream-parser/-/stream-parser-0.3.1.tgz",
@ -2289,6 +2412,11 @@
}
}
},
"string.prototype.codepointat": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/string.prototype.codepointat/-/string.prototype.codepointat-0.2.1.tgz",
"integrity": "sha512-2cBVCj6I4IOvEnjgO/hWqXjqBGsY+zwPmHl12Srk9IXSZ56Jwwmy+66XO5Iut/oQVR7t5ihYdLB0GMa4alEUcg=="
},
"strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
@ -2313,12 +2441,27 @@
"has-flag": "^4.0.0"
}
},
"svgdom": {
"version": "0.1.17",
"resolved": "https://registry.npmjs.org/svgdom/-/svgdom-0.1.17.tgz",
"integrity": "sha512-lhmZmXF0T2mCCrHnuNxgXnS72H3NJzTXz2oNoWxp+XGWpeuwlkJFko44ci9XEcrY1+zIE+8v99I561V010ZJyQ==",
"requires": {
"fontkit": "^2.0.2",
"image-size": "^1.0.2",
"sax": "^1.2.4"
}
},
"text-table": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
"integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==",
"dev": true
},
"tiny-inflate": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
"integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="
},
"truncate-utf8-bytes": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz",
@ -2327,6 +2470,11 @@
"utf8-byte-length": "^1.0.1"
}
},
"tslib": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz",
"integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q=="
},
"type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
@ -2342,6 +2490,24 @@
"integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==",
"dev": true
},
"unicode-properties": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz",
"integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==",
"requires": {
"base64-js": "^1.3.0",
"unicode-trie": "^2.0.0"
}
},
"unicode-trie": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz",
"integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==",
"requires": {
"pako": "^0.2.5",
"tiny-inflate": "^1.0.0"
}
},
"uri-js": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
@ -2383,37 +2549,6 @@
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"dev": true
},
"xmlbuilder2": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/xmlbuilder2/-/xmlbuilder2-3.0.2.tgz",
"integrity": "sha512-h4MUawGY21CTdhV4xm3DG9dgsqyhDkZvVJBx88beqX8wJs3VgyGQgAn5VreHuae6unTQxh115aMK5InCVmOIKw==",
"requires": {
"@oozcitak/dom": "1.15.10",
"@oozcitak/infra": "1.0.8",
"@oozcitak/util": "8.3.8",
"@types/node": "*",
"js-yaml": "3.14.0"
},
"dependencies": {
"argparse": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
"requires": {
"sprintf-js": "~1.0.2"
}
},
"js-yaml": {
"version": "3.14.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.0.tgz",
"integrity": "sha512-/4IbIeHcD9VMHFqDR/gQ7EdZdLimOvW2DdcxFjdyyZ9NsbS+ccrXqVWDtab/lRl5AlUqmpBx8EhPaWR+OtY17A==",
"requires": {
"argparse": "^1.0.7",
"esprima": "^4.0.0"
}
}
}
},
"yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",

View File

@ -1,19 +1,22 @@
{
"name": "bbb-export-annotations",
"version": "0.0.1",
"version": "2.0",
"description": "BigBlueButton's Presentation Annotation Exporter",
"scripts": {
"start": "node master.js",
"lint:fix": "eslint --fix **/*.js"
"lint:fix": "eslint --fix '**/*.js' --ignore-pattern 'node_modules/'",
"lint": "eslint '**/*.js' --ignore-pattern 'node_modules/'"
},
"dependencies": {
"@svgdotjs/svg.js": "^3.2.0",
"axios": "^1.6.5",
"form-data": "^4.0.0",
"opentype.js": "^1.3.4",
"perfect-freehand": "^1.0.16",
"probe-image-size": "^7.2.3",
"redis": "^4.0.3",
"sanitize-filename": "^1.6.3",
"xmlbuilder2": "^3.0.2"
"svgdom": "^0.1.17"
},
"devDependencies": {
"eslint": "^8.20.0",
@ -22,5 +25,6 @@
"engines": {
"node": ">=18.16.0",
"npm": ">=9.5.0"
}
},
"type": "module"
}

View File

@ -0,0 +1,287 @@
import {Path, Marker, Defs} from '@svgdotjs/svg.js';
import {Shape} from './Shape.js';
import {TAU, circleFromThreePoints, normalize, rotate}
from '../shapes/helpers.js';
import {ColorTypes} from '../shapes/Shape.js';
/**
* Creates an SVG path from Tldraw v2 arrow data.
*
* @class Arrow
* @extends {Shape}
*/
export class Arrow extends Shape {
/**
* @param {Object} arrow - The arrow shape JSON.
*/
constructor(arrow) {
super(arrow);
this.start = this.props?.start;
this.end = this.props?.end;
this.arrowheadStart = this.props?.arrowheadStart;
this.arrowheadEnd = this.props?.arrowheadEnd;
this.bend = this.props?.bend;
}
/**
* Calculates the midpoint of a curve considering the bend of the line.
* The midpoint is adjusted by the bend property to represent the
* actual midpoint of a quadratic Bezier curve defined by the start
* and end points with the bend as control point offset.
*
* @return {number[]} An array containing the x and y coordinates.
*/
getMidpoint() {
const mid = [
(this.start.x + this.end.x) / 2,
(this.start.y + this.end.y) / 2];
const unitVector = normalize([
this.end.x - this.start.x,
this.end.y - this.start.y]);
const unitRotated = rotate(unitVector);
const bendOffset = [
unitRotated[0] * -this.bend,
unitRotated[1] * -this.bend];
const middle = [
mid[0] + bendOffset[0],
mid[1] + bendOffset[1]];
return middle;
}
/**
* Calculates the angle in radians between the line segments joining the start
* point to the midpoint and the endpoint to the midpoint of a given set of
* points. Assumes that `this.getMidpoint()` is a method which calculates the
* midpoint between the start and end points, `this.start` is the start point,
* and `this.end` is the end point of the line segments. The points are
* objects with `x` and `y`properties representing their coordinates.
* @return {number} Angle between the two line segments at the midpoint.
*/
getTheta() {
const [middleX, middleY] = this.getMidpoint();
const ab = Math.hypot(this.start.y - middleY, this.start.x - middleX);
const bc = Math.hypot(middleY - this.end.y, middleX - this.end.x);
const ca = Math.hypot(this.end.y - this.start.y, this.end.x - this.start.x);
const theta = Math.acos((bc * bc + ca * ca - ab * ab) / (2 * bc * ca)) * 2;
return theta || 0;
}
/**
* Constructs the path for the arrow, considering straight and curved lines.
*
* @return {string} - The SVG path string.
*/
constructPath() {
const [startX, startY] = [this.start.x, this.start.y];
const [endX, endY] = [this.end.x, this.end.y];
const bend = this.bend;
const isStraightLine = (bend.toFixed(2) === '0.00');
const straightLine = `M ${startX} ${startY} L ${endX} ${endY}`;
if (isStraightLine) {
return straightLine;
}
const [middleX, middleY] = this.getMidpoint();
const [,, r] = circleFromThreePoints(
[startX, startY],
[middleX, middleY],
[endX, endY]);
// Could not calculate a circle
if (!r) {
return straightLine;
}
const radius = r.toFixed(2);
// Whether to draw the longer arc
const theta = this.getTheta();
const largeArcFlag = theta > (TAU / 4) ? '1' : '0';
// Clockwise or counterclockwise
const sweepFlag = ((endX - startX) * (middleY - startY) -
(middleX - startX) * (endY - startY) > 0 ? '0' : '1');
const path = `M ${startX} ${startY} ` +
`A ${radius} ${radius} 0 ${largeArcFlag} ${sweepFlag} ` +
`${endX} ${endY}`;
return path;
}
/**
* Calculates the tangent angles at the start and end points of a path.
* This method assumes that the path is an instance of a class with
* a method `pointAt` which returns a point with `x` and `y` properties
* given a distance along the path.
*
* @param {Object} path - SVG.js path with the `pointAt` method.
* @return {Object} An object with `startAngleDegrees` and `endAngleDegrees`
* properties.
*/
getTangentAngleAtEnds(path) {
const length = path.length();
const start = path.pointAt(0);
const epsilon = 0.01; // A small value
const end = path.pointAt(length);
// Get points just a little further along the path to calculate the tangent
const startTangentPoint = path.pointAt(epsilon);
const endTangentPoint = path.pointAt(length - epsilon);
// Calculate angles using Math.atan2 to find the slope of the tangent
const startAngleRadians = Math.atan2(
startTangentPoint.y - start.y,
startTangentPoint.x - start.x) + + TAU / 2;
const endAngleRadians = Math.atan2(
end.y - endTangentPoint.y,
end.x - endTangentPoint.x);
// Convert to degrees
const startAngleDegrees = startAngleRadians * (360 / TAU);
const endAngleDegrees = endAngleRadians * (360 / TAU);
return {startAngleDegrees, endAngleDegrees};
}
/**
* Creates a marker element with specified attributes
* and shape based on the type. The marker is configured with default
* properties which can be overridden according to the type.
* The marker type determines the path and fill of the SVG element.
*
* @param {string} type - One of 'arrow', 'diamond', 'triangle', 'inverted',
* 'square', 'dot', 'bar'.
* @param {string} url - URL reference in SVG.
* @param {number} [angle=0] - Angle in degrees for the marker.
* @return {Marker} A new Marker instance.
*/
createMarker(type, url, angle = 0) {
const arrowMarker = new Marker({
id: url,
viewBox: '0 0 10 10',
refX: '5',
refY: '5',
markerWidth: '6',
markerHeight: '6',
orient: angle,
});
const fillColor = Shape.colorToHex(this.color, ColorTypes.FillColor);
switch (type) {
case 'arrow':
arrowMarker.path('M 0 0 L 10 5 L 0 10 Z').fill(this.shapeColor);
break;
case 'diamond':
arrowMarker.path('M 5 0 L 10 5 L 5 10 L 0 5 z')
.stroke(this.shapeColor)
.fill(fillColor);
break;
case 'triangle':
arrowMarker.path('M 0 0 L 10 5 L 0 10 Z')
.stroke(this.shapeColor)
.fill(fillColor);
break;
case 'inverted':
arrowMarker.attr('orient', angle + 180);
arrowMarker.path('M 0 0 L 10 5 L 0 10 Z')
.stroke(this.shapeColor)
.fill(fillColor);
break;
case 'square':
arrowMarker.path('M 0 0 L 10 0 L 10 10 L 0 10 Z')
.stroke(this.shapeColor)
.fill(fillColor);
break;
case 'dot':
const circleSize = 5;
arrowMarker.attr('refX', '0');
arrowMarker.attr('refY', circleSize / 2);
arrowMarker.attr('markerUnits', 'strokeWidth');
arrowMarker.attr('markerWidth', '6');
arrowMarker.attr('markerHeight', '6');
arrowMarker.stroke('context-stroke');
arrowMarker.fill(fillColor);
arrowMarker.circle(circleSize)
.stroke(this.shapeColor)
.fill(fillColor);
break;
case 'bar':
arrowMarker.attr('refX', '0');
arrowMarker.attr('refY', '2.5');
arrowMarker.path('M 0 0 L 0 -5 L 2 -5 L 2 5 L 0 5 Z')
.stroke(this.shapeColor)
.fill(this.shapeColor);
break;
default:
arrowMarker.path('M 0 0 L 10 5 L 0 10 z').fill(this.shapeColor);
}
return arrowMarker;
}
/**
* Renders the arrow object as an SVG group element.
*
* @return {G} - An SVG group element.
*/
draw() {
const arrowGroup = this.shapeGroup;
const arrowPath = new Path();
const pathData = this.constructPath();
arrowPath.attr({
'd': pathData,
'stroke': this.shapeColor,
'stroke-width': this.thickness,
'style': this.dasharray,
'fill': 'none',
});
const angles = this.getTangentAngleAtEnds(arrowPath);
// If there are arrowheads, create the markers
if (this.arrowheadStart !== 'none' || this.arrowheadEnd !== 'none') {
const defs = new Defs();
// There is an arrowhead at the start
if (this.arrowheadStart !== 'none') {
const url = `${this.arrowheadStart}-${this.id}-start`;
const startMarker = this.createMarker(
this.arrowheadStart,
url,
angles.startAngleDegrees);
defs.add(startMarker);
arrowPath.attr('marker-start', `url(#${url})`);
}
// There is an arrowhead at the end
if (this.arrowheadEnd !== 'none') {
const url = `${this.arrowheadEnd}-${this.id}-end`;
const endMarker = this.createMarker(
this.arrowheadEnd,
url,
angles.endAngleDegrees);
defs.add(endMarker);
arrowPath.attr('marker-end', `url(#${url})`);
}
arrowGroup.add(defs);
}
arrowGroup.add(arrowPath);
this.drawLabel(arrowGroup);
return arrowGroup;
}
}

View File

@ -0,0 +1,43 @@
import {Polygon as SVGPolygon} from '@svgdotjs/svg.js';
import {Geo} from './Geo.js';
/**
* Creates an SVG down-arrow shape.
*
* @class ArrowDown
* @extends {Geo}
*/
export class ArrowDown extends Geo {
/**
* Draws a down arrow shape on the SVG canvas.
* @return {G} Returns the SVG group element containing the down arrow.
*/
draw() {
const w = this.w;
const h = this.h + this.growY;
const ox = w * 0.16;
const oy = Math.min(w, h) * 0.38;
const points = [
[ox, 0],
[w - ox, 0],
[w - ox, h - oy],
[w, h - oy],
[w / 2, h],
[0, h - oy],
[ox, h - oy],
].map(([x, y]) => `${x},${y}`).join(' ');
const arrowGroup = this.shapeGroup;
const arrow = new SVGPolygon();
arrow.plot(points)
.stroke({color: this.shapeColor, width: this.thickness})
.style({dasharray: this.dasharray});
this.setFill(arrow);
arrowGroup.add(arrow);
this.drawLabel(arrowGroup);
return arrowGroup;
}
}

View File

@ -0,0 +1,43 @@
import {Polygon as SVGPolygon} from '@svgdotjs/svg.js';
import {Geo} from './Geo.js';
/**
* Creates an SVG left-arrow shape.
*
* @class ArrowLeft
* @extends {Geo}
*/
export class ArrowLeft extends Geo {
/**
* Draws a left arrow shape on the SVG canvas.
* @return {G} Returns the SVG group element containing the left arrow.
*/
draw() {
const w = this.w;
const h = this.h + this.growY;
const ox = Math.min(w, h) * 0.38;
const oy = h * 0.16;
const points = [
[ox, 0],
[ox, oy],
[w, oy],
[w, h - oy],
[ox, h - oy],
[ox, h],
[0, h / 2],
].map(([x, y]) => `${x},${y}`).join(' ');
const arrowGroup = this.shapeGroup;
const arrow = new SVGPolygon();
arrow.plot(points)
.stroke({color: this.shapeColor, width: this.thickness})
.style({dasharray: this.dasharray});
this.setFill(arrow);
arrowGroup.add(arrow);
this.drawLabel(arrowGroup);
return arrowGroup;
}
}

View File

@ -0,0 +1,43 @@
import {Polygon as SVGPolygon} from '@svgdotjs/svg.js';
import {Geo} from './Geo.js';
/**
* Creates an SVG right-arrow shape.
*
* @class ArrowRight
* @extends {Geo}
*/
export class ArrowRight extends Geo {
/**
* Draws a right arrow shape on the SVG canvas.
* @return {G} Returns the SVG group element containing the right arrow.
*/
draw() {
const w = this.w;
const h = this.h + this.growY;
const ox = Math.min(w, h) * 0.38;
const oy = h * 0.16;
const points = [
[0, oy],
[w - ox, oy],
[w - ox, 0],
[w, h / 2],
[w - ox, h],
[w - ox, h - oy],
[0, h - oy],
].map(([x, y]) => `${x},${y}`).join(' ');
const arrowGroup = this.shapeGroup;
const arrow = new SVGPolygon();
arrow.plot(points)
.stroke({color: this.shapeColor, width: this.thickness})
.style({dasharray: this.dasharray});
this.setFill(arrow);
arrowGroup.add(arrow);
this.drawLabel(arrowGroup);
return arrowGroup;
}
}

View File

@ -0,0 +1,43 @@
import {Polygon as SVGPolygon} from '@svgdotjs/svg.js';
import {Geo} from './Geo.js';
/**
* Creates an SVG up-arrow shape.
*
* @class ArrowUp
* @extends {Geo}
*/
export class ArrowUp extends Geo {
/**
* Draws an up arrow shape on the SVG canvas.
* @return {G} Returns the SVG group element containing the up arrow.
*/
draw() {
const w = this.w;
const h = this.h + this.growY;
const ox = w * 0.16;
const oy = Math.min(w, h) * 0.38;
const points = [
[w / 2, 0],
[w, oy],
[w - ox, oy],
[w - ox, h],
[ox, h],
[ox, oy],
[0, oy],
].map(([x, y]) => `${x},${y}`).join(' ');
const arrowGroup = this.shapeGroup;
const arrow = new SVGPolygon();
arrow.plot(points)
.stroke({color: this.shapeColor, width: this.thickness})
.style({dasharray: this.dasharray});
this.setFill(arrow);
arrowGroup.add(arrow);
this.drawLabel(arrowGroup);
return arrowGroup;
}
}

View File

@ -0,0 +1,67 @@
import {Line} from '@svgdotjs/svg.js';
import {Rectangle} from './Rectangle.js';
/**
* Creates an SVG "Checkbox" shape, which is a rectangle with a checkmark in it.
*
* @class Checkbox
* @extends {Rectangle}
*/
export class Checkbox extends Rectangle {
/**
* Gets the lines to draw a checkmark inside a given width and height.
* @param {number} w The width of the bounding rectangle.
* @param {number} h The height of the bounding rectangle.
* @return {Array} An array of lines, each defined by two arrays
* representing points.
* @see {@link https://github.com/tldraw/tldraw/blob/main/packages/tldraw/src/lib/shapes/geo/GeoShapeUtil.tsx} Adapted from Tldraw.
*/
static getCheckBoxLines(w, h) {
const size = Math.min(w, h) * 0.82;
const ox = (w - size) / 2;
const oy = (h - size) / 2;
const clampX = (x) => Math.max(0, Math.min(w, x));
const clampY = (y) => Math.max(0, Math.min(h, y));
return [
[
[clampX(ox + size * 0.25), clampY(oy + size * 0.52)],
[clampX(ox + size * 0.45), clampY(oy + size * 0.82)],
],
[
[clampX(ox + size * 0.45), clampY(oy + size * 0.82)],
[clampX(ox + size * 0.82), clampY(oy + size * 0.22)],
],
];
}
/**
* Draws a "Checkbox" shape on the SVG canvas.
* @return {G} Returns the SVG group element containing
* the rectangle and the checkmark.
*/
draw() {
// Draw the base rectangle
const rectGroup = super.draw();
// Get the lines for the checkmark
const lines = Checkbox.getCheckBoxLines(this.w, this.h + this.growY);
lines.forEach(([start, end]) => {
const line = new Line();
line.plot(start[0], start[1], end[0], end[1])
.stroke({color: this.shapeColor,
width: this.thickness,
linecap: 'round'})
.style({dasharray: this.dasharray});
// Add the line to the group
rectGroup.add(line);
});
this.drawLabel(rectGroup);
return rectGroup;
}
}

View File

@ -0,0 +1,309 @@
import {Path} from '@svgdotjs/svg.js';
import {Geo} from './Geo.js';
import {angle, rng, TAU, getPointOnCircle, calculateDistance,
clockwiseAngleDist} from '../shapes/helpers.js';
/**
* Class representing a Cloud shape.
* @see {@link https://github.com/tldraw/tldraw/blob/main/packages/tldraw/src/lib/shapes/geo/cloudOutline.ts} Adapted from Tldraw.
*/
export class Cloud extends Geo {
/**
* Generate points on an arc between two given points.
*
* @param {Object} startPoint - Starting point with 'x' and 'y' properties.
* @param {Object} endPoint - End point with 'x' and 'y' properties.
* @param {Object|null} center - Center point with 'x' and 'y' properties
* @param {number} radius - The radius of the circle.
* @param {number} numPoints - The number of points to generate along the arc.
* @return {Array} Array of point objects representing the points on the arc.
*/
static pointsOnArc(startPoint, endPoint, center, radius, numPoints) {
if (center === null) {
return [startPoint, endPoint];
}
const results = [];
const startAngle = angle(center, startPoint);
const endAngle = angle(center, endPoint);
const l = clockwiseAngleDist(startAngle, endAngle);
for (let i = 0; i < numPoints; i++) {
const t = i / (numPoints - 1);
const angle = startAngle + l * t;
const point = getPointOnCircle(center.x, center.y, radius, angle);
results.push(point);
}
return results;
}
/**
* Function to get points on the "pill" shape.
*
* @static
* @param {number} width - The width of the pill shape.
* @param {number} height - The height of the pill shape.
* @param {number} numPoints - The number of points to generate.
* @return {Array} - Array of points on the pill shape.
*/
static getPillPoints(width, height, numPoints) {
const radius = Math.min(width, height) / 2;
const longSide = Math.max(width, height) - radius * 2;
const circumference = TAU * radius + 2 * longSide;
const spacing = circumference / numPoints;
const sections = width > height ?
[
{type: 'straight', start: {x: radius, y: 0}, delta: {x: 1, y: 0}},
{type: 'arc', center: {x: width - radius, y: radius},
startAngle: -TAU / 4},
{type: 'straight', start: {x: width - radius, y: height},
delta: {x: -1, y: 0}},
{type: 'arc', center: {x: radius, y: radius}, startAngle: TAU / 4},
] :
[
{type: 'straight', start: {x: width, y: radius}, delta: {x: 0, y: 1}},
{type: 'arc', center: {x: radius, y: height - radius}, startAngle: 0},
{type: 'straight', start: {x: 0, y: height - radius},
delta: {x: 0, y: - 1}},
{type: 'arc', center: {x: radius, y: radius}, startAngle: TAU / 2},
];
let sectionOffset = 0;
const points = [];
for (let i = 0; i < numPoints; i++) {
const section = sections[0];
if (section.type === 'straight') {
points.push({
x: section.start.x + section.delta.x * sectionOffset,
y: section.start.y + section.delta.y * sectionOffset,
});
} else {
points.push(getPointOnCircle(
section.center.x,
section.center.y,
radius,
section.startAngle + sectionOffset / radius,
));
}
sectionOffset += spacing;
let sectionLength =
section.type === 'straight' ? longSide : (TAU / 2) * radius;
while (sectionOffset > sectionLength) {
sectionOffset -= sectionLength;
sections.push(sections.shift());
sectionLength =
sections[0].type === 'straight' ? longSide : (TAU / 2) * radius;
}
}
return points;
}
/**
* Returns a numerical value based on the given size parameter.
*
* @static
* @param {string} size - The size style, one of: 's', 'm', 'l', 'xl'.
* @return {number} The numerical value corresponding to the given size.
* @throws Will default to 130 if the size parameter doesn't match any case.
*/
static switchSize(size) {
switch (size) {
case 's':
return 50;
case 'm':
return 70;
case 'l':
return 100;
case 'xl':
return 130;
default:
return 130;
}
}
/**
* Calculates the circumference of a pill shape.
*
* A pill shape is a rectangle with semi-circular ends. The function
* calculates the total distance around the shape using its width and height.
*
* @static
* @param {number} width - The width of the pill shape.
* @param {number} height - The height of the pill shape.
* @return {number} The circumference of the pill shape.
*/
static getPillCircumference(width, height) {
const radius = Math.min(width, height) / 2;
const longSide = Math.max(width, height) - radius * 2;
return TAU * radius + 2 * longSide;
}
/**
* Get arcs for generating a cloud shape.
*
* @static
* @param {number} width - The width of the cloud.
* @param {number} height - The height of the cloud.
* @param {string} seed - The random seed for the cloud.
* @param {Object} size - The size style for the cloud.
* @return {Array} An array of arcs data.
*/
static getCloudArcs(width, height, seed, size) {
const getRandom = rng(seed);
const pillCircumference = Cloud.getPillCircumference(width, height);
const numBumps = Math.max(
Math.ceil(pillCircumference / Cloud.switchSize(size)),
6,
Math.ceil(pillCircumference / Math.min(width, height)),
);
const targetBumpProtrusion = (pillCircumference / numBumps) * 0.2;
const innerWidth = Math.max(width - targetBumpProtrusion * 2, 1);
const innerHeight = Math.max(height - targetBumpProtrusion * 2, 1);
const paddingX = (width - innerWidth) / 2;
const paddingY = (height - innerHeight) / 2;
const distanceBetweenPointsOnPerimeter =
Cloud.getPillCircumference(innerWidth, innerHeight) / numBumps;
let bumpPoints = Cloud.getPillPoints(innerWidth, innerHeight, numBumps);
bumpPoints = bumpPoints.map((p) => {
return {
x: p.x + paddingX,
y: p.y + paddingY,
};
});
const maxWiggleX = width < 20 ? 0 : targetBumpProtrusion * 0.3;
const maxWiggleY = height < 20 ? 0 : targetBumpProtrusion * 0.3;
const wiggledPoints = bumpPoints.slice(0);
for (let i = 0; i < Math.floor(numBumps / 2); i++) {
wiggledPoints[i].x += getRandom() * maxWiggleX;
wiggledPoints[i].y += getRandom() * maxWiggleY;
wiggledPoints[numBumps - i - 1].x += getRandom() * maxWiggleX;
wiggledPoints[numBumps - i - 1].y += getRandom() * maxWiggleY;
}
const arcs = [];
for (let i = 0; i < wiggledPoints.length; i++) {
const j = i === wiggledPoints.length - 1 ? 0 : i + 1;
const leftWigglePoint = wiggledPoints[i];
const rightWigglePoint = wiggledPoints[j];
const leftPoint = bumpPoints[i];
const rightPoint = bumpPoints[j];
const midPoint = {
x: (leftPoint.x + rightPoint.x) / 2,
y: (leftPoint.y + rightPoint.y) / 2,
};
const offsetAngle = Math.atan2(rightPoint.y - leftPoint.y,
rightPoint.x - leftPoint.x) - TAU;
const distanceBetweenOriginalPoints =
Math.sqrt(Math.pow(rightPoint.x - leftPoint.x, 2) +
Math.pow(rightPoint.y - leftPoint.y, 2));
const curvatureOffset =
distanceBetweenPointsOnPerimeter - distanceBetweenOriginalPoints;
const distanceBetweenWigglePoints =
Math.sqrt(Math.pow(rightWigglePoint.x - leftWigglePoint.x, 2) +
Math.pow(rightWigglePoint.y - leftWigglePoint.y, 2));
const relativeSize =
distanceBetweenWigglePoints / distanceBetweenOriginalPoints;
const finalDistance = (Math.max(paddingX, paddingY) +
curvatureOffset) * relativeSize;
const arcPoint = {
x: midPoint.x + Math.cos(offsetAngle) * finalDistance,
y: midPoint.y + Math.sin(offsetAngle) * finalDistance,
};
arcPoint.x = Math.min(Math.max(arcPoint.x, 0), width);
arcPoint.y = Math.min(Math.max(arcPoint.y, 0), height);
arcs.push({
leftPoint: leftWigglePoint,
rightPoint: rightWigglePoint,
arcPoint,
});
}
return arcs;
}
/**
* Generate an SVG path string to represent a cloud shape using arc segments.
*
* @param {number} width - The width of the cloud.
* @param {number} height - The height of the cloud.
* @param {number} seed - The seed value for randomization (if applicable).
* @param {number} size - The size of the cloud.
* @return {string} - An SVG path string representing the cloud.
*/
static cloudSvgPath(width, height, seed, size) {
// Get cloud arcs based on input parameters
const arcs = Cloud.getCloudArcs(width, height, seed, size);
// Initialize SVG path starting with the 'M' command
// for the first arc's leftPoint
const initialX = arcs[0].leftPoint.x.toFixed(2);
const initialY = arcs[0].leftPoint.y.toFixed(2);
let path = `M${initialX},${initialY}`;
// Loop through all arcs to construct the 'A' commands for the SVG path
for (const {leftPoint, rightPoint, arcPoint} of arcs) {
// Approximate radius through heuristic, as determining the true
// radius from the circle formed by the three points proved numerically
// unstable.
const radius = calculateDistance(leftPoint, arcPoint).toFixed(2);
const endPointX = rightPoint.x.toFixed(2);
const endPointY = rightPoint.y.toFixed(2);
path += `A${radius},${radius} 0 0, 1 ${endPointX},${endPointY}`;
}
// Close the SVG path with 'Z'
path += ' Z';
return path;
}
/**
* Renders a cloud shape on the SVG canvas. It uses a predefined SVG path
* for the cloud shape, which is scaled to the dimensions of the instance.
* @return {G} An SVG group element (`<g>`)
* that contains the cloud path and label.
*/
draw() {
const points = Cloud.cloudSvgPath(
this.w,
this.h + this.growY,
this.id,
this.size);
const cloudGroup = this.shapeGroup;
const cloud = new Path({
'd': points,
'stroke': this.shapeColor,
'stroke-width': this.thickness,
'style': this.dasharray,
});
this.setFill(cloud);
cloudGroup.add(cloud);
this.drawLabel(cloudGroup);
return cloudGroup;
}
}

View File

@ -0,0 +1,43 @@
import {Polygon as SVGPolygon} from '@svgdotjs/svg.js';
import {Geo} from './Geo.js';
/**
* Creates an SVG diamond shape from Tldraw v2 JSON data.
*
* @class Diamond
* @extends {Geo}
*/
export class Diamond extends Geo {
/**
* Draws a diamond shape on the SVG canvas.
* @return {G} Returns the SVG group element containing the diamond.
*/
draw() {
const width = this.w;
const height = this.h + this.growY;
const halfWidth = width / 2;
const halfHeight = height / 2;
// Shape begins from the upper left corner
const points = [
[0, halfHeight],
[halfWidth, 0],
[width, halfHeight],
[halfWidth, height],
].map((p) => p.join(',')).join(' ');
const diamondGroup = this.shapeGroup;
const diamond = new SVGPolygon({
points,
'stroke': this.shapeColor,
'stroke-width': this.thickness,
'style': this.dasharray,
});
this.setFill(diamond);
diamondGroup.add(diamond);
this.drawLabel(diamondGroup);
return diamondGroup;
}
}

View File

@ -0,0 +1,126 @@
import pkg from 'perfect-freehand';
import {TAU} from '../shapes/helpers.js';
import {Path} from '@svgdotjs/svg.js';
import {Shape} from './Shape.js';
const {getStrokePoints, getStrokeOutlinePoints} = pkg;
/**
* Creates an SVG path from Tldraw v2 pencil data.
*
* @class Draw
* @extends {Shape}
*/
export class Draw extends Shape {
/**
* @param {Object} draw - The draw shape JSON.
*/
constructor(draw) {
super(draw);
this.segments = this.props?.segments;
this.isClosed = this.props?.isClosed;
this.isComplete = this.props?.isComplete;
}
static simulatePressure = {
easing: (t) => Math.sin((t * TAU) / 4),
simulatePressure: true,
};
static realPressure = {
easing: (t) => t * t,
simulatePressure: false,
};
/**
* Turns an array of points into a path of quadradic curves.
* @param {Array} annotationPoints
* @param {Boolean} closed - whether the path end and start
* should be connected (default)
* @return {Array} - an SVG quadratic curve path
*/
getSvgPath(annotationPoints, closed = true) {
const svgPath = annotationPoints.reduce(
(acc, [x0, y0], i, arr) => {
if (!arr[i + 1]) return acc;
const [x1, y1] = arr[i + 1];
acc.push(x0.toFixed(2), y0.toFixed(2),
((x0 + x1) / 2).toFixed(2),
((y0 + y1) / 2).toFixed(2));
return acc;
},
['M', ...annotationPoints[0], 'Q'],
);
if (closed) svgPath.push('Z');
return svgPath;
}
/**
* Renders the draw object as an SVG group element.
*
* @return {G} - An SVG group element.
*/
draw() {
const shapePoints = this.segments[0]?.points;
const shapePointsLength = shapePoints?.length || 0;
const isDashDraw = (this.dash === 'draw');
const drawGroup = this.shapeGroup;
const options = {
size: 1 + this.thickness * 1.5,
thinning: 0.65,
streamline: 0.65,
smoothing: 0.65,
...(shapePoints[1]?.z === 0.5 ?
this.simulatePressure : this.realPressure),
last: this.isComplete,
};
const strokePoints = getStrokePoints(shapePoints, options);
const drawPath = new Path();
const fillShape = new Path();
const last = shapePoints[shapePointsLength - 1];
// Avoid single dots from not being drawn
if (strokePoints[0].point[0] == last[0] &&
strokePoints[0].point[1] == last[1]) {
strokePoints.push({point: last});
}
const solidPath = strokePoints.map((strokePoint) => strokePoint.point);
const svgPath = this.getSvgPath(solidPath, this.isClosed);
fillShape.attr('d', svgPath);
// In case the background shape is the shape itself, add the stroke to it
if (!isDashDraw) {
fillShape.attr('stroke', this.shapeColor);
fillShape.attr('stroke-width', this.thickness);
fillShape.attr('style', this.dasharray);
}
// Fill only applies for closed shapes
if (!this.isClosed) {
this.fill = 'none';
}
this.setFill(fillShape);
if (isDashDraw) {
const strokeOutlinePoints = getStrokeOutlinePoints(strokePoints, options);
const svgPath = this.getSvgPath(strokeOutlinePoints);
drawPath.attr('fill', this.shapeColor);
drawPath.attr('d', svgPath);
}
drawGroup.add(fillShape);
drawGroup.add(drawPath);
return drawGroup;
}
}

View File

@ -0,0 +1,36 @@
import {Ellipse as SVGEllipse} from '@svgdotjs/svg.js';
import {Geo} from './Geo.js';
/**
* Creates an SVG ellipse shape from Tldraw v2 JSON data.
*
* @class Ellipse
* @extends {Geo}
*/
export class Ellipse extends Geo {
/**
* Draws an ellipse shape on the SVG canvas.
* @return {G} Returns the SVG group element containing the ellipse.
*/
draw() {
const rx = this.w / 2;
const ry = (this.h + this.growY) / 2;
const ellipseGroup = this.shapeGroup;
const ellipse = new SVGEllipse({
'cx': rx.toFixed(2),
'cy': ry.toFixed(2),
'rx': rx.toFixed(2),
'ry': ry.toFixed(2),
'stroke': this.shapeColor,
'stroke-width': this.thickness,
'style': this.dasharray,
});
this.setFill(ellipse);
ellipseGroup.add(ellipse);
this.drawLabel(ellipseGroup);
return ellipseGroup;
}
}

View File

@ -0,0 +1,99 @@
import {Shape} from './Shape.js';
import {Rect, Text, ClipPath, Defs, G} from '@svgdotjs/svg.js';
import {ColorTypes} from '../shapes/Shape.js';
import {overlayAnnotation} from '../workers/process.js';
/**
* Creates an SVG frame from Tldraw v2 pencil data.
*
* @class Frane
* @extends {Shape}
*/
export class Frame extends Shape {
/**
* @param {Object} frame - The Frame shape JSON.
*/
constructor(frame) {
super(frame);
this.name = this.props?.name;
this.w = this.props?.w;
this.h = this.props?.h;
this.children = frame.children;
}
/**
* Renders the frame object as an SVG group element.
*
* @return {G} - An SVG group element.
*/
draw() {
// Parent group
const frameGroup = this.shapeGroup;
// Group for clipped elements
const clipGroup = new G();
const fillColor = Shape.colorToHex(ColorTypes.SemiFillColor,
ColorTypes.SemiFillColor);
const frameLabel = this.name || 'Frame';
// The text element is not clipped
const textElement = new Text()
.text(frameLabel)
.move(0, -20)
.font({
'family': 'Arial',
'size': 12,
})
.fill('black');
// The frame rectangle that is not clipped
const frame = new Rect({
'x': 0,
'y': 0,
'width': this.w,
'height': this.h,
'stroke': 'black',
'stroke-width': 1,
'fill': fillColor,
});
// Create the clip path with the same properties as the frame
const clipPath = new ClipPath().id(`clipFrame-${this.id}`);
const clipFrame = new Rect({
'x': 0,
'y': 0,
'width': this.w,
'height': this.h,
});
clipPath.add(clipFrame);
// Definitions for clip paths
const defs = new Defs();
defs.add(clipPath);
// Add defs to the parent group
frameGroup.add(defs);
const children = this.children || [];
// Add the children to the clipGroup so they will be clipped
children.forEach((child) => {
overlayAnnotation(clipGroup, child);
});
// Apply clipping to the clipGroup only
clipGroup.clipWith(clipPath);
// Add non-clipped...
frameGroup.add(frame);
frameGroup.add(textElement);
// ...and clipped elements to the frame group
frameGroup.add(clipGroup);
return frameGroup;
}
}

View File

@ -0,0 +1,65 @@
import {Shape} from './Shape.js';
import {TAU} from './helpers.js';
/**
* Class representing geometric shapes.
*
* @class Geo
* @extends {Shape}
*/
export class Geo extends Shape {
/**
* Creates an instance of Geo.
*
* @param {Object} geo - JSON containing geometric shape properties.
*/
constructor(geo) {
super(geo);
this.url = this.props?.url;
this.font = this.props?.font;
this.w = this.props?.w;
this.h = this.props?.h;
this.growY = this.props?.growY;
this.align = this.props?.align;
this.geo = this.props?.geo;
this.verticalAlign = this.props?.verticalAlign;
this.labelColor = this.props?.labelColor;
}
/**
* Gets the vertices of a polygon given its dimensions and the number of sides.
* @param {number} width The width of the bounding box for the polygon.
* @param {number} height The height of the bounding box for the polygon.
* @param {number} sides The number of sides for the polygon.
* @return {Array} An array of objects with x and y coordinates for each vertex.
* @see {@link https://github.com/tldraw/tldraw/blob/main/packages/editor/src/lib/primitives/utils.ts} Adapted from Tldraw.
*/
static getPolygonVertices(width, height, sides) {
const cx = width / 2;
const cy = height / 2;
const pointsOnPerimeter = [];
let minX = Infinity;
let minY = Infinity;
for (let i = 0; i < sides; i++) {
const step = TAU / sides;
const t = -(TAU / 4) + i * step;
const x = cx + cx * Math.cos(t);
const y = cy + cy * Math.sin(t);
if (x < minX) minX = x;
if (y < minY) minY = y;
pointsOnPerimeter.push({x, y});
}
if (minX !== 0 || minY !== 0) {
for (let i = 0; i < pointsOnPerimeter.length; i++) {
const pt = pointsOnPerimeter[i];
pt.x -= minX;
pt.y -= minY;
}
}
return pointsOnPerimeter;
}
}

View File

@ -0,0 +1,42 @@
import {Polygon as SVGPolygon} from '@svgdotjs/svg.js';
import {Geo} from './Geo.js';
/**
* Creates an SVG hexagon shape from Tldraw v2 JSON data.
*
* @class Hexagon
* @extends {Geo}
*/
export class Hexagon extends Geo {
/**
* Draws a hexagon shape on the SVG canvas.
* @return {G} Returns the SVG group element containing the hexagon.
*/
draw() {
const width = this.w;
const height = this.h + this.growY;
const sides = 6;
// Get the vertices of the hexagon
const pointsOnPerimeter = Geo.getPolygonVertices(width, height, sides);
// Convert the vertices to SVG polygon points format
const points = pointsOnPerimeter.map((p) => `${p.x},${p.y}`).join(' ');
// Create the SVG polygon
const hexagonGroup = this.shapeGroup;
const hexagon = new SVGPolygon({
points,
'stroke': this.shapeColor,
'stroke-width': this.thickness,
'style': this.dasharray,
});
// Fill the polygon if required
this.setFill(hexagon);
hexagonGroup.add(hexagon);
this.drawLabel(hexagonGroup);
return hexagonGroup;
}
}

View File

@ -0,0 +1,23 @@
import {Draw} from './Draw.js';
/**
* Represents a Highlight shape, extending the functionality of the Draw class.
*
* @class Highlight
* @extends {Draw}
*/
export class Highlight extends Draw {
/**
* Creates an instance of the Highlight class.
*
* @param {Object} highlight - The highlighter's JSON data.
*/
constructor(highlight) {
super(highlight);
this.fill = 'none';
this.shapeColor = '#fedd00';
this.thickness = this.thickness * 7;
this.isClosed = false;
}
}

View File

@ -0,0 +1,81 @@
import {Path} from '@svgdotjs/svg.js';
import {Shape} from './Shape.js';
/**
* Creates an SVG path from Tldraw v2 line data.
*
* @class Line
* @extends {Shape}
*/
export class Line extends Shape {
/**
* @param {Object} line - The line shape JSON.
*/
constructor(line) {
super(line);
this.handles = this.props?.handles;
this.spline = this.props?.spline;
}
/**
* Given the line type (spline, line), constructs the SVG path.
*
* @param {Object} handles - The vertex points and control points.
* @param {string} spline - The type of spline ('cubic' or 'line').
* @return {string} - The SVG path data.
*/
constructPath(handles, spline) {
const start = handles.start;
const end = handles.end;
const ctl = handles['handle:a1V'];
let path = `M${start.x},${start.y} `;
if (spline === 'cubic' && ctl) {
// Compute adjusted control points to make curve pass through `ctl`
const t = 0.5; // Assumes the curve passes through `ctl` at t = 0.5
const b = {
x: (ctl.x - (1-t) ** 3 * start.x - t ** 3 * end.x) / (3 * (1-t) * t),
y: (ctl.y - (1-t) ** 3 * start.y - t ** 3 * end.y) / (3 * (1-t) * t),
};
const c = {
x: (ctl.x - (1-t) ** 3 * start.x - t ** 3 * end.x) / (3 * (1-t) * t),
y: (ctl.y - (1-t) ** 3 * start.y - t ** 3 * end.y) / (3 * (1-t) * t),
};
// Draw cubic spline
path += `C${b.x},${b.y} ${c.x},${c.y} ${end.x},${end.y}`;
} else if (spline === 'line' && ctl) {
// Draw straight lines to and from control point
path += `L${ctl.x},${ctl.y} L${end.x},${end.y}`;
} else {
// Draw straight line
path += `L${end.x},${end.y}`;
}
return path;
}
/**
* Renders the line object as an SVG group element.
*
* @return {G} - An SVG group element.
*/
draw() {
const lineGroup = this.shapeGroup;
const linePath = new Path();
const svgPath = this.constructPath(this.handles, this.spline);
linePath.attr({
'd': svgPath,
'stroke': this.shapeColor,
'stroke-width': this.thickness,
'style': this.dasharray,
'fill': 'none',
});
lineGroup.add(linePath);
this.drawLabel(lineGroup);
return lineGroup;
}
}

View File

@ -0,0 +1,60 @@
import {Polygon as SVGPolygon} from '@svgdotjs/svg.js';
import {Geo} from './Geo.js';
import {TAU} from '../shapes/helpers.js';
/**
* Creates an SVG oval shape from Tldraw v2 JSON data.
*
* @class Oval
* @extends {Geo}
*/
export class Oval extends Geo {
/**
* Draws an oval shape on the SVG canvas.
* @return {G} Returns the SVG group element containing the oval.
* @see {@link https://github.com/tldraw/tldraw/blob/main/packages/editor/src/lib/primitives/geometry/Stadium2d.ts} Adapted from Tldraw.
*/
draw() {
const w = Math.max(1, this.w);
const h = Math.max(1, this.h + this.growY);
const cx = w / 2;
const cy = h / 2;
const len = 25; // Number of vertices to use for the oval
const points = Array(len * 2 - 2).fill(null).map(() => []);
if (h > w) {
for (let i = 0; i < len - 1; i++) {
const t1 = -(TAU / 2) + ((TAU / 2) * i) / (len - 2);
const t2 = ((TAU / 2) * i) / (len - 2);
points[i] = [cx + cx * Math.cos(t1), cx + cx * Math.sin(t1)];
points[i + (len - 1)] = [cx + cx * Math.cos(t2),
h - cx + cx * Math.sin(t2)];
}
} else {
for (let i = 0; i < len - 1; i++) {
const t1 = -(TAU / 4) + (TAU / 2 * i) / (len - 2);
const t2 = (TAU / 4) + (TAU / 2 * -i) / (len - 2);
points[i] = [w - cy + cy * Math.cos(t1), h - cy + cy * Math.sin(t1)];
points[i + (len - 1)] = [cy - cy * Math.cos(t2),
h - cy + cy * Math.sin(t2)];
}
}
const formattedPoints = points.map((p) => p.join(',')).join(' ');
const ovalGroup = this.shapeGroup;
const oval = new SVGPolygon({
'points': formattedPoints,
'stroke': this.shapeColor,
'stroke-width': this.thickness,
'style': this.dasharray,
});
this.setFill(oval);
ovalGroup.add(oval);
this.drawLabel(ovalGroup);
return ovalGroup;
}
}

View File

@ -0,0 +1,43 @@
import {Rect} from '@svgdotjs/svg.js';
import {Geo} from './Geo.js';
/**
* Creates an SVG rectangle shape from Tldraw v2 JSON data.
*
* @class Rectangle
* @extends {Geo}
*/
export class Rectangle extends Geo {
/**
* Draws a rectangle shape based on the instance properties.
*
* @method draw
* @return {G} An SVG group element containing the drawn rectangle shape.
*
*/
draw() {
const rectGroup = this.shapeGroup;
const rectangle = new Rect({
'x': 0,
'y': 0,
'width': this.w,
'height': this.h + this.growY,
'stroke': this.shapeColor,
'stroke-width': this.thickness,
'style': this.dasharray,
});
// Simulate perfect-freehand effect
if (this.dash === 'draw') {
rectangle.attr('rx', this.thickness);
rectangle.attr('ry', this.thickness);
}
this.setFill(rectangle);
rectGroup.add(rectangle);
this.drawLabel(rectGroup);
return rectGroup;
}
}

View File

@ -0,0 +1,44 @@
import {Polygon as SVGPolygon} from '@svgdotjs/svg.js';
import {Geo} from './Geo.js';
/**
* Creates an SVG rhombus shape from Tldraw v2 JSON data.
*
* @class Rhombus
* @extends {Geo}
*/
export class Rhombus extends Geo {
/**
* Draws a rhombus shape on the SVG canvas.
* @return {G} Returns the SVG group element containing the rhombus.
*/
draw() {
const width = this.w;
const height = this.h + this.growY;
// Internal angle between adjacent sides varies with width and height
const offset = Math.min(width * 0.38, height * 0.38);
// Coordinates for the four vertices of the rhombus
const points = [
[offset, 0], // Top left vertex
[width, 0], // Top right vertex
[width - offset, height], // Bottom right vertex
[0, height], // Bottom left vertex
].map((p) => p.join(',')).join(' ');
const rhombusGroup = this.shapeGroup;
const rhombus = new SVGPolygon({
points,
'stroke': this.shapeColor,
'stroke-width': this.thickness,
'style': this.dasharray,
});
this.setFill(rhombus);
rhombusGroup.add(rhombus);
this.drawLabel(rhombusGroup);
return rhombusGroup;
}
}

View File

@ -0,0 +1,507 @@
import {Pattern, Line, Defs, Rect, G, Text, Tspan} from '@svgdotjs/svg.js';
import {radToDegree} from '../shapes/helpers.js';
import opentype from 'opentype.js';
import fs from 'fs';
/**
* Represents a basic Tldraw shape on the whiteboard.
*
* @class Shape
* @typedef {Object} ColorTypes
* @property {'shape'} ShapeColor - Color for shape outlines or borders.
* @property {'fill'} FillColor - Solid fill color inside the shape.
* @property {'semi'} SemiFillColor - Semi fill shape color.
* @property {'sticky'} StickyColor - Color for sticky notes.
*/
export class Shape {
/**
* Creates an instance of Shape.
* @constructor
* @param {Object} params - The shape's parameters.
* @param {String} params.id - The the shape ID.
* @param {Number} params.x - The shape's x-coordinate.
* @param {Number} params.y - The shape's y-coordinate.
* @param {Number} params.rotation - The shape's rotation angle in radians.
* @param {Number} params.opacity - The shape's opacity.
* @param {Object} params.props - Shape-specific properties.
*/
constructor({
id,
x,
y,
rotation,
opacity,
props,
}) {
this.id = id;
this.x = x;
this.y = y;
this.rotation = rotation;
this.opacity = opacity;
this.props = props;
this.size = this.props?.size;
this.color = this.props?.color;
this.dash = this.props?.dash;
this.fill = this.props?.fill;
this.text = this.props?.text;
// Derived SVG properties
this.thickness = Shape.getStrokeWidth(this.size);
this.dasharray = Shape.determineDasharray(this.dash, this.size);
this.shapeColor = Shape.colorToHex(this.color, ColorTypes.ShapeColor);
// SVG representation
this.shapeGroup = new G({
transform: this.getTransform(),
opacity: this.opacity,
});
}
/**
* Generates an SVG <defs> element with a pattern for filling the shape.
*
* @method getFillPattern
* @param {String} shapeColor - The color to use for the pattern lines.
* @return {Defs} An SVG <defs> element containing the pattern.
*/
getFillPattern(shapeColor) {
const defs = new Defs();
const pattern = new Pattern({
id: `hash_pattern-${this.id}`,
width: 8,
height: 8,
patternUnits: 'userSpaceOnUse',
patternTransform: 'rotate(45 0 0)',
});
pattern.add(new Rect({width: 8, height: 8, fill: 'white'}));
pattern.add(new Line({'x1': 0, 'y1': 0, 'x2': 0, 'y2': 8,
'stroke': shapeColor, 'stroke-width': 3.5,
'stroke-dasharray': '4, 4'}));
defs.add(pattern);
return defs;
}
/**
* Applies the appropriate fill style to the given SVG shape element based on
* the object's `fill` property. It supports 'solid', 'semi', 'pattern', and
* 'none' as fill options.
* @param {SVGElement} shape - The element to be filled
*/
setFill(shape) {
switch (this.fill) {
case 'solid':
const fillColor = Shape.colorToHex(this.color, ColorTypes.FillColor);
shape.attr('fill', fillColor);
break;
case 'semi':
const semiColor = Shape.colorToHex(this.fill, ColorTypes.SemiFillColor);
shape.attr('fill', semiColor);
break;
case 'pattern':
const shapeColor = Shape.colorToHex(this.color, ColorTypes.ShapeColor);
const pattern = this.getFillPattern(shapeColor);
this.shapeGroup.add(pattern);
shape.attr('fill', `url(#hash_pattern-${this.id})`);
break;
default:
shape.attr('fill', 'none');
break;
}
}
/**
* Generates a transformation string for SVG elements based on the object's
* rotation and position properties. The transformation includes translation,
* rotation, and setting the transform origin to the center.
*
* @return {string} The SVG transform attribute value.
*/
getTransform() {
const x = this.x.toFixed(2);
const y = this.y.toFixed(2);
const rotation = radToDegree(this.rotation);
const translate = `translate(${x} ${y})`;
const transformOrigin = 'transform-origin: center';
const rotate = `rotate(${rotation})`;
const transform = `${translate}; ${transformOrigin}; ${rotate}`;
return transform;
}
/**
* Converts a tldraw color name to its corresponding HEX code.
*
* @param {string} color - The name of the color (e.g., 'blue', 'red').
* @param {string} colorType - Context to select the appropriate mapping.
* Valid values are 'shape', 'fill',
* 'semi', and 'sticky'.
*
* @return {string} The HEX code for the given color and color type.
* Returns '#0d0d0d' if not found.
*/
static colorToHex(color, colorType) {
const colorMap = {
'black': '#161616',
'grey': '#9EA6B0',
'light-violet': '#DD80F5',
'violet': '#9C1FBE',
'blue': '#3348E5',
'light-blue': '#4099F5',
'yellow': '#FDB365',
'orange': '#F3500B',
'green': '#148355',
'light-green': '#38B845',
'light-red': '#FC7075',
'red': '#D61A25',
};
const fillMap = {
'black': '#E2E2E2',
'grey': '#E7EAEC',
'light-violet': '#F2E5F9',
'violet': '#E7D3EF',
'blue': '#D4D8F6',
'light-blue': '#D6E8F9',
'yellow': '#F8ECE0',
'orange': '#F5DBCA',
'green': '#CAE5DC',
'light-green': '#D4EED9',
'light-red': '#F0D1D3',
'red': '#F0D1D3',
};
const stickyMap = {
'black': '#FEC78C',
'grey': '#B6BDC3',
'light-violet': '#E4A1F7',
'violet': '#B65ACF',
'blue': '#6476EC',
'light-blue': '#6FB3F6',
'yellow': '#FEC78C',
'orange': '#F57D48',
'green': '#47A37F',
'light-green': '#64C46F',
'light-red': '#FC9598',
'red': '#E05458',
};
const semiFillMap = {
'semi': '#F5F9F7',
};
const colors = {
shape: colorMap,
fill: fillMap,
semi: semiFillMap,
sticky: stickyMap,
};
return colors[colorType][color] || '#0d0d0d';
}
/**
* Determines SVG style attributes based on the dash type.
*
* @param {string} dash - The type of dash ('dashed', 'dotted').
* @param {string} size - The size ('s', 'm', 'l', 'xl').
*
* @return {string} A string representing the SVG attributes
* for the given dash and gap.
*/
static determineDasharray(dash, size) {
const gapSettings = {
'dashed': {
's': '4.37 4.91',
'm': '8.16 10.21',
'l': '11.85 14.81',
'xl': '21.41 32.12',
'default': '8 8',
},
'dotted': {
's': '0.02 4',
'm': '0.03 8',
'l': '0.05 12',
'xl': '0.12 16',
'default': '0.03 8',
},
};
const gap = gapSettings[dash]?.[size] ||
gapSettings[dash]?.['default'] ||
'0';
const dashSettings = {
'dashed': `stroke-linecap:butt;stroke-dasharray:${gap};`,
'dotted': `stroke-linecap:round;stroke-dasharray:${gap};`,
};
return dashSettings[dash] || 'stroke-linejoin:round;stroke-linecap:round;';
}
/**
* Get the stroke width based on the size.
*
* @param {string} size - The size of the stroke ('s', 'm', 'l', 'xl').
* @return {number} - The corresponding stroke width.
*/
static getStrokeWidth(size) {
const strokeWidths = {
's': 2,
'm': 3.5,
'l': 5,
'xl': 7.5,
};
return strokeWidths[size] || 1;
}
/**
* Get the font size in pixels.
*
* @param {string} size - The size of the font ('s', 'm', 'l', 'xl').
* @return {number} - The corresponding font size, in pixels.
*/
static determineFontSize(size) {
const fontSizes = {
's': 26,
'm': 36,
'l': 54,
'xl': 64,
};
return fontSizes[size] || 16;
}
/**
* Aligns horizontally based on the given alignment type.
*
* @param {string} align - One of ('start', 'middle', 'end').
* @param {number} width - The width of the container.
* @return {string} The calculated horizontal position as a string with
* two decimal places. Coordinates are relative to the container.
* @static
*/
static alignHorizontally(align, width) {
switch (align) {
case 'middle': return (width / 2).toFixed(2);
case 'end': return (width).toFixed(2);
default: return '0';
}
}
/**
* Aligns vertically based on the given alignment type.
*
* @param {string} align - One of ('start', 'middle', 'end').
* @param {number} height - The height of the container.
* @return {string} The calculated vertical position as a string with
* two decimal places. Coordinates are relative to the container.
* @static
*/
static alignVertically(align, height) {
switch (align) {
case 'middle': return (height / 2).toFixed(2);
case 'end': return height.toFixed(2);
default: return '0';
}
}
/**
* Determines the font to use based on the specified font family.
* Supported families are 'draw', 'sans', 'serif', and 'mono'. Any other input
* defaults to the Caveat Brush font.
*
* @param {string} family The name of the font family.
* @return {string} The font that corresponds to the given family.
* @static
*/
static determineFontFromFamily(family) {
switch (family) {
case 'sans': return 'Source Sans Pro';
case 'serif': return 'Crimson Pro';
case 'mono': return 'Source Code Pro';
case 'draw':
default: return 'Caveat Brush';
}
}
/**
* Measures the width of a given text string using font metrics.
* @param {string} text - The text to measure.
* @param {opentype.Font} font - The loaded font object.
* @param {number} fontSize - The size of the font.
* @return {number} The width of the text.
*/
measureTextWidth(text, font, fontSize) {
const scale = 1 / font.unitsPerEm * fontSize;
const glyphs = font.stringToGlyphs(text);
let width = 0;
glyphs.forEach((glyph) => {
if (glyph.advanceWidth) {
width += glyph.advanceWidth * scale;
}
});
return width;
}
/**
* Wraps text to fit within a specified width and height.
* @param {string} text - The text to wrap.
* @param {number} width - The width of the bounding box.
* @return {string[]} An array of strings, each being a line.
*/
wrapText(text, width) {
const config = JSON.parse(
fs.readFileSync(
'./config/settings.json',
'utf8'));
const font = this.props?.font || 'draw';
const fontPath = config.fonts[font];
const words = text.split(' ');
let line = '';
const lines = [];
// Read the font file into a Buffer
const fontBuffer = fs.readFileSync(fontPath);
// Convert the Buffer to an ArrayBuffer
const arrayBuffer = fontBuffer.buffer.slice(
fontBuffer.byteOffset,
fontBuffer.byteOffset + fontBuffer.byteLength);
// Parse the font using the ArrayBuffer
const parsedFont = opentype.parse(arrayBuffer);
const fontSize = Shape.determineFontSize(this.size);
for (const word of words) {
const testLine = line + word + ' ';
const testWidth = this.measureTextWidth(
testLine,
parsedFont,
fontSize);
if (testWidth > width) {
if (line !== '') {
lines.push(line);
}
line = word + ' ';
} else {
line = testLine;
}
}
if (line !== '') {
lines.push(line.trim());
}
// Split newlines into separate lines
const brokenLines = lines
.map((line) => line.split('\n'))
.flat();
return brokenLines;
}
/**
* Draws label text on the SVG canvas.
* @param {SVGG} group The SVG group element to add the label to.
*/
drawLabel(group) {
// Do nothing if there is no text
if (!this.text) return;
// Sticky notes have a width and height of 200 and can't be resized,
// unless the text becomes too long.
if (!this.w) {
this.w = 200;
}
if (!this.h) {
this.h = 200;
}
if (!this.growY) {
this.growY = 0;
}
const width = this.w;
const height = this.h + this.growY;
const x = Shape.alignHorizontally(this.align, width);
let y = Shape.alignVertically(this.verticalAlign, height);
const lineHeight = Shape.determineFontSize(this.size);
const fontFamily = Shape.determineFontFromFamily(this.props?.font);
if (this.verticalAlign === 'end' || this.verticalAlign === 'middle') {
y -= (lineHeight / 2);
}
// Create a new SVG text element
// Text is escaped by SVG.js
const textElement = new Text()
.move(x, y)
.font({
'family': fontFamily,
'size': lineHeight,
'anchor': this.align,
'alignment-baseline': 'baseline',
});
const lines = this.wrapText(this.text, width);
lines.forEach((line) => {
const tspan = new Tspan()
.text(line)
.attr({
x: x,
dy: lineHeight,
});
textElement.add(tspan);
});
// Set the fill color for the text
textElement.fill(this.labelColor || 'black');
// If there's a URL, make the text clickable
if (this.url) {
textElement.linkTo(this.url);
}
group.add(textElement);
}
/**
* Placeholder method for drawing the shape.
* Intended to be overridden by subclasses.
*
* @method draw
* @return {G} An empty SVG group element.
*/
draw() {
return new G();
}
}
/**
* An object representing various types of colors used in shapes.
* This object is frozen to prevent modifications.
*
* @const {ColorTypes}
*/
export const ColorTypes = Object.freeze({
ShapeColor: 'shape',
FillColor: 'fill',
SemiFillColor: 'semi',
StickyColor: 'sticky',
});

View File

@ -0,0 +1,90 @@
import {Polygon as SVGPolygon} from '@svgdotjs/svg.js';
import {Geo} from './Geo.js';
import {TAU} from './helpers.js';
/**
* Creates an SVG star shape from Tldraw v2 JSON data.
*
* @class Star
* @extends {Geo}
*/
export class Star extends Geo {
/**
* Calculates the vertices of an n-point star.
*
* @param {number} w - The width of the bounding box.
* @param {number} h - The height of the bounding box.
* @param {number} n - The number of points on the star.
* @return {Array} - An array of {x, y} objects representing star vertices.
* @see {@link https://github.com/tldraw/tldraw/blob/main/packages/editor/src/lib/primitives/utils.ts} Adapted from Tldraw.
*/
getStarVertices(w, h, n) {
const sides = n;
const step = TAU / sides / 2;
const rightMostIndex = Math.floor(sides / 4) * 2;
const leftMostIndex = sides * 2 - rightMostIndex;
const topMostIndex = 0;
const bottomMostIndex = Math.floor(sides / 2) * 2;
const maxX = (Math.cos(-(TAU/4) + rightMostIndex * step) * w) / 2;
const minX = (Math.cos(-(TAU/4) + leftMostIndex * step) * w) / 2;
const minY = (Math.sin(-(TAU/4) + topMostIndex * step) * h) / 2;
const maxY = (Math.sin(-(TAU/4) + bottomMostIndex * step) * h) / 2;
const diffX = w - Math.abs(maxX - minX);
const diffY = h - Math.abs(maxY - minY);
const offsetX = w / 2 + minX - (w / 2 - maxX);
const offsetY = h / 2 + minY - (h / 2 - maxY);
const ratio = 1;
const cx = (w - offsetX) / 2;
const cy = (h - offsetY) / 2;
const ox = (w + diffX) / 2;
const oy = (h + diffY) / 2;
const ix = (ox * ratio) / 2;
const iy = (oy * ratio) / 2;
const points = Array.from(Array(sides * 2)).map((_, i) => {
const theta = -(TAU/4) + i * step;
return {
x: cx + (i % 2 ? ix : ox) * Math.cos(theta),
y: cy + (i % 2 ? iy : oy) * Math.sin(theta),
};
});
return points;
}
/**
* Draws a star shape on the SVG canvas.
* @return {G} Returns the SVG group element containing the star.
*/
draw() {
const width = this.w;
const height = this.h + this.growY;
// Get the vertices of the star
const pointsOnPerimeter = this.getStarVertices(width, height, 5);
// Convert the vertices to SVG polygon points format
const points = pointsOnPerimeter.map((p) => `${p.x},${p.y}`).join(' ');
// Create the SVG polygon
const starGroup = this.shapeGroup;
const star = new SVGPolygon({
points,
'stroke': this.shapeColor,
'stroke-width': this.thickness,
'style': this.dasharray,
});
// Fill the polygon if required
this.setFill(star);
starGroup.add(star);
this.drawLabel(starGroup);
return starGroup;
}
}

View File

@ -0,0 +1,53 @@
import {Rect} from '@svgdotjs/svg.js';
import {Shape, ColorTypes} from './Shape.js';
/**
* Represents a sticky note, extending the Shape class.
* @extends {Shape}
*/
export class StickyNote extends Shape {
/**
* Creates an instance of a StickyNote.
* @param {Object} params - Parameters for the sticky note.
* @param {string} [params.url] - URL associated with the sticky note.
* @param {string} [params.text=""] - Text content of the sticky note.
* @param {string} [params.align] - Text alignment within the sticky note.
* @param {string} [params.verticalAlign] - Vertical text alignment.
* @param {number} [params.growY] - Additional height for long notes.
* @param {ColorTypes} [params.color] - Color category for the sticky note.
*/
constructor(params) {
super(params);
this.url = this.props?.url;
this.text = this.props?.text || '';
this.align = this.props?.align;
this.verticalAlign = this.props?.verticalAlign;
this.growY = this.props?.growY;
this.shapeColor = Shape.colorToHex(this.color, ColorTypes.StickyColor);
}
/**
* Draws the sticky note and adds it to the SVG.
* Overrides the placeholder draw method in the Shape base class.
* @override
* @method draw
* @return {G} An SVG group element containing the note.
*/
draw() {
const stickyNote = this.shapeGroup;
const rectW = 200;
const rectH = 200 + this.growY;
const cornerRadius = 10;
// Create rectangle element
const rect = new Rect()
.size(rectW, rectH)
.radius(cornerRadius)
.fill(this.shapeColor);
stickyNote.add(rect);
this.drawLabel(stickyNote);
return stickyNote;
}
}

View File

@ -0,0 +1,59 @@
import {Text} from '@svgdotjs/svg.js';
import {Shape} from './Shape.js';
/**
* Draws the text shape on the SVG canvas, aligning and styling it
* based on the provided properties.
* @override
* @return {G} The SVG group element containing the text element.
*/
export class TextShape extends Shape {
/**
* Constructs a new TextShape instance with the given parameters.
* Inherits from Shape and initializes text-specific properties.
*
* @param {Object} params - The configuration object for the text shape.
* @param {string} [params.text=""] - The text content for the shape.
* @param {string} [params.align] - The horizontal text alignment.
* @param {number} [params.w] - The width of the shape.
* @param {number} [params.h] - The height of the shape.
* @param {string} [params.font] - The font family for the text.
*/
constructor(params) {
super(params);
this.text = this.props?.text || '';
this.align = this.props?.align;
this.w = this.props?.w;
this.h = this.props?.h;
this.fontSize = Shape.determineFontSize(this.size);
this.fontFamily = Shape.determineFontFromFamily(this.props?.font);
}
/**
* Draws the text shape and adds it to the SVG.
* Overrides the placeholder draw method in the Shape base class.
* @override
* @method draw
* @return {G} An SVG group element containing the text.
*/
draw() {
const x = Shape.alignHorizontally(this.align, this.w);
const y = 0;
const textGroup = this.shapeGroup;
const textElement = new Text()
.text(this.text)
.move(x, y)
.font({
'family': this.fontFamily,
'size': this.fontSize,
'anchor': this.align,
'alignment-baseline': 'middle',
})
.fill(this.shapeColor);
textGroup.add(textElement);
return textGroup;
}
}

View File

@ -0,0 +1,45 @@
import {Polygon as SVGPolygon} from '@svgdotjs/svg.js';
import {Geo} from './Geo.js';
/**
* Creates an SVG trapezoid shape from Tldraw v2 JSON data.
*
* @class Trapezoid
* @extends {Geo}
*/
export class Trapezoid extends Geo {
/**
* Draws a trapezoid shape on the SVG canvas.
* @return {G} Returns the SVG group element containing the trapezoid.
*/
draw() {
const width = this.w;
const height = this.h + this.growY;
// Adjust this value as needed for the trapezoid
const topWidth = width * 0.6;
const xOffset = (width - topWidth) / 2;
// Shape begins from the upper left corner
const points = [
[xOffset, 0],
[xOffset + topWidth, 0],
[width, height],
[0, height],
].map((p) => p.join(',')).join(' ');
const trapezoidGroup = this.shapeGroup;
const trapezoid = new SVGPolygon({
points,
'stroke': this.shapeColor,
'stroke-width': this.thickness,
'style': this.dasharray,
});
this.setFill(trapezoid);
trapezoidGroup.add(trapezoid);
this.drawLabel(trapezoidGroup);
return trapezoidGroup;
}
}

View File

@ -0,0 +1,41 @@
import {Polygon as SVGPolygon} from '@svgdotjs/svg.js';
import {Geo} from './Geo.js';
/**
* Creates an SVG triangle shape from Tldraw v2 JSON data.
*
* @class Triangle
* @extends {Geo}
*/
export class Triangle extends Geo {
/**
* Draws a triangle shape on the SVG canvas.
* @return {G} Returns the SVG group element containing the triangle.
*/
draw() {
const width = this.w;
const height = this.h + this.growY;
const halfWidth = width / 2;
// Shape begins from the upper left corner
const points = [
[halfWidth, 0],
[width, height],
[0, height],
].map((p) => p.join(',')).join(' ');
const triangleGroup = this.shapeGroup;
const triangle = new SVGPolygon({
points,
'stroke': this.shapeColor,
'stroke-width': this.thickness,
'style': this.dasharray,
});
this.setFill(triangle);
triangleGroup.add(triangle);
this.drawLabel(triangleGroup);
return triangleGroup;
}
}

View File

@ -0,0 +1,40 @@
import {Line} from '@svgdotjs/svg.js';
import {Rectangle} from './Rectangle.js';
/**
* Creates an SVG "XBox" shape, which is a rectangle
* with an "X" drawn through it.
*
* @class XBox
* @extends {Rectangle}
*/
export class XBox extends Rectangle {
/**
* Draws an "XBox" shape on the SVG canvas.
* @return {G} Returns the SVG group element
* containing the rectangle and the X.
*/
draw() {
// Draw the base rectangle
const rectGroup = super.draw();
// Add the first diagonal line from upper-left to lower-right
const line1 = new Line();
line1.plot(0, 0, this.w, this.h + this.growY)
.stroke({color: this.shapeColor, width: this.thickness})
.style({dasharray: this.dasharray});
// Add the second diagonal line from upper-right to lower-left
const line2 = new Line();
line2.plot(this.w, 0, 0, this.h + this.growY)
.stroke({color: this.shapeColor, width: this.thickness})
.style({dasharray: this.dasharray});
// Add the lines to the group
rectGroup.add(line1);
rectGroup.add(line2);
this.drawLabel(rectGroup);
return rectGroup;
}
}

View File

@ -0,0 +1,64 @@
import {Geo} from './Geo.js';
import {Rectangle} from './Rectangle.js';
import {Ellipse} from './Ellipse.js';
import {Diamond} from './Diamond.js';
import {Triangle} from './Triangle.js';
import {Trapezoid} from './Trapezoid.js';
import {Rhombus} from './Rhombus.js';
import {Hexagon} from './Hexagon.js';
import {Oval} from './Oval.js';
import {Star} from './Star.js';
import {ArrowRight} from './ArrowRight.js';
import {ArrowLeft} from './ArrowLeft.js';
import {ArrowUp} from './ArrowUp.js';
import {ArrowDown} from './ArrowDown.js';
import {XBox} from './XBox.js';
import {Checkbox} from './Checkbox.js';
import {Cloud} from './Cloud.js';
/**
* Creates a geometric object instance based on the provided annotations.
*
* @function createGeoObject
* @param {Object} annotations - The annotations for the geometric object.
* @param {Object} [annotations.props] - The properties of the annotations.
* @param {String} [annotations.props.geo] - Which geometric object to create.
* @return {Geo} The created geometric object.
*/
export function createGeoObject(annotations) {
switch (annotations.props?.geo) {
case 'rectangle':
return new Rectangle(annotations);
case 'ellipse':
return new Ellipse(annotations);
case 'diamond':
return new Diamond(annotations);
case 'triangle':
return new Triangle(annotations);
case 'trapezoid':
return new Trapezoid(annotations);
case 'rhombus':
return new Rhombus(annotations);
case 'hexagon':
return new Hexagon(annotations);
case 'oval':
return new Oval(annotations);
case 'star':
return new Star(annotations);
case 'arrow-right':
return new ArrowRight(annotations);
case 'arrow-left':
return new ArrowLeft(annotations);
case 'arrow-up':
return new ArrowUp(annotations);
case 'arrow-down':
return new ArrowDown(annotations);
case 'x-box':
return new XBox(annotations);
case 'check-box':
return new Checkbox(annotations);
case 'cloud':
return new Cloud(annotations);
default:
return new Geo(annotations);
}
}

View File

@ -0,0 +1,245 @@
/**
* Represents the constant TAU, which is equal to 2 * PI.
*
* TAU is often used in trigonometric calculations as it represents
* one full turn in radians, making it more intuitive than using 2 * PI.
* For example, half a circle is TAU / 2, a quarter is TAU / 4, etc.,
* which makes the math easier to follow.
*
* @constant {number}
*/
export const TAU = Math.PI * 2;
/**
* Sorts an array of objects lexicographically based on a nested key-value pair.
*
* @param {Array} array - The array to be sorted.
* @param {string} key - The key in each object to be used for sorting.
* @param {string} value - The nested key within the 'key' object to be used
* for sorting.
* @return {Array} - Returns a new array sorted lexicographically
* by the specified nested key-value pair.
*
* @example
* const data = [
* {annotationInfo: {index: 'a1V'}, type: 'shape'},
* {annotationInfo: {index: 'a2'}, type: 'shape'},
* {annotationInfo: {index: 'a1'}, type: 'draw'}
* ];
* const sortedData = sortByKey(data, 'annotationInfo', 'index');
* // Output: [{ annotationInfo: { index: 'a1' }, type: 'draw' },
* { annotationInfo: { index: 'a1V' }, type: 'shape' },
* { annotationInfo: { index: 'a2' }, type: 'shape' }]
*/
export function sortByKey(array, key, value) {
return array.sort((a, b) => {
const [x, y] = [a[key][value], b[key][value]];
return x.localeCompare(y);
});
}
/**
* Converts an angle from radians to degrees.
*
* @param {number} angle - The angle in radians.
* @return {number} The angle in degrees, fixed to two decimal places.
*/
export function radToDegree(angle) {
return parseFloat(angle * (360 / TAU)).toFixed(2) || 0;
}
/**
* Random number generator based on a seed value.
* This uses a variation of the xorshift algorithm to generate
* pseudo-random numbers. The function returns a `next` function that,
* when called, generates the next random number in sequence.
*
* @param {string} [seed=''] - The seed value for the random number generator.
* Default is an empty string.
* @return {Function} The `next` function to generate random numbers.
* @see {@link https://github.com/tldraw/tldraw/blob/main/packages/utils/src/lib/number.ts} Adapted from Tldraw.
*/
export function rng(seed = '') {
let x = 0;
let y = 0;
let z = 0;
let w = 0;
/**
* Generates the next number in the pseudo-random sequence using bitwise
* operations. This function uses a form of 'xorshift', a type of pseudo-
* random number generator algorithm. It manipulates four state variables
* \( x, y, z, w \) with bitwise operations to produce a new random number
* upon each call. The returned value is scaled to the range [0, 2).
* @return {number} The next pseudo-random number within [0, 2).
*/
function next() {
const t = x ^ (x << 11);
x = y;
y = z;
z = w;
w ^= ((w >>> 19) ^ t ^ (t >>> 8)) >>> 0;
return (w / 0x100000000) * 2;
}
for (let k = 0; k < seed.length + 64; k++) {
x ^= seed.charCodeAt(k) | 0;
next();
}
return next;
}
/**
* Get a point on the perimeter of a circle.
*
* @param {number} cx - The center x of the circle.
* @param {number} cy - The center y of the circle.
* @param {number} r - The radius of the circle.
* @param {number} a - The angle in radians to get the point from.
* @return {Object} A point object with 'x' and 'y' properties
* @public
*/
export function getPointOnCircle(cx, cy, r, a) {
return {
x: cx + r * Math.cos(a),
y: cy + r * Math.sin(a),
};
}
/**
* Calculates the angle (in radians) between a center point and another point
* using the arctangent of the quotient of their coordinates.
* The angle is measured in the coordinate system where x-axis points to the
* right and y-axis points down. The angle is measured counterclockwise
* from the positive x-axis.
*
* @param {Object} center - The center point with x and y coordinates.
* @param {number} center.x - The x-coordinate of the center point.
* @param {number} center.y - The y-coordinate of the center point.
*
* @param {Object} point - The other point with x and y coordinates.
* @param {number} point.x - The x-coordinate of the other point.
* @param {number} point.y - The y-coordinate of the other point.
*
* @return {number} The angle in radians between the line from the center
* to the other point and the positive x-axis.
*/
export function angle(center, point) {
const dy = point.y - center.y;
const dx = point.x - center.x;
return Math.atan2(dy, dx);
}
/**
* Calculate the clockwise angular distance between two angles.
*
* This function takes two angles in radians and calculates the
* shortest angular distance between them in the clockwise direction.
* The result is also in radians and accounts for full circle rotation.
*
* @param {number} startAngle - The starting angle in radians.
* @param {number} endAngle - The ending angle in radians.
* @return {number} The Clockwise angular distance in radians
* between the start and end angles.
*/
export function clockwiseAngleDist(startAngle, endAngle) {
let l = endAngle - startAngle;
if (l < 0) {
l += TAU;
}
return l;
}
/**
* Calculate the distance between two points.
*
* @param {Object} point1 - The first point, represented as an object {x, y}.
* @param {Object} point2 - The second point, represented as an object {x, y}.
* @return {number} - The calculated distance.
*/
export function calculateDistance(point1, point2) {
const dx = point2.x - point1.x;
const dy = point2.y - point1.y;
return Math.sqrt(dx * dx + dy * dy);
}
/**
* Calculate the circle that passes through three points A, B, and C.
* Returns the circle's center as [x, y] and its radius.
*
* @param {number[]} A - Point A as [x1, y1].
* @param {number[]} B - Point B as [x2, y2].
* @param {number[]} C - Point C as [x3, y3].
* @return {number[]|null} - The circle's center [x, y] and radius,
* or null if the points are collinear.
*/
export function circleFromThreePoints(A, B, C) {
const [x1, y1] = A;
const [x2, y2] = B;
const [x3, y3] = C;
const a = x1 * (y2 - y3) - y1 * (x2 - x3) + x2 * y3 - x3 * y2;
if (a === 0) {
return null;
}
const b =
(x1 * x1 + y1 * y1) * (y3 - y2) +
(x2 * x2 + y2 * y2) * (y1 - y3) +
(x3 * x3 + y3 * y3) * (y2 - y1);
const c =
(x1 * x1 + y1 * y1) * (x2 - x3) +
(x2 * x2 + y2 * y2) * (x3 - x1) +
(x3 * x3 + y3 * y3) * (x1 - x2);
const x = -b / (2 * a);
const y = -c / (2 * a);
return [x, y, Math.hypot(x - x1, y - y1)];
}
/**
* Normalize a 2D vector represented as an array [x, y].
*
* @param {Array<number>} A - The 2D vector to normalize.
* @return {Array<number>} The normalized vector,
*/
export function normalize(A) {
const length = Math.sqrt(A[0] * A[0] + A[1] * A[1]);
return [A[0] / length, A[1] / length];
}
/**
* Rotates a vector [x,y] 90 degrees counter-clockwise.
*
* @param {Array<number>} vec - The 2D vector to rotate.
* @return {Array<number>} The rotated vector.
*/
export function rotate(vec) {
const [x, y] = vec;
return [y, -x];
}
/**
* Escapes special characters in a string to their corresponding HTML entities
* to prevent misinterpretation of HTML content. This function converts
* ampersands, single quotes, double quotes, greater-than signs, and
* less-than signs to their corresponding HTML entity codes, making it safe
* to insert the string into HTML or XML content where these characters would
* otherwise be mistaken for markup.
*
* @param {string} string - The string to be escaped.
* @return {string} The escaped string with HTML entities.
*/
export function escapeSVGText(string) {
return string
.replace(/&/g, '\\&amp;') // Escape ampersands.
.replace(/'/g, '\\&apos;') // Escape single quotes.
.replace(/"/g, '\\&quot;') // Escape double quotes.
.replace(/>/g, '\\&gt;') // Escape greater-than signs.
.replace(/</g, '\\&lt;'); // Escape less-than signs.
}

View File

@ -1,21 +1,20 @@
const Logger = require('../lib/utils/logger');
const axios = require('axios').default;
const config = require('../config');
const cp = require('child_process');
const fs = require('fs');
const path = require('path');
const redis = require('redis');
const sanitize = require('sanitize-filename');
const stream = require('stream');
const WorkerStarter = require('../lib/utils/worker-starter');
const {PresAnnStatusMsg} = require('../lib/utils/message-builder');
const {workerData} = require('worker_threads');
const {promisify} = require('util');
import Logger from '../lib/utils/logger.js';
import axios from 'axios';
import fs from 'fs';
import path from 'path';
import redis from 'redis';
import sanitize from 'sanitize-filename';
import stream from 'stream';
import WorkerStarter from '../lib/utils/worker-starter.js';
import {PresAnnStatusMsg} from '../lib/utils/message-builder.js';
import {workerData} from 'worker_threads';
import {promisify} from 'util';
const jobId = workerData.jobId;
const logger = new Logger('presAnn Collector');
logger.info(`Collecting job ${jobId}`);
const config = JSON.parse(fs.readFileSync('./config/settings.json', 'utf8'));
const dropbox = path.join(config.shared.presAnnDropboxDir, jobId);
// Takes the Job from the dropbox
@ -23,11 +22,26 @@ const job = fs.readFileSync(path.join(dropbox, 'job'));
const exportJob = JSON.parse(job);
const jobType = exportJob.jobType;
/**
* Asynchronously collects annotations from Redis, processes them,
* and handles the collection of presentation page files. It removes
* the annotations from Redis after collection, writes them to a file,
* and manages the retrieval of SVGs, PNGs, or JPEGs. Errors during the
* process are logged, and the status of the operation is published to
* a Redis channel.
*
* @async
* @function collectAnnotationsFromRedis
* @throws Will log an error if an error occurs in connecting to Redis.
* @return {Promise<void>} Resolves when the function has completed its task.
*/
async function collectAnnotationsFromRedis() {
const client = redis.createClient({
host: config.redis.host,
port: config.redis.port,
password: config.redis.password,
socket: {
host: config.redis.host,
port: config.redis.port
}
});
client.on('error', (err) => logger.info('Redis Client Error', err));
@ -59,49 +73,46 @@ async function collectAnnotationsFromRedis() {
const statusUpdate = new PresAnnStatusMsg(exportJob);
if (fs.existsSync(pdfFile)) {
// If there's a PDF file, we leverage the existing converted SVG slides
for (const p of pages) {
const pageNumber = p.page;
const outputFile = path.join(dropbox, `slide${pageNumber}`);
const imageName = `slide${pageNumber}`;
const convertedSVG = path.join(
exportJob.presLocation,
'svgs',
`${imageName}.svg`);
// CairoSVG doesn't handle transparent SVG and PNG embeds properly,
// e.g., in rasterized text. So textboxes may get a black background
// when downloading/exporting repeatedly. To avoid that, we take slides
// from the uploaded file, but later probe the dimensions from the SVG
// so it matches what was shown in the browser.
const extract_png_from_pdf = [
'-png',
'-f', pageNumber,
'-l', pageNumber,
'-scale-to', config.collector.pngWidthRasterizedSlides,
'-singlefile',
'-cropbox',
pdfFile, outputFile,
];
const outputFile = path.join(dropbox, `slide${pageNumber}.svg`);
try {
cp.spawnSync(config.shared.pdftocairo, extract_png_from_pdf, {shell: false});
fs.copyFileSync(convertedSVG, outputFile);
} catch (error) {
logger.error(`PDFtoCairo failed extracting slide ${pageNumber} in job ${jobId}: ${error.message}`);
logger.error('Failed collecting slide ' + pageNumber +
' in job ' + jobId + ': ' + error.message);
statusUpdate.setError();
}
await client.publish(config.redis.channels.publish, statusUpdate.build(pageNumber));
await client.publish(
config.redis.channels.publish,
statusUpdate.build(pageNumber));
}
} else {
const imageName = 'slide1';
if (fs.existsSync(`${presFile}.png`)) {
fs.copyFileSync(`${presFile}.png`, path.join(dropbox, `${imageName}.png`));
fs.copyFileSync(`${presFile}.png`,
path.join(dropbox, `${imageName}.png`));
} else if (fs.existsSync(`${presFile}.jpeg`)) {
fs.copyFileSync(`${presFile}.jpeg`, path.join(dropbox, `${imageName}.jpeg`));
fs.copyFileSync(`${presFile}.jpeg`,
path.join(dropbox, `${imageName}.jpeg`));
} else if (fs.existsSync(`${presFile}.jpg`)) {
// JPG file available: copy changing extension to JPEG
fs.copyFileSync(`${presFile}.jpg`, path.join(dropbox, `${imageName}.jpeg`));
fs.copyFileSync(`${presFile}.jpg`,
path.join(dropbox, `${imageName}.jpeg`));
} else {
await client.publish(config.redis.channels.publish, statusUpdate.build());
client.disconnect();
return logger.error(`No PDF, PNG, JPG or JPEG file available for job ${jobId}`);
return logger.error(`PDF/PNG/JPG/JPEG file not found for job ${jobId}`);
}
await client.publish(config.redis.channels.publish, statusUpdate.build());
@ -113,6 +124,15 @@ async function collectAnnotationsFromRedis() {
process.process();
}
/**
* Creates a promise that resolves after a specified number of milliseconds,
* effectively pausing execution for that duration. Used to delay operations
* in an asynchronous function.
* @async
* @function sleep
* @param {number} ms - The amount of time in milliseconds to sleep.
* @return {Promise<void>} Resolves after the specified number of milliseconds.
*/
async function sleep(ms) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
@ -129,8 +149,10 @@ async function collectSharedNotes(retries = 3) {
const padId = exportJob.presId;
const notesFormat = 'pdf';
const serverSideFilename = `${sanitize(exportJob.serverSideFilename.replace(/\s/g, '_'))}.${notesFormat}`;
const notes_endpoint = `${config.bbbPadsAPI}/p/${padId}/export/${notesFormat}`;
const underscoredFilename = exportJob.serverSideFilename.replace(/\s/g, '_');
const sanitizedFilename = sanitize(underscoredFilename);
const serverSideFilename = `${sanitizedFilename}.${notesFormat}`;
const notesEndpoint = `${config.bbbPadsAPI}/p/${padId}/export/${notesFormat}`;
const filePath = path.join(dropbox, serverSideFilename);
const finishedDownload = promisify(stream.finished);
@ -139,7 +161,7 @@ async function collectSharedNotes(retries = 3) {
try {
const response = await axios({
method: 'GET',
url: notes_endpoint,
url: notesEndpoint,
responseType: 'stream',
});
response.data.pipe(writer);
@ -157,13 +179,21 @@ async function collectSharedNotes(retries = 3) {
}
}
const notifier = new WorkerStarter({jobType, jobId, serverSideFilename, filename: exportJob.filename});
const notifier = new WorkerStarter({jobType, jobId,
serverSideFilename, filename: exportJob.filename});
notifier.notify();
}
switch (jobType) {
case 'PresentationWithAnnotationExportJob': return collectAnnotationsFromRedis();
case 'PresentationWithAnnotationDownloadJob': return collectAnnotationsFromRedis();
case 'PadCaptureJob': return collectSharedNotes();
default: return logger.error(`Unknown job type ${jobType}`);
case 'PresentationWithAnnotationExportJob':
collectAnnotationsFromRedis();
break;
case 'PresentationWithAnnotationDownloadJob':
collectAnnotationsFromRedis();
break;
case 'PadCaptureJob':
collectSharedNotes();
break;
default:
logger.error(`Unknown job type ${jobType}`);
}

View File

@ -1,16 +1,17 @@
const Logger = require('../lib/utils/logger');
const config = require('../config');
const fs = require('fs');
const FormData = require('form-data');
const redis = require('redis');
const axios = require('axios').default;
const path = require('path');
const {NewPresFileAvailableMsg} = require('../lib/utils/message-builder');
const {workerData} = require('worker_threads');
const [jobType, jobId, serverSideFilename] = [workerData.jobType, workerData.jobId, workerData.serverSideFilename];
import Logger from '../lib/utils/logger.js';
import fs from 'fs';
import FormData from 'form-data';
import redis from 'redis';
import axios from 'axios';
import path from 'path';
import {NewPresFileAvailableMsg} from '../lib/utils/message-builder.js';
import {workerData} from 'worker_threads';
const [jobType, jobId, serverSideFilename] = [workerData.jobType,
workerData.jobId,
workerData.serverSideFilename];
const logger = new Logger('presAnn Notifier Worker');
const config = JSON.parse(fs.readFileSync('./config/settings.json', 'utf8'));
const dropbox = `${config.shared.presAnnDropboxDir}/${jobId}`;
const job = fs.readFileSync(path.join(dropbox, 'job'));
@ -20,15 +21,18 @@ const exportJob = JSON.parse(job);
* sending a message through Redis PubSub */
async function notifyMeetingActor() {
const client = redis.createClient({
host: config.redis.host,
port: config.redis.port,
password: config.redis.password,
socket: {
host: config.redis.host,
port: config.redis.port
}
});
await client.connect();
client.on('error', (err) => logger.info('Redis Client Error', err));
const link = path.join('presentation',
const link = path.join(
'presentation',
exportJob.parentMeetingId, exportJob.parentMeetingId,
exportJob.presId, 'pdf', jobId, serverSideFilename);
@ -37,14 +41,17 @@ async function notifyMeetingActor() {
logger.info(`Annotated PDF available at ${link}`);
await client.publish(config.redis.channels.publish, notification.build());
client.disconnect();
}
/** Upload PDF to a BBB room
* @param {String} filePath - Absolute path to the file, including the extension
*/
async function upload(filePath) {
const callbackUrl = `${config.bbbWebAPI}/bigbluebutton/presentation/${exportJob.presentationUploadToken}/upload`;
const apiPath = '/bigbluebutton/presentation/';
const uploadToken = exportJob.presentationUploadToken;
const uploadAction = '/upload';
const callbackUrl = config.bbbWebAPI + apiPath + uploadToken + uploadAction;
const formData = new FormData();
formData.append('conference', exportJob.parentMeetingId);
formData.append('pod_id', config.notifier.pod_id);
@ -64,7 +71,10 @@ async function upload(filePath) {
if (jobType == 'PresentationWithAnnotationDownloadJob') {
notifyMeetingActor();
} else if (jobType == 'PresentationWithAnnotationExportJob') {
const filePath = `${exportJob.presLocation}/pdfs/${jobId}/${serverSideFilename}`;
const baseDirectory = exportJob.presLocation;
const subDirectory = 'pdfs';
const filePath = path.join(baseDirectory, subDirectory,
jobId, serverSideFilename);
upload(filePath);
} else if (jobType == 'PadCaptureJob') {
const filePath = `${dropbox}/${serverSideFilename}`;

File diff suppressed because it is too large Load Diff

View File

@ -16,6 +16,7 @@ export default function buildRedisMessage(sessionVariables: Record<string, unkno
const body = {
userId: routing.userId,
networkRttInMs: input.networkRttInMs
};
return { eventName, routing, header, body };

View File

@ -1,23 +0,0 @@
import { RedisMessage } from '../types';
export default function buildRedisMessage(sessionVariables: Record<string, unknown>, input: Record<string, unknown>): RedisMessage {
const eventName = `UserConnectionUpdateRttReqMsg`;
const routing = {
meetingId: sessionVariables['x-hasura-meetingid'] as String,
userId: sessionVariables['x-hasura-userid'] as String
};
const header = {
name: eventName,
meetingId: routing.meetingId,
userId: routing.userId
};
const body = {
userId: routing.userId,
networkRttInMs: input.networkRttInMs
};
return { eventName, routing, header, body };
}

View File

@ -2,6 +2,7 @@ package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"github.com/iMDT/bbb-graphql-middleware/internal/common"
@ -10,7 +11,9 @@ import (
log "github.com/sirupsen/logrus"
"net/http"
"os"
"runtime"
"strconv"
"strings"
"time"
)
@ -25,6 +28,36 @@ func main() {
log.SetFormatter(&log.JSONFormatter{})
log := log.WithField("_routine", "main")
go func() {
for {
time.Sleep(5 * time.Second)
hasuraConnections := common.GetActivitiesOverview()["__HasuraConnection"].Started
topMessages := make(map[string]common.ActivitiesOverviewObj)
for index, item := range common.GetActivitiesOverview() {
if strings.HasPrefix(index, "_") || item.Started > hasuraConnections*3 || item.DataReceived > hasuraConnections*5 {
topMessages[index] = item
}
}
jsonOverviewBytes, err := json.Marshal(topMessages)
if err != nil {
log.Errorf("Error occurred during marshaling. Error: %s", err.Error())
}
log.WithField("data", string(jsonOverviewBytes)).Info("Top Activities Overview")
activitiesOverviewSummary := make(map[string]int64)
activitiesOverviewSummary["activeWsConnections"] = common.GetActivitiesOverview()["__WebsocketConnection"].Started - common.GetActivitiesOverview()["__WebsocketConnection"].Completed
activitiesOverviewSummary["activeBrowserHandlers"] = common.GetActivitiesOverview()["__BrowserConnection"].Started - common.GetActivitiesOverview()["__BrowserConnection"].Completed
activitiesOverviewSummary["activeSubscriptions"] = common.GetActivitiesOverview()["_Sum-subscription"].Started - common.GetActivitiesOverview()["_Sum-subscription"].Completed
activitiesOverviewSummary["pendingMutations"] = common.GetActivitiesOverview()["_Sum-mutation"].Started - common.GetActivitiesOverview()["_Sum-mutation"].Completed
activitiesOverviewSummary["numGoroutine"] = int64(runtime.NumGoroutine())
jsonOverviewSummaryBytes, _ := json.Marshal(activitiesOverviewSummary)
log.WithField("data", string(jsonOverviewSummaryBytes)).Info("Activities Overview Summary")
}
}()
common.InitUniqueID()
log = log.WithField("graphql-middleware-uid", common.GetUniqueID())
@ -36,6 +69,10 @@ func main() {
// Listen msgs from akka (for example to invalidate connection)
go websrv.StartRedisListener()
if jsonPatchDisabled := os.Getenv("BBB_GRAPHQL_MIDDLEWARE_JSON_PATCH_DISABLED"); jsonPatchDisabled != "" {
log.Infof("Json Patch Disabled!")
}
// Websocket listener
//Define IP to listen
@ -65,6 +102,9 @@ func main() {
ctx, cancel := context.WithTimeout(r.Context(), 120*time.Second)
defer cancel()
common.ActivitiesOverviewStarted("__WebsocketConnection")
defer common.ActivitiesOverviewCompleted("__WebsocketConnection")
if err := rateLimiter.Wait(ctx); err != nil {
if !errors.Is(err, context.Canceled) {
http.Error(w, "Request cancelled or rate limit exceeded", http.StatusTooManyRequests)

View File

@ -2,6 +2,7 @@ package common
import (
"github.com/google/uuid"
"sync"
)
var uniqueID string
@ -13,3 +14,61 @@ func InitUniqueID() {
func GetUniqueID() string {
return uniqueID
}
type ActivitiesOverviewObj struct {
Started int64
Completed int64
DataReceived int64
}
var activitiesOverview = make(map[string]ActivitiesOverviewObj)
var activitiesOverviewMux = sync.Mutex{}
func ActivitiesOverviewStarted(index string) {
activitiesOverviewMux.Lock()
defer activitiesOverviewMux.Unlock()
if _, exists := activitiesOverview[index]; !exists {
activitiesOverview[index] = ActivitiesOverviewObj{
Started: 0,
Completed: 0,
DataReceived: 0,
}
}
updatedValues := activitiesOverview[index]
updatedValues.Started++
activitiesOverview[index] = updatedValues
}
func ActivitiesOverviewDataReceived(index string) {
activitiesOverviewMux.Lock()
defer activitiesOverviewMux.Unlock()
if updatedValues, exists := activitiesOverview[index]; exists {
updatedValues.DataReceived++
activitiesOverview[index] = updatedValues
}
}
func ActivitiesOverviewCompleted(index string) {
activitiesOverviewMux.Lock()
defer activitiesOverviewMux.Unlock()
if updatedValues, exists := activitiesOverview[index]; exists {
updatedValues.Completed++
activitiesOverview[index] = updatedValues
}
}
func GetActivitiesOverview() map[string]ActivitiesOverviewObj {
activitiesOverviewMux.Lock()
defer activitiesOverviewMux.Unlock()
return activitiesOverview
}

View File

@ -24,6 +24,18 @@ var hasuraEndpoint = os.Getenv("BBB_GRAPHQL_MIDDLEWARE_HASURA_WS")
// Hasura client connection
func HasuraClient(browserConnection *common.BrowserConnection, cookies []*http.Cookie, fromBrowserToHasuraChannel *common.SafeChannel, fromHasuraToBrowserChannel *common.SafeChannel) error {
log := log.WithField("_routine", "HasuraClient").WithField("browserConnectionId", browserConnection.Id)
common.ActivitiesOverviewStarted("__HasuraConnection")
defer common.ActivitiesOverviewCompleted("__HasuraConnection")
defer func() {
//Remove subscriptions from ActivitiesOverview here once Hasura-Reader will ignore "complete" msg for them
browserConnection.ActiveSubscriptionsMutex.RLock()
for _, subscription := range browserConnection.ActiveSubscriptions {
common.ActivitiesOverviewStarted(string(subscription.Type) + "-" + subscription.OperationName)
common.ActivitiesOverviewStarted("_Sum-" + string(subscription.Type))
}
browserConnection.ActiveSubscriptionsMutex.RUnlock()
}()
// Obtain id for this connection
lastHasuraConnectionId++

View File

@ -61,6 +61,12 @@ func handleMessageReceivedFromHasura(hc *common.HasuraConnection, fromHasuraToBr
//When Hasura send msg type "complete", this query is finished
if messageType == "complete" {
handleCompleteMessage(hc, queryId)
common.ActivitiesOverviewCompleted(string(subscription.Type) + "-" + subscription.OperationName)
common.ActivitiesOverviewCompleted("_Sum-" + string(subscription.Type))
}
if messageType == "data" {
common.ActivitiesOverviewDataReceived(string(subscription.Type) + "-" + subscription.OperationName)
}
if messageType == "data" &&

View File

@ -1,10 +1,13 @@
package writer
import (
"context"
"errors"
"github.com/iMDT/bbb-graphql-middleware/internal/common"
"github.com/iMDT/bbb-graphql-middleware/internal/msgpatch"
log "github.com/sirupsen/logrus"
"nhooyr.io/websocket/wsjson"
"os"
"strings"
"sync"
)
@ -105,6 +108,9 @@ RangeLoop:
if ok && strings.HasPrefix(operationName, "Patched_") {
jsonPatchSupported = true
}
if jsonPatchDisabled := os.Getenv("BBB_GRAPHQL_MIDDLEWARE_JSON_PATCH_DISABLED"); jsonPatchDisabled != "" {
jsonPatchSupported = false
}
browserConnection.ActiveSubscriptionsMutex.Lock()
browserConnection.ActiveSubscriptions[queryId] = common.GraphQlSubscription{
@ -121,12 +127,21 @@ RangeLoop:
}
// log.Tracef("Current queries: %v", browserConnection.ActiveSubscriptions)
browserConnection.ActiveSubscriptionsMutex.Unlock()
common.ActivitiesOverviewStarted(string(messageType) + "-" + operationName)
common.ActivitiesOverviewStarted("_Sum-" + string(messageType))
}
if fromBrowserMessageAsMap["type"] == "stop" {
var queryId = fromBrowserMessageAsMap["id"].(string)
browserConnection.ActiveSubscriptionsMutex.RLock()
jsonPatchSupported := browserConnection.ActiveSubscriptions[queryId].JsonPatchSupported
//Remove subscriptions from ActivitiesOverview here once Hasura-Reader will ignore "complete" msg for them
common.ActivitiesOverviewCompleted(string(browserConnection.ActiveSubscriptions[queryId].Type) + "-" + browserConnection.ActiveSubscriptions[queryId].OperationName)
common.ActivitiesOverviewCompleted("_Sum-" + string(browserConnection.ActiveSubscriptions[queryId].Type))
browserConnection.ActiveSubscriptionsMutex.RUnlock()
if jsonPatchSupported {
msgpatch.RemoveConnSubscriptionCacheFile(browserConnection, queryId)
@ -144,7 +159,9 @@ RangeLoop:
log.Tracef("sending to hasura: %v", fromBrowserMessageAsMap)
err := wsjson.Write(hc.Context, hc.Websocket, fromBrowserMessageAsMap)
if err != nil {
log.Errorf("error on write (we're disconnected from hasura): %v", err)
if !errors.Is(err, context.Canceled) {
log.Errorf("error on write (we're disconnected from hasura): %v", err)
}
return
}
}

View File

@ -29,6 +29,8 @@ var BrowserConnectionsMutex = &sync.RWMutex{}
// This is the connection that comes from browser
func ConnectionHandler(w http.ResponseWriter, r *http.Request) {
log := log.WithField("_routine", "ConnectionHandler")
common.ActivitiesOverviewStarted("__BrowserConnection")
defer common.ActivitiesOverviewCompleted("__BrowserConnection")
// Obtain id for this connection
lastBrowserConnectionId++
@ -48,6 +50,7 @@ func ConnectionHandler(w http.ResponseWriter, r *http.Request) {
}
browserWsConn, err := websocket.Accept(w, r, &acceptOptions)
browserWsConn.SetReadLimit(9999999) //10MB
if err != nil {
log.Errorf("error: %v", err)
}

View File

@ -39,7 +39,7 @@ func BrowserConnectionReader(browserConnectionId string, ctx context.Context, ct
if errors.Is(err, context.Canceled) {
log.Debugf("Closing Browser ws connection as Context was cancelled!")
} else {
log.Debugf("Hasura is disconnected, skipping reading of ws message: %v", err)
log.Debugf("Browser is disconnected, skipping reading of ws message: %v", err)
}
return
}

View File

@ -670,10 +670,9 @@ JOIN "user" u ON u."userId" = "user_breakoutRoom"."userId";
CREATE TABLE "user_connectionStatus" (
"userId" varchar(50) PRIMARY KEY REFERENCES "user"("userId") ON DELETE CASCADE,
"meetingId" varchar(100) REFERENCES "meeting"("meetingId") ON DELETE CASCADE,
"connectionAliveAtMaxIntervalMs" numeric,
"connectionAliveAt" timestamp with time zone,
"userClientResponseAt" timestamp with time zone,
"networkRttInMs" numeric,
"applicationRttInMs" numeric,
"status" varchar(25),
"statusUpdatedAt" timestamp with time zone
);
@ -681,9 +680,30 @@ create index "idx_user_connectionStatus_meetingId" on "user_connectionStatus"("m
create view "v_user_connectionStatus" as select * from "user_connectionStatus";
--Populate connectionAliveAtMaxIntervalMs to calc clientNotResponding
--It will sum settings public.stats.interval + public.stats.rtt (critical)
CREATE OR REPLACE FUNCTION "update_connectionAliveAtMaxIntervalMs"()
RETURNS TRIGGER AS $$
BEGIN
SELECT ("clientSettingsJson"->'public'->'stats'->'rtt'->>(jsonb_array_length("clientSettingsJson"->'public'->'stats'->'rtt') - 1))::int
+
("clientSettingsJson"->'public'->'stats'->'interval')::int
INTO NEW."connectionAliveAtMaxIntervalMs"
from "meeting_clientSettings" mcs
where mcs."meetingId" = NEW."meetingId";
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER "trigger_update_connectionAliveAtMaxIntervalMs"
BEFORE INSERT ON "user_connectionStatus"
FOR EACH ROW
EXECUTE FUNCTION "update_connectionAliveAtMaxIntervalMs"();
--CREATE TABLE "user_connectionStatusHistory" (
-- "userId" varchar(50) REFERENCES "user"("userId") ON DELETE CASCADE,
-- "applicationRttInMs" numeric,
-- "status" varchar(25),
-- "statusUpdatedAt" timestamp with time zone
--);
@ -692,7 +712,6 @@ create view "v_user_connectionStatus" as select * from "user_connectionStatus";
-- "status" varchar(25),
-- "totalOfOccurrences" integer,
-- "highestNetworkRttInMs" numeric,
-- "highestApplicationRttInMs" numeric,
-- "statusInsertedAt" timestamp with time zone,
-- "statusUpdatedAt" timestamp with time zone,
-- CONSTRAINT "user_connectionStatusHistory_pkey" PRIMARY KEY ("userId","status")
@ -707,9 +726,6 @@ CREATE TABLE "user_connectionStatusMetrics" (
"lowestNetworkRttInMs" numeric,
"highestNetworkRttInMs" numeric,
"lastNetworkRttInMs" numeric,
"lowestApplicationRttInMs" numeric,
"highestApplicationRttInMs" numeric,
"lastApplicationRttInMs" numeric,
CONSTRAINT "user_connectionStatusMetrics_pkey" PRIMARY KEY ("userId","status")
);
@ -717,70 +733,38 @@ create index "idx_user_connectionStatusMetrics_userId" on "user_connectionStatus
--This function populate rtt, status and the table user_connectionStatusMetrics
CREATE OR REPLACE FUNCTION "update_user_connectionStatus_trigger_func"() RETURNS TRIGGER AS $$
DECLARE
"newApplicationRttInMs" numeric;
"newStatus" varchar(25);
BEGIN
IF NEW."connectionAliveAt" IS NULL OR NEW."userClientResponseAt" IS NULL THEN
IF NEW."connectionAliveAt" IS NULL THEN
RETURN NEW;
END IF;
"newApplicationRttInMs" := (EXTRACT(EPOCH FROM (NEW."userClientResponseAt" - NEW."connectionAliveAt")) * 1000);
"newStatus" := CASE WHEN COALESCE(NEW."networkRttInMs",0) > 2000 THEN 'critical'
WHEN COALESCE(NEW."networkRttInMs",0) > 1000 THEN 'danger'
WHEN COALESCE(NEW."networkRttInMs",0) > 500 THEN 'warning'
ELSE 'normal' END;
--Update table user_connectionStatusMetrics
WITH upsert AS (UPDATE "user_connectionStatusMetrics" SET
"occurrencesCount" = "user_connectionStatusMetrics"."occurrencesCount" + 1,
"highestApplicationRttInMs" = GREATEST("user_connectionStatusMetrics"."highestApplicationRttInMs","newApplicationRttInMs"),
"lowestApplicationRttInMs" = LEAST("user_connectionStatusMetrics"."lowestApplicationRttInMs","newApplicationRttInMs"),
"lastApplicationRttInMs" = "newApplicationRttInMs",
"highestNetworkRttInMs" = GREATEST("user_connectionStatusMetrics"."highestNetworkRttInMs",NEW."networkRttInMs"),
"lowestNetworkRttInMs" = LEAST("user_connectionStatusMetrics"."lowestNetworkRttInMs",NEW."networkRttInMs"),
"lastNetworkRttInMs" = NEW."networkRttInMs",
"lastOccurrenceAt" = current_timestamp
WHERE "userId"=NEW."userId" AND "status"= "newStatus" RETURNING *)
WHERE "userId"=NEW."userId" AND "status"= NEW."status" RETURNING *)
INSERT INTO "user_connectionStatusMetrics"("userId","status","occurrencesCount", "firstOccurrenceAt",
"highestApplicationRttInMs", "lowestApplicationRttInMs", "lastApplicationRttInMs",
"highestNetworkRttInMs", "lowestNetworkRttInMs", "lastNetworkRttInMs")
SELECT NEW."userId", "newStatus", 1, current_timestamp,
"newApplicationRttInMs", "newApplicationRttInMs", "newApplicationRttInMs",
SELECT NEW."userId", NEW."status", 1, current_timestamp,
NEW."networkRttInMs", NEW."networkRttInMs", NEW."networkRttInMs"
WHERE NOT EXISTS (SELECT * FROM upsert);
--Update networkRttInMs, applicationRttInMs, status, statusUpdatedAt in user_connectionStatus
UPDATE "user_connectionStatus"
SET "applicationRttInMs" = "newApplicationRttInMs",
"status" = "newStatus",
"statusUpdatedAt" = now()
WHERE "userId" = NEW."userId";
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER "update_user_connectionStatus_trigger" AFTER UPDATE OF "userClientResponseAt" ON "user_connectionStatus"
CREATE TRIGGER "update_user_connectionStatus_trigger" AFTER UPDATE OF "connectionAliveAt" ON "user_connectionStatus"
FOR EACH ROW EXECUTE FUNCTION "update_user_connectionStatus_trigger_func"();
--This function clear userClientResponseAt and applicationRttInMs when connectionAliveAt is updated
CREATE OR REPLACE FUNCTION "update_user_connectionStatus_connectionAliveAt_trigger_func"() RETURNS TRIGGER AS $$
BEGIN
IF NEW."connectionAliveAt" <> OLD."connectionAliveAt" THEN
NEW."userClientResponseAt" := NULL;
NEW."applicationRttInMs" := NULL;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER "update_user_connectionStatus_connectionAliveAt_trigger" BEFORE UPDATE OF "connectionAliveAt" ON "user_connectionStatus"
FOR EACH ROW EXECUTE FUNCTION "update_user_connectionStatus_connectionAliveAt_trigger_func"();
CREATE OR REPLACE VIEW "v_user_connectionStatusReport" AS
SELECT u."meetingId", u."userId",
max(cs."connectionAliveAt") AS "connectionAliveAt",
max(cs."status") AS "currentStatus",
--COALESCE(max(cs."applicationRttInMs"),(EXTRACT(EPOCH FROM (current_timestamp - max(cs."connectionAliveAt"))) * 1000)) AS "applicationRttInMs",
CASE WHEN max(cs."connectionAliveAt") < current_timestamp - INTERVAL '12 seconds' THEN TRUE ELSE FALSE END AS "clientNotResponding",
CASE WHEN max(cs."connectionAliveAt") < current_timestamp - INTERVAL '1 millisecond' * max(cs."connectionAliveAtMaxIntervalMs") THEN TRUE ELSE FALSE END AS "clientNotResponding",
(array_agg(csm."status" ORDER BY csm."lastOccurrenceAt" DESC))[1] as "lastUnstableStatus",
max(csm."lastOccurrenceAt") AS "lastUnstableStatusAt"
FROM "user" u
@ -870,6 +854,20 @@ FROM "user" u
JOIN "user_reaction" ur ON u."userId" = ur."userId" AND "expiresAt" > current_timestamp
GROUP BY u."meetingId", ur."userId";
CREATE TABLE "user_transcriptionError"(
"userId" varchar(50) PRIMARY KEY REFERENCES "user"("userId") ON DELETE CASCADE,
"meetingId" varchar(100) references "meeting"("meetingId") ON DELETE CASCADE,
"errorCode" varchar(255),
"errorMessage" text,
"lastUpdatedAt" timestamp with time zone DEFAULT now()
);
CREATE INDEX "idx_user_transcriptionError_meetingId" ON "user_transcriptionError"("meetingId");
CREATE INDEX "idx_user_transcriptionError_userId" ON "user_transcriptionError"("userId");
create view "v_user_transcriptionError" as select * from "user_transcriptionError";
create view "v_meeting" as

View File

@ -460,11 +460,7 @@ type Mutation {
}
type Mutation {
userSetConnectionAlive: Boolean
}
type Mutation {
userSetConnectionRtt(
userSetConnectionAlive(
networkRttInMs: Float!
): Boolean
}

View File

@ -421,12 +421,6 @@ actions:
handler: '{{HASURA_BBB_GRAPHQL_ACTIONS_ADAPTER_URL}}'
permissions:
- role: bbb_client
- name: userSetConnectionRtt
definition:
kind: synchronous
handler: '{{HASURA_BBB_GRAPHQL_ACTIONS_ADAPTER_URL}}'
permissions:
- role: bbb_client
- name: userSetEmojiStatus
definition:
kind: synchronous

View File

@ -22,11 +22,9 @@ select_permissions:
columns:
- connectionAliveAt
- meetingId
- applicationRttInMs
- networkRttInMs
- status
- statusUpdatedAt
- userClientResponseAt
- userId
filter:
_and:

View File

@ -70,6 +70,15 @@ object_relationships:
remote_table:
name: v_sharedNotes_session
schema: public
- name: transcriptionError
using:
manual_configuration:
column_mapping:
userId: userId
insertion_order: null
remote_table:
name: v_user_transcriptionError
schema: public
- name: userClientSettings
using:
manual_configuration:

View File

@ -0,0 +1,19 @@
table:
name: v_user_transcriptionError
schema: public
configuration:
column_config: {}
custom_column_names: {}
custom_name: user_transcriptionError
custom_root_fields: {}
select_permissions:
- role: bbb_client
permission:
columns:
- errorCode
- errorMessage
- lastUpdatedAt
filter:
userId:
_eq: X-Hasura-UserId
comment: ""

View File

@ -54,6 +54,7 @@
- "!include public_v_user_reaction.yaml"
- "!include public_v_user_reaction_current.yaml"
- "!include public_v_user_ref.yaml"
- "!include public_v_user_transcriptionError.yaml"
- "!include public_v_user_typing_private.yaml"
- "!include public_v_user_typing_public.yaml"
- "!include public_v_user_voice.yaml"

View File

@ -1 +1 @@
git clone --branch v0.1.0 --depth 1 https://github.com/bigbluebutton/bbb-transcription-controller bbb-transcription-controller
git clone --branch v0.2.3 --depth 1 https://github.com/bigbluebutton/bbb-transcription-controller bbb-transcription-controller

View File

@ -1559,7 +1559,7 @@ if [ -n "$HOST" ]; then
sudo yq e -i ".freeswitch.esl_password = \"$ESL_PASSWORD\"" $WEBRTC_SFU_ETC_CONFIG
sudo xmlstarlet edit --inplace --update 'configuration/settings//param[@name="password"]/@value' --value $ESL_PASSWORD /opt/freeswitch/etc/freeswitch/autoload_configs/event_socket.conf.xml
if [ -f /usr/local/bigbluebutton/bbb-transcription-controller/config/default.yml ]; then
sudo yq w -i /usr/local/bigbluebutton/bbb-transcription-controller/config/default.yml freeswitch.esl_password "$ESL_PASSWORD"
sudo yq w -i /usr/local/bigbluebutton/bbb-transcription-controller/config/default.yml freeswitch.password "$ESL_PASSWORD"
fi
echo "Restarting BigBlueButton $BIGBLUEBUTTON_RELEASE ..."

View File

@ -258,32 +258,19 @@ export default withTracker(() => {
},
);
const currentMeeting = Meetings.findOne({ meetingId: Auth.meetingID },
{
fields: {
randomlySelectedUser: 1,
layout: 1,
},
});
const {
randomlySelectedUser,
} = currentMeeting;
const meetingLayoutObj = Meetings
.findOne({ meetingId: Auth.meetingID }) || {};
const { layout } = meetingLayoutObj;
const {
currentLayoutType: meetingLayout,
propagateLayout: pushLayoutMeeting,
cameraDockIsResizing: isMeetingLayoutResizing,
cameraDockPlacement: meetingLayoutCameraPosition,
presentationVideoRate: meetingLayoutVideoRate,
cameraDockAspectRatio: meetingLayoutVideoRate,
cameraWithFocus: meetingLayoutFocusedCamera,
} = layout;
const meetingLayout = LAYOUT_TYPE[layout.currentLayoutType];
const meetingLayoutUpdatedAt = new Date(layout.updatedAt).getTime();
const meetingPresentationIsOpen = !layout.presentationMinimized;

View File

@ -1,4 +1,4 @@
import React, { Component } from 'react';
import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import { Session } from 'meteor/session';
import {
@ -13,6 +13,7 @@ import AudioDial from '../audio-dial/component';
import AudioAutoplayPrompt from '../autoplay/component';
import Settings from '/imports/ui/services/settings';
import CaptionsSelectContainer from '/imports/ui/components/audio/captions/select/container';
import { usePreviousValue } from '../../utils/hooks';
const propTypes = {
intl: PropTypes.shape({
@ -48,6 +49,14 @@ const propTypes = {
localEchoEnabled: PropTypes.bool.isRequired,
showVolumeMeter: PropTypes.bool.isRequired,
notify: PropTypes.func.isRequired,
isRTL: PropTypes.bool.isRequired,
priority: PropTypes.string.isRequired,
isOpen: PropTypes.bool.isRequired,
setIsOpen: PropTypes.func.isRequired,
AudioError: PropTypes.shape({
MIC_ERROR: PropTypes.number.isRequired,
NO_SSL: PropTypes.number.isRequired,
}).isRequired,
};
const defaultProps = {
@ -124,288 +133,169 @@ const intlMessages = defineMessages({
},
});
class AudioModal extends Component {
constructor(props) {
super(props);
const AudioModal = (props) => {
const [content, setContent] = useState(null);
const [hasError, setHasError] = useState(false);
const [disableActions, setDisableActions] = useState(false);
const [errCode, setErrCode] = useState(null);
const [autoplayChecked, setAutoplayChecked] = useState(false);
this.state = {
content: null,
hasError: false,
errCode: null,
};
const {
forceListenOnlyAttendee,
joinFullAudioImmediately,
listenOnlyMode,
audioLocked,
isUsingAudio,
autoplayBlocked,
closeModal,
isEchoTest,
exitAudio,
resolve,
leaveEchoTest,
AudioError,
joinEchoTest,
isConnecting,
localEchoEnabled,
joinListenOnly,
changeInputStream,
joinMicrophone,
intl,
isMobileNative,
formattedDialNum,
isRTL,
isConnected,
inputDeviceId,
outputDeviceId,
changeInputDevice,
changeOutputDevice,
showVolumeMeter,
notify,
formattedTelVoice,
handleAllowAutoplay,
showPermissionsOvelay,
isIE,
isOpen,
priority,
setIsOpen,
} = props;
this.handleGoToAudioOptions = this.handleGoToAudioOptions.bind(this);
this.handleGoToAudioSettings = this.handleGoToAudioSettings.bind(this);
this.handleRetryGoToEchoTest = this.handleRetryGoToEchoTest.bind(this);
this.handleGoToEchoTest = this.handleGoToEchoTest.bind(this);
this.handleJoinMicrophone = this.handleJoinMicrophone.bind(this);
this.handleJoinLocalEcho = this.handleJoinLocalEcho.bind(this);
this.handleJoinListenOnly = this.handleJoinListenOnly.bind(this);
this.skipAudioOptions = this.skipAudioOptions.bind(this);
const prevAutoplayBlocked = usePreviousValue(autoplayBlocked);
this.contents = {
echoTest: {
title: intlMessages.echoTestTitle,
component: () => this.renderEchoTest(),
},
settings: {
title: intlMessages.settingsTitle,
component: () => this.renderAudioSettings(),
},
help: {
title: intlMessages.helpTitle,
component: () => this.renderHelp(),
},
audioDial: {
title: intlMessages.audioDialTitle,
component: () => this.renderAudioDial(),
},
autoplayBlocked: {
title: intlMessages.autoplayPromptTitle,
component: () => this.renderAutoplayOverlay(),
},
};
this.failedMediaElements = [];
}
componentDidMount() {
const {
forceListenOnlyAttendee,
joinFullAudioImmediately,
listenOnlyMode,
audioLocked,
isUsingAudio,
} = this.props;
if (!isUsingAudio) {
if (forceListenOnlyAttendee || audioLocked) return this.handleJoinListenOnly();
if (joinFullAudioImmediately && !listenOnlyMode) return this.handleJoinMicrophone();
if (!listenOnlyMode) return this.handleGoToEchoTest();
useEffect(() => {
if (prevAutoplayBlocked && !autoplayBlocked) {
setAutoplayChecked(true);
}
return false;
}
}, [autoplayBlocked]);
componentDidUpdate(prevProps) {
const { autoplayBlocked, closeModal } = this.props;
if (autoplayBlocked !== prevProps.autoplayBlocked) {
if (autoplayBlocked) {
this.setContent({ content: 'autoplayBlocked' });
} else {
closeModal();
}
const handleJoinMicrophoneError = (err) => {
const { type } = err;
switch (type) {
case 'MEDIA_ERROR':
setContent('help');
setErrCode(0);
setDisableActions(false);
break;
case 'CONNECTION_ERROR':
default:
setErrCode(0);
setDisableActions(false);
break;
}
}
};
componentWillUnmount() {
const {
isEchoTest,
exitAudio,
resolve,
} = this.props;
if (isEchoTest) {
exitAudio();
}
if (resolve) resolve();
Session.set('audioModalIsOpen', false);
}
handleGoToAudioOptions() {
this.setState({
content: null,
hasError: true,
disableActions: false,
});
}
handleGoToAudioSettings() {
const { leaveEchoTest } = this.props;
leaveEchoTest().then(() => {
this.setState({
content: 'settings',
});
});
}
handleRetryGoToEchoTest() {
this.setState({
hasError: false,
content: null,
});
return this.handleGoToEchoTest();
}
handleGoToLocalEcho() {
const handleGoToLocalEcho = () => {
// Simplified echo test: this will return the AudioSettings with:
// - withEcho: true
// Echo test will be local and done in the AudioSettings view instead of the
// old E2E -> yes/no -> join view
this.setState({
content: 'settings',
});
}
setContent('settings');
};
handleGoToEchoTest() {
const { AudioError } = this.props;
const handleGoToEchoTest = () => {
const { MIC_ERROR } = AudioError;
const noSSL = !window.location.protocol.includes('https');
if (noSSL) {
return this.setState({
content: 'help',
errCode: MIC_ERROR.NO_SSL,
});
setContent('help');
setErrCode(MIC_ERROR.NO_SSL);
return null;
}
const {
joinEchoTest,
isConnecting,
localEchoEnabled,
} = this.props;
const {
disableActions,
} = this.state;
if (disableActions && isConnecting) return null;
if (localEchoEnabled) return this.handleGoToLocalEcho();
if (localEchoEnabled) return handleGoToLocalEcho();
this.setState({
hasError: false,
disableActions: true,
});
setHasError(false);
setDisableActions(true);
return joinEchoTest().then(() => {
this.setState({
content: 'echoTest',
disableActions: false,
});
setContent('echoTest');
setDisableActions(true);
}).catch((err) => {
this.handleJoinMicrophoneError(err);
handleJoinMicrophoneError(err);
});
}
};
handleJoinListenOnly() {
const {
joinListenOnly,
isConnecting,
} = this.props;
const handleGoToAudioOptions = () => {
setContent(null);
setHasError(true);
setDisableActions(false);
};
const {
disableActions,
} = this.state;
const handleGoToAudioSettings = () => {
leaveEchoTest().then(() => {
setContent('settings');
});
};
const handleRetryGoToEchoTest = () => {
setHasError(false);
setContent(null);
return handleGoToEchoTest();
};
const handleJoinListenOnly = () => {
if (disableActions && isConnecting) return null;
this.setState({
disableActions: true,
});
setDisableActions(true);
return joinListenOnly().then(() => {
this.setState({
disableActions: false,
});
setDisableActions(false);
}).catch((err) => {
if (err.type === 'MEDIA_ERROR') {
this.setState({
content: 'help',
});
setContent('help');
}
});
}
handleJoinLocalEcho(inputStream) {
const { changeInputStream } = this.props;
// Reset the modal to a connecting state - this kind of sucks?
// prlanzarin Apr 04 2022
this.setState({
content: null,
});
if (inputStream) changeInputStream(inputStream);
this.handleJoinMicrophone();
}
handleJoinMicrophone() {
const {
joinMicrophone,
isConnecting,
} = this.props;
const {
disableActions,
} = this.state;
};
const handleJoinMicrophone = () => {
if (disableActions && isConnecting) return;
this.setState({
hasError: false,
disableActions: true,
});
setHasError(false);
setDisableActions(true);
joinMicrophone().then(() => {
this.setState({
disableActions: false,
});
setDisableActions(false);
}).catch((err) => {
this.handleJoinMicrophoneError(err);
handleJoinMicrophoneError(err);
});
}
};
handleJoinMicrophoneError(err) {
const { type } = err;
switch (type) {
case 'MEDIA_ERROR':
this.setState({
content: 'help',
errCode: 0,
disableActions: false,
});
break;
case 'CONNECTION_ERROR':
default:
this.setState({
errCode: 0,
disableActions: false,
});
break;
}
}
const handleJoinLocalEcho = (inputStream) => {
// Reset the modal to a connecting state - this kind of sucks?
// prlanzarin Apr 04 2022
setContent(null);
if (inputStream) changeInputStream(inputStream);
handleJoinMicrophone();
};
setContent(content) {
this.setState(content);
}
const skipAudioOptions = () => (isConnecting || (forceListenOnlyAttendee && !autoplayChecked))
&& !content
&& !hasError;
skipAudioOptions() {
const {
isConnecting,
} = this.props;
const {
content,
hasError,
} = this.state;
return isConnecting && !content && !hasError;
}
renderAudioOptions() {
const {
intl,
listenOnlyMode,
forceListenOnlyAttendee,
joinFullAudioImmediately,
audioLocked,
isMobileNative,
formattedDialNum,
isRTL,
} = this.props;
const showMicrophone = forceListenOnlyAttendee || audioLocked;
const renderAudioOptions = () => {
const hideMicrophone = forceListenOnlyAttendee || audioLocked;
const arrow = isRTL ? '←' : '→';
const dialAudioLabel = `${intl.formatMessage(intlMessages.audioDialTitle)} ${arrow}`;
@ -413,45 +303,43 @@ class AudioModal extends Component {
return (
<div>
<Styled.AudioOptions data-test="audioModalOptions">
{!showMicrophone && !isMobileNative
&& (
<>
<Styled.AudioModalButton
label={intl.formatMessage(intlMessages.microphoneLabel)}
data-test="microphoneBtn"
aria-describedby="mic-description"
icon="unmute"
circle
size="jumbo"
disabled={audioLocked}
onClick={
joinFullAudioImmediately
? this.handleJoinMicrophone
: this.handleGoToEchoTest
}
/>
<span className="sr-only" id="mic-description">
{intl.formatMessage(intlMessages.microphoneDesc)}
</span>
</>
)}
{listenOnlyMode
&& (
<>
<Styled.AudioModalButton
label={intl.formatMessage(intlMessages.listenOnlyLabel)}
data-test="listenOnlyBtn"
aria-describedby="listenOnly-description"
icon="listen"
circle
size="jumbo"
onClick={this.handleJoinListenOnly}
/>
<span className="sr-only" id="listenOnly-description">
{intl.formatMessage(intlMessages.listenOnlyDesc)}
</span>
</>
)}
{!hideMicrophone && !isMobileNative && (
<>
<Styled.AudioModalButton
label={intl.formatMessage(intlMessages.microphoneLabel)}
data-test="microphoneBtn"
aria-describedby="mic-description"
icon="unmute"
circle
size="jumbo"
disabled={audioLocked}
onClick={
joinFullAudioImmediately
? handleJoinMicrophone
: handleGoToEchoTest
}
/>
<span className="sr-only" id="mic-description">
{intl.formatMessage(intlMessages.microphoneDesc)}
</span>
</>
)}
{listenOnlyMode && (
<>
<Styled.AudioModalButton
label={intl.formatMessage(intlMessages.listenOnlyLabel)}
data-test="listenOnlyBtn"
aria-describedby="listenOnly-description"
icon="listen"
circle
size="jumbo"
onClick={handleJoinListenOnly}
/>
<span className="sr-only" id="listenOnly-description">
{intl.formatMessage(intlMessages.listenOnlyDesc)}
</span>
</>
)}
</Styled.AudioOptions>
{formattedDialNum ? (
<Styled.AudioDial
@ -459,78 +347,36 @@ class AudioModal extends Component {
size="md"
color="secondary"
onClick={() => {
this.setState({
content: 'audioDial',
});
setContent('audioDial');
}}
/>
) : null}
<CaptionsSelectContainer />
</div>
);
}
};
renderContent() {
const {
isEchoTest,
intl,
} = this.props;
const { content } = this.state;
const { animations } = Settings.application;
if (this.skipAudioOptions()) {
return (
<Styled.Connecting role="alert">
<span data-test={!isEchoTest ? 'establishingAudioLabel' : 'connectingToEchoTest'}>
{intl.formatMessage(intlMessages.connecting)}
</span>
<Styled.ConnectingAnimation animations={animations} />
</Styled.Connecting>
);
}
return content ? this.contents[content].component() : this.renderAudioOptions();
}
renderEchoTest() {
return (
<EchoTest
handleNo={this.handleGoToAudioSettings}
handleYes={this.handleJoinMicrophone}
/>
);
}
renderAudioSettings() {
const {
isConnecting,
isConnected,
isEchoTest,
inputDeviceId,
outputDeviceId,
joinEchoTest,
changeInputDevice,
changeOutputDevice,
localEchoEnabled,
showVolumeMeter,
notify,
} = this.props;
const renderEchoTest = () => (
<EchoTest
handleNo={handleGoToAudioSettings}
handleYes={handleJoinMicrophone}
/>
);
const renderAudioSettings = () => {
const confirmationCallback = !localEchoEnabled
? this.handleRetryGoToEchoTest
: this.handleJoinLocalEcho;
? handleRetryGoToEchoTest
: handleJoinLocalEcho;
const handleGUMFailure = () => {
this.setState({
content: 'help',
errCode: 0,
disableActions: false,
});
setContent('help');
setErrCode(0);
setDisableActions(false);
};
return (
<AudioSettings
handleBack={this.handleGoToAudioOptions}
handleBack={handleGoToAudioOptions}
handleConfirmation={confirmationCallback}
handleGUMFailure={handleGUMFailure}
joinEchoTest={joinEchoTest}
@ -547,12 +393,9 @@ class AudioModal extends Component {
notify={notify}
/>
);
}
renderHelp() {
const { errCode } = this.state;
const { AudioError } = this.props;
};
const renderHelp = () => {
const audioErr = {
...AudioError,
code: errCode,
@ -560,88 +403,138 @@ class AudioModal extends Component {
return (
<Help
handleBack={this.handleGoToAudioOptions}
handleBack={handleGoToAudioOptions}
audioErr={audioErr}
/>
);
}
};
renderAudioDial() {
const { formattedDialNum, formattedTelVoice } = this.props;
return (
<AudioDial
formattedDialNum={formattedDialNum}
telVoice={formattedTelVoice}
handleBack={this.handleGoToAudioOptions}
/>
);
}
const renderAudioDial = () => (
<AudioDial
formattedDialNum={formattedDialNum}
telVoice={formattedTelVoice}
handleBack={handleGoToAudioOptions}
/>
);
renderAutoplayOverlay() {
const { handleAllowAutoplay } = this.props;
return (
<AudioAutoplayPrompt
handleAllowAutoplay={handleAllowAutoplay}
/>
);
}
const renderAutoplayOverlay = () => (
<AudioAutoplayPrompt
handleAllowAutoplay={handleAllowAutoplay}
/>
);
render() {
const {
intl,
showPermissionsOvelay,
closeModal,
isIE,
isOpen,
priority,
setIsOpen,
} = this.props;
const contents = {
echoTest: {
title: intlMessages.echoTestTitle,
component: renderEchoTest,
},
settings: {
title: intlMessages.settingsTitle,
component: renderAudioSettings,
},
help: {
title: intlMessages.helpTitle,
component: renderHelp,
},
audioDial: {
title: intlMessages.audioDialTitle,
component: renderAudioDial,
},
autoplayBlocked: {
title: intlMessages.autoplayPromptTitle,
component: renderAutoplayOverlay,
},
};
const { content } = this.state;
const renderContent = () => {
const { animations } = Settings.application;
return (
<>
{showPermissionsOvelay ? <PermissionsOverlay closeModal={closeModal} /> : null}
<Styled.AudioModal
modalName="AUDIO"
onRequestClose={closeModal}
data-test="audioModal"
contentLabel={intl.formatMessage(intlMessages.ariaModalTitle)}
title={
!this.skipAudioOptions()
? (
content
? intl.formatMessage(this.contents[content].title)
: intl.formatMessage(intlMessages.audioChoiceLabel)
)
: null
}
{...{
setIsOpen,
isOpen,
priority,
}}
>
{isIE ? (
<Styled.BrowserWarning>
<FormattedMessage
id="app.audioModal.unsupportedBrowserLabel"
description="Warning when someone joins with a browser that isn't supported"
values={{
0: <a href="https://www.google.com/chrome/">Chrome</a>,
1: <a href="https://getfirefox.com">Firefox</a>,
}}
/>
</Styled.BrowserWarning>
) : null}
<Styled.Content>
{this.renderContent()}
</Styled.Content>
</Styled.AudioModal>
</>
);
}
}
if (skipAudioOptions()) {
return (
<Styled.Connecting role="alert">
<span data-test={!isEchoTest ? 'establishingAudioLabel' : 'connectingToEchoTest'}>
{intl.formatMessage(intlMessages.connecting)}
</span>
<Styled.ConnectingAnimation animations={animations} />
</Styled.Connecting>
);
}
return content ? contents[content].component() : renderAudioOptions();
};
useEffect(() => {
if (!isUsingAudio) {
if (forceListenOnlyAttendee || audioLocked) {
handleJoinListenOnly();
return;
}
if (joinFullAudioImmediately && !listenOnlyMode) {
handleJoinMicrophone();
return;
}
if (!listenOnlyMode) {
handleGoToEchoTest();
}
}
}, []);
useEffect(() => {
if (autoplayBlocked) {
setContent('autoplayBlocked');
} else if (prevAutoplayBlocked) {
closeModal();
}
}, [autoplayBlocked]);
useEffect(() => () => {
if (isEchoTest) {
exitAudio();
}
if (resolve) resolve();
Session.set('audioModalIsOpen', false);
}, []);
let title = content
? intl.formatMessage(contents[content].title)
: intl.formatMessage(intlMessages.audioChoiceLabel);
title = !skipAudioOptions() ? title : null;
return (
<>
{showPermissionsOvelay ? <PermissionsOverlay closeModal={closeModal} /> : null}
<Styled.AudioModal
modalName="AUDIO"
onRequestClose={closeModal}
data-test="audioModal"
contentLabel={intl.formatMessage(intlMessages.ariaModalTitle)}
title={title}
{...{
setIsOpen,
isOpen,
priority,
}}
>
{isIE ? (
<Styled.BrowserWarning>
<FormattedMessage
id="app.audioModal.unsupportedBrowserLabel"
description="Warning when someone joins with a browser that isn't supported"
values={{
0: <a href="https://www.google.com/chrome/">Chrome</a>,
1: <a href="https://getfirefox.com">Firefox</a>,
}}
/>
</Styled.BrowserWarning>
) : null}
<Styled.Content>
{renderContent()}
</Styled.Content>
</Styled.AudioModal>
</>
);
};
AudioModal.propTypes = propTypes;
AudioModal.defaultProps = defaultProps;

View File

@ -1,4 +1,4 @@
import React, { useCallback, useRef } from 'react';
import React, { useCallback, useEffect, useRef } from 'react';
import { useSubscription } from '@apollo/client';
import { isEqual } from 'radash';
import { defineMessages, useIntl } from 'react-intl';
@ -87,13 +87,30 @@ const ChatAlertGraphql: React.FC<ChatAlertGraphqlProps> = (props) => {
const prevPrivateUnreadMessages = usePreviousValue(privateUnreadMessages);
const publicMessagesDidChange = !isEqual(prevPublicUnreadMessages, publicUnreadMessages);
const privateMessagesDidChange = !isEqual(prevPrivateUnreadMessages, privateUnreadMessages);
const shouldRenderPublicChatAlerts = publicMessagesDidChange && publicUnreadMessages;
const shouldRenderPrivateChatAlerts = privateMessagesDidChange && privateUnreadMessages;
const shouldRenderPublicChatAlerts = publicMessagesDidChange
&& !!publicUnreadMessages
&& publicUnreadMessages.length > 0;
const shouldRenderPrivateChatAlerts = privateMessagesDidChange
&& !!privateUnreadMessages
&& privateUnreadMessages.length > 0;
const shouldPlayAudioAlert = useCallback(
(m: Message) => m.chatId !== idChatOpen && !history.current.has(m.messageId),
(m: Message) => (m.chatId !== idChatOpen || document.hidden) && !history.current.has(m.messageId),
[idChatOpen, history.current],
);
useEffect(() => {
if (shouldRenderPublicChatAlerts) {
publicUnreadMessages.forEach((m) => {
history.current.add(m.messageId);
});
}
if (shouldRenderPrivateChatAlerts) {
privateUnreadMessages.forEach((m) => {
history.current.add(m.messageId);
});
}
});
let playAudioAlert = false;
if (shouldRenderPublicChatAlerts) {
@ -102,7 +119,6 @@ const ChatAlertGraphql: React.FC<ChatAlertGraphqlProps> = (props) => {
if (shouldRenderPrivateChatAlerts && !playAudioAlert) {
playAudioAlert = privateUnreadMessages.some(shouldPlayAudioAlert);
}
playAudioAlert ||= document.hidden;
if (audioAlertEnabled && playAudioAlert) {
Service.playAlertSound();
@ -138,7 +154,6 @@ const ChatAlertGraphql: React.FC<ChatAlertGraphqlProps> = (props) => {
const renderToast = (message: Message) => {
if (history.current.has(message.messageId)) return null;
history.current.add(message.messageId);
if (message.chatId === idChatOpen) return null;
const messageChatId = message.chatId === PUBLIC_GROUP_CHAT_ID ? PUBLIC_CHAT_ID : message.chatId;

View File

@ -1,30 +1,22 @@
import { useEffect, useRef } from 'react';
import { useMutation, useSubscription } from '@apollo/client';
import { CONNECTION_STATUS_SUBSCRIPTION } from './queries';
import { UPDATE_CONNECTION_ALIVE_AT, UPDATE_USER_CLIENT_RTT } from './mutations';
import { useMutation } from '@apollo/client';
import { UPDATE_CONNECTION_ALIVE_AT } from './mutations';
const STATS_INTERVAL = window.meetingClientSettings.public.stats.interval;
const ConnectionStatus = () => {
const networkRttInMs = useRef(null); // Ref to store the current timeout
const lastStatusUpdatedAtReceived = useRef(null); // Ref to store the current timeout
const networkRttInMs = useRef(0); // Ref to store the last rtt
const timeoutRef = useRef(null);
const [updateUserClientRtt] = useMutation(UPDATE_USER_CLIENT_RTT);
const handleUpdateUserClientResponseAt = () => {
updateUserClientRtt({
variables: {
networkRttInMs: networkRttInMs.current,
},
});
};
const [updateConnectionAliveAtToMeAsNow] = useMutation(UPDATE_CONNECTION_ALIVE_AT);
const [updateConnectionAliveAtM] = useMutation(UPDATE_CONNECTION_ALIVE_AT);
const handleUpdateConnectionAliveAt = () => {
const startTime = performance.now();
updateConnectionAliveAtToMeAsNow().then(() => {
updateConnectionAliveAtM({
variables: {
networkRttInMs: networkRttInMs.current,
},
}).then(() => {
const endTime = performance.now();
networkRttInMs.current = endTime - startTime;
}).finally(() => {
@ -39,27 +31,13 @@ const ConnectionStatus = () => {
};
useEffect(() => {
handleUpdateConnectionAliveAt();
// Delay first connectionAlive to avoid high RTT misestimation
// due to initial subscription and mutation traffic at client render
timeoutRef.current = setTimeout(() => {
handleUpdateConnectionAliveAt();
}, STATS_INTERVAL / 2);
}, []);
const { loading, error, data } = useSubscription(CONNECTION_STATUS_SUBSCRIPTION);
useEffect(() => {
if (!loading && !error && data) {
data.user_connectionStatus.forEach((curr) => {
if (curr.connectionAliveAt != null
&& curr.userClientResponseAt == null
&& (curr.statusUpdatedAt == null
|| curr.statusUpdatedAt !== lastStatusUpdatedAtReceived.current
)
) {
lastStatusUpdatedAtReceived.current = curr.statusUpdatedAt;
handleUpdateUserClientResponseAt();
}
});
}
}, [data]);
return null;
};

View File

@ -1,18 +1,12 @@
import { gql } from '@apollo/client';
export const UPDATE_CONNECTION_ALIVE_AT = gql`
mutation UpdateConnectionAliveAt {
userSetConnectionAlive
}`;
export const UPDATE_USER_CLIENT_RTT = gql`
mutation UpdateConnectionRtt($networkRttInMs: Float!) {
userSetConnectionRtt(
mutation UpdateConnectionAliveAt($networkRttInMs: Float!) {
userSetConnectionAlive(
networkRttInMs: $networkRttInMs
)
}`;
export default {
UPDATE_CONNECTION_ALIVE_AT,
UPDATE_USER_CLIENT_RTT,
};

View File

@ -1,6 +1,6 @@
import { gql } from '@apollo/client';
export const CONNECTION_STATUS_REPORT_SUBSCRIPTION = gql`subscription {
export const CONNECTION_STATUS_REPORT_SUBSCRIPTION = gql`subscription ConnStatusReport {
user_connectionStatusReport {
user {
userId
@ -17,7 +17,7 @@ export const CONNECTION_STATUS_REPORT_SUBSCRIPTION = gql`subscription {
}
}`;
export const CONNECTION_STATUS_SUBSCRIPTION = gql`subscription {
export const CONNECTION_STATUS_SUBSCRIPTION = gql`subscription ConnStatus {
user_connectionStatus {
connectionAliveAt
userClientResponseAt

View File

@ -103,7 +103,7 @@ class PushLayoutEngine extends React.Component {
layoutContextDispatch({
type: ACTIONS.SET_CAMERA_DOCK_POSITION,
value: meetingLayoutCameraPosition,
value: meetingLayoutCameraPosition || 'contentTop',
});
if (!equalDouble(meetingLayoutVideoRate, 0)) {

View File

@ -7,7 +7,7 @@ import {
HookEvents,
} from 'bigbluebutton-html-plugin-sdk/dist/cjs/core/enum';
import { DataConsumptionHooks } from 'bigbluebutton-html-plugin-sdk/dist/cjs/data-consumption/enums';
import { UpdatedEventDetails } from 'bigbluebutton-html-plugin-sdk/dist/cjs/core/types';
import { SubscribedEventDetails, UpdatedEventDetails } from 'bigbluebutton-html-plugin-sdk/dist/cjs/core/types';
import useLoadedPageGathering from '/imports/ui/core/hooks/useLoadedChatMessages';
import { Message } from '/imports/ui/Types/message';
import formatLoadedChatMessagesDataFromGraphql from './utils';
@ -37,9 +37,9 @@ const LoadedChatMessagesHookContainer = () => {
}, [chatMessagesData, sendSignal]);
useEffect(() => {
const updateHookUseLoadedChatMessages = () => {
setSendSignal(!sendSignal);
};
const updateHookUseLoadedChatMessages = ((event: CustomEvent<SubscribedEventDetails>) => {
if (event.detail.hook === DataConsumptionHooks.LOADED_CHAT_MESSAGES) setSendSignal((signal) => !signal);
}) as EventListener;
window.addEventListener(
HookEvents.SUBSCRIBED, updateHookUseLoadedChatMessages,
);

View File

@ -4,6 +4,7 @@ import * as PluginSdk from 'bigbluebutton-html-plugin-sdk';
import {
HookEvents,
} from 'bigbluebutton-html-plugin-sdk/dist/cjs/core/enum';
import { SubscribedEventDetails } from 'bigbluebutton-html-plugin-sdk/dist/cjs/core/types';
import { DataConsumptionHooks } from 'bigbluebutton-html-plugin-sdk/dist/cjs/data-consumption/enums';
import { CurrentPresentation } from '/imports/ui/Types/presentation';
@ -40,9 +41,9 @@ const CurrentPresentationHookContainer = () => {
}, [currentPresentation, sendSignal]);
useEffect(() => {
const updateHookUseCurrentPresentation = () => {
setSendSignal(!sendSignal);
};
const updateHookUseCurrentPresentation = ((event: CustomEvent<SubscribedEventDetails>) => {
if (event.detail.hook === DataConsumptionHooks.CURRENT_PRESENTATION) setSendSignal((signal) => !signal);
}) as EventListener;
window.addEventListener(
HookEvents.SUBSCRIBED, updateHookUseCurrentPresentation,
);

View File

@ -2,7 +2,7 @@ import { useEffect, useState } from 'react';
import { useSubscription, gql } from '@apollo/client';
import logger from '/imports/startup/client/logger';
import { CustomSubscriptionArguments } from 'bigbluebutton-html-plugin-sdk/dist/cjs/data-consumption/domain/shared/custom-subscription/types';
import { UpdatedEventDetails } from 'bigbluebutton-html-plugin-sdk/dist/cjs/core/types';
import { SubscribedEventDetails, UpdatedEventDetails } from 'bigbluebutton-html-plugin-sdk/dist/cjs/core/types';
import {
HookEvents,
} from 'bigbluebutton-html-plugin-sdk/dist/cjs/core/enum';
@ -53,8 +53,8 @@ const CustomSubscriptionHookContainer = (props: HookWithArgumentsContainerProps)
}, [customSubscriptionData, sendSignal]);
useEffect(() => {
const updateHookUseCustomSubscription = (() => {
setSendSignal((previous) => !previous);
const updateHookUseCustomSubscription = ((event: CustomEvent<SubscribedEventDetails>) => {
if (event.detail.hook === DataConsumptionHooks.CUSTOM_SUBSCRIPTION) setSendSignal((signal) => !signal);
}) as EventListener;
window.addEventListener(
HookEvents.SUBSCRIBED, updateHookUseCustomSubscription,

View File

@ -4,7 +4,7 @@ import {
HookEvents,
} from 'bigbluebutton-html-plugin-sdk/dist/cjs/core/enum';
import { DataConsumptionHooks } from 'bigbluebutton-html-plugin-sdk/dist/cjs/data-consumption/enums';
import { UpdatedEventDetails } from 'bigbluebutton-html-plugin-sdk/dist/cjs/core/types';
import { SubscribedEventDetails, UpdatedEventDetails } from 'bigbluebutton-html-plugin-sdk/dist/cjs/core/types';
import formatTalkingIndicatorDataFromGraphql from './utils';
import { UserVoice } from '/imports/ui/Types/userVoice';
import { useTalkingIndicatorList } from '/imports/ui/core/hooks/useTalkingIndicator';
@ -36,9 +36,9 @@ const TalkingIndicatorHookContainer = () => {
}, [userVoice, sendSignal]);
useEffect(() => {
const updateHookUseTalkingIndicator = () => {
setSendSignal(!sendSignal);
};
const updateHookUseTalkingIndicator = ((event: CustomEvent<SubscribedEventDetails>) => {
if (event.detail.hook === DataConsumptionHooks.TALKING_INDICATOR) setSendSignal((signal) => !signal);
}) as EventListener;
window.addEventListener(
HookEvents.SUBSCRIBED, updateHookUseTalkingIndicator,
);

View File

@ -1,11 +1,12 @@
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import * as PluginSdk from 'bigbluebutton-html-plugin-sdk';
import { UpdatedEventDetails } from 'bigbluebutton-html-plugin-sdk/dist/cjs/core/types';
import { SubscribedEventDetails, UpdatedEventDetails } from 'bigbluebutton-html-plugin-sdk/dist/cjs/core/types';
import {
HookEvents,
} from 'bigbluebutton-html-plugin-sdk/dist/cjs/core/enum';
import { DataConsumptionHooks } from 'bigbluebutton-html-plugin-sdk/dist/cjs/data-consumption/enums';
import { equals } from 'ramda';
import formatCurrentUserResponseFromGraphql from './utils';
import { User } from '/imports/ui/Types/user';
import { GeneralHookManagerProps } from '../../../types';
@ -17,6 +18,7 @@ const CurrentUserHookContainer: React.FunctionComponent<
props: GeneralHookManagerProps<GraphqlDataHookSubscriptionResponse<Partial<User>>>,
) => {
const [sendSignal, setSendSignal] = useState(false);
const previousCurrentUser = useRef<GraphqlDataHookSubscriptionResponse<Partial<User>> | null>(null);
const { data: currentUser } = props;
@ -38,13 +40,18 @@ const CurrentUserHookContainer: React.FunctionComponent<
);
};
useEffect(() => {
updateUserForPlugin();
}, [currentUser, sendSignal]);
if (!equals(previousCurrentUser.current, currentUser)) {
previousCurrentUser.current = currentUser;
updateUserForPlugin();
}
}, [currentUser]);
useEffect(() => {
const updateHookUseCurrentUser = () => {
setSendSignal(!sendSignal);
};
updateUserForPlugin();
}, [sendSignal]);
useEffect(() => {
const updateHookUseCurrentUser = ((event: CustomEvent<SubscribedEventDetails>) => {
if (event.detail.hook === DataConsumptionHooks.CURRENT_USER) setSendSignal((signal) => !signal);
}) as EventListener;
window.addEventListener(
HookEvents.SUBSCRIBED, updateHookUseCurrentUser,
);

View File

@ -5,7 +5,7 @@ import {
HookEvents,
} from 'bigbluebutton-html-plugin-sdk/dist/cjs/core/enum';
import { DataConsumptionHooks } from 'bigbluebutton-html-plugin-sdk/dist/cjs/data-consumption/enums';
import { UpdatedEventDetails } from 'bigbluebutton-html-plugin-sdk/dist/cjs/core/types';
import { SubscribedEventDetails, UpdatedEventDetails } from 'bigbluebutton-html-plugin-sdk/dist/cjs/core/types';
import formatLoadedUserListDataFromGraphql from './utils';
import { useLocalUserList } from '/imports/ui/core/hooks/useLoadedUserList';
@ -33,9 +33,9 @@ const LoadedUserListHookContainer = () => {
}, [usersData, sendSignal]);
useEffect(() => {
const updateHookUseLoadedUserList = () => {
setSendSignal(!sendSignal);
};
const updateHookUseLoadedUserList = ((event: CustomEvent<SubscribedEventDetails>) => {
if (event.detail.hook === DataConsumptionHooks.LOADED_USER_LIST) setSendSignal((signal) => !signal);
}) as EventListener;
window.addEventListener(
HookEvents.SUBSCRIBED, updateHookUseLoadedUserList,
);

View File

@ -43,7 +43,8 @@ const ActionBarPluginStateContainer = ((
pluginApi.setActionsBarItems = (items: PluginSdk.ActionsBarInterface[]) => {
const itemsWithId = items.map(generateItemWithId) as PluginSdk.ActionsBarInterface[];
return setActionBarItems(itemsWithId);
setActionBarItems(itemsWithId);
return itemsWithId.map((i) => i.id);
};
return null;
}) as ExtensibleAreaComponentManager;

View File

@ -46,7 +46,8 @@ const ActionButtonDropdownPluginStateContainer = ((
pluginApi.setActionButtonDropdownItems = (items: PluginSdk.ActionButtonDropdownInterface[]) => {
const itemsWithId = items.map(generateItemWithId) as PluginSdk.ActionButtonDropdownInterface[];
return setActionButtonDropdownItems(itemsWithId);
setActionButtonDropdownItems(itemsWithId);
return itemsWithId.map((i) => i.id);
};
return null;
}) as ExtensibleAreaComponentManager;

View File

@ -45,7 +45,8 @@ const AudioSettingsDropdownPluginStateContainer = ((
pluginApi.setAudioSettingsDropdownItems = (items: PluginSdk.AudioSettingsDropdownInterface[]) => {
const itemsWithId = items.map(generateItemWithId) as PluginSdk.AudioSettingsDropdownInterface[];
return setAudioSettingsDropdownItems(itemsWithId);
setAudioSettingsDropdownItems(itemsWithId);
return itemsWithId.map((i) => i.id);
};
return null;
}) as ExtensibleAreaComponentManager;

View File

@ -46,7 +46,8 @@ const CameraSettingsDropdownPluginStateContainer = ((
pluginApi.setCameraSettingsDropdownItems = (items: PluginSdk.CameraSettingsDropdownInterface[]) => {
const itemsWithId = items.map(generateItemWithId) as PluginSdk.CameraSettingsDropdownInterface[];
return setCameraSettingsDropdownItems(itemsWithId);
setCameraSettingsDropdownItems(itemsWithId);
return itemsWithId.map((i) => i.id);
};
return null;
}) as ExtensibleAreaComponentManager;

View File

@ -46,7 +46,8 @@ const FloatingWindowPluginStateContainer = ((
pluginApi.setFloatingWindows = (items: PluginSdk.FloatingWindowInterface[]) => {
const itemsWithId = items.map(generateItemWithId) as PluginSdk.FloatingWindowInterface[];
return setFloatingWindows(itemsWithId);
setFloatingWindows(itemsWithId);
return itemsWithId.map((i) => i.id);
};
return null;
}) as ExtensibleAreaComponentManager;

View File

@ -46,7 +46,8 @@ const GenericComponentPluginStateContainer = ((
pluginApi.setGenericComponents = (items: PluginSdk.GenericComponentInterface[]) => {
const itemsWithId = items.map(generateItemWithId) as PluginSdk.GenericComponentInterface[];
return setGenericComponents(itemsWithId);
setGenericComponents(itemsWithId);
return itemsWithId.map((i) => i.id);
};
return null;
}) as ExtensibleAreaComponentManager;

View File

@ -46,7 +46,8 @@ const NavBarPluginStateContainer = ((
pluginApi.setNavBarItems = (items: PluginSdk.NavBarInterface[]) => {
const itemsWithId = items.map(generateItemWithId) as PluginSdk.NavBarInterface[];
return setNavBarItems(itemsWithId);
setNavBarItems(itemsWithId);
return itemsWithId.map((i) => i.id);
};
return null;
}) as ExtensibleAreaComponentManager;

View File

@ -46,7 +46,8 @@ const OptionsDropdownPluginStateContainer = ((
pluginApi.setOptionsDropdownItems = (items: PluginSdk.OptionsDropdownInterface[]) => {
const itemsWithId = items.map(generateItemWithId) as PluginSdk.OptionsDropdownInterface[];
return setOptionsDropdownItems(itemsWithId);
setOptionsDropdownItems(itemsWithId);
return itemsWithId.map((i) => i.id);
};
return null;
}) as ExtensibleAreaComponentManager;

View File

@ -46,7 +46,8 @@ const PresentationDropdownPluginStateContainer = ((
pluginApi.setPresentationDropdownItems = (items: PluginSdk.PresentationDropdownInterface[]) => {
const itemsWithId = items.map(generateItemWithId) as PluginSdk.PresentationDropdownInterface[];
return setPresentationDropdownItems(itemsWithId);
setPresentationDropdownItems(itemsWithId);
return itemsWithId.map((i) => i.id);
};
return null;
}) as ExtensibleAreaComponentManager;

View File

@ -46,7 +46,8 @@ const PresentationToolbarPluginStateContainer = ((
pluginApi.setPresentationToolbarItems = (items: PluginSdk.PresentationToolbarInterface[]) => {
const itemsWithId = items.map(generateItemWithId) as PluginSdk.PresentationToolbarInterface[];
return setPresentationToolbarItems(itemsWithId);
setPresentationToolbarItems(itemsWithId);
return itemsWithId.map((i) => i.id);
};
return null;
}) as ExtensibleAreaComponentManager;

View File

@ -46,7 +46,8 @@ const UserCameraDropdownPluginStateContainer = ((
pluginApi.setUserCameraDropdownItems = (items: PluginSdk.UserCameraDropdownInterface[]) => {
const itemsWithId = items.map(generateItemWithId) as PluginSdk.UserCameraDropdownInterface[];
return setUserCameraDropdownItems(itemsWithId);
setUserCameraDropdownItems(itemsWithId);
return itemsWithId.map((i) => i.id);
};
return null;
}) as ExtensibleAreaComponentManager;

Some files were not shown because too many files have changed in this diff Show More