diff --git a/app/static/js/core/custom-functions-code.js b/app/static/js/core/custom-functions-code.js index ea3aa9a..f1a844b 100644 --- a/app/static/js/core/custom-functions-code.js +++ b/app/static/js/core/custom-functions-code.js @@ -1,6 +1,261 @@ -async function hello(name) { - let r = await window.hello(name); +// async function hello(name) { +// let r = await window.hello(name); +// return r.toJs(); +// } + +// CustomFunctions.associate("HELLO", hello); + +// async function sql(query, tables) { +// let r = await window.sql(query, tables); +// return r.toJs(); +// } + +// CustomFunctions.associate("SQL", sql); + +///////////////////////////////////////// + +const debug = false; +let invocations = new Set(); +let bodies = new Set(); +let runtime; +let contentLanguage; +let socket = null; + +Office.onReady(function (info) { + // Socket.io + socket = globalThis.socket ? globalThis.socket : null; + + if (socket !== null) { + socket.on("disconnect", () => { + if (debug) { + console.log("disconnect"); + } + for (let invocation of invocations) { + invocation.setResult([["Stream disconnected"]]); + } + invocations.clear(); + }); + + socket.on("connect", () => { + // Without this, you'd have to hit Ctrl+Alt+F9, which isn't available on the web + if (debug) { + console.log("connect"); + } + for (let body of bodies) { + socket.emit("xlwings:function-call", body); + } + }); + } + + // Runtime version + if ( + Office.context.requirements.isSetSupported("CustomFunctionsRuntime", "1.4") + ) { + runtime = "1.4"; + } else if ( + Office.context.requirements.isSetSupported("CustomFunctionsRuntime", "1.3") + ) { + runtime = "1.3"; + } else if ( + Office.context.requirements.isSetSupported("CustomFunctionsRuntime", "1.2") + ) { + runtime = "1.2"; + } else { + runtime = "1.1"; + } + + // Content Language + contentLanguage = Office.context.contentLanguage; +}); + +function flattenVarargsArray(arr) { + // Turn [ [[0]], [ [[0]], [[0]] ] ] into: + // result: [ [[0]], [[0]], [[0]] ] + // indices: [ [ 0 ], [ 1, 0 ], [ 1, 1 ] ] + const result = []; + const indices = []; + + function isTripleNested(item) { + return ( + Array.isArray(item) && Array.isArray(item[0]) && Array.isArray(item[0][0]) + ); + } + + for (let i = 0; i < arr.length; i++) { + const item = arr[i]; + + if (isTripleNested(item)) { + result.push(...item); + for (let j = 0; j < item.length; j++) { + indices.push([i, j]); + } + } else { + result.push(item); + indices.push([i]); + } + } + + return { + result, + indices, + }; +} + +// Workbook name +let cachedWorkbookName = null; + +async function getWorkbookName() { + if (cachedWorkbookName) { + return cachedWorkbookName; + } + const context = new Excel.RequestContext(); + const workbook = context.workbook; + workbook.load("name"); + await context.sync(); + cachedWorkbookName = workbook.name; + return cachedWorkbookName; +} + +class Semaphore { + constructor(maxConcurrency) { + this.maxConcurrency = maxConcurrency; + this.currentConcurrency = 0; + this.queue = []; + } + + async acquire() { + if (this.currentConcurrency < this.maxConcurrency) { + this.currentConcurrency++; + return; + } + return new Promise((resolve) => this.queue.push(resolve)); + } + + release() { + this.currentConcurrency--; + if (this.queue.length > 0) { + this.currentConcurrency++; + const nextResolve = this.queue.shift(); + nextResolve(); + } + } +} + +const semaphore = new Semaphore(1000); + +async function base() { + await Office.onReady(); // Block execution until office.js is ready + // Arguments + let argsArr = Array.prototype.slice.call(arguments); + let funcName = argsArr[0]; + let isStreaming = argsArr[1]; + let args = argsArr.slice(2, -1); + let invocation = argsArr[argsArr.length - 1]; + + const workbookName = await getWorkbookName(); + const officeApiClient = localStorage.getItem("Office API client"); + + // For arguments that are Entities, replace the arg with their address (cache key). + // The issues is that invocation.parameterAddresses returns a flat list while args + // contains a nested array for varargs (in Office.js called 'repeating'). + const { result: flatArgs, indices } = flattenVarargsArray(args); + + // Process each flattened item with respect to its path + flatArgs.forEach((item, index) => { + if (item && item[0][0]?.type === "Entity") { + const address = `${officeApiClient}[${workbookName}]${invocation.parameterAddresses[index]}`; + + let target = args; + const path = indices[index]; + + for (let i = 0; i < path.length - 1; i++) { + target = target[path[i]]; + } + + const lastIndex = path[path.length - 1]; + target[lastIndex] = [address]; + } + }); + + // Body + let body = { + func_name: funcName, + args: args, + caller_address: `${officeApiClient}[${workbookName}]${invocation.address}`, // not available for streaming functions + content_language: contentLanguage, + version: "0.33.3", + runtime: runtime, + }; + + // Streaming functions communicate via socket.io + if (isStreaming) { + if (socket === null) { + console.error( + "To enable streaming functions, you need to load the socket.io js client before xlwings.min.js and custom-functions-code", + ); + return; + } + let taskKey = `${funcName}_${args}`; + body.task_key = taskKey; + socket.emit("xlwings:function-call", body); + if (debug) { + console.log(`emit xlwings:function-call ${funcName}`); + } + invocation.setResult([["Waiting for stream..."]]); + + socket.off(`xlwings:set-result-${taskKey}`); + socket.on(`xlwings:set-result-${taskKey}`, (data) => { + invocation.setResult(data.result); + if (debug) { + console.log(`Set Result`); + } + }); + + invocations.add(invocation); + bodies.add(body); + + return; + } + + // Normal functions communicate via REST API + return await makeJsCall(body); +} + +async function makeJsCall(body) { + const processedArgs = body.args + ? body.args.map((arg) => JSON.stringify(arg)) + : []; + + // Call function with processed arguments + let r = await window[body.func_name](...processedArgs); return r.toJs(); } +function showError(errorMessage) { + if ( + Office.context.requirements.isSetSupported("CustomFunctionsRuntime", "1.2") + ) { + // Error message is only visible by hovering over the error flag! + let excelError = new CustomFunctions.Error( + CustomFunctions.ErrorCode.invalidValue, + errorMessage, + ); + throw excelError; + } else { + return [[errorMessage]]; + } +} + +async function hello() { + let args = ["hello", false]; + args.push.apply(args, arguments); + return await base.apply(null, args); +} CustomFunctions.associate("HELLO", hello); + +async function sql() { + let args = ["sql", false]; + args.push.apply(args, arguments); + return await base.apply(null, args); +} +CustomFunctions.associate("SQL", sql); diff --git a/app/static/main.py b/app/static/main.py index 6bb1c96..0909f2b 100644 --- a/app/static/main.py +++ b/app/static/main.py @@ -7,11 +7,12 @@ import json import os +import sqlite3 -import matplotlib as mpl -import matplotlib.pyplot as plt - -mpl.use("agg") +# To use matplotlib, add it to pyscript.json +# import matplotlib as mpl +# import matplotlib.pyplot as plt +# mpl.use("agg") os.environ["XLWINGS_LICENSE_KEY"] = "noncommercial" import xlwings as xw # noqa: E402 @@ -31,10 +32,10 @@ async def test(event): print(sheet1["A1:A2"].value) book.sheets[0]["A3"].value = "xxxxxxx" - fig = plt.figure() - plt.plot([1, 2, 3]) - sheet1.pictures.add(fig, name="MyPlot", update=True, anchor=sheet1["C5"]) - sheet1["A1"].select() + # fig = plt.figure() + # plt.plot([1, 2, 3]) + # sheet1.pictures.add(fig, name="MyPlot", update=True, anchor=sheet1["C5"]) + # sheet1["A1"].select() # Process actions (this could be improved so methods are applied immediately) xwjs.runActionsStandalone(json.dumps(book.json())) @@ -46,3 +47,84 @@ async def hello(name): window.hello = hello + + +# @func +# @arg("tables", expand="table", ndim=2) +async def sql(query, *tables): + print("xx", query) + query = json.loads(query)[0][0] + print("yy", tables) + processed_tables = [ + json.loads(table) if isinstance(table, str) else table + for table in tables # TODO: remove ending [0] when converter applied + ] + print("zz", processed_tables) + res = _sql(query, *processed_tables) + print("rr", res) + return res + + +window.sql = sql + + +def conv_value(value, col_is_str): + if value is None: + return "NULL" + if col_is_str: + return repr(str(value)) + elif isinstance(value, bool): + return 1 if value else 0 + else: + return repr(value) + + +def _sql(query, *tables): + conn = sqlite3.connect(":memory:") + print("t", tables) + + c = conn.cursor() + for i, table in enumerate(tables): + # TODO: remove [0] in next 2 rows + cols = table[0][0] + rows = table[0][1:] + print(cols) + print(rows) + types = [any(isinstance(row[j], str) for row in rows) for j in range(len(cols))] + name = chr(65 + i) + + stmt = "CREATE TABLE %s (%s)" % ( + name, + ", ".join( + "'%s' %s" % (col, "STRING" if typ else "REAL") + for col, typ in zip(cols, types) + ), + ) + c.execute(stmt) + + if rows: + stmt = "INSERT INTO %s VALUES %s" % ( + name, + ", ".join( + "(%s)" + % ", ".join( + conv_value(value, type) for value, typ in zip(row, types) + ) + for row in rows + ), + ) + # Fixes values like these: + # sql('SELECT a FROM a', [['a', 'b'], ["""X"Y'Z""", 'd']]) + stmt = stmt.replace("\\'", "''") + c.execute(stmt) + + res = [] + c.execute(query) + res.append([x[0] for x in c.description]) + for row in c: + res.append(list(row)) + print("marker3") + print(res) + print(type(res)) + + return res diff --git a/app/static/pyscript.json b/app/static/pyscript.json index 327ecc2..a70aeb0 100644 --- a/app/static/pyscript.json +++ b/app/static/pyscript.json @@ -1,3 +1,3 @@ { - "packages": ["xlwings", "matplotlib"] + "packages": ["xlwings", "sqlite3"] }