Libraryless. Compilation Failed (19458L/144K).
sclass WebChatBot2 { bool newDesign = true; // use new chat bot design bool ariaLiveTrick = false; bool ariaLiveTrick2 = true; S dbBotID = ""; S templateID = #1028952/*#1028282*/; S cssID = #1028951; S thoughtBotID = null; // thought bot is this program S botName = "Demo Bot"; S heading = "Demo Bot"; S adminName = "Demo Chat Bot Admin"; S botImageID = #1102935; S userImageID = #1102803; S chatHeaderImageID = #1102802; S timeZone = ukTimeZone_string(); S baseLink = ""; bool botOnRight = true; !include once #1028434 // WorkerChat new WorkerChat workerChat; void start { standardTimeZone(); standardTimeZone_name = timeZone; thoughtBot = this; baseLink = ""; realPW(); // make password pWebChatBot(); } O html(virtual Request request) { ret main html(request); } !include #1029545 // API for Eleu } void processParams(SS map) {} new ThreadLocal<Out> out; new ThreadLocal<Conversation> conv; transient long lastConversationChange = now(); bool testFunctions = false; class FormStep { S key; S displayText, desc; S defaultValue; S placeholder; // null for same as displayText, "" for none LS buttons; bool allowFreeText; // only matters when there are buttons S value; // called before data entry void update(Runnable onChange) {} // called after data entry // return error message or null // call conv->cancelForm(); to cancel the form (and make sure to return a text) S verifyData(S s) { null; } } class FormInFlight { Conversation conversation; S hashTag; new L<FormStep> steps; int stepIndex; // in steps list S handleInput(S s) { null; } FormStep currentStep() { ret get(steps, stepIndex); } void update(Runnable onChange) { if (currentStep() != null) currentStep().update(onChange); } S complete() { ret "Form complete"; } S cancel() { ret "Request cancelled"; } FormStep byKey(S key) { ret objectWhere(steps, +key); } S getValue(S key) { FormStep step = byKey(key); ret step?.value; } SS allValues() { SS map = litorderedmap(); for (FormStep step : steps) map.put(step.key, step.value); ret map; } bool allowGeneralOverride() { false; } Conversation cancelMe() { Conversation conv = conversation; conversation.cancelForm(); ret conv; } void change { if (conversation != null) conversation.change(); } } bool debug; S answer(S s) { out.set(new Out); if (creator() == null) if "debug" set debug; S a = rawAnswer(s); // handle hashtags LS tokHashtags = regexpICMatchesAsCNC(regexpNegativeLookbehind("\\w") + "#\\w+\\b", a); for (int i = 1; i < l(tokHashtags); i += 2) { print("Found hashtag " + tokHashtags.get(i)); /*S a2 = Handover.handleHashtag(conv!, tokHashtags.get(i)); if (a2 != null) { print("Replaced with " + quote(a2)); tokHashtags.set(i, a2); }*/ } a = join(tokHashtags); ret deliverAnswerAndFormStep(a); } S rawAnswer(S s) { FormInFlight form = conv->form; S a = null; // enter propose mode, get general answer O bot = dbBot(); out->proposeMode = true; S generalAnswer; { // call without #default temp tempSetTL((ThreadLocal) getOpt(bot, 'opt_noDefault), true); generalAnswer = (S) call(bot, 'answer, s, conv->language()); } out->proposeMode = false; if (form != null && generalAnswer != null && form.allowGeneralOverride()) conv->cancelForm(); else if (form != null) { if ((a = form.handleInput(s)) != null) ret a; if (eqicOneOf(s, "cancel", "Abbrechen", unicode_crossProduct())) { S answer = form.cancel(); conv->cancelForm(); ret answer; } else if (eqicOneOf(s, "back", "zurück", unicode_undoArrow()) && form.stepIndex > 0) { --form.stepIndex; conv->change(); ret ""; } else if (form.currentStep() != null) { FormStep step = form.currentStep(); if (!step.allowFreeText && nempty(step.buttons) && !cic(step.buttons, s)) ret de() ? "Bitte wählen Sie eine Option!" : "Please choose an option."; print("Verifying data " + quote(s) + " in step " + step); S error = step.verifyData(s); if (error != null) ret error; step.value = s; ++form.stepIndex; conv->change(); if (form.currentStep() == null) { S answer = form.complete(); if (conv->form == form) conv->cancelForm(); // if complete() hasn't put us on a new form ret answer; } ret ""; } } // process general answer, switch language a = generalAnswer; if (a == null) a = (S) call(bot, 'answer, "#default", conv->language()); if (out->proposedForm != null) conv->setForm(out->proposedForm); ret a; } S deliverAnswerAndFormStep(S answer) { FormInFlight form = conv->form; // form may have cancelled itself in update if (form == null || form.currentStep() == null) ret answer; FormStep step = form.currentStep(); printVars_str(+answer, displayText := step.displayText); answer = joinNemptiesWithSpace(answer, step.displayText); out->placeholder = or(step.placeholder, step.displayText); print("Step " + form.stepIndex + ": " + sfu(step)); out->defaultInput = or2(step.value, step.defaultValue); out->buttons = cloneList(step.buttons); if (form.stepIndex > 0) out->buttons.add(de() ? "Zurück" : "Back"); out->buttons.add(de() ? "Abbrechen" : "Cancel"); ret answer; } S initialMessage() { ret template("#greeting"); } sO dbBot() { null; // TODO ret getBot(dbBotID); } bool de() { ret eqic(conv->language(), "de"); } // Web Chat Bot Include sO thoughtBot; int longPollTick = 200; int longPollMaxWait = 1000*30; // lowered to 30 seconds int activeConversationSafetyMargin = 15000; // allow client 15 seconds to reload Set<S> specialButtons = litciset("Cancel", "Back", "Abbrechen", "Zurück"); class Out extends DynamicObject { LS buttons; bool multipleChoice; S multipleChoiceSeparator; S placeholder; S defaultInput; Int progressBarValue; bool glow; transient bool proposeMode; transient FormInFlight proposedForm; } class Msg extends DynamicObject { long time; bool fromUser; Worker fromWorker; S text; Out out; *() {} *(bool *fromUser, S *text) { time = now(); } *(S *text, bool *fromUser) { time = now(); } } concept AuthedDialogID { S dialogID; Worker loggedIn; // who is logged in with this cookie } //concept Session {} // LEGACY // our base concept - a conversation between a user and a bot or sales representative concept Conversation { S cookie, ip, country; new LL<Msg> oldDialogs; new L<Msg> msgs; long lastPing; bool botOn = true; Worker worker; // who are we talking to? transient long userTyping, botTyping; // sysNow timestamps bool testMode; transient bool dryRun; transient FormInFlight proposedForm; FormInFlight form; S language; Long lastProposedDate; void add(Msg m) { m.text = trim(m.text); if (!m.fromUser && empty(m.text)) ret; // don't store empty msgs from bot syncAdd(msgs, m); noteConversationChange(); change(); vmBus_send chatBot_messageAdded(mc(), this, m); } int allCount() { ret lengthLevel2(oldDialogs) + l(msgs); } int archiveSize() { ret lengthLevel2(oldDialogs); } long lastMsgTime() { Msg m = last(msgs); ret m == null ? 0 : m.time; } void cancelForm { if (form != null) { print("Cancelling form " + form); form.conversation = null; cset(this, form := null); } } <A extends FormInFlight> A setForm(A form) { form.conversation = this; cset(this, +form); ret form; } S language() { ret or2(language, 'en); } void updateForm { if (form != null) form.update(r change); } void turnBotOff { cset(this, botOn := false); noteConversationChange(); updateForm(); } void turnBotOn { cset(this, botOn := true, worker := null); S backMsg = getCannedAnswer("#botBack", this); if (empty(msgs) || lastMessageIsFromUser() || !eq(last(msgs).text, backMsg)) add(new Msg(backMsg, false)); noteConversationChange(); } bool lastMessageIsFromUser() { ret nempty(msgs) && last(msgs).fromUser; } void newDialog { cancelForm(); lastProposedDate = null; oldDialogs.add(msgs); cset(this, msgs := new L); change(); vmBus_send chatBot_clearedSession(mc(), this); } } void pWebChatBot { dbIndexing(Conversation, 'cookie, Conversation, 'worker, Conversation, 'lastPing, Worker, 'loginName, AuthedDialogID, 'dialogID); Class envType = fieldType(thoughtBot, "env"); if (envType != null) setOpt(thoughtBot, "env", proxy(envType, (O) mc())); } sO html(virtual WebRequest request) { temp tempRegisterThread(); S uri = cast get(request, 'uri); SS params = cast get(request, 'params); S cookie = params.get('cookie); if (empty(cookie)) cookie = (S) call(request, 'cookie); bool workerMode = nempty(params.get("workerMode")) || startsWith(uri, "/worker"); Conversation conv = nempty(cookie) ? getConv(cookie) : null; if (conv != null && !workerMode) cset(conv, ip := (S) call(request, 'clientIP)); print("URI: " + uri + ", cookie: " + cookie + ", msgs: " + l(conv.msgs)); S dialogID = getDialogID(); // for authing S pw = trim(params.get('pw)); if (nempty(pw)) { if (neq(pw, realPW())) ret errorMsg("Bad password, please try again"); uniq AuthedDialogID(+dialogID); if (nempty(params.get('redirect))) ret hrefresh(params.get('redirect)); } new Matches m; if (startsWith(uri, "/worker-image/", m)) { long id = parseLong(m.rest()); ret subBot_serveFile(workerImageFile(id), "image/jpeg"); } AuthedDialogID auth = authObject(); bool requestAuthed = auth != null; if (eq(uri, "/stats")) { if (!requestAuthed) ret serveAuthForm(rawLink(uri)); ret "Threads: " + ul_htmlEncode(getThreadNames(registeredThreads())); } if (eq(uri, "/logs")) { if (!requestAuthed) ret serveAuthForm(rawLink(uri)); ret webChatBotLogsHTML2(rawLink(uri), params); } if (eq(uri, "/auth-only")) { if (eq(params.get('logout), "1")) cdelete(AuthedDialogID, dialogID := getDialogID()); if (!requestAuthed) ret serveAuthForm(params.get('uri)); ret ""; } if (workerChat != null) try object workerChat.html(uri, params, conv, auth); { lock dbLock(); S message = trim(params.get("btn")); if (empty(message)) message = trim(params.get("message")); if (match("new dialog", message)) { conv.newDialog(); message = null; } main.conv.set(conv); if (!workerMode && empty(conv.msgs)) addReplyToConvo(conv, () -> deliverAnswerAndFormStep(initialMessage())); if (nempty(message) && !lastUserMessageWas(conv, message)) { print("Adding message: " + message); if (workerMode) { Msg msg = new(false, message); msg.fromWorker = auth.loggedIn; conv.add(msg); } else conv.add(new Msg(true, message)); } S testMode = params.get("testMode"); if (nempty(testMode)) { print("Setting testMode", testMode); cset(conv, testMode := eq("1", testMode)); } if (!workerMode && conv.botOn && nempty(conv.msgs) && last(conv.msgs).fromUser) addReplyToConvo(conv, () -> makeReply(last(conv.msgs).text)); } // locked if (eq(uri, "/msg")) ret withHeader("OK"); if (eq(uri, "/typing")) { if (workerMode) { conv.botTyping = sysNow(); print(conv.botTyping + " Bot typing in: " + conv.cookie); } else { conv.userTyping = sysNow(); print(conv.userTyping + " User typing in: " + conv.cookie); } ret withHeader("OK"); } if (eq(uri, "/incremental")) { vmBus_send chatBot_userPolling(mc(), conv); cset(conv, lastPing := now()); int a = parseInt(params.get("a")); long start = sysNow(); L msgs; bool first = true; while (licensed() && sysNow() < start+longPollMaxWait) { int as = conv.archiveSize(); msgs = cloneSubList(conv.msgs, a-as); bool newDialog = a <= as; long typing = workerMode ? conv.userTyping : conv.botTyping; bool otherPartyTyping = typing > start; if (empty(msgs) && !otherPartyTyping) { if (first) { print("Long poll starting on " + cookie + ", " + a + "/" + a); first = false; } sleep(longPollTick); } else { if (first) print("Long poll ended."); new StringBuilder buf; if (otherPartyTyping) { print("Noticed " + (workerMode ? "user" : "bot") + " typing in " + conv.cookie); buf.append(hscript("showTyping();")); } renderMessages(buf, msgs); if (ariaLiveTrick2 && !workerMode) { Msg msg = lastBotMsg(msgs); if (msg != null) { S author = msg.fromWorker != null ? htmlEncode2(msg.fromWorker.displayName) : botName; buf.append(hscript([[$("#screenreadertrick").html(]] + jsQuote(author + " says: " + msg.text) + ");")); } } if (a != 0 && anyInterestingMessages(msgs, workerMode)) buf.append(hscript( "window.playChatNotification();\n" + "window.setTitleStatus(" + jsQuote((workerMode ? "User" : botName) + " says…") + ");" )); ret withHeader("<!-- " + conv.allCount() + " " + (newDialog ? "NEW DIALOG " : "") + "-->\n" + buf); } } ret withHeader(""); } { lock dbLock(); processParams(params); S html = loadSnippet(templateID); // TODO: cache S workerModeParam = workerMode ? "workerMode=1&" : ""; S langlinks = "<!-- langlinks here -->"; if (html.contains(langlinks)) html = html.replace(langlinks, ahref(rawLink("eng"), "English") + " | " + ahref(rawLink("deu"), "German")); html = html.replace("#BOTIMG#", imageSnippetURLOrEmptyGIF(chatHeaderImageID)); html = html.replace("#N#", "0"); html = html.replace("#INCREMENTALURL#", baseLink + "/incremental?" + workerModeParam + "a="); html = html.replace("#MSGURL#", baseLink + "/msg?" + workerModeParam + "message="); html = html.replace("#TYPINGURL#", baseLink + "/typing?" + workerModeParam); html = html.replace("#CSS_ID#", psI_str(cssID)); if (ariaLiveTrick || ariaLiveTrick2) html = html.replace([[aria-live="polite">]], ">"); html = html.replace("#OTHERSIDE#", workerMode ? "User" : "Representative"); if (nempty(params.get("debug"))) html = html.replace("var showActions = false;", "var showActions = true;"); html = html.replace("#AUTOOPEN#", jsBool(workerMode || botAutoOpen())); html = html.replace("#BOT_ON#", jsBool(botOn())); html = html.replace("$HEADING", heading); html = html.replace("#WORKERMODE", jsBool(workerMode)); html = html.replace("<!-- MSGS HERE -->", ""); html = hreplaceTitle(html, heading); if (eqGet(params, "_botDemo", "1")) ret hhtml(hhead( htitle(heading) + loadJQuery() ) + hbody(hjavascript(html))); else ret withHeader(subBot_serveJavaScript(html)); } } void addReplyToConvo(Conversation conv, IF0<S> think) { out.set(new Out); S reply = ""; pcall { reply = think!; } Msg msg = new Msg(false, reply); msg.out = out!; conv.add(msg); } sO withHeader(S html) { ret withHeader(subBot_noCacheHeaders(subBot_serveHTML(html))); } sO withHeader(O response) { call(response, 'addHeader, "Access-Control-Allow-Origin", "*"); ret response; } S renderMessageText(S text, bool htmlEncode) { text = trim(text); if (htmlEncode) text = htmlEncode2(text); text = nlToBr(text); ret replace(text, ":wave:", html_wavingHand()); } void renderMessages(StringBuilder buf, L<Msg> msgs) { if (empty(msgs)) ret; new Set<S> buttonsToSkip; new LS buttonsHtml; for (Msg m : msgs) { if (!m.fromUser && eq(m.text, "-")) continue; S html = renderMessageText(m.text, shouldHtmlEncodeMsg(m)); // pull back & cancel buttons to beginning of msg if (m == last(msgs) && m.out != null) { fOr (S btn : m.out.buttons) if (specialButtons.contains(btn)) { buttonsToSkip.add(btn); buttonsHtml.add(renderButtons(ll(btn))); } } if (nempty(buttonsHtml) && l(m.out.buttons) == l(buttonsToSkip)) html += " " + hspan(" ", class := "chat-button-span") + lines(buttonsHtml); else buttonsToSkip.clear(); appendMsg(buf, m.fromUser ? defaultUserName() : botName, formatTime(m.time), html, !m.fromUser, m.fromWorker); } appendButtons(buf, last(msgs).out, buttonsToSkip); } void appendMsg(StringBuilder buf, S name, S time, S text, bool bot, Worker fromWorker) { bool useTrick = ariaLiveTrick; S tag = useTrick ? "div" : "span"; if (bot) { S id = randomID(); S author = fromWorker != null ? htmlEncode2(fromWorker.displayName ): botName; if (fromWorker != null) buf.append([[<div class="chat_botname"><p>]] + author + [[</p>]]); buf.append("<" + tag + [[ class="chat_msg_item chat_msg_item_admin"]] + (useTrick ? [[ id="]] + id + [[" aria-live="polite" tabindex="-1"]] : "") + ">"); S imgURL = snippetImgLink(botImageID); if (fromWorker != null && fileExists(workerImageFile(fromWorker.id))) imgURL = fullRawLink("worker-image/" + fromWorker.id); if (nempty(imgURL)) buf.append([[ <div class="chat_avatar"> <img src="$IMG"/> </div>]] .replace("$IMG", imgURL)); buf.append([[<span class="sr-only">]] + (fromWorker != null ? "" : botName + " ") + [[says</span>]]); buf.append(text); buf.append([[</]] + tag + [[>]]); if (fromWorker != null) buf.append("</div>"); if (useTrick) buf.append(hscript("$('#" + id + "').focus();")); } else buf.append(([[ <span class="sr-only">You say</span> <]] + tag + [[ class="chat_msg_item chat_msg_item_user"]] + (useTrick ? [[ aria-live="polite"]] : "") + [[>$TEXT</]] + tag + [[> ]]).replace("$TEXT", text)); } S replaceButtonText(S s) { if (eqicOneOf(s, "back", "zurück")) ret unicode_undoArrow(); if (eqicOneOf(s, "cancel", "Abbrechen")) ret unicode_crossProduct(); ret s; } S renderMultipleChoice(LS buttons, Cl<S> selections, S multipleChoiceSeparator) { print(+selections); Set<S> selectionSet = asCISet(selections); S rand = randomID(); S className = "chat_multiplechoice_" + rand; S allCheckboxes = [[$(".]] + className + [[")]]; ret joinWithBR(map(buttons, name -> hcheckbox("", contains(selectionSet, name), value := name, class := className) + " " + name)) + "<br>" + hbuttonOnClick_returnFalse("OK", "submitMsg()", style := "float: right") + hscript(allCheckboxes + ".change(function() {" //+ " console.log('multiple choice change');" + " var theList = $('." + className + ":checkbox:checked').map(function() { return this.value; }).get();" + " console.log('theList: ' + theList);" + " $('#chat_message').val(theList.join(" + jsQuote(multipleChoiceSeparator) + "));" + "});"); } S renderButtons(LS buttons) { new LS out; for i over buttons: { S code = buttons.get(i); S text = replaceButtonText(code); out.add(hbuttonOnClick_returnFalse(text, "submitAMsg(" + jsQuote(text) + ")", class := "chatbot-choice-button", title := eq(code, text) ? null : code)); if (!specialButtons.contains(code) && i+1 < l(buttons) && specialButtons.contains(buttons.get(i+1))) out.add(" "); } ret lines(out); } void appendButtons(StringBuilder buf, Out out, Set<S> buttonsToSkip) { S placeholder = out == null ? "" : unnull(out.placeholder); S defaultInput = out == null ? "" : unnull(out.defaultInput); buf.append(hscript("chatBot_setInput(" + jsQuote(defaultInput) + ", " + jsQuote(placeholder) + ");")); if (out == null) ret; LS buttons = listMinusSet(out.buttons, buttonsToSkip); if (empty(buttons)) ret; printVars_str(+buttons, +buttonsToSkip); S buttonsHtml; if (out.multipleChoice) buttonsHtml = renderMultipleChoice(buttons, mcSplit(out.defaultInput, out.multipleChoiceSeparator), out.multipleChoiceSeparator); else buttonsHtml = renderButtons(buttons); buf.append([[<span class="chat_msg_item chat_msg_item_admin chat_buttons">]]); buf.append(buttonsHtml); buf.append([[</span>]]); } void appendDate(StringBuilder buf, S date) { buf.append([[ <div class="chat-box-single-line"> <abbr class="timestamp">DATE</abbr> </div>]].replace("DATE", date)); } bool lastUserMessageWas(Conversation conv, S message) { Msg m = last(conv.msgs); ret m != null && m.fromUser && eq(m.text, message); } S makeReply(S message) { try { ret answer(message); } catch print e { ret "Internal error"; } } S formatTime(long time) { ret timeInTimeZoneWithOptionalDate_24(timeZone, time); } S formatDialog(S id, L<Msg> msgs) { new L<S> lc; for (Msg m : msgs) lc.add(htmlencode((m.fromUser ? "> " : "< ") + m.text)); ret id + ul(lc); } Conversation getConv(fS cookie) { ret withDBLock(func -> Conversation { uniq(Conversation, +cookie) }); } S serveAuthForm(S redirect) { ret hhtml(hhead(htitle("Authorization required")) + hbody(hfullcenter( h3_htmlEncode(adminName) + hpostform( hhidden(+redirect) + "Password: " + hpassword('pw) + "<br><br>" + hsubmit())))); } S errorMsg(S msg) { ret hhtml(hhead_title("Error") + hbody(hfullcenter(msg + "<br><br>" + ahref(jsBackLink(), "Back")))); } S defaultUserName() { ret de() ? "Sie" : "You"; } bool botOn() { ret isTrue(call(dbBot(), "botOn")); } bool botAutoOpen() { ret isTrue(call(dbBot(), "botAutoOpen")); } File workerImageFile(long id) { ret id == 0 ? null : javaxDataDir("adaptive-bot/images/" + id + ".jpg"); } long activeConversationTimeout() { ret longPollMaxWait+activeConversationSafetyMargin; } AuthedDialogID authObject() { ret conceptWhere AuthedDialogID(dialogID := getDialogID()); } bool anyInterestingMessages(L<Msg> msgs, bool workerMode) { ret any(msgs, m -> m.fromUser == workerMode); } bool shouldHtmlEncodeMsg(Msg msg) { ret msg.fromUser; } S getCountry(Conversation c) { if (empty(c.country) && nempty(c.ip)) cset(c, country := ipToCountry2020_safe(c.ip)); ret or2(c.country, "?"); } void noteConversationChange { lastConversationChange = now(); } // TODO: check that action is persistable & persist void addTimeout(double seconds, Runnable action) { doAfter(seconds, action); print("Timeout added: " + seconds + " => " + action); } S template(S hashtag, O... params) { ret replaceSquareBracketVars(getCannedAnswer(hashtag), params); } S getCannedAnswer(S hashtag, Conversation conv default null) { if (!startsWith(hashtag, "#")) ret hashtag; S lang = conv == null ? "en" : conv.language(); temp tempSetTL((ThreadLocal) getOpt(dbBot(), 'opt_noDefault), true); ret or2((S) call(dbBot(), 'answer, hashtag, lang), hashtag); // keep hashtag if no answer found } LS mcSplit(S input, S multipleChoiceSeparator) { ret trimAll(splitAt(input, dropSpaces(multipleChoiceSeparator))); } Msg lastBotMsg(L<Msg> l) { ret lastThat(l, msg -> !msg.fromUser); } S dbStats() { Cl<Conversation> all = list(Conversation); int nRealConvos = countPred(all, c -> l(c.msgs) > 1); //int nTestConvos = countPred(all, c -> startsWith(c.cookie, "test_")); //ret n2(l(all)-nTestConvos, "real conversation") + ", " + n2(nTestConvos, "test conversation"); ret nConversations(nRealConvos); } void proposeForm(Conversation conversation default conv!, FormInFlight form) { if (out! != null && out->proposeMode) out->proposedForm = form; else if (conversation != null) conversation.setForm(form); } S realPW() { ret loadSecretTextFileOrCreateWithRandomID("password.txt"); } }
Began life as a copy of #1029541
download show line numbers debug dex old transpilations
Travelled to 7 computer(s): bhatertpkbcr, mqqgnosmbjvj, pyentgdyhuwx, pzhvpgtvlbxg, tvejysmllsmz, vouqrxazstgt, xrpafgyirdlv
No comments. add comment
Snippet ID: | #1029565 |
Snippet name: | WebChatBot2 [dev.] |
Eternal ID of this version: | #1029565/4 |
Text MD5: | d3d40c0953ddc6f19576d398cccee6f9 |
Transpilation MD5: | 0762d8d211f3a9687ef40b30999cc093 |
Author: | stefan |
Category: | javax / web chat bots |
Type: | JavaX fragment (include) |
Public (visible to everyone): | Yes |
Archived (hidden from active list): | No |
Created/modified: | 2020-08-24 12:05:27 |
Source code size: | 26494 bytes / 828 lines |
Pitched / IR pitched: | No / No |
Views / Downloads: | 201 / 258 |
Version history: | 3 change(s) |
Referenced in: | [show references] |