diff --git a/.types/index.d.ts b/.types/index.d.ts index 5c1559643..40c2fe931 100644 --- a/.types/index.d.ts +++ b/.types/index.d.ts @@ -218,7 +218,10 @@ type CharacterProperties = { _defaulttoken: string; }; -declare type Roll20Character = Prettify>; +declare type Roll20Character = Prettify & { + /** Experimental: legacy Roll20 attributes vs Beacon sheet */ + sheetEnvironment?: "legacy" | "beacon"; +}>; // Attribute type with proper properties type AttributeProperties = { @@ -554,7 +557,11 @@ declare function getAllObjs(): Roll20Object[]; */ declare function getAttrByName(character_id: string, attribute_name: string, value_type?: "current" | "max"): string; -type SheetItemOptions = { allowThrow?: boolean }; +type SheetItemOptions = { + allowThrow?: boolean, + createAttr?: boolean, + withWorker?: boolean +}; type SheetItemValueTYpe = "current" | "max"; /** diff --git a/libSmartAttributes/0.0.4/.libSmartAttributes.js.swp b/libSmartAttributes/0.0.4/.libSmartAttributes.js.swp new file mode 100644 index 000000000..a9d953656 Binary files /dev/null and b/libSmartAttributes/0.0.4/.libSmartAttributes.js.swp differ diff --git a/libSmartAttributes/0.0.4/libSmartAttributes.js b/libSmartAttributes/0.0.4/libSmartAttributes.js new file mode 100644 index 000000000..a05379fb7 --- /dev/null +++ b/libSmartAttributes/0.0.4/libSmartAttributes.js @@ -0,0 +1,95 @@ +// libSmartAttributes v0.0.4 by GUD Team | libSmartAttributes provides an interface for managing beacon attributes in a slightly smarter way. +var libSmartAttributes = (function () { + 'use strict'; + + async function getAttribute(characterId, name, type = "current") { + // Try for a legacy attribute or beacon computed + const attr = await getSheetItem(characterId, name, type); + if (attr !== null && attr !== undefined) { + return attr; + } + // Then try for the user attribute + const userAttr = await getSheetItem(characterId, `user.${name}`, type); + if (userAttr !== null && userAttr !== undefined) { + return userAttr; + } + log(`Attribute ${name} not found on character ${characterId}`); + return undefined; + } + async function setAttribute(characterId, name, value, type = "current", options) { + try { + await setSheetItem(characterId, name, value, type, { + allowThrow: true, + createAttr: options?.noCreate === undefined ? true : !options.noCreate, + withWorker: options?.setWithWorker === undefined ? true : options.setWithWorker + }); + return true; + } + catch (e) { + // throw will happen on beacon sheets if the computed doesn't exist or is read-only + switch (e.type) { + // for read only computeds, we don't want to make a shadow "user." version. + case "COMPUTED_READONLY": + return false; + } + } + // Then default to a user attribute + try { + await setSheetItem(characterId, `user.${name}`, value, type, { + allowThrow: true, + createAttr: options?.noCreate === undefined ? true : !options.noCreate, + withWorker: options?.setWithWorker === undefined ? true : options.setWithWorker + }); + return true; + } + catch { + return false; + } + } + async function deleteAttribute(characterId, name, type = "current") { + const character = getObj("character", characterId); + if (!character) { + return false; + } + if (character?.sheetEnvironment === "legacy" || character?.sheetEnvironment === undefined) { + const legacyAttr = findObjs({ + _type: "attribute", + _characterid: characterId, + name: name, + })[0]; + if (legacyAttr) { + legacyAttr.remove(); + return true; + } + return false; + } + // Beacon computeds cannot be deleted (no change to the computed value). + const beaconAttr = await getSheetItem(characterId, name, type); + if (beaconAttr !== null && beaconAttr !== undefined) { + return false; + } + // Then try for the user attribute + const userAttr = await getSheetItem(characterId, `user.${name}`, type); + if (userAttr !== null && userAttr !== undefined) { + try { + await setSheetItem(characterId, `user.${name}`, undefined, type, { + allowThrow: true, + createAttr: false + }); + return true; + } + catch { + return false; + } + } + return false; + } + var index = { + getAttribute, + setAttribute, + deleteAttribute, + }; + + return index; + +})(); diff --git a/libSmartAttributes/script.json b/libSmartAttributes/script.json index 9e156eeb2..66de2c135 100644 --- a/libSmartAttributes/script.json +++ b/libSmartAttributes/script.json @@ -1,6 +1,6 @@ { "name": "libSmartAttributes", - "version": "0.0.3", + "version": "0.0.4", "description": "libSmartAttributes provides an interface for managing beacon attributes in a slightly smarter way.", "authors": "GUD Team", "roll20userid": "8705027", @@ -10,6 +10,7 @@ "script": "libSmartAttributes.js", "useroptions": [], "previousversions": [ + "0.0.3", "0.0.2", "0.0.1" ] diff --git a/libSmartAttributes/src/.index.ts.swo b/libSmartAttributes/src/.index.ts.swo new file mode 100644 index 000000000..c03ccdc4f Binary files /dev/null and b/libSmartAttributes/src/.index.ts.swo differ diff --git a/libSmartAttributes/src/index.ts b/libSmartAttributes/src/index.ts index 37bcefc17..f17837576 100644 --- a/libSmartAttributes/src/index.ts +++ b/libSmartAttributes/src/index.ts @@ -22,9 +22,16 @@ async function getAttribute( }; type SetOptions = { + setWithWorker?: boolean; noCreate?: boolean; }; +type SheetItemError = Error & { + type: string; + details?: Record; +}; + + async function setAttribute( characterId: string, name: string, @@ -34,54 +41,75 @@ async function setAttribute( ) { try { - await setSheetItem(characterId, name, value, type, {allowThrow: true}); - return; - } catch { + await setSheetItem(characterId, name, value, type, { + allowThrow: true, + createAttr: options?.noCreate === undefined ? true : !options.noCreate, + withWorker: options?.setWithWorker === undefined ? true : options.setWithWorker + }); + return true; + } catch (e) { // throw will happen on beacon sheets if the computed doesn't exist or is read-only - } - - // Guard against creating user attributes if noCreate is set - if (options?.noCreate) { - log(`Attribute ${name} not found on character ${characterId}, and noCreate option is set. Skipping creation.`); - return; + switch((e as SheetItemError).type){ + // for read only computeds, we don't want to make a shadow "user." version. + case "COMPUTED_READONLY": + return false; + } } // Then default to a user attribute - setSheetItem(characterId, `user.${name}`, value, type); - return; + try { + await setSheetItem(characterId, `user.${name}`, value, type, { + allowThrow: true, + createAttr: options?.noCreate === undefined ? true : !options.noCreate, + withWorker: options?.setWithWorker === undefined ? true : options.setWithWorker + }); + return true; + } catch { + return false; + } }; async function deleteAttribute(characterId: string, name: string, type: AttributeType = "current") { - // Try for legacy attribute first - const legacyAttr = findObjs({ - _type: "attribute", - _characterid: characterId, - name: name, - })[0]; - - if (legacyAttr) { - legacyAttr.remove(); - return; + const character = getObj("character",characterId); + if(!character) { + return false; + } + + if (character?.sheetEnvironment === "legacy" || character?.sheetEnvironment === undefined) { + const legacyAttr = findObjs({ + _type: "attribute", + _characterid: characterId, + name: name, + })[0]; + + if (legacyAttr) { + legacyAttr.remove(); + return true; + } + return false; } - // Then try for the beacon computed + // Beacon computeds cannot be deleted (no change to the computed value). const beaconAttr = await getSheetItem(characterId, name, type); if (beaconAttr !== null && beaconAttr !== undefined) { - log(`Cannot delete beacon computed attribute ${name} on character ${characterId}. Setting to undefined instead`); - setSheetItem(characterId, name, undefined, type); - return; + return false; } // Then try for the user attribute const userAttr = await getSheetItem(characterId, `user.${name}`, type); if (userAttr !== null && userAttr !== undefined) { - log(`Deleting user attribute ${name} on character ${characterId}`); - setSheetItem(characterId, `user.${name}`, undefined, type); - return; + try { + await setSheetItem(characterId, `user.${name}`, undefined, type, { + allowThrow: true, + createAttr: false + }); + return true; + } catch { + return false; + } } - log(`Attribute ${type} not found on character ${characterId}, nothing to delete`); - return; + return false; }; export default { diff --git a/libSmartAttributes/src/types.d.ts b/libSmartAttributes/src/types.d.ts index b881d3d2a..d76b339e2 100644 --- a/libSmartAttributes/src/types.d.ts +++ b/libSmartAttributes/src/types.d.ts @@ -1,5 +1,5 @@ declare namespace SmartAttributes { function getAttribute(characterId: string, name: string, type?: "current" | "max"): Promise; - function setAttribute(characterId: string, name: string, value: unknown, type?: "current" | "max", options?: { setWithWorker?: boolean, noCreate?: boolean }): Promise; - function deleteAttribute(characterId: string, name: string, type?: "current" | "max", options?: { setWithWorker?: boolean }): Promise; + function setAttribute(characterId: string, name: string, value: unknown, type?: "current" | "max", options?: { setWithWorker?: boolean, noCreate?: boolean }): Promise; + function deleteAttribute(characterId: string, name: string, type?: "current" | "max"): Promise; } \ No newline at end of file diff --git a/libSmartAttributes/tests/index.test.ts b/libSmartAttributes/tests/index.test.ts index b3f2e5fc8..95f7de0b6 100644 --- a/libSmartAttributes/tests/index.test.ts +++ b/libSmartAttributes/tests/index.test.ts @@ -1,20 +1,42 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import SmartAttributes from "../src/index"; -// Mock Roll20 API functions +const mockFindObjs = vi.fn(); +const mockGetObj = vi.fn(); const mockGetSheetItem = vi.fn(); const mockSetSheetItem = vi.fn(); const mockLog = vi.fn(); - -// Setup global mocks +vi.stubGlobal("findObjs", mockFindObjs); +vi.stubGlobal("getObj", mockGetObj); vi.stubGlobal("getSheetItem", mockGetSheetItem); vi.stubGlobal("setSheetItem", mockSetSheetItem); vi.stubGlobal("log", mockLog); +/** Matches default setSheetItem options from setAttribute */ +const sheetOpts = (overrides: { + allowThrow?: boolean; + createAttr?: boolean; + withWorker?: boolean; +} = {}) => ({ + allowThrow: true, + createAttr: true, + withWorker: true, + ...overrides, +}); + +/** Mimics setSheetItem errors from displayErrorMessage(..., true, errorType, details) */ +const sheetItemError = (type: string, message = "setSheetItem failed") => { + const err = new Error(message) as Error & { type: string; details?: Record }; + err.type = type; + return err; +}; + describe("SmartAttributes", () => { beforeEach(() => { vi.clearAllMocks(); + mockFindObjs.mockReturnValue([]); + mockGetObj.mockReturnValue({ sheetEnvironment: "beacon" }); }); describe("getAttribute", () => { @@ -50,21 +72,21 @@ describe("SmartAttributes", () => { }); it("should handle falsy beacon values correctly", async () => { - mockGetSheetItem.mockResolvedValueOnce(0); // 0 is now treated as valid + mockGetSheetItem.mockResolvedValueOnce(0); const result = await SmartAttributes.getAttribute(characterId, attributeName); - expect(result).toBe(0); // 0 is returned as valid beacon value + expect(result).toBe(0); expect(mockGetSheetItem).toHaveBeenCalledTimes(1); expect(mockGetSheetItem).toHaveBeenCalledWith(characterId, attributeName, "current"); }); it("should handle empty string beacon values correctly", async () => { - mockGetSheetItem.mockResolvedValueOnce(""); // '' is now treated as valid + mockGetSheetItem.mockResolvedValueOnce(""); const result = await SmartAttributes.getAttribute(characterId, attributeName); - expect(result).toBe(""); // Empty string is returned as valid beacon value + expect(result).toBe(""); expect(mockGetSheetItem).toHaveBeenCalledTimes(1); expect(mockGetSheetItem).toHaveBeenCalledWith(characterId, attributeName, "current"); }); @@ -75,62 +97,278 @@ describe("SmartAttributes", () => { const attributeName = "strength"; const value = "18"; - it("should set beacon computed attribute when no legacy attribute but beacon exists", async () => { + it("should return true when setSheetItem succeeds on computed", async () => { mockSetSheetItem.mockResolvedValue("updated-value"); const result = await SmartAttributes.setAttribute(characterId, attributeName, value); - expect(mockSetSheetItem).toHaveBeenCalledWith(characterId, attributeName, value, "current", {allowThrow: true}); - expect(result).toBeUndefined(); + expect(mockSetSheetItem).toHaveBeenCalledTimes(1); + expect(mockSetSheetItem).toHaveBeenCalledWith( + characterId, + attributeName, + value, + "current", + sheetOpts({ allowThrow: true }) + ); + expect(result).toBe(true); }); - it("should default to user attribute when no legacy or beacon attribute exists", async () => { + it("should return true when falling through to user attribute", async () => { mockSetSheetItem - .mockImplementationOnce(()=>{throw new Error("missing computed");}) + .mockRejectedValueOnce(new Error("missing computed")) .mockResolvedValue("user-value"); const result = await SmartAttributes.setAttribute(characterId, attributeName, value); - expect(mockSetSheetItem).toHaveBeenCalledWith(characterId, `user.${attributeName}`, value, "current"); - expect(result).toBeUndefined(); + expect(mockSetSheetItem).toHaveBeenCalledTimes(2); + expect(mockSetSheetItem).toHaveBeenNthCalledWith( + 1, + characterId, + attributeName, + value, + "current", + sheetOpts({ allowThrow: true }) + ); + expect(mockSetSheetItem).toHaveBeenNthCalledWith( + 2, + characterId, + `user.${attributeName}`, + value, + "current", + sheetOpts({ allowThrow: true }) + ); + expect(result).toBe(true); + }); + + it("should return false and not create user attribute when computed is read-only", async () => { + mockSetSheetItem.mockRejectedValueOnce( + sheetItemError("COMPUTED_READONLY", 'ERROR: Readonly Property "strength".') + ); + + const result = await SmartAttributes.setAttribute(characterId, attributeName, value); + + expect(mockSetSheetItem).toHaveBeenCalledTimes(1); + expect(mockSetSheetItem).toHaveBeenCalledWith( + characterId, + attributeName, + value, + "current", + sheetOpts({ allowThrow: true }) + ); + expect(result).toBe(false); + }); + + it("should return true when falling through for non-readonly setSheetItem errors", async () => { + mockSetSheetItem + .mockRejectedValueOnce( + sheetItemError("COMPUTED_INVALID", 'ERROR: Property "strength" doesn\'t exist.') + ) + .mockResolvedValue("user-value"); + + const result = await SmartAttributes.setAttribute(characterId, attributeName, value); + + expect(mockSetSheetItem).toHaveBeenCalledTimes(2); + expect(mockSetSheetItem).toHaveBeenNthCalledWith( + 2, + characterId, + `user.${attributeName}`, + value, + "current", + sheetOpts({ allowThrow: true }) + ); + expect(result).toBe(true); + }); + + it("should return false when user attribute fallback also fails", async () => { + mockSetSheetItem + .mockRejectedValueOnce(new Error("missing computed")) + .mockRejectedValueOnce(new Error("user set failed")); + + const result = await SmartAttributes.setAttribute(characterId, attributeName, value); + + expect(mockSetSheetItem).toHaveBeenCalledTimes(2); + expect(result).toBe(false); + }); + + it("should pass createAttr false when noCreate is set", async () => { + mockSetSheetItem + .mockRejectedValueOnce(new Error("missing computed")) + .mockResolvedValue("user-value"); + + const result = await SmartAttributes.setAttribute(characterId, attributeName, value, "current", { + noCreate: true, + }); + + expect(mockSetSheetItem).toHaveBeenNthCalledWith( + 1, + characterId, + attributeName, + value, + "current", + sheetOpts({ allowThrow: true, createAttr: false }) + ); + expect(mockSetSheetItem).toHaveBeenNthCalledWith( + 2, + characterId, + `user.${attributeName}`, + value, + "current", + sheetOpts({ allowThrow: true, createAttr: false }) + ); + expect(result).toBe(true); + }); + + it("should pass withWorker false when setWithWorker is false", async () => { + mockSetSheetItem.mockResolvedValue("ok"); + + const result = await SmartAttributes.setAttribute(characterId, attributeName, value, "current", { + setWithWorker: false, + }); + + expect(mockSetSheetItem).toHaveBeenCalledWith( + characterId, + attributeName, + value, + "current", + sheetOpts({ allowThrow: true, withWorker: false }) + ); + expect(result).toBe(true); }); - it("should handle complex values correctly", async () => { + it("should return true for complex values via user fallback", async () => { const complexValue = { nested: { value: 42 } }; mockSetSheetItem - .mockImplementationOnce(()=>{throw new Error("missing computed");}) + .mockRejectedValueOnce(new Error("missing computed")) .mockResolvedValue(complexValue); const result = await SmartAttributes.setAttribute(characterId, attributeName, complexValue); expect(mockSetSheetItem).toHaveBeenCalledTimes(2); - expect(mockSetSheetItem).toHaveBeenCalledWith(characterId, attributeName, complexValue, "current", {allowThrow:true}); - expect(mockSetSheetItem).toHaveBeenCalledWith(characterId, `user.${attributeName}`, complexValue, "current"); - expect(result).toBeUndefined(); + expect(result).toBe(true); }); - it("should handle null and undefined values", async () => { - mockSetSheetItem.mockResolvedValue(null); + it("should return true when setting null via user fallback", async () => { mockSetSheetItem - .mockImplementationOnce(()=>{throw new Error("missing computed");}) + .mockRejectedValueOnce(new Error("missing computed")) .mockResolvedValue(null); const result = await SmartAttributes.setAttribute(characterId, attributeName, null); expect(mockSetSheetItem).toHaveBeenCalledTimes(2); - expect(mockSetSheetItem).toHaveBeenCalledWith(characterId, attributeName, null, "current",{allowThrow:true}); - expect(mockSetSheetItem).toHaveBeenCalledWith(characterId, `user.${attributeName}`, null, "current"); - expect(result).toBeUndefined(); + expect(result).toBe(true); }); + }); - it("should handle falsy beacon values correctly for setting", async () => { - mockGetSheetItem.mockResolvedValue(0); // 0 is now treated as valid existing beacon value - mockSetSheetItem.mockResolvedValue("updated"); + describe("deleteAttribute", () => { + const characterId = "char123"; + const attributeName = "strength"; - const result = await SmartAttributes.setAttribute(characterId, attributeName, value); + it("should return true when removing a legacy attribute", async () => { + const mockRemove = vi.fn(); + mockGetObj.mockReturnValue({ sheetEnvironment: "legacy" }); + mockFindObjs.mockReturnValue([{ remove: mockRemove }]); + + const result = await SmartAttributes.deleteAttribute(characterId, attributeName); + + expect(mockFindObjs).toHaveBeenCalledWith({ + _type: "attribute", + _characterid: characterId, + name: attributeName, + }); + expect(mockRemove).toHaveBeenCalled(); + expect(mockGetSheetItem).not.toHaveBeenCalled(); + expect(result).toBe(true); + }); - expect(mockSetSheetItem).toHaveBeenCalledWith(characterId, attributeName, value,"current",{allowThrow:true}); - expect(result).toBeUndefined(); + it("should return false when legacy character has no matching attribute", async () => { + mockGetObj.mockReturnValue({ sheetEnvironment: "legacy" }); + mockFindObjs.mockReturnValue([]); + + const result = await SmartAttributes.deleteAttribute(characterId, attributeName); + + expect(mockGetSheetItem).not.toHaveBeenCalled(); + expect(result).toBe(false); + }); + + it("should return false when character is not found", async () => { + mockGetObj.mockReturnValue(null); + + const result = await SmartAttributes.deleteAttribute(characterId, attributeName); + + expect(result).toBe(false); + }); + + it("should return false when a beacon computed exists and no legacy attribute exists", async () => { + mockGetSheetItem.mockResolvedValueOnce("10"); + + const result = await SmartAttributes.deleteAttribute(characterId, attributeName); + + expect(mockFindObjs).toHaveBeenCalledTimes(0); + expect(mockGetSheetItem).toHaveBeenCalledTimes(1); + expect(mockGetSheetItem).toHaveBeenCalledWith(characterId, attributeName, "current"); + expect(mockSetSheetItem).not.toHaveBeenCalled(); + expect(result).toBe(false); + }); + + it("should return false for falsy beacon computed values without calling setSheetItem", async () => { + mockGetSheetItem.mockResolvedValueOnce(0); + + const result = await SmartAttributes.deleteAttribute(characterId, attributeName); + + expect(mockSetSheetItem).not.toHaveBeenCalled(); + expect(result).toBe(false); + }); + + it("should ignore legacy attribute on beacon character before checking computed", async () => { + const mockRemove = vi.fn(); + mockGetObj.mockReturnValue({ sheetEnvironment: "beacon" }); + mockFindObjs.mockReturnValue([{ remove: mockRemove }]); + + const result = await SmartAttributes.deleteAttribute(characterId, attributeName); + + expect(mockRemove).not.toHaveBeenCalled(); + expect(mockGetSheetItem).toHaveBeenCalledTimes(2); + expect(result).toBe(false); + }); + + it("should return true when deleting an existing user attribute", async () => { + mockGetSheetItem + .mockResolvedValueOnce(null) + .mockResolvedValueOnce("user-value"); + mockSetSheetItem.mockResolvedValue(true); + + const result = await SmartAttributes.deleteAttribute(characterId, attributeName); + + expect(mockGetSheetItem).toHaveBeenNthCalledWith(1, characterId, attributeName, "current"); + expect(mockGetSheetItem).toHaveBeenNthCalledWith(2, characterId, `user.${attributeName}`, "current"); + expect(mockSetSheetItem).toHaveBeenCalledWith( + characterId, + `user.${attributeName}`, + undefined, + "current", + { allowThrow: true, createAttr: false } + ); + expect(result).toBe(true); + }); + + it("should return false when no attribute exists", async () => { + mockGetSheetItem.mockResolvedValue(null); + + const result = await SmartAttributes.deleteAttribute(characterId, attributeName); + + expect(mockSetSheetItem).not.toHaveBeenCalled(); + expect(result).toBe(false); + }); + + it("should return false when user attribute delete fails", async () => { + mockGetSheetItem + .mockResolvedValueOnce(null) + .mockResolvedValueOnce("user-value"); + mockSetSheetItem.mockRejectedValueOnce(new Error("delete failed")); + + const result = await SmartAttributes.deleteAttribute(characterId, attributeName); + + expect(result).toBe(false); }); }); @@ -149,7 +387,7 @@ describe("SmartAttributes", () => { }); it("should handle boolean values in attributes", async () => { - mockGetSheetItem.mockResolvedValueOnce(false); // Test with false to show falsy values are valid + mockGetSheetItem.mockResolvedValueOnce(false); const result = await SmartAttributes.getAttribute(characterId, attributeName); @@ -165,32 +403,42 @@ describe("SmartAttributes", () => { mockGetSheetItem.mockResolvedValue("beacon-10"); mockSetSheetItem.mockResolvedValue("beacon-15"); - // Get current value const currentValue = await SmartAttributes.getAttribute(characterId, attributeName); expect(currentValue).toBe("beacon-10"); - // Set new value const result = await SmartAttributes.setAttribute(characterId, attributeName, "beacon-15"); - expect(result).toBeUndefined(); + expect(result).toBe(true); }); - it("should handle get returning undefined but set still working", async () => { + it("should return true when set falls through to user attribute", async () => { mockGetSheetItem.mockResolvedValue(null); mockSetSheetItem - .mockImplementationOnce(()=>{throw new Error("missing computed");}) + .mockRejectedValueOnce(new Error("missing computed")) .mockResolvedValue("new-value"); - // Get returns undefined const currentValue = await SmartAttributes.getAttribute(characterId, attributeName); expect(currentValue).toBeUndefined(); - // But set still works by creating user attribute const result = await SmartAttributes.setAttribute(characterId, attributeName, "new-value"); - expect(result).toBeUndefined(); + expect(result).toBe(true); expect(mockSetSheetItem).toHaveBeenCalledTimes(2); - expect(mockSetSheetItem).toHaveBeenCalledWith(characterId, attributeName, "new-value", "current",{allowThrow:true}); - expect(mockSetSheetItem).toHaveBeenCalledWith(characterId, `user.${attributeName}`, "new-value", "current"); + expect(mockSetSheetItem).toHaveBeenNthCalledWith( + 1, + characterId, + attributeName, + "new-value", + "current", + sheetOpts({ allowThrow: true }) + ); + expect(mockSetSheetItem).toHaveBeenNthCalledWith( + 2, + characterId, + `user.${attributeName}`, + "new-value", + "current", + sheetOpts({ allowThrow: true }) + ); }); }); });