Uses 44637K of libraries. Click here for Pure Java version (35156L/196K).
// Gazelle's "left brain hemisphere" (where all the bots run) set flag DynModule. // for transpilation //set flag dm_evalJava_withModule_debug. //set flag veryQuickJava3_debug. // store bot data per conversation concept Conversation { S cookie; S dataStruct; } asclass DynGazelleMultiBot > DynGazelleBot { switchable int maxEvalResultLength = oneMegabyte_int(); switchable double evalTimeout = 30.0; switchable long botProcessed; // timestamp of last post processed by bots switchable int maxBotAnswerLength = 100000; switchable bool runAutoBots = true; switchable bool doPython = true; switchable int maxRuntimeMinutes = 60; transient new L<Bot> bots; Map<Long, GazellePost> allPosts = syncTreeMap(); transient Map<Long, O> loadedCodePosts = syncTreeMap(); transient bool considerMasterPostsSafe; sclass CouldntLoadCode {} transient Lock codeLoadLock = lock(); volatile long requestsServed; transient double systemLoad; transient long processSize; transient Lock statsLock = lock(); transient ReliableSingleThread rstPersistRarely = dm_rstWithPreDelay(this, 60.0, r change); set flag NoNanoHTTPD. !include #1029545 // API for Eleu class Bot { S name; *() {} *(S *name) {} *(S *name, IVF1<GazellePost> *handlePost) {} swappable void handlePost(GazellePost post) {} GazelleBotCred cred() { ret GazelleBotCred(_user, _botToken, name).server(gazelleServer); } void postReply(GazellePost post, S text, S type, S title default null) { if (empty(text) && empty(title)) text = "<no output>"; gazelle_createPost(cred(), text, type, refs := post.id, +title); } // post: post we are replying to void createPostFromBotResult(GazellePost post, IF0 f, IF1<S> modifyBotInfo default null) { S text, type = "Code Result", title = "", botInfo = ""; bool overrideLastPost = false; try { O result = f!; if (result == null) ret; print("Result type: " + className(result)); if (result cast CreatePost) { print("Is CreatePost"); O[] params = toObjectArray(result.params); text = (S) optPar text(params); type = (S) optPar type(params, type); title = (S) optPar title(params); botInfo = (S) optPar botInfo(params); overrideLastPost = boolPar overrideLastPost(params); } else text = str_shortenSyntheticAndStandardToString(result); } catch print e { text = getStackTrace(e); } if (empty(text) && empty(title)) text = "<no output>"; botInfo = callFOrKeep(modifyBotInfo, botInfo); GazelleBotCred cred = cred(); if (nempty(botInfo)) cred.botInfo = botInfo; if (overrideLastPost) { // find last post S _botInfo = botInfo; GazellePost lastPost = firstThat(repliesTo(post), p -> eqic(p.botInfo, _botInfo)); print("Last post with bot info " + botInfo + ": " + lastPost); if (lastPost != null) { gazelle_editPost(cred, lastPost.id, text, type, +title, refs := post.id); ret; } } gazelle_createPost(cred, text, type, +title, refs := post.id); } } srecord CreatePost(L params) {} start { dm_require("#1017856/SystemLoad"); dm_vmBus_onMessage systemLoad(voidfunc(double load) { if (setField_noPersist(systemLoad := load)) distributeDivChanges("serverLoadLeftHemi"); }); dm_vmBus_onMessage processSize(voidfunc(long processSize) { if (setField_noPersist(+processSize)) distributeDivChanges("memLeftHemi"); }); // test if we can share classes with dynamically loaded code hotwire_addSharedClasses(Pair); dm_useLocalMechListCopies(); dbIndexing(Conversation, "cookie"); grabLoop.handlePosts = posts -> { dm_mediumRefreshTranspiler(); grabLoop.handlePosts_base(posts); for (GazellePost post : posts) setField(botProcessed := max(botProcessed, post.modified)); }; // legacy conversion to sort allPosts setField_noCheck(allPosts := asSyncTreeMap(allPosts)); bots.add(new Bot("Math Bot", post -> { if (post.creating) ret; gazelle_mathBot1_handlePost_2(_user, _botToken, post); })); bots.add(new Bot("Code Safety Checker") { void handlePost(GazellePost post) { if (post.creating) ret; if (post.isJavaXCode()) gazelle_createPost(cred(), codeSafetyCheckResult(post.text), "Code Safety", refs := post.id); } }); bots.add(new Bot("Python Code Safety Checker") { void handlePost(GazellePost post) { if (post.creating) ret; if (post.isPythonCode()) gazelle_createPost(cred(), pythonCodeSafetyCheckResult(post.text), "Python Code Safety", refs := post.id); } }); bots.add(new Bot("Safe Code Runner") { void handlePost(GazellePost post) { if (post.creating) ret; if (post.isJavaXCode()) { int runtime = min(maxRuntimeMinutes, parseFirstInt(jextractIC("runtime <int> minutes", post.type))); S code = post.text; if (isCodePostSafe(post)) { S _code = prepareCode(code, post); //S out = shorten(maxEvalResultLength, runCode(code)); createPostFromBotResult(post, () -> evalCode(runtime*60.0, _code, post)); } } } }); bots.add(new Bot("Python Safe Code Runner") { void handlePost(GazellePost post) { if (post.creating) ret; if (!doPython) ret; if (post.isPythonCode()) { S code = post.text; if (isPythonCodeSafe(code)) { IF0 f = () -> { PythonInterpreter interpreter = jython(); PyCode compiled = interpreter.compile(code); TraceFunction traceFunction = new { @Override public TraceFunction traceCall(PyFrame frame) { ret null with ping(); } @Override public TraceFunction traceReturn(PyFrame frame, PyObject ret) { ret null with ping(); } @Override public TraceFunction traceLine(PyFrame frame, int line) { ret null with ping(); } @Override public TraceFunction traceException(PyFrame frame, PyException exc) { ret null with ping(); } }; Py.getThreadState().tracefunc = traceFunction; ret interpreter.eval(compiled); }; createPostFromBotResult(post, () -> evalWithTimeoutOrFail(evalTimeout, f)); } } } }); bots.add(new Bot("Run Code On All Posts") { void handlePost(GazellePost post) { if (post.creating) ret; if (eqic(post.type, "Instruction") && match("Please run this code on all posts", post.text)) { long ref = gazelle_firstPostRef(post.id); if (ref == 0) ret; S code = gazelle_text(ref); if (!isCodeSafe(code)) ret; S code2 = "ret func(S post) { " + code + " };"; O function = evalCode(code2); new LS lines; S out = shorten(maxEvalResultLength, runCode(code)); for (GazellePost post2 : cloneValues(allPosts)) { lines.add("Post " + post2.id + " (" + quote(shorten(20, post2.text)) + "): " + shorten(80, runFunc(() -> callF(function, post2.text)))); } gazelle_createPost(cred(), lines(lines), "Code Result", refs := post.id); } } }); bots.add(new Bot("Mark identifiers safe") { void handlePost(GazellePost post) { if (post.creating) ret; if (/*eqic(post.type, "Instruction") &&*/ post.creator.isMaster && match("Mark safe", post.text)) { S text = getPost(first(post.postRefs)).text; LS ids = tok_identifiersInOrder(regexpFirstGroupIC("Unknown identifiers: (.+)", text)); print("Marking safe: " + ids); postReply(post, markSafe(ids), "Marked safe"); } } }); bots.add(new Bot("Post Deleter") { void handlePost(GazellePost post) { if (post.creating) ret; if (/*eqic(post.type, "Instruction") &&*/ post.creator.isMaster && match("Delete posts", post.text)) { long ref = gazelle_firstPostRef(post.id); if (ref == 0) ret; S text = gazelle_text(ref); L<Long> postIDs = allToLong(regexpAllFirstGroups(gazelle_deletePostRegexp(), text)); print("Deleting posts: " + postIDs); if (nempty(postIDs)) { Map result = gazelle_deletePosts(cred(), postIDs); postReply(post, str(result), "Deletion result"); } else postReply(post, "No mentioned posts found", "Deletion result"); } } }); bots.add(new Bot("Detector Runner") { void handlePost(GazellePost post) { if (post.creating) ret; ret unless eqic(post.type, "Instruction") && match("Please run detector", post.text); try { long detectorID = post.refWithTagOrFail("detector"); long posExamplesID = post.refWithTagOrFail("positive examples"); long negExamplesID = post.refWithTagOrFail("negative examples"); S code = getPost(detectorID).text; LS posExamples = tlft(getPost(posExamplesID).text); LS negExamples = tlft(getPost(negExamplesID).text); LPair<S, Bool> examples = trueFalseBPairs(posExamples, negExamples); if (!isCodeSafe(code)) fail("Detector code not safe"); IF1<S, O> detector = proxy IF1(evalCode(code)); new LS good; new LS bad; new Scorer scorer; long time = sysNow(); evalWithTimeoutOrFail(evalTimeout, r { for (Pair<S, Bool> example : examples) { S result = runFunc(() -> detector.get(example.a)); bool ok = eq(result, str(example.b)); scorer.add(ok); S line = (ok ? "OK" : example.b ? "False negative" : "False positive") + " (" + shorten(10, result) + "): " + example.a; (ok ? good : bad).add(line); } }); time = sysNow()-time; S text = "Detector code:\n\n" + indentx(code) + "\n\n" + n2(good, "correct answer") + ", " + n2(bad, "bad answer") + ". Runtime: " + n2(time) + " ms\n\n" + or2(trim(lines(concatLists(bad, ll(""), good))), "No errors"); S title = "Score for detector " + detectorID + ": " + scorer; gazelle_createPost(cred(), text, "Detector Score", +title, refs := joinWithSpace(ll(post.id, detectorID, posExamplesID, negExamplesID)), refTags := linesLL_rtrim("", "detector", "positive examples", "negative examples")); } catch e { postReply(post, getStackTrace(e), "Error"); } } }); bots.add(new Bot("Auto Bot Runner") { void handlePost(GazellePost post) { if (!runAutoBots) ret; if (post.creating) ret; print("Auto Bot Runner: " + post.id); for (GazellePost botPost : values(allPosts)) pcall { continue unless eqicOneOf(botPost.type, "JavaX Code (Bot run on every post)", "JavaX Code (Live Auto Bot)") && botPost.creator.isMaster; print("Processing auto-bot " + botPost.id + " on " + post.id); O code = codeForPost(botPost); if (shortNameIs CouldntLoadCode(code)) continue with print("Couldn't load code for auto-bot " + botPost.id); print("Code type: " + className(code)); O botInstance = evalWithTimeoutOrFail(evalTimeout, () -> callFOrNewInstance(code)); print("Bot instance type: " + className(botInstance)); setOpt(botInstance, postID := post.id); setOpt(botInstance, post := post.text); setOpt(botInstance, postType := post.type); // prevent endless loop of auto-bots replying to themselves or each other // (unless they explicitly request this) if (post.isAutoBotMade()) { // not a bot by master user? don't allow override if (!botPost.isMasterMade()) continue; // check for override request if (!isTrue(evalWithTimeoutOrFail(evalTimeout, () -> call(botInstance, "runOnAutoBotPost")))) continue; } createPostFromBotResult(post, () -> evalWithTimeoutOrFail(evalTimeout, () -> call(botInstance, "calc")), botInfo -> joinNemptiesWithColon("Auto-Bot " + botPost.id, botInfo)); } } }); dm_doEvery(60.0, 3600.0, r removeDeletedPosts); } void handlePost(GazellePost post) { allPosts.put(post.id, post); change(); //if (grabLoop.firstGrab) ret; loadedCodePosts.remove(post.id); if (post.modified > botProcessed) { print("modified post: " + post.id + " - " + post.modified + "/" + botProcessed); for (Bot bot : bots) pcall { bot.handlePost(post); } } } // if post != null, store transpilation O evalCode(S code, GazellePost post default null) { ret evalCode(evalTimeout, code, post); } O evalCode(double timeout, S code, GazellePost post default null) { //printWithIndent("CODE> ", code); veryQuickJava_transpiled.set(post != null ? "" : null); // request transpilation try { // TODO: don't makeDependent ret dm_javaEvalWithTimeout(timeout, code); } finally { if (post != null) { S java = veryQuickJava_transpiled!; print("Transpilation for " + post.id + ": " + shorten(java)); saveTextFile(transpilationFile(post.id), nullOnEmpty(java)); } } } // assumes code is safety-checked S runCode(S code) { printWithIndent("CODE> ", code); ret runFunc(() -> str_shortenSyntheticAndStandardToString(dm_javaEval(code))); } // run IF0 with timeout, exception to string, convert result to string S runFunc(IF0 f) { ret str_shortenSyntheticAndStandardToString(evalWithTimeoutOrException(evalTimeout, func { try { ret str_shortenSyntheticAndStandardToString(f!); } catch e { ret getStackTrace(e); } })); } L<GazellePost> repliesTo(GazellePost post) { ret filter(values(allPosts), p -> contains(p.postRefs, post.id)); } L<GazellePost> repliesWithTag(GazellePost post, S tag) { Pair<Long, S> pair = pair(post.id, upper(tag)); ret filter(repliesTo(post), p -> contains(mapPairsB toUpper(p.taggedRefs()), pair)); } GazellePost getPost(long id) { ret allPosts.get(id); } S getPostText(long id) { ret getPost(id).text; } Cl<GazellePost> getAllPosts() { ret values(allPosts); } CreatePost createPost(O... _) { ret new CreatePost(asList(_)); } CodeSafetyChecker codeSafetyChecker() { new CodeSafetyChecker checker; checker.init(); checker.markSafe("getAllPosts"); ret checker; } PythonCodeSafetyChecker pythonCodeSafetyChecker() { new PythonCodeSafetyChecker checker; checker.init(); checker.markSafe("getAllPosts"); ret checker; } S codeSafetyCheckResult(S code) { CodeSafetyChecker checker = codeSafetyChecker(); checker.checkCode(code); ret checker.verbalCheckResult(); } S pythonCodeSafetyCheckResult(S code) { PythonCodeSafetyChecker checker = pythonCodeSafetyChecker(); checker.checkCode(code); ret checker.verbalCheckResult(); } bool isCodePostSafe(GazellePost post) { ret considerMasterPostsSafe && post.creator.isMaster || isCodeSafe(post.text); } bool isCodeSafe(S code) { CodeSafetyChecker checker = codeSafetyChecker(); checker.checkCode(code); ret checker.isSafe(); } bool isPythonCodeSafe(S code) { PythonCodeSafetyChecker checker = pythonCodeSafetyChecker(); checker.checkCode(code); ret checker.isSafe(); } S prepareCode(S code, GazellePost post) { if (tok_isStaticLevelCode(code)) ret code; code = tok_addReturn(code); CodeInRewriting cir = new(javaTok(code)); augmentCode(cir, post); ret cir!; } // define implicit vars and functions void augmentCode(CodeInRewriting cir, GazellePost post) { // post = text of parent post if (cir.contains("post")) { long ref = gazelle_firstPostRef(post.id); if (ref != 0) cir.add("S post = " + quote(gazelle_text(ref)) + ";"); } // post = text of grandparent post if (cir.contains("post2")) { long ref = gazelle_firstPostRef(gazelle_firstPostRef(post.id)); if (ref != 0) cir.add("S post2 = " + quote(gazelle_text(ref)) + ";"); } // postType = typeof parent post if (cir.contains("postType")) { GazellePost post2 = getPost(gazelle_firstPostRef(post.id)); if (post2 != null) cir.add("S postType = " + quote(post2.type) + ";"); } if (cir.contains("getAllPosts")) cir.add([[ embedded Cl<GazellePost> getAllPosts() { ret lazyMap_bitSet quickImport(asList((Cl) dm_call(dm_current_generic(), "getAllPosts"))); } ]]); if (cir.contains("createPost")) cir.add([[ embedded O createPost(O... _) { ret dm_call(dm_current_generic(), "createPost", _); } ]]); // optimize gazelle_text if (cir.contains("gazelle_text")) cir.add([[ embedded S gazelle_text(long id) { ret (S) dm_call(dm_current_generic(), "getPostText", id); } ]]); } class CodeInRewriting { LS tok; new LS additions; *() {} *(LS *tok) {} bool contains(S token) { ret containsToken(tok, token); } S get() { ret lines(additions) + join(tok); } void add(S code) { addIfNempty(additions, code); } } O html(IWebRequest req) { { lock statsLock; requestsServed++; rstPersistRarely.trigger(); requestServed(); } new Matches m; if (eqic(req.uri(), "/favicon.ico")) ret serveFile(loadLibrary(#1400439), faviconMimeType()); if (startsWith(req.uri(), "/htmlBot/", m) && isInteger(m.rest())) { req.noSpam(); long postID = parseLong(m.rest()); GazellePost post = getPost(postID); if (post == null) ret serve404("Post " + postID + " not found"); O code = codeForPost(post); IF0 calc; // Case 1: Static page (code just returns a string) if (code instanceof S) calc = () -> code; // Case 2: Code is an argumentless function else if (implementsInterfaceShortNamed IF0(code)) calc = toIF0(code); else { // Case 3: Code is IF1<IWebRequest, ?> // Sadly, for a lambda like (IF1<IWebRequest, S>) req -> ..., we can't find the // IWebRequest type by reflection. So we find the IWebRequest interface by name. Class reqType = getClassInRealm("main$IWebRequest", code); //print(+reqType); if (reqType == null) fail("IWebRequest not found in bot"); O wrappedReq = proxy(reqType, req); calc = () -> callF(code, wrappedReq); } O result = evalWithTimeoutOrFail(evalTimeout, () -> calc!); ret hcomment("Made by HTML bot " + postID) + "\n" + str(result); } if (startsWith(req.uri(), "/chatBotReply/", m) && isInteger(m.rest())) { req.noSpam(); long postID = parseLong(m.rest()); GazellePost post = getPost(postID); if (post == null) ret serve404("Post " + postID + " not found"); O code = codeForPost(post); if (shortNameIs CouldntLoadCode(code)) ret withHeader(serve500("Couldn't load code for post")); S q = req.params().get("q"); // user input bool initial = eq(req.params().get("initial"), "1"); S cookie = req.params().get("cookie"); if (!initial && empty(q)) ret withHeader(serveText("")); S answer = ""; if (implementsInterfaceShortNamed IF1(code)) { // stateless bot if (!initial) answer = evalWithTimeoutOrFail(evalTimeout, () -> strOrEmpty(callF(code, q))); } else { print("Stateful bot. cookie: " + cookie); if (empty(cookie)) ret withHeader(serve500("Need cookie for stateful bot")); Conversation conv = uniq(Conversation, +cookie); O instance = code; if (instance == null) ret withHeader(serve500("No bot instance")); print("Stateful bot instance: " + instance); if (nempty(conv.dataStruct)) { O data = unstructureInRealm(conv.dataStruct, instance); // hopefully this is safe print("Unstructured: " + data); copyAllThisDollarFields(instance, data); // probably not needed anymore instance = data; } O _instance = instance; answer = evalWithTimeoutOrFail(evalTimeout, () -> strOrEmpty( initial ? callOpt(_instance, "initialMessage") : call(_instance, "answer", q))); cset(conv, dataStruct := structure(instance)); print("Structured: " + conv.dataStruct); } if (initial && empty(answer)) answer = "Bot " + post.id + " ready"; ret withHeader(serveJSON(litorderedmap(answer := shorten(maxBotAnswerLength, answer)))); } if (startsWith(req.uri(), "/transpilation/", m) && isInteger(m.rest())) { long postID = parseLong(m.rest()); GazellePost post = getPost(postID); if (post == null) ret serve404("Post " + postID + " not found"); S src = loadTextFile(transpilationFile(post.id)); if (empty(src)) src = "No transpilation found for post " + postID + "." + (!post.isJavaXCode() ? " Code is not a code post." : " Please try \"Touch post\" and wait a few seconds"); else pcall { src = javaPrettyPrint(src); } ret serveText(src); } if (eq(req.uri(), "/")) { ret "Loaded code posts:" + ul(keys(loadedCodePosts)); } ret serve404(); } O codeForPost(GazellePost post) { lock codeLoadLock; O code = loadedCodePosts.get(post.id); if (code == null) { try { S codeText = post.text; if (!isCodePostSafe(post)) fail("Code is not safe: " + codeSafetyCheckResult(codeText)); codeText = prepareCode(codeText, null); code = evalCode(codeText, post); } catch print e { code = new CouldntLoadCode; } loadedCodePosts.put(post.id, code); } dm_pointSubmoduleToMe(mainClass(code)); ret code; } File transpilationFile(long postID) { ret programFile("Post-Transpilations/" + postID + ".java"); } O withHeader(O response) { call(response, 'addHeader, "Access-Control-Allow-Origin", "*"); ret response; } O _getReloadData() { ret loadedCodePosts; } void _setReloadData(Map<Long, O> data) { if (data != null) loadedCodePosts = data; } void forgetLoadedCodePosts { clear(loadedCodePosts); } void enhanceFrame(Container f) { super.enhanceFrame(f); internalFramePopupMenuItems(f, "Forget loaded code", rEnter forgetLoadedCodePosts, "Remove deleted posts", rThreadEnter removeDeletedPosts); } void removeDeletedPosts { Cl<Long> posts = asSet(grabLoop.allPostIDs()); print("Keeping " + nPosts(posts)); if (syncRemoveAllExcept(allPosts, posts)) change(); print("New count: " + nPosts(allPosts)); removeAllExcept(loadedCodePosts, posts); } // web sockets class WebSocketInfo { S uri; SS params; new Set<Pair<S, O>> liveDivs; // id/content info } transient Map<virtual WebSocket, WebSocketInfo> webSockets = syncWeakHashMap(); void cleanMeUp_webSockets { closeAllKeysAndClear((Map) webSockets); } void handleWebSocket(virtual WebSocket ws) { set(ws, onClose := r { webSockets.remove(ws) }); set(ws, onOpen := rEnter { S uri = cast rcall getUri(ws); SS params = cast rcall getParms(ws); print("WebSocket opened! uri: " + uri + ", params: " + params); new WebSocketInfo info; info.uri = uri; info.params = params; webSockets.put(ws, info); }); setFieldToIVF1Proxy(ws, onMessage := msg -> { temp enter(); pcall { WebSocketInfo info = webSockets.get(ws); if (info == null) ret; S data = rcall_string getTextPayload(msg); Map map = jsonDecodeMap(data); O div = map.get("liveDiv"); if (div cast S) { S contentDesc = div; syncAdd(info.liveDivs, pair(div, (O) contentDesc)); reloadDiv(ws, div, calcDivContent(contentDesc)); } }}); } transient ReliableSingleThread_Multi<S> rstDistributeDivChanges = new(1000, lambda1 distributeDivChanges_impl); void distributeDivChanges(S contentDesc) { rstDistributeDivChanges.add(contentDesc); } void distributeDivChanges_impl(S contentDesc) enter { //print("distributeDivChanges_impl " + contentDesc); S content = null; for (Pair<virtual WebSocket, WebSocketInfo> p : syncMapToPairs(webSockets)) { for (S div : asForPairsWithB(p.b.liveDivs, contentDesc)) { if (content == null) content = calcDivContent(contentDesc); if (content == null) { print("No content for " + contentDesc); ret; } reloadDiv(p.a, div, content); } } } void reloadDiv(virtual WebSocket ws, S div, S content) { print("Reloading div " + div + " through WebSocket"); S jsCode = replaceDollarVars( [[ $("#" + $div).html($content);]], div := jsQuote(div), content := jsQuote(content)); dm_call(ws, "send", jsonEncode(litmap(eval := jsCode))); } S calcDivContent(S contentDesc) { if (eq(contentDesc, "webRequestsLeftHemi")) ret n2(requestsServed); if (eq(contentDesc, "serverLoadLeftHemi")) ret formatDoubleX(systemLoad, 1); if (eq(contentDesc, "memLeftHemi")) ret str_toM(processSize); null; } void requestServed { distributeDivChanges("webRequestsLeftHemi"); } void setFirstGrab { grabLoop.firstGrab = true; change(); } } // end of module
Began life as a copy of #1029997
download show line numbers debug dex old transpilations
Travelled to 4 computer(s): bhatertpkbcr, mqqgnosmbjvj, pyentgdyhuwx, vouqrxazstgt
No comments. add comment
Snippet ID: | #1030627 |
Snippet name: | DynGazelleMultiBot |
Eternal ID of this version: | #1030627/22 |
Text MD5: | 267beffe9a303e1f7704f10549c1b0e5 |
Transpilation MD5: | b88ba97144c14a2cb0c7a0d1a7cdc9cc |
Author: | stefan |
Category: | javax / gazelle.rocks |
Type: | JavaX fragment (include) |
Public (visible to everyone): | Yes |
Archived (hidden from active list): | No |
Created/modified: | 2021-06-28 22:16:01 |
Source code size: | 27408 bytes / 802 lines |
Pitched / IR pitched: | No / No |
Views / Downloads: | 329 / 650 |
Version history: | 21 change(s) |
Referenced in: | [show references] |