diff --git a/extensions/Lily/ListTools.js b/extensions/Lily/ListTools.js
new file mode 100644
index 0000000000..44e065abeb
--- /dev/null
+++ b/extensions/Lily/ListTools.js
@@ -0,0 +1,645 @@
+// Name: List Tools
+// ID: lmsListTools
+// Description: An assortment of new ways to interact with lists.
+// By: LilyMakesThings
+
+// (It's getting harder and harder to think of original descriptions now)
+
+(function (Scratch) {
+ "use strict";
+
+ /* -- SETUP -- */
+ const vm = Scratch.vm;
+ const runtime = vm.runtime;
+
+ const getVarObjectFromName = function (name, util, type) {
+ const stageTarget = runtime.getTargetForStage();
+ const target = util.target;
+ let listObject = Object.create(null);
+
+ listObject = stageTarget.lookupVariableByNameAndType(name, type);
+ if (listObject) return listObject;
+ listObject = target.lookupVariableByNameAndType(name, type);
+ if (listObject) return listObject;
+ };
+
+ class Data {
+ getInfo() {
+ return {
+ id: "lmsData",
+ name: "List Tools",
+ color1: "#ff661a",
+ color2: "#f2590d",
+ color3: "#e64d00",
+ blocks: [
+ {
+ opcode: "deleteItems",
+ blockType: Scratch.BlockType.COMMAND,
+ text: "delete items [NUM1] to [NUM2] of [LIST]",
+ arguments: {
+ NUM1: {
+ type: Scratch.ArgumentType.NUMBER,
+ defaultValue: "1",
+ },
+ NUM2: {
+ type: Scratch.ArgumentType.NUMBER,
+ defaultValue: "3",
+ },
+ LIST: {
+ type: Scratch.ArgumentType.STRING,
+ menu: "lists",
+ },
+ },
+ },
+ {
+ opcode: "deleteAllOfItem",
+ blockType: Scratch.BlockType.COMMAND,
+ text: "delete all [ITEM] in [LIST]",
+ arguments: {
+ ITEM: {
+ type: Scratch.ArgumentType.STRING,
+ defaultValue: "thing",
+ },
+ LIST: {
+ type: Scratch.ArgumentType.STRING,
+ menu: "lists",
+ },
+ },
+ },
+ {
+ opcode: "replaceAllOfItem",
+ blockType: Scratch.BlockType.COMMAND,
+ text: "replace all [ITEM1] with [ITEM2] in [LIST]",
+ arguments: {
+ ITEM1: {
+ type: Scratch.ArgumentType.STRING,
+ defaultValue: "apple",
+ },
+ ITEM2: {
+ type: Scratch.ArgumentType.STRING,
+ defaultValue: "banana",
+ },
+ LIST: {
+ type: Scratch.ArgumentType.STRING,
+ menu: "lists",
+ },
+ },
+ },
+ {
+ opcode: "repeatList",
+ blockType: Scratch.BlockType.COMMAND,
+ text: "repeat [LIST1] [NUM] times in [LIST2]",
+ arguments: {
+ LIST1: {
+ type: Scratch.ArgumentType.STRING,
+ menu: "lists",
+ },
+ LIST2: {
+ type: Scratch.ArgumentType.STRING,
+ menu: "lists",
+ },
+ NUM: {
+ type: Scratch.ArgumentType.NUMBER,
+ defaultValue: "3",
+ },
+ },
+ },
+
+ "---",
+
+ {
+ opcode: "getListJoin",
+ blockType: Scratch.BlockType.REPORTER,
+ text: "get list [LIST] joined by [STRING]",
+ disableMonitor: true,
+ arguments: {
+ LIST: {
+ type: Scratch.ArgumentType.STRING,
+ menu: "lists",
+ },
+ STRING: {
+ type: Scratch.ArgumentType.STRING,
+ defaultValue: ",",
+ },
+ },
+ },
+ {
+ opcode: "timesItemAppears",
+ blockType: Scratch.BlockType.REPORTER,
+ text: "# of times [ITEM] appears in [LIST]",
+ disableMonitor: true,
+ arguments: {
+ ITEM: {
+ type: Scratch.ArgumentType.STRING,
+ defaultValue: "thing",
+ },
+ LIST: {
+ type: Scratch.ArgumentType.STRING,
+ menu: "lists",
+ },
+ },
+ },
+ {
+ opcode: "itemIndex",
+ blockType: Scratch.BlockType.REPORTER,
+ text: "index # [INDEX] of item [ITEM] in [LIST]",
+ disableMonitor: true,
+ arguments: {
+ INDEX: {
+ type: Scratch.ArgumentType.NUMBER,
+ defaultValue: "1",
+ },
+ ITEM: {
+ type: Scratch.ArgumentType.STRING,
+ defaultValue: "thing",
+ },
+ LIST: {
+ type: Scratch.ArgumentType.STRING,
+ menu: "lists",
+ },
+ },
+ },
+
+ "---",
+
+ {
+ opcode: "listIsEmpty",
+ blockType: Scratch.BlockType.BOOLEAN,
+ text: "[LIST] is empty?",
+ disableMonitor: true,
+ arguments: {
+ LIST: {
+ type: Scratch.ArgumentType.STRING,
+ menu: "lists",
+ },
+ },
+ },
+ {
+ opcode: "itemNumExists",
+ blockType: Scratch.BlockType.BOOLEAN,
+ text: "item [NUM] exists in [LIST]?",
+ disableMonitor: true,
+ arguments: {
+ NUM: {
+ type: Scratch.ArgumentType.NUMBER,
+ defaultValue: "1",
+ },
+ LIST: {
+ type: Scratch.ArgumentType.STRING,
+ menu: "lists",
+ },
+ },
+ },
+ {
+ opcode: "orderIs",
+ blockType: Scratch.BlockType.BOOLEAN,
+ text: "order of [LIST] is [ORDER]?",
+ disableMonitor: true,
+ arguments: {
+ LIST: {
+ type: Scratch.ArgumentType.STRING,
+ menu: "lists",
+ },
+ ORDER: {
+ type: Scratch.ArgumentType.STRING,
+ menu: "orderTypeSort",
+ },
+ },
+ },
+
+ "---",
+
+ {
+ opcode: "orderList",
+ blockType: Scratch.BlockType.COMMAND,
+ text: "set order of [LIST] to [ORDER]",
+ disableMonitor: true,
+ arguments: {
+ LIST: {
+ type: Scratch.ArgumentType.STRING,
+ menu: "lists",
+ },
+ ORDER: {
+ type: Scratch.ArgumentType.STRING,
+ defaultValue: "reversed",
+ menu: "orderType",
+ },
+ },
+ },
+ {
+ opcode: "setListToList",
+ blockType: Scratch.BlockType.COMMAND,
+ text: "set items of [LIST1] to [LIST2]",
+ arguments: {
+ LIST1: {
+ type: Scratch.ArgumentType.STRING,
+ menu: "lists",
+ },
+ LIST2: {
+ type: Scratch.ArgumentType.STRING,
+ menu: "lists",
+ },
+ },
+ },
+ {
+ opcode: "joinLists",
+ blockType: Scratch.BlockType.COMMAND,
+ text: "concatenate [LIST1] onto [LIST2]",
+ arguments: {
+ LIST1: {
+ type: Scratch.ArgumentType.STRING,
+ menu: "lists",
+ },
+ LIST2: {
+ type: Scratch.ArgumentType.STRING,
+ menu: "lists",
+ },
+ },
+ },
+
+ "---",
+
+ {
+ opcode: "forEachListItem",
+ blockType: Scratch.BlockType.LOOP,
+ text: "for each item value [VAR] in [LIST]",
+ hideFromPalette:
+ !runtime.extensionManager.isExtensionLoaded("lmsTempVars2"),
+ arguments: {
+ VAR: {
+ type: Scratch.ArgumentType.STRING,
+ defaultValue: "thread variable",
+ },
+ LIST: {
+ type: Scratch.ArgumentType.STRING,
+ menu: "lists",
+ },
+ },
+ },
+ {
+ opcode: "forEachListItemNum",
+ blockType: Scratch.BlockType.LOOP,
+ text: "for each item # [VAR] in [LIST]",
+ hideFromPalette:
+ !runtime.extensionManager.isExtensionLoaded("lmsTempVars2"),
+ arguments: {
+ VAR: {
+ type: Scratch.ArgumentType.STRING,
+ defaultValue: "thread variable",
+ },
+ LIST: {
+ type: Scratch.ArgumentType.STRING,
+ menu: "lists",
+ },
+ },
+ },
+
+ "---",
+
+ {
+ opcode: "setListArray",
+ blockType: Scratch.BlockType.COMMAND,
+ text: "set [LIST] to array [ARRAY]",
+ disableMonitor: true,
+ arguments: {
+ LIST: {
+ type: Scratch.ArgumentType.STRING,
+ menu: "lists",
+ },
+ ARRAY: {
+ type: Scratch.ArgumentType.STRING,
+ defaultValue: '["apple","banana"]',
+ },
+ },
+ },
+ {
+ opcode: "getListArray",
+ blockType: Scratch.BlockType.REPORTER,
+ text: "[LIST] as array",
+ disableMonitor: true,
+ arguments: {
+ LIST: {
+ type: Scratch.ArgumentType.STRING,
+ menu: "lists",
+ },
+ },
+ },
+ ],
+ menus: {
+ operator: {
+ acceptReporters: false,
+ items: [
+ {
+ text: "=",
+ value: "=",
+ },
+ {
+ text: ">",
+ value: ">",
+ },
+ {
+ text: "<",
+ value: "<",
+ },
+ ],
+ },
+ orderType: {
+ acceptReporters: false,
+ items: [
+ {
+ text: "reversed",
+ value: "reversed",
+ },
+ {
+ text: "ascending",
+ value: "ascending",
+ },
+ {
+ text: "descending",
+ value: "descending",
+ },
+ {
+ text: "randomised",
+ value: "randomised",
+ },
+ ],
+ },
+ orderTypeSort: {
+ acceptReporters: false,
+ items: [
+ {
+ text: "ascending",
+ value: "ascending",
+ },
+ {
+ text: "descending",
+ value: "descending",
+ },
+ ],
+ },
+ indexType: {
+ acceptReporters: false,
+ items: [
+ {
+ text: "first",
+ value: "first",
+ },
+ {
+ text: "last",
+ value: "last",
+ },
+ {
+ text: "random",
+ value: "random",
+ },
+ ],
+ },
+ lists: {
+ acceptReporters: true,
+ items: "_getLists",
+ },
+ },
+ };
+ }
+
+ deleteItems(args, util) {
+ const list = getVarObjectFromName(args.LIST, util, "list");
+ if (!list) return false;
+ const listLength = list.value.length;
+ let num1 = 0;
+ let num2 = 0;
+ if (!list) return;
+ if (args.NUM1 > args.NUM2) {
+ num1 = args.NUM2 - 1;
+ num2 = args.NUM1 - 1;
+ } else {
+ num1 = args.NUM1 - 1;
+ num2 = args.NUM2 - 1;
+ }
+ const listPart1 = list.value.slice(0, num1);
+ const listPart2 = list.value.slice(num2 + 1, listLength);
+ list.value = listPart1.concat(listPart2);
+ }
+
+ deleteAllOfItem(args, util) {
+ const list = getVarObjectFromName(args.LIST, util, "list");
+ if (!list) return;
+ const newList = list.value.filter(function (model) {
+ return model !== args.ITEM;
+ });
+ list.value = newList;
+ }
+
+ replaceAllOfItem(args, util) {
+ const list = getVarObjectFromName(args.LIST, util, "list");
+ if (!list) return;
+ const listLength = list.value.length;
+ const item1 = args.ITEM1;
+ const item2 = args.ITEM2;
+ let newList = [];
+ for (let i = 0; i < listLength; i++) {
+ if (list.value[i] === item1) {
+ newList.push(item2);
+ } else {
+ newList.push(list.value[i]);
+ }
+ }
+ list.value = newList;
+ }
+
+ repeatList(args, util) {
+ const list1 = getVarObjectFromName(args.LIST1, util, "list");
+ if (!list1) return;
+ const list2 = getVarObjectFromName(args.LIST2, util, "list");
+ if (!list2) return;
+ const currentVal = list1.value;
+ for (let i = 0; i < args.NUM; i++) {
+ list1.value = list1.value.concat(currentVal);
+ }
+ }
+
+ getListJoin(args, util) {
+ const list = getVarObjectFromName(args.LIST, util, "list");
+ if (!list) return "";
+ return list.value.join(args.STRING);
+ }
+
+ timesItemAppears(args, util) {
+ const list = getVarObjectFromName(args.LIST, util, "list");
+ if (!list) return 0;
+ return list.value.filter((model) => model == args.ITEM).length;
+ }
+
+ itemIndex(args, util) {
+ const list = getVarObjectFromName(args.LIST, util, "list");
+ if (!list) return 0;
+ let indexes = [];
+ for (let index = 0; index < list.value.length; index++) {
+ if (list.value[index] === args.ITEM) {
+ indexes.push(index);
+ }
+ }
+
+ switch (args.INDEX) {
+ case "_first_":
+ return Scratch.Cast.toNumber(indexes[0] + 1);
+ case "_last_":
+ return Scratch.Cast.toNumber(indexes[indexes.length - 1] + 1);
+ case "_random_":
+ return Scratch.Cast.toNumber(
+ indexes[Math.floor(Math.random() * indexes.length)] + 1
+ );
+ default:
+ return Scratch.Cast.toNumber(indexes[args.INDEX - 1] + 1);
+ }
+ }
+
+ listIsEmpty(args, util) {
+ const list = getVarObjectFromName(args.LIST, util, "list");
+ if (!list) return true;
+ if (list.value.length > 0) return false;
+ return true;
+ }
+
+ itemNumExists(args, util) {
+ const list = getVarObjectFromName(args.LIST, util, "list");
+ if (!list) return false;
+ const listIndex = Scratch.Cast.toListIndex(
+ args.NUM,
+ list.value.length,
+ false
+ );
+ if (listIndex === Scratch.Cast.LIST_INVALID) return false;
+ return true;
+ }
+
+ orderIs(args, util) {
+ const list = getVarObjectFromName(args.LIST, util, "list");
+ if (!list) return false;
+
+ for (let i = 0; i < list.value.length - 1; i++) {
+ const compare = Scratch.Cast.compare(list.value[i + 1], list.value[i]);
+ if (compare > 0 && args.ORDER === "descending") return false;
+ if (compare < 0 && args.ORDER === "ascending") return false;
+ }
+ return true;
+ }
+
+ orderList(args, util) {
+ const list = getVarObjectFromName(args.LIST, util, "list");
+ if (!list) return;
+ if (args.ORDER === "reversed") {
+ list.value.reverse();
+ } else if (args.ORDER === "randomised") {
+ const randomised = list.value
+ .map((value) => ({ value, sort: Math.random() }))
+ .sort((a, b) => a.sort - b.sort)
+ .map(({ value }) => value);
+ list.value = randomised;
+ } else if (args.ORDER === "ascending") {
+ list.value.sort(Scratch.Cast.compare);
+ } else if (args.ORDER === "descending") {
+ list.value.sort(Scratch.Cast.compare).reverse();
+ }
+ list._monitorUpToDate = false;
+ }
+
+ setListToList(args, util) {
+ const list1 = getVarObjectFromName(args.LIST1, util, "list");
+ if (!list1) return;
+ const list2 = getVarObjectFromName(args.LIST2, util, "list");
+ if (!list2) return;
+ list1.value = list2.value;
+ }
+
+ joinLists(args, util) {
+ const list1 = getVarObjectFromName(args.LIST1, util, "list");
+ if (!list1) return;
+ const list2 = getVarObjectFromName(args.LIST2, util, "list");
+ if (!list2) return;
+ list2.value = list2.value.concat(list1.value);
+ }
+
+ forEachListItem(args, util) {
+ const list = getVarObjectFromName(args.LIST, util, "list");
+ if (!list) return false;
+ const listLength = list.value.length;
+
+ const thread = util.thread;
+ if (!thread.variables) thread.variables = {};
+ const vars = thread.variables;
+
+ if (typeof util.stackFrame.index === "undefined") {
+ util.stackFrame.index = 0;
+ }
+
+ if (util.stackFrame.index < listLength) {
+ let itemIndex = util.stackFrame.index;
+ vars[args.VAR] = list.value[itemIndex];
+ util.stackFrame.index++;
+ return true;
+ }
+ }
+
+ forEachListItemNum(args, util) {
+ const list = getVarObjectFromName(args.LIST, util, "list");
+ if (!list) return false;
+ const listLength = list.value.length;
+
+ const thread = util.thread;
+ if (!thread.variables) thread.variables = {};
+ const vars = thread.variables;
+
+ if (typeof util.stackFrame.index === "undefined") {
+ util.stackFrame.index = 0;
+ }
+
+ if (util.stackFrame.index < listLength) {
+ util.stackFrame.index++;
+ vars[args.VAR] = util.stackFrame.index;
+ return true;
+ }
+ }
+
+ setListArray(args, util) {
+ const list = getVarObjectFromName(args.LIST, util, "list");
+ if (!list) return;
+
+ let array;
+ try {
+ array = JSON.parse(args.ARRAY);
+ } catch (error) {
+ return;
+ }
+
+ if (!Array.isArray(array)) return;
+ const newArray = array;
+ list.value = newArray;
+ list._monitorUpToDate = false;
+ }
+
+ getListArray(args, util) {
+ const list = getVarObjectFromName(args.LIST, util, "list");
+ if (!list) return "";
+ return JSON.stringify(list.value);
+ }
+
+ _getLists() {
+ // @ts-expect-error - Blockly not typed yet
+ // eslint-disable-next-line no-undef
+ const lists =
+ typeof Blockly === "undefined"
+ ? []
+ : Blockly.getMainWorkspace()
+ .getVariableMap()
+ .getVariablesOfType("list")
+ .map((model) => model.name);
+ if (lists.length > 0) {
+ return lists;
+ } else {
+ return [""];
+ }
+ }
+ }
+ Scratch.extensions.register(new Data());
+})(Scratch);
diff --git a/extensions/extensions.json b/extensions/extensions.json
index 5b983b75b8..b8b4a1581c 100644
--- a/extensions/extensions.json
+++ b/extensions/extensions.json
@@ -27,6 +27,7 @@
"Lily/ClonesPlus",
"Lily/LooksPlus",
"Lily/MoreEvents",
+ "Lily/ListTools",
"NexusKitten/moremotion",
"CubesterYT/WindowControls",
"veggiecan/browserfullscreen",
diff --git a/images/Lily/ListTools.svg b/images/Lily/ListTools.svg
new file mode 100644
index 0000000000..11feacec82
--- /dev/null
+++ b/images/Lily/ListTools.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/images/README.md b/images/README.md
index 495fbb26d6..03e7082343 100644
--- a/images/README.md
+++ b/images/README.md
@@ -269,6 +269,10 @@ All images in this folder are licensed under the [GNU General Public License ver
## Lily/AllMenus.svg
- Created by [YogaindoCR](https://github.com/YogaindoCR) in https://github.com/TurboWarp/extensions/issues/90#issuecomment-1681839774
+## Lily/ListTools.svg
+ - Created by [@LilyMakesThings](https://github.com/LilyMakesThings).
+ - Background "blobs" by Scratch.
+
## Lily/MoreEvents.svg
- Created by [@LilyMakesThings](https://github.com/LilyMakesThings).
- Background "blobs" by Scratch.