diff --git a/app/assets/javascripts/beak/nlogo-file.coffee b/app/assets/javascripts/beak/nlogo-file.coffee new file mode 100644 index 000000000..fad7f1748 --- /dev/null +++ b/app/assets/javascripts/beak/nlogo-file.coffee @@ -0,0 +1,53 @@ +# +# NlogoFile class is a utility class to handle NetLogo files. +# This is not a full implementation. Expand as necessary. +# +class AbstractNlogoFile + constructor: (source) -> + @source = source + + getSource: -> + return @source + + getCode: -> + return 'dummy' + +class NlogoFile extends AbstractNlogoFile + constructor: (source) -> + super(source) + @delimiter = "@#$#@#$#@" + + getSource: -> + return @source + + getCode: -> + # Extract the code from the source using the delimiter + parts = @source.split(@delimiter) + if parts.length > 1 + return parts[0].trim() + else + return @source.trim() + +class NlogoXFile extends AbstractNlogoFile + constructor: (source) -> + super(source) + parser = new DOMParser() + @doc = parser.parseFromString(source, "text/xml") + errorNode = @doc.querySelector("parsererror") + if errorNode + throw new Error("Invalid Nlogo XML: " + errorNode.textContent) + + getSource: -> + return @source + + getCode: -> + codeElement = @doc.querySelector("code") + codeText = codeElement.innerHTML + code = if not codeText.startsWith("".length)) + + return code + +export { NlogoXFile, NlogoFile } diff --git a/app/assets/javascripts/beak/nlw-extensions-loader.coffee b/app/assets/javascripts/beak/nlw-extensions-loader.coffee new file mode 100644 index 000000000..deed2940b --- /dev/null +++ b/app/assets/javascripts/beak/nlw-extensions-loader.coffee @@ -0,0 +1,177 @@ + + + +# This is a singleton class for managing NetLogo Web (NLW) extensions. +# There is a few, unfortunately, global objects that we have to depend on: +# 1. Extensions. -- Managed by Tortoise Engine +# 2. URLExtensionsRepo -- Managed by Galapagos +# +# I tried to keep this file the main source of truth for NLW extensions as +# much as possible. This works hand-in-hand with the Tortoise Engine, particularly +# the NLWExtensionsManager class, which is responsible for managing the +# extensions in the Tortoise Engine. +# +# A lot of the functionality here is part of an API used by the Tortoise Engine. +# I could've implemented those in Scala, but I chose not to do so because +# I wanted to keep the NLW extensions management logic in JavaScript, where +# it is easier to work with URLs and dynamic imports. +# Also, because we cannot trigger asynchronous work in Scala and support in JavaScript +# easily––at least without the browser complaining about it––the `loadURLExtensions` +# had to be implemented in JavaScript anyways. +# +# - Omar Ibrahim, July 2025 +# +class NLWExtensionsLoader + @instance: null + @allowedExtensions = ["js"] + constructor: (compiler) -> + if NLWExtensionsLoader.instance? + return NLWExtensionsLoader.instance + + NLWExtensionsLoader.instance = this + window.NLWExtensionsLoader = NLWExtensionsLoader.instance + + @compiler = compiler + @urlRepo = {} + + loadURLExtensions: (source) -> + urlRepo = @urlRepo + extensions = @compiler.listExtensions(source) + url_extensions = Object.fromEntries(await Promise.all(extensions + .filter((ext) -> ext.url isnt null) + .map((ext) => + {name, url} = ext + url = @normalizeURL(@removeURLProtocol(url)) + baseName = NLWExtensionsLoader.getBaseNameFromURL(url) + primURL = NLWExtensionsLoader.getPrimitiveJSONSrc(url) + if urlRepo[url]? + # If the extension is already loaded, just return it + return [url, urlRepo[url]] + # We want to get a lazy loader for the extension, + # and fetch the primitives JSON file before we + # trigger the recompilation. + return Promise.resolve().then(() -> + extensionModule = await NLWExtensionsLoader.getModuleFromURL(url) + prims = await NLWExtensionsLoader.fetchPrimitives(primURL) + NLWExtensionsLoader.confirmNamesMatch(prims, name) + return [url, { + extensionModule, + prims + }] + ) + ) + )) + + Object.assign(@urlRepo, url_extensions) + NLWExtensionsLoader.updateGlobalExtensionsObject(url_extensions) + url_extensions + + # Public API + getPrimitivesFromURL: (url) -> + # Get the primitives JSON file from the URL, if it exists + url = @normalizeURL(url) + if @urlRepo[url]? + return @urlRepo[url].prims + else + return null + + getExtensionModuleFromURL: (url) -> + # Get the extension module from the URL, if it exists + url = @normalizeURL(url) + if @urlRepo[url]? + return @urlRepo[url].extensionModule + else + return null + + appendURLProtocol: (url) -> + if not url.startsWith("url://") + return "url://" + url + else + return url + + removeURLProtocol: (name) -> + # Remove the "url://" protocol from the name + if name.startsWith("url://") + return name.slice("url://".length) + else + return name + + isURL: (name) -> + return name.startsWith("url://") + + validateURL: (url) -> + try + url = @normalizeURL(url) + fileExtension = url.split('.').pop().toLowerCase() + if NLWExtensionsLoader.allowedExtensions.includes(fileExtension) + return true + else + console.error("Invalid file extension: #{fileExtension}. " + + "Allowed extensions are: #{@allowedExtensions.join(', ')}" + ) + return false + catch e + console.error("Invalid URL: #{url} - #{e.message}") + return false + + normalizeURL: (url) -> + # Remove Search Params and Hash from the URL + try + url = @removeURLProtocol(url) + uri = new URL(url) + uri.search = '' + uri.hash = '' + return uri.toString() + catch e + console.error("Error normalizing URL: #{url} - #{e.message}") + return url + + # Helpers + @getModuleFromURL: (url) -> + # Get the module from the URL, if it exists + extensionImport = await import(url) + extensionKeys = Object.keys(extensionImport) + if extensionKeys.length > 0 + return extensionImport[extensionKeys[0]] + else + throw new Error("Extension module at #{url} does not export anything.") + + @updateGlobalExtensionsObject: (url_extensions) -> + # Update the global Extensions object with the new URL extensions + if not window.URLExtensionsRepo? + window.URLExtensionsRepo = {} + Object.assign(window.URLExtensionsRepo, url_extensions) + + @confirmNamesMatch: (primitives, name) -> + # Check if the primitives JSON file name matches the extension name + if primitives?.name.toLowerCase() isnt name.toLowerCase() + console.warn("Primitives JSON file name '#{primitives.name.toLowerCase()}' " + + "does not match extension import name '#{name.toLowerCase()}'") + + @fetchPrimitives: (primURL) -> + try + response = await fetch(primURL) + if response.ok + return await response.json() + else + throw new Error("Failed to fetch primitives from #{primURL}: HTTP #{response.status}") + catch ex + console.error("Error fetching primitives from #{primURL}: #{ex.message}") + return null + + @getBaseNameFromURL: (url) -> + # Remove the file extension from the URL + # to get the base name. By convention, the + # primitives JSON file is named after the extension + # base name, with a .json extension. + # e.g. "my-extension.js" becomes "my-extension.json" + url.split('.').slice(0, -1).join('.') + + @getPrimitiveJSONSrc: (url) -> + # Get the base name from the URL and append '.json' + baseName = NLWExtensionsLoader.getBaseNameFromURL(url) + return "#{baseName}.json" + + +# Exports +export default NLWExtensionsLoader diff --git a/app/assets/javascripts/beak/session-lite.coffee b/app/assets/javascripts/beak/session-lite.coffee index 194e9fffd..b3c034a88 100644 --- a/app/assets/javascripts/beak/session-lite.coffee +++ b/app/assets/javascripts/beak/session-lite.coffee @@ -40,7 +40,8 @@ class SessionLite # (Tortoise, Element|String, BrowserCompiler, Array[Rewriter], Array[Listener], Array[Widget], # String, String, Boolean, String, String, NlogoSource, String, Boolean) constructor: (@tortoise, container, @compiler, @rewriters, listeners, widgets, - code, info, isReadOnly, @locale, workInProgressState, @nlogoSource, modelJS, lastCompileFailed) -> + code, info, isReadOnly, @locale, workInProgressState, @nlogoSource, modelJS, lastCompileFailed, + @onBeforeRecompile) -> @hnw = new HNWSession( (() => @widgetController) , ((ps) => @compiler.compilePlots(ps))) @@ -231,7 +232,6 @@ class SessionLite if @widgetController.ractive.get('isEditing') and @hnw.isHNW() parent.postMessage({ type: "recompile" }, "*") else - code = @widgetController.code() oldWidgets = @widgetController.widgets() rewritten = @rewriteCode(code) @@ -251,6 +251,10 @@ class SessionLite @widgetController.ractive.fire('recompile-start', source, rewritten, code) try + # # Execute the onBeforeRecompile callbacks + for callback in @onBeforeRecompile + await callback(source, rewritten, code) + res = @compiler.fromModel(compileParams) if res.model.success diff --git a/app/assets/javascripts/beak/tortoise.coffee b/app/assets/javascripts/beak/tortoise.coffee index 318e7b05e..e165fdb0c 100644 --- a/app/assets/javascripts/beak/tortoise.coffee +++ b/app/assets/javascripts/beak/tortoise.coffee @@ -2,11 +2,13 @@ import SessionLite from "./session-lite.js" import { DiskSource, NewSource, UrlSource, ScriptSource } from "./nlogo-source.js" import { toNetLogoWebMarkdown, nlogoToSections, sectionsToNlogo } from "./tortoise-utils.js" import { createNotifier, listenerEvents } from "../notifications/listener-events.js" +import NLWExtensionsLoader from "./nlw-extensions-loader.js" +import { NlogoXFile, NlogoFile } from "./nlogo-file.js" # (String|DomElement, BrowserCompiler, Array[Rewriter], Array[Listener], ModelResult, # Boolean, String, String, NlogoSource, Boolean) => SessionLite newSession = (container, compiler, rewriters, listeners, modelResult, - isReadOnly, locale, workInProgressState, nlogoSource, lastCompileFailed) -> + isReadOnly, locale, workInProgressState, nlogoSource, lastCompileFailed, onBeforeRecompile) -> { code, info, model: { result }, widgets: wiggies } = modelResult widgets = globalEval(wiggies) info = toNetLogoWebMarkdown(info) @@ -24,10 +26,12 @@ newSession = (container, compiler, rewriters, listeners, modelResult, , workInProgressState , nlogoSource , result - , lastCompileFailed + , lastCompileFailed, + onBeforeRecompile or [] ) session + # (() => Unit) => Unit startLoading = (process) -> document.querySelector("#loading-overlay").style.display = "" @@ -88,6 +92,7 @@ fromNlogoXMLSync = (nlogoxSource, container, locale, isUndoReversion, getWorkInProgress, callback, rewriters, listeners, extraWidgets = []) -> compiler = new BrowserCompiler() + extensionsLoader = new NLWExtensionsLoader(compiler) notifyListeners = createNotifier(listenerEvents, listeners) @@ -106,10 +111,36 @@ fromNlogoXMLSync = (nlogoxSource, container, locale, isUndoReversion, extrasReducer = (extras, rw) -> if rw.getExtraCommands? then extras.concat(rw.getExtraCommands()) else extras extraCommands = rewriters.reduce(extrasReducer, []) + file = new NlogoXFile(rewrittenNlogoXML) + code = file.getCode() + notifyListeners('compile-start', rewrittenNlogoXML, startingNlogoXML) + + compileSuccess = true + errors = [] + try + await extensionsLoader.loadURLExtensions(code) + catch e + compileSuccess = false + errors.push(e.message) + result = compiler.fromNlogoXML(rewrittenNlogoXML, extraCommands, { code: "", widgets: extraWidgets }) + if not result.model.success + compileSuccess = false + errors = [...result.model.result, ...errors] - if result.model.success + + onBeforeRecompile = [ + (source, rewritten, code) -> + try + await extensionsLoader.loadURLExtensions(rewritten) + return true + catch e + return false + ] + + + if compileSuccess # result.code = if (startingNlogoXML is rewrittenNlogoXML) # result.code @@ -127,6 +158,7 @@ fromNlogoXMLSync = (nlogoxSource, container, locale, isUndoReversion, , workInProgressState , nlogoxSource , false + , onBeforeRecompile ) callback({ @@ -152,13 +184,14 @@ fromNlogoXMLSync = (nlogoxSource, container, locale, isUndoReversion, , workInProgressState , nlogoxSource , true + , onBeforeRecompile ) callback({ type: 'failure' , source: 'compile-recoverable' , session: session - , errors: result.model.result + , errors }) result.commands.forEach( (c) -> if c.success then (new Function(c.result))() ) rewriters.forEach( (rw) -> rw.compileComplete?() ) @@ -168,7 +201,7 @@ fromNlogoXMLSync = (nlogoxSource, container, locale, isUndoReversion, callback({ type: 'failure' , source: 'compile-fatal' - , errors: result.model.result + , errors }) return @@ -202,6 +235,9 @@ fromNlogoSync = (nlogoSource, container, locale, isUndoReversion, getWorkInProgress, callback, rewriters, listeners, extraWidgets = []) -> compiler = new BrowserCompiler() + extensionsLoader = new NLWExtensionsLoader(compiler) + + notifyListeners = createNotifier(listenerEvents, listeners) @@ -221,15 +257,40 @@ fromNlogoSync = (nlogoSource, container, locale, isUndoReversion, extraCommands = rewriters.reduce(extrasReducer, []) notifyListeners('compile-start', rewrittenNlogo, startingNlogo) + + file = new NlogoFile(rewrittenNlogo) + code = file.getCode() + + compileSuccess = true + errors = [] + try + await extensionsLoader.loadURLExtensions(code) + catch e + compileSuccess = false + errors.push(e.message) + result = compiler.fromNlogo(rewrittenNlogo, extraCommands, { code: "", widgets: extraWidgets }) + if not result.model.success + compileSuccess = false + errors = [...result.model.result, ...errors] + + onBeforeRecompile = [ + (source, rewritten, code) -> + try + await extensionsLoader.loadURLExtensions(rewritten) + return true + catch e + return false + ] - if result.model.success + if compileSuccess result.code = if (startingNlogo is rewrittenNlogo) result.code else nlogoToSections(startingNlogo)[0].slice(0, -1) - + + session = newSession( container , compiler @@ -241,6 +302,7 @@ fromNlogoSync = (nlogoSource, container, locale, isUndoReversion, , workInProgressState , nlogoSource , false + , onBeforeRecompile ) callback({ @@ -266,6 +328,7 @@ fromNlogoSync = (nlogoSource, container, locale, isUndoReversion, , workInProgressState , nlogoSource , true + , onBeforeRecompile ) callback({ diff --git a/app/assets/javascripts/keywords.coffee b/app/assets/javascripts/keywords.coffee index c2cff6da8..64e0549db 100644 --- a/app/assets/javascripts/keywords.coffee +++ b/app/assets/javascripts/keywords.coffee @@ -10,6 +10,7 @@ directives = [ 'DIRECTED-LINK-BREED', 'UNDIRECTED-LINK-BREED', 'EXTENSIONS', + 'EXTENSION', '__INCLUDES' ] diff --git a/build.sbt b/build.sbt index 91feaf2d5..eb3ee022c 100644 --- a/build.sbt +++ b/build.sbt @@ -13,7 +13,7 @@ import scala.sys.process.{ Process, ProcessLogger } name := "Galapagos" version := "1.0-SNAPSHOT" -val tortoiseVersion = "1.0-2f7bb74" +val tortoiseVersion = sys.env.getOrElse("TORTOISE_VERSION", "1.0-2f7bb74") resolvers ++= Seq( "tortoise" at "https://dl.cloudsmith.io/public/netlogo/tortoise/maven/"